Module: Axn::Internal::Reflection::PropertyNames

Defined in:
lib/axn/internal/reflection/property_names.rb

Overview

The three rules a declared name must satisfy to be a JSON property: it must render through Ruby's own to_s (so the property it names is one fact rather than one answer per reader), that rendering must have a UTF-8 form, and it must not collapse onto a property another declared name already renders as. They are judged in that order, each being the next one's premise.

These live with reflection because they are judged on what reflection EMITS — the walk reads the property names out of a built schema rather than predicting them from the declarations. Six mechanisms contribute names at a node (a top-level field, a subfield leaf at its resolved parent, a shape member at any depth, a model:-generated <field>_id, a nested key a dotted on: introduces, and a structured type's own members), and any check that predicted their combined output had to re-derive, per mechanism, both "does this emit here" and "under what name" — a second copy of a rule that must agree with the emitter, and every copy that drifted produced either a missed collapse or a rejected legal declaration.

Internal, not an adapter surface (see AGENTS.md's namespace policy): Core::Contract is the only caller. One rule deliberately does NOT live here — the renderability of an exposes field name, which reaches the serialized body through Values.serialize_exposed regardless of what any schema emits, so it is not projection-gated and stays eager in the contract.

A module of functions rather than a class: every entry point is one-shot over a schema or a config list, and the only state any of them carries (a path, a size budget) lives for the length of one call.

Class Method Summary collapse

Class Method Details

.inspect_field_name(name) ⇒ Object

How a declared name is written into a message. A String or Symbol is named by its escaped spelling; anything else is named by its CLASS, derived without dispatching anything the name defines.

A name that is neither is reachable only as a shape member's (the field path symbolizes every declared name before any guard runs), and it gets here having rendered a property through its to_s — so it is a real object whose inspect is real caller code. Dispatching that inspect while building the very error the name caused lets the name replace that error with an exception of its own, and one outside StandardError then escapes class definition entirely. That is the same hazard the bound inspect above exists to avoid, and it has nothing to do with encoding: a class name identifies the offender without running a line the offender wrote, exactly as Reflection::Values#describe_key_classes names a colliding Hash key.



203
204
205
# File 'lib/axn/internal/reflection/property_names.rb', line 203

def inspect_field_name(name)
  field_name_spelling(name) || "a name of class #{renderable_class_name(name)}"
end

.reject_unrenderable_field_names!(names, kind: "a field name") ⇒ Object

A declared name becomes a JSON property name — in the reflected schema for an inbound field, in serialized output for an outbound one — so it carries the same UTF-8 promise the serializer enforces on a Hash key. Canonicalization belongs to the layer that renders the property, so the check and the rendering it predicts cannot disagree.

Runs before any collision comparison: two unrenderable names both canonicalize to nil, so a collision check reached first would compare nil to nil and report a shared property for two names that share none.



254
255
256
257
258
259
260
# File 'lib/axn/internal/reflection/property_names.rb', line 254

def reject_unrenderable_field_names!(names, kind: "a field name")
  names.each do |name|
    next if Axn::Internal::Reflection::Values.canonical_wire_key(name)

    raise ArgumentError, unrenderable_name_message(name, kind)
  end
end

.renderable_class_name(value) ⇒ Object

How a foreign value's CLASS — or a class named in its own right, a declared type: or a tool axn — is written into a message. Two hazards, one seam, because they are different failures with the same outcome and a layer that closed one of them has historically missed the other:

  1. Running the value's own class/inspect lets it raise INSTEAD of the report, and outside StandardError that escapes the rescue meant to settle the failure. Internal::ClassName answers from bound base implementations, so nothing the value defines runs.
  2. The name it answers with is still foreign BYTES. A constant may hold non-UTF-8 ones — Object.const_set(:"Caf\xE9", Class.new) is accepted and Module#to_s returns those bytes — so interpolating the name into a UTF-8 message raises Encoding::CompatibilityError from the reporting itself, destroying the failure exactly as a hostile class would.

So a class name is composed through Internal::Rendering, which pairs the same undispatched Internal::ClassName read with a render of its bytes: an ordinary ASCII name is byte-identical, a Latin-1 one reads as its text, and bytes with no UTF-8 rendering at all come back escaped. That cannot recurse back into inspect_field_name's class branch above: Module#to_s always answers with a genuine String ("#<Class:0x…>" for an anonymous class), so field_name_spelling resolves it from its String branch.



225
# File 'lib/axn/internal/reflection/property_names.rb', line 225

def renderable_class_name(value) = Axn::Internal::Rendering.class_name(value)

.renderable_label(name) ⇒ Object

How a name is written into a message that names ONE thing rather than distinguishing two spellings: the UTF-8 property it canonicalizes to, falling back to the escaped form above when its bytes have no UTF-8 rendering at all.

Every message axn builds is a UTF-8 String, and joining raw non-UTF-8 bytes to one raises Encoding::CompatibilityError from the reporting itself — so a caller gets an encoding failure instead of the failure being reported, or loses a log line entirely. Two ASCII-compatible encodings concatenate fine, which is why this only bites once a message carries non-ASCII text from BOTH sides: a Latin-1 :"caf\xE9" beside a UTF-8 :naïve.

The canonical property is byte-identical to the raw spelling for every ASCII name, so ordinary messages are unchanged. Shared by every layer that names something in prose — a shape member in a validation error, a stranded subfield path, a Hash key in a log line, a declared name in a declaration error — because each deriving its own is how three copies of it appeared, and because the fallback has to be the SAFE escape: an exotic name's own inspect is caller code that can raise while the message is built.



244
# File 'lib/axn/internal/reflection/property_names.rb', line 244

def renderable_label(name) = Values.canonical_wire_key(name) || inspect_field_name(name)

.renderable_module_name(mod) ⇒ Object



227
# File 'lib/axn/internal/reflection/property_names.rb', line 227

def renderable_module_name(mod) = Axn::Internal::Rendering.module_name(mod)

.same_declared_name?(first, second) ⇒ Boolean

Returns:

  • (Boolean)


179
180
181
182
183
184
185
186
187
188
189
190
# File 'lib/axn/internal/reflection/property_names.rb', line 179

def same_declared_name?(first, second)
  return true if first.equal?(second)

  case first
  when ::String
    case second
    when ::String then STRING_NAME_EQ.bind_call(first, second)
    else false
    end
  else false
  end
end

.validate_inbound!(klass) ⇒ Object

For APP SETUP, which must validate a class's inbound projection without going through its input_schema: that name belongs to the class, and an adapter base that already defines it keeps it (see Core::SchemaReflection) — so calling the class method runs the adapter's transport-shaped reader, builds no axn projection, and validates nothing. Exactly the case that matters most, since a tool subclassing its adapter's base class is the ordinary shape of one.

Builds axn's projection here instead — the same build the reader performs, through the same one owner (Schema.build_input_for) — and validates it. The schema is discarded: setup wants the verdict. Deliberately NOT memoized, unlike the outbound verdict: nothing reads an inbound verdict later, and a memo would only make a second setup pass skip a check that costs one build.



99
100
101
102
# File 'lib/axn/internal/reflection/property_names.rb', line 99

def validate_inbound!(klass)
  validated_input(klass) { Schema.build_input_for(klass) }
  nil
end

.validate_outbound!(klass) ⇒ Object

For render, which needs the outbound verdict but has no schema of its own to hand over, so it would pay a whole build_output per call. That is the one place a memo earns its keep: rendering is a hot path (measured ~2x per render without it), and the verdict is established once per class.

The narrow consequence, stated rather than hidden: a caller that mutates a retained shape: graph after the first render is not re-validated HERE. It still is by output_schema, which validates every build, and the rendered body itself is still protected by the serializer's own runtime defenses (colliding exposed field names, colliding Hash keys). What is lost is only the earliest warning.

Keyed on the IDENTITY of the config arrays, exactly as _resolved_subfields keys its cache: those are copy-on-write, so every declaration mints new ones and a grown contract misses with no invalidation hook to keep in sync. A subclass holds its own ivars, so it never inherits a verdict. The verdict is recorded only after validation passes, so a failure raises again on every render rather than being swallowed.



117
118
119
120
121
122
123
124
125
# File 'lib/axn/internal/reflection/property_names.rb', line 117

def validate_outbound!(klass)
  configs = klass.external_field_configs
  # `equal?` on the CACHED value, so a nil cache is simply not equal rather than needing a guard.
  return nil if configs.equal?(klass.instance_variable_get(:@_axn_validated_outbound))

  validate_and_build(configs, direction: :output) { Schema.build_output(configs) }
  klass.instance_variable_set(:@_axn_validated_outbound, configs)
  nil
end

.validated_input(klass) ⇒ Object

THE TRIGGER SET, and the guarantee: the rules run before any of the three public paths that expose a JSON projection can return one — input_schema, output_schema, and Axn::Extensions::Serialization.render. That set is exhaustive as audited: those are the only public methods that build or emit property names (Axn::Result defines no to_h/as_json/to_json, and Schema.build_input/build_output are reached only through them). Adding a fourth projection path means adding it here, or the guarantee narrows silently.

For a TOOL axn, "first demanded" is made to happen at app setup: Axn::Tools.validate_contracts! projects every registered tool once, driven under Rails by config.after_initialize and config.to_prepare (see Axn::RailsIntegration::Engine), and called directly by a non-Rails app. That entry point documents exactly how wide its coverage is — it depends on an adapter being registered and on the tool being loaded — rather than implying it is total. Everything it does not reach falls back to first projection, which is where every non-tool axn is validated anyway. It projects through validate_inbound!/ validate_outbound! rather than through the two readers, because those NAMES may not be axn's: see validate_inbound!.

Nothing but a projection can be harmed by a colliding or unrenderable name: for an axn that never projects, two names that canonicalize alike stay two distinct fields with their own readers and validations, and the contract works. So validating on demand is not a weaker promise for those actions — it is the promise stated where it is true.

render is a trigger even though it builds no schema — the alternative is that a render-only adapter learns about a collision from the runtime serialize_exposed defense on a live call instead of at setup, and that defense is a last line rather than a substitute for telling the author. It is the ONE path whose verdict is memoized; see validate_outbound!.

A projection that is BUILT is validated, every time — the verdict is not memoized here. The schema is rebuilt on every call anyway (a caller may mutate the Hash it is handed, so it cannot be shared), and validating what was just built is what makes the guarantee exact: a caller that retains the mutable shape: Hash or members Array it declared with, and mutates it afterwards, changes what the schema emits without changing any config array — so an identity-keyed verdict would be stale and the new schema would come back unvalidated. Measured at ~16% of the input build and ~59% of the (much smaller) output build, against a build that had to happen regardless.

resolved: is the class's own cached subfield resolution — the artifact Schema.build_input_for nests properties from — so the size cap reads the emitter's tree rather than building a second one.



80
81
82
83
# File 'lib/axn/internal/reflection/property_names.rb', line 80

def validated_input(klass, &)
  validate_and_build(klass.internal_field_configs, klass.subfield_configs,
                     direction: :input, resolved: klass._resolved_subfields, &)
end

.validated_output(klass) ⇒ Object



85
86
87
# File 'lib/axn/internal/reflection/property_names.rb', line 85

def validated_output(klass, &)
  validate_and_build(klass.external_field_configs, direction: :output, &)
end