Module: Axn::Core::Contract::SubfieldContradictions
- Defined in:
- lib/axn/core/contract/subfield_contradictions.rb
Overview
Declaration-time rejection of contradiction-only subfield contracts (PRO-2889). Walks a CANDIDATE tree (prospective configs included; nothing committed) and raises ArgumentError on the first provable contradiction. Every judgment reuses the canonical derivation in satisfiability mode (unknowable-at-declaration counts as satisfiable) — never a parallel re-derivation, the failure mode that sank PRO-2877's pulled detectors. Side-effect-free: inspects declared configs only, never runs user code.
Class Method Summary collapse
-
.check!(field_configs, subfield_configs) ⇒ Object
Both checks re-scan the WHOLE candidate tree (prospective configs included), never just the new batch: a NEW declaration can invalidate an OLD subfield regardless of order — a new required descendant kills an old tolerance (dead-tolerance check), and a new type/shape declaration on a parent kills an old subfield's answerability (e.g.
expects "bar.baz", on: :payloadaccepted whilebaris unknown, thenexpects :bar, ..., type: Stringretro-strandsbar.baz). -
.check_conflicting_defaults!(tree) ⇒ Object
The EXPLICIT-CONFLICT check (PRO-2901): a wire node reached by two+ routes where more than one route carries a
default:. -
.check_dead_nil_tolerance!(tree, field_configs) ⇒ Object
Families 1+3: a statically-declared nil-tolerance (allow_nil:/optional:/allow_blank:/ presence: false) whose omission unconditionally fails — the flag advertises an omission the contract can never accept.
-
.check_unanswerable_segments!(tree) ⇒ Object
The UNANSWERABLE-SEGMENT check: a subfield whose resolution provably cannot traverse some segment — for EVERY contract-valid input, the read settles absent (a failed dig/method read is UnextractableError → nil, PRO-2886).
-
.describe_default(config) ⇒ Object
A default's description for the conflict message.
-
.each_explicit_node(roots, &block) ⇒ Object
Depth-first over every explicit subfield node, yielding (parent_node, key, node).
-
.first_required_descendant(node, ann, prefix = []) ⇒ Object
The shallowest explicit required descendant's dotted path (for the message) — descends through implicit intermediates that are required only transitively.
-
.model_omittable?(config, node, field_configs, ann) ⇒ Boolean
Mirrors apply_model_id_requiredness!'s omittability (satisfiability flavor): the model may be omitted when it is itself optional-for-schema AND no child subtree requires presence — OR a defaulted explicit
<field>_idsibling supplies the lookup token on omission. - .raise_conflicting_defaults!(configs, wire_path) ⇒ Object
- .raise_dead_tolerance!(config, owner, node, ann) ⇒ Object
- .raise_unanswerable!(config, blocker, segment) ⇒ Object
-
.segment_blocker(node, carried, segment) ⇒ Object
The first enforced declaration at this position that provably cannot answer
segment(nil when the position is answerable). - .walk_children(parent, &block) ⇒ Object
Class Method Details
.check!(field_configs, subfield_configs) ⇒ Object
Both checks re-scan the WHOLE candidate tree (prospective configs included), never just the new
batch: a NEW declaration can invalidate an OLD subfield regardless of order — a new required
descendant kills an old tolerance (dead-tolerance check), and a new type/shape declaration on a
parent kills an old subfield's answerability (e.g. expects "bar.baz", on: :payload accepted
while bar is unknown, then expects :bar, ..., type: String retro-strands bar.baz).
23 24 25 26 27 28 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 23 def check!(field_configs, subfield_configs) tree = Axn::Internal::SubfieldTree.build(field_configs, subfield_configs) check_unanswerable_segments!(tree) # first: an unreachable path moots any conflict on it check_conflicting_defaults!(tree) # before dead-tolerance: an explicit conflict is the plainer diagnosis check_dead_nil_tolerance!(tree, field_configs) end |
.check_conflicting_defaults!(tree) ⇒ Object
The EXPLICIT-CONFLICT check (PRO-2901): a wire node reached by two+ routes where more than one
route carries a default:. PRO-2883 made merged wire nodes first-class — the same wire key can
be declared via two routes (expects "meta.count", on: :payload and expects :count, on: :meta, as: :meta_count) — but only ONE inbound default can win the shared wire key, and the executor's
declaration-order pass silently lets the first-declared default write while every later route
sees the key present and skips. Two explicit defaults for one wire value have no principled
winner (declaration order is not a principle), so — unlike the inferred families 1–3, which defer
— this rejects at declaration per the AGENTS.md doctrine that an explicit conflict raises loudly.
Rejected uniformly, even for equal literals: agreeing today drifts tomorrow, and two Proc defaults
can't be compared at all. Subfield-tree-only by construction (a top-level field can't merge with
itself — the duplicate-field guard prevents it; the top-level <field>_id/model: default
interplay is covered by PRO-2889's usable_id_token_default? sites).
42 43 44 45 46 47 48 49 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 42 def check_conflicting_defaults!(tree) each_explicit_node(tree.roots) do |_parent, _key, node| defaulted = node.configs.select(&:applied_default?) next if defaulted.size < 2 raise_conflicting_defaults!(defaulted, tree.index[defaulted.first].wire_path) end end |
.check_dead_nil_tolerance!(tree, field_configs) ⇒ Object
Families 1+3: a statically-declared nil-tolerance (allow_nil:/optional:/allow_blank:/ presence: false) whose omission unconditionally fails — the flag advertises an omission the contract can never accept. Keyed on STATIC declarations only, so a future dynamic/ conditional requiredness signal (PRO-2881) is outside the reject set by construction.
128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 128 def check_dead_nil_tolerance!(tree, field_configs) ann = Axn::Internal::Reflection::Schema.derive_annotations(tree.roots, satisfiability: true) field_configs.each do |config| next if Axn::Internal::Reflection::Schema::EXCLUDED_FROM_INPUT_SCHEMA.include?(config.field) next unless Axn::Internal::Reflection::Schema.nil_accepted?(config) node = tree.roots[config.reader_as] omittable = if config.validations[:model] model_omittable?(config, node, field_configs, ann) else Axn::Internal::Reflection::Schema.field_optional?(config, node.children, ann, satisfiability: true) end raise_dead_tolerance!(config, config.field, node, ann) unless omittable end each_explicit_node(tree.roots) do |parent, key, node| node.configs.each do |config| next unless Axn::Internal::Reflection::Schema.nil_accepted?(config) next if Axn::Internal::Reflection::Schema.node_optional?(node, ann, [config], satisfiability: true) # Skip ANY nil-accepted config at a sibling-id-rescued node, not only the model route: a # merged nil-tolerant non-model route (and a required grandchild the resolved record answers) # is exercisable via the same rescue the annotation credit grants — one shared predicate. next if Axn::Internal::Reflection::Schema.sibling_id_rescued?(parent, key, node) # Name the declaration by the field the user wrote (config.field) — symmetric with the # top-level loop above; the `on:` parent is implied and the stranded descendant is named. raise_dead_tolerance!(config, config.field, node, ann) end end end |
.check_unanswerable_segments!(tree) ⇒ Object
The UNANSWERABLE-SEGMENT check: a subfield whose resolution provably cannot traverse some segment — for EVERY contract-valid input, the read settles absent (a failed dig/method read is UnextractableError → nil, PRO-2886). Judged only along the hops the runtime actually digs (after the deepest reader-bearing ancestor — the same recipe resolve_parent uses), against each position's enforced declarations: its explicit configs plus the shape members an implicit position stands in for (ALL colliding members, nestable or not — answerability is about reading through the member's value, not nesting under it). Rejected regardless of the subfield's own optional:/default: — an unreachable path is dead machinery, rejected like the dotted-name model: spelling (PRO-2877), and with a default it degenerates to a constant field.
89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 89 def check_unanswerable_segments!(tree) tree.index.each do |config, path| next unless config.subfield? # skip top-level depth-0 configs; they read no segment reader_index = Axn::Core::ContractForSubfields.deepest_reader_index(path) next if reader_index.nil? carried = [] path.ancestors.each_with_index do |(node, seg), i| if i >= reader_index && (blocker = segment_blocker(node, carried, seg)) raise_unanswerable!(config, blocker, seg) end carried = node.children[seg]&.implicit? ? Axn::Internal::Reflection::Schema.shape_members_at(node.configs + carried, seg) : [] end end end |
.describe_default(config) ⇒ Object
A default's description for the conflict message. Side-effect-free BY CONSTRUCTION: it dispatches
NO method on the default object, so no user code — a custom or singleton #inspect/#respond_to?, etc.
— can run (and mask the intended declaration error) while reflection builds it. Classification is by
class match (Klass === value, a C-level kind-of check that never invokes the value's own methods —
the same trust normalize_schema_literal places in type checks). Only IMMEDIATES render their value:
Ruby forbids singleton methods on Integer/Float/Symbol/true/false, so their #inspect is provably the
core one and safe; a String (singleton #inspect possible), a container (recurses #inspect into
arbitrary elements), a Proc, and any other object are named by kind instead. Reached only for applied
defaults, so the value is never nil.
69 70 71 72 73 74 75 76 77 78 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 69 def describe_default(config) case config.default when Proc then "a callable" when Integer, Float, Symbol, TrueClass, FalseClass then config.default.inspect when String then "a String value" when Hash then "a Hash value" when Array then "an Array value" else "a non-literal default" end end |
.each_explicit_node(roots, &block) ⇒ Object
Depth-first over every explicit subfield node, yielding (parent_node, key, node).
161 162 163 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 161 def each_explicit_node(roots, &block) roots.each_value { |root| walk_children(root, &block) } end |
.first_required_descendant(node, ann, prefix = []) ⇒ Object
The shallowest explicit required descendant's dotted path (for the message) — descends through implicit intermediates that are required only transitively.
189 190 191 192 193 194 195 196 197 198 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 189 def first_required_descendant(node, ann, prefix = []) node.children.each do |key, child| path = prefix + [key] return path if ann[child].required && !child.implicit? deeper = first_required_descendant(child, ann, path) return deeper if deeper end nil end |
.model_omittable?(config, node, field_configs, ann) ⇒ Boolean
Mirrors apply_model_id_requiredness!'s omittability (satisfiability flavor): the model may
be omitted when it is itself optional-for-schema AND no child subtree requires presence —
OR a defaulted explicit <field>_id sibling supplies the lookup token on omission.
175 176 177 178 179 180 181 182 183 184 185 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 175 def model_omittable?(config, node, field_configs, ann) explicit_id = field_configs.find { |c| c.field == Internal::FieldConfig.model_id_key(config.field) } return true if explicit_id && Axn::Internal::Reflection::Schema.usable_id_token_default?(explicit_id) # The model's OWN usable default supplies a record on omission, so the tolerance is # exercisable regardless of a required descendant — mirrors field_optional?'s parent-default # short-circuit (checked BEFORE the child test, not gated behind it). return true if Axn::Internal::Reflection::Schema.usable_default?(config, subfield: false, satisfiability: true) Axn::Internal::Reflection::Schema.optional_for_schema?(config, satisfiability: true) && !Axn::Internal::Reflection::Schema.children_require_presence?(node.children, ann) end |
.raise_conflicting_defaults!(configs, wire_path) ⇒ Object
51 52 53 54 55 56 57 58 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 51 def raise_conflicting_defaults!(configs, wire_path) routes = configs.map { |c| "#{c.field.inspect} (on #{c.on.inspect}, default: #{describe_default(c)})" }.join(" and ") raise ArgumentError, "conflicting default: declarations on wire path #{wire_path.join('.').inspect}: routes #{routes} both " \ "carry a default: for the same wire value, and only declaration order — not any principle — decides " \ "which one applies (the first-declared default writes the wire key; every later route then sees the " \ "key present and is silently skipped). Keep a single default:, or split the routes onto distinct wire keys." end |
.raise_dead_tolerance!(config, owner, node, ann) ⇒ Object
200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 200 def raise_dead_tolerance!(config, owner, node, ann) # Names are rendered rather than interpolated raw, and the stranded path is joined from rendered # SEGMENTS: a declared name may hold non-UTF-8 bytes (a valid Latin-1 Symbol), and joining one to a # non-ASCII UTF-8 name raises Encoding::CompatibilityError from the message itself — so the author gets # an encoding failure instead of the contradiction being reported. `Symbol#inspect` supplies the leading # colon these read with, and escapes bytes that have no UTF-8 rendering. name = owner.inspect segments = first_required_descendant(node, ann)&.map { |segment| Axn::Internal::Reflection::PropertyNames.renderable_label(segment) } stranded = segments && ":#{segments.join('.')}" model_hint = if config.validations[:model] " For a model: field, a record-supplying default: on #{name} or a defaulted " \ "#{Axn::Internal::Reflection::PropertyNames.renderable_label(owner)}_id sibling (declared first) also rescues omission." else "" end raise ArgumentError, "#{name} is declared nil-tolerant (allow_nil:/optional:/allow_blank:, or an untyped " \ "presence: false), but " \ "#{stranded || 'its subtree'} is required and nothing rescues an omitted #{name} — " \ "the tolerance can never be exercised (every nil/omitted #{name} fails validation). " \ "Drop the tolerance on #{name}, or mark #{stranded || 'the subtree'} optional: or give it a " \ "default: (declare rescuing defaults BEFORE the dependent subfield). If it is only required when " \ "#{name} is supplied, gate it conditionally: `expects ..., if: -> { " \ "#{Axn::Internal::Reflection::PropertyNames.renderable_label(owner)}.present? }`.#{model_hint}" end |
.raise_unanswerable!(config, blocker, segment) ⇒ Object
115 116 117 118 119 120 121 122 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 115 def raise_unanswerable!(config, blocker, segment) types = Axn::Internal::Reflection::Schema.object_type_branches(blocker).map { |b| b.is_a?(Class) ? b.name : b.inspect }.join(", ") raise ArgumentError, "subfield #{config.field.inspect} (on #{config.on.inspect}) can never resolve: segment #{segment.inspect} " \ "is read from #{blocker.field.inspect}, declared #{types}, which cannot answer it (no key access, no such " \ "method) — no contract-valid input ever reaches this subfield. Make #{blocker.field.inspect} object-shaped, " \ "or drop the subfield." end |
.segment_blocker(node, carried, segment) ⇒ Object
The first enforced declaration at this position that provably cannot answer segment
(nil when the position is answerable). A position with any model: route resolves to a
record — never refutable.
109 110 111 112 113 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 109 def segment_blocker(node, carried, segment) return nil if node.configs.any? { |c| c.validations[:model] } (node.configs + carried).find { |c| !Axn::Internal::Reflection::Schema.config_answers_segment?(c, segment) } end |
.walk_children(parent, &block) ⇒ Object
165 166 167 168 169 170 |
# File 'lib/axn/core/contract/subfield_contradictions.rb', line 165 def walk_children(parent, &block) parent.children.each do |key, node| yield(parent, key, node) unless node.implicit? walk_children(node, &block) end end |