Module: Hecks::Bluebook::MetaValidator::Readings
- Included in:
- Judge, Reconstruction
- Defined in:
- lib/hecks/bluebook/meta_validator/readings.rb
Overview
The parts of a bluebook the walk cannot read by name alone.
The language now spells its fields exactly as the IR spells them, so the
judge reads almost everything straight through: command.givens,
value_object.invariants, read_model.aggregate_heads. What remains here
is not naming drift — it is places where the IR's SHAPE differs from the
language's, and no amount of renaming would close that:
transitions one declaration expands to SEVERAL rows, because `from`
may be a list of states
value_objects the IR holds objects ; the language holds their names
normalisations not on the bluebook at all — they come from the canonical
form table the expression grammar keeps
members plain hashes, one Member root per row, pairs per entry
lifecycle one IR object feeding two separate fields
Everything in this file is a difference in shape. If something here is only a difference in NAME, it is in the wrong file: rename the language.
Instance Method Summary collapse
-
#closed_set_size(node) ⇒ Object
Only a DECLARED closed set has a row count.
-
#declared_name(node) ⇒ Object
What the BLUEBOOK calls a node, whichever kind of thing the node is.
-
#encode_literal(value) ⇒ Object
A LITERAL, written so it can be read back exactly.
-
#field_value(category, node, field, parent_id) ⇒ Object
One field of a Declare payload.
- #filter_options(node) ⇒ Object
-
#identity_rows(node) ⇒ Object
AN IDENTITY IS A LIST OF PARTS, so it is offered one part at a time — the same way attributes and transitions are.
-
#mutation_rows(node) ⇒ Object
A mutation is ONE declaration, but the language's Change holds a single field/kind/source triple — and an append binds SEVERAL fields at once (
append: { name: :name, amount: :amount }). -
#normalisation_rows ⇒ Object
The normalisation table belongs to the expression grammar, not to any one bluebook — it is how the canonical form of a rule is spelled.
-
#normalisation_table(_node) ⇒ Object
The canonical-form table is the expression grammar's, not this chapter's, so the node is not consulted at all.
-
#option_rows(node, filters: false) ⇒ Object
EVERY SPECIFICATION OPTION AN ASK CARRIES, flattened to rows.
-
#pair_rows(map) ⇒ Object
An OPEN MAP — a member's fields, a dispatch's argument bindings — has no value object that can hold it, so each entry becomes its own row.
- #parts(option, held, at) ⇒ Object
-
#points_at(row, aggregate_id) ⇒ Object
Reference<Customer>is an IR ENCODING, not a domain fact. -
#read_model_option_rows(node) ⇒ Object
A read model carries the same options an ask does, plus its filters — see option_rows.
-
#reference_type(points_at_id) ⇒ Object
The way back out: an aggregate id becomes the type the IR spells.
-
#row_value(row, field) ⇒ Object
One value out of a row, named by the value object's field.
-
#rows_for(category, list_name, node) ⇒ Object
A list the walk is about to offer, as rows it can shape into dispatches.
-
#set_row(mutation) ⇒ Object
A set/increment/decrement reads one thing: a command argument, or a literal written into the bluebook.
-
#setter_value(category, node, target) ⇒ Object
What a setting command writes.
-
#through(node, object, member) ⇒ Object
One member of the object a field folds into.
-
#transition_rows(node) ⇒ Object
lifecycle :status do transition "Retire" => "retired", from: ["issued", "active"] endis ONE declaration and TWO transitions. -
#value_object_names(node) ⇒ Object
The language holds a value object's NAME here ; the IR holds the object.
-
#where_rows(node) ⇒ Object
A where-clause is read through its own to_h, which is where the IR spells a symbol argument as ":ceiling".
-
#with_spec_rows(node) ⇒ Object
Through to_h, which is where Bluebook.render_value spells a symbol argument as ":source".
Instance Method Details
#closed_set_size(node) ⇒ Object
Only a DECLARED closed set has a row count. An empty one is the defect,
so rows must stay absent rather than arrive as zero.
287 288 289 290 291 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 287 def closed_set_size(node) return nil unless node.respond_to?(:closed_set?) && node.closed_set? Array(node.members).size end |
#declared_name(node) ⇒ Object
What the BLUEBOOK calls a node, whichever kind of thing the node is.
This used to sniff — respond_to?(:hecks_name) ? … : node.name — because
only value objects had crossed over. Every construct answers now, so
there is nothing to choose between. It disappears entirely when the DSL
stops handing the judge nodes at all.
214 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 214 def declared_name(node) = node.hecks_name |
#encode_literal(value) ⇒ Object
A LITERAL, written so it can be read back exactly.
The language holds a default and a literal mutation source as text, and
to_s threw the type away: 0.0 came back "0.0", and { value: "good" }
came back its inspect string with nowhere to say it had been a hash. The
language already stores code as text — canonical: "cents >= 0" — so an
encoding is in keeping; it simply has to be SELF-DESCRIBING. That rule is
now Hecks::Literal's, stated once and shared with every other
to_h-bound literal field ; Shapes#decode_literal reads it back.
nil stays nil rather than becoming "nil": absent is a real answer here, and the language's own field is optional.
324 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 324 def encode_literal(value) = value.nil? ? nil : Literal.render(value) |
#field_value(category, node, field, parent_id) ⇒ Object
One field of a Declare payload. Mostly a reader of the same name — the exceptions are fields the IR keeps somewhere else, or not at all. READ FROM THE TABLE, not from a branch per category.
These were eight hand-written cases — Entity.owner, Member.shape, two
lifecycle members twice over, and three of a query's — each one restating
something Assembly::Contracts already declares. A parent pointer is
:parent there ; a folded field names the object and member it lives in.
So the exceptions are looked up rather than repeated, and a new fold is one
line in one file instead of two lines in two.
226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 226 def field_value(category, node, field, parent_id) return declared_name(node) if field == :name contract = Assembly.contract(category) # A setter names its target as a STRING and a Declare field arrives a Symbol, # so the lookup keys on a Symbol either way. The case statement this replaced # was type-blind because it interpolated ; a Hash is not. named = field.to_sym return parent_id if contract.kind_of(named) == :parent object, member = contract.folded(named) return through(node, object, member) if member # `limit` is a language field AND an object in the IR — `Array(an_object)` # wraps rather than destructures, so offering it stored # "#<struct LimitSpec value=3>". return node.limit&.to_h&.fetch(:value, nil) if "#{category}.#{field}" == "Query.limit" # `provenance from: {...}` is a HASH offered into a text field, and # handing it over raw let the runtime's own coercion spell it — which # meant Ruby's `Hash#to_s`, whose spelling changed under us between # 3.3 and 3.4. Encoded here, the same way `default:` already is and the # same way Shapes#provenance reads it back. return encode_literal(node.provenance) if field == :provenance # `identified_by` is no longer a FIELD of any declaration — it is a list, # filled by Identify one part at a time, so it is read through `identity_rows` # like every other list rather than special-cased here. What this branch # existed to protect is now structural : a path cannot come back as its head, # because there is nowhere left that holds only a head. node.respond_to?(field) ? node.public_send(field) : nil end |
#filter_options(node) ⇒ Object
134 135 136 137 138 139 140 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 134 def (node) { wheres: Array(node.wheres).map(&:to_h), order_by: node.order_by&.to_h, limit: node.limit&.to_h }.reject { |_, held| held.nil? || held == [] } end |
#identity_rows(node) ⇒ Object
AN IDENTITY IS A LIST OF PARTS, so it is offered one part at a time — the same way attributes and transitions are. The IR holds the paths ; the language holds a row per path, and the ORDER between them is the whole meaning, because the identity is their join.
49 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 49 def identity_rows(node) = node.identity_paths.map { |path| { value: path } } |
#mutation_rows(node) ⇒ Object
A mutation is ONE declaration, but the language's Change holds a single
field/kind/source triple — and an append binds SEVERAL fields at once
(append: { name: :name, amount: :amount }). So an append is offered
once per binding, and each one is judged.
The judge used to send field: v(""), kind: v("argument"), source: v("")
here — three stubbed values, so every rule about what a mutation reads
was being handed a blank and could never refuse.
156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 156 def mutation_rows(node) Array(node.mutations).flat_map do |mutation| # `:delegate` (CommandBuilder#delegates_to's own comment) rides # the SAME multi-binding shape `:append` does — `with: {...}` # is a field map, same as append's own `fields:`. next set_row(mutation) unless [:append, :delegate].include?(mutation.op) mutation.source.map do |field, argument| # Spelled the way Mutation#appended_fields spells it, because # Assembly::Marks reads this row back through the same reader it # reads that field with. `then_set :marks, append: { direction: # "out" }` binds a LITERAL, and storing it raw made it # indistinguishable from an argument called out. { target: mutation.target, op: mutation.op, field: field, kind: argument.is_a?(Symbol) ? "argument" : "literal", source: Literal.render(argument) } end end end |
#normalisation_rows ⇒ Object
The normalisation table belongs to the expression grammar, not to any one bluebook — it is how the canonical form of a rule is spelled. The language models it because a bluebook's rules are canonicalised on the way in.
189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 189 def normalisation_rows table = Expression::CanonicalForm.table return [] unless table table.map do |entry| { strategy: entry[:strategy], source_token: entry[:source_token], replacement: entry[:replacement], boundary: entry[:boundary], position: entry[:position] } end rescue StandardError # The table is a convenience of the Ruby side ; a bluebook that cannot # produce one is not malformed. [] end |
#normalisation_table(_node) ⇒ Object
The canonical-form table is the expression grammar's, not this chapter's, so the node is not consulted at all.
62 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 62 def normalisation_table(_node) = normalisation_rows |
#option_rows(node, filters: false) ⇒ Object
EVERY SPECIFICATION OPTION AN ASK CARRIES, flattened to rows.
offset, cursor, nulls, authorize and inspect_query are
five options, one compound (authorize names a policy AND a
tenant). extra_options_to_h already spells every one of them
and drops the absent ones, so this reads that rather than
naming them here — a sixth option needs no change on either
side.
filters: true adds a read model's wheres, order_by and limit —
at tells repeated rows apart, so two wheres do not collapse.
THE LANGUAGE MAY HOLD MORE THAN to_h CARRIES, and this is where that
mattered. Until 2026-08-11, ReadModel#to_h omitted all three —
extra_options_to_h rejects them by name, still does — so a read
model's filtering had never been in the wire contract, and I first
read that as a wall: if the wire cannot carry it, the language cannot
hold it, and a graph assembled from the language must lose it.
That was the wrong conclusion. to_h is a PROJECTION ; the language
is the SOURCE. They have to agree about everything
to_h spells, not about everything the language knows. Held as option
rows, the filters survived the round trip regardless of whether the
wire carried them too — which is exactly why, when a LATER task
(Rust read-model codegen) needed wheres/order_by/limit on the
wire for an unrelated reason, ReadModel#to_h could be extended to
spell them (the same mechanism Query#to_h already used) without
touching this method at all: this reads node.wheres/node. order_by/node.limit off the live object directly below
(filter_options), never off to_h, so the wire format moving did
not move this.
Named wheres, order_by and limit so they gather back into exactly the
declaration keys the assembly already reads.
120 121 122 123 124 125 126 127 128 129 130 131 132 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 120 def option_rows(node, filters: false) return [] unless node.respond_to?(:extra_options_to_h) spelled = node. spelled = (node).merge(spelled) if filters spelled.flat_map do |option, held| case held when Array then held.each_with_index.flat_map { |one, at| parts(option, one, at) } else parts(option, held, nil) end end end |
#pair_rows(map) ⇒ Object
An OPEN MAP — a member's fields, a dispatch's argument bindings — has no value object that can hold it, so each entry becomes its own row. This is why Member and Dispatch are roots in the language rather than lists.
82 83 84 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 82 def pair_rows(map) Array(map&.to_h).map { |key, value| { key: key, value: value } } end |
#parts(option, held, at) ⇒ Object
142 143 144 145 146 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 142 def parts(option, held, at) Hash(held).map do |key, value| { option: option.to_s, key: key.to_s, value: value, at: at&.to_s } end end |
#points_at(row, aggregate_id) ⇒ Object
Reference<Customer> is an IR ENCODING, not a domain fact. The fact is
that the attribute points at Customer's head — so the language is offered
that head's ID, and resolution does the rest. Encoding and decoding both
live here, because this is where the IR's shape differs from the
language's and nowhere else should know the spelling.
298 299 300 301 302 303 304 305 306 307 308 309 310 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 298 def points_at(row, aggregate_id) return nil unless row.reference? # THE CHAPTER THIS HEAD IS IN, AND THE HEAD IT POINTS AT — which is exactly # how an aggregate is identified, so it is built the same way rather than # spelled again with a separator of its own. This is dispatched as a REAL # REFERENCE VALUE (`Aggregate.Reference`'s `points_at:`), resolved by # `repository.find` against the target Aggregate-within-Meta record's OWN # stored id — so it MUST equal what that record's identity actually # derives, not a wire-format spelling. `reference_type`, below, is the # separate later reader that un-derives it back into "Reference<X>". Naming.identity([aggregate_id.split(Naming::IDENTITY_JOIN).first, row.type.target_name]) end |
#read_model_option_rows(node) ⇒ Object
A read model carries the same options an ask does, plus its filters — see option_rows.
58 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 58 def read_model_option_rows(node) = option_rows(node, filters: true) |
#reference_type(points_at_id) ⇒ Object
The way back out: an aggregate id becomes the type the IR spells. The
id is a JOIN of chapter + name (Naming::IDENTITY_JOIN, the same join
points_at built it with, not the "::" a real bluebook's own type
names never carry) ; the wire format wants only the bare name.
330 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 330 def reference_type(points_at_id) = "Reference<#{points_at_id.to_s.split(Naming::IDENTITY_JOIN).last}>" |
#row_value(row, field) ⇒ Object
One value out of a row, named by the value object's field.
333 334 335 336 337 338 339 340 341 342 343 344 345 346 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 333 def row_value(row, field) # A Hash FIRST. Hash answers to `key` (Hash#key(value)) and to `value` on # some rows, so asking respond_to? before checking for a Hash reads a # member pair through entirely the wrong method. return row[field] if row.is_a?(Hash) return row.public_send(field) if row.respond_to?(field) # A Struct answers to [] but RAISES for a member it does not have, so it # is read through to_h — a field the row simply lacks reads as absent. return row.to_h[field] if row.respond_to?(:to_h) && !row.is_a?(String) # A bare scalar row — `emits` is a list of event NAMES, and the # Announcement value object has to call that string something. row end |
#rows_for(category, list_name, node) ⇒ Object
A list the walk is about to offer, as rows it can shape into dispatches.
FROM THE TABLE. This was nine hand-written cases keyed "Category.list", and
every one of them was a fact Assembly::Contracts is the right place to
keep: which shaper turns this list into rows. A list with no shaper reads
straight off the node, which is most of them.
29 30 31 32 33 34 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 29 def rows_for(category, list_name, node) shaper = Assembly.contract(category).shaper(list_name) return Array(node.public_send(list_name)) unless shaper public_send(shaper, node) end |
#set_row(mutation) ⇒ Object
A set/increment/decrement reads one thing: a command argument, or a literal written into the bluebook.
178 179 180 181 182 183 184 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 178 def set_row(mutation) classified = mutation.to_h[:source] || {} [{ target: mutation.target, op: mutation.op, field: mutation.target, kind: classified[:kind], source: classified[:name] || encode_literal(classified[:value]) }] end |
#setter_value(category, node, target) ⇒ Object
What a setting command writes. A setter whose source is absent is not dispatched at all — ABSENT is not EMPTY, and offering "" would turn every "if you declare it, declare something" rule into "you must declare it".
274 275 276 277 278 279 280 281 282 283 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 274 def setter_value(category, node, target) # `rows` folds into `closed_set` and `members` between them, with no single # member to name, so it keeps its own reading — see Contract#folded. return closed_set_size(node) if "#{category}.#{target}" == "ValueObject.rows" object, member = Assembly.contract(category).folded(target.to_sym) return through(node, object, member) if member node.respond_to?(target) ? node.public_send(target) : nil end |
#through(node, object, member) ⇒ Object
One member of the object a field folds into. to_h first, because the
member names are the ones the IR SPELLS — a Lifecycle's default, an
OrderBy's direction — and reading the object raw is how a colon or a type
goes missing.
264 265 266 267 268 269 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 264 def through(node, object, member) held = node.respond_to?(object) ? node.public_send(object) : nil return nil unless held member == :transitions ? held : held.to_h[member] end |
#transition_rows(node) ⇒ Object
lifecycle :status do transition "Retire" => "retired", from: ["issued", "active"] end
is ONE declaration and TWO transitions. Offering it once would leave the
second unjudged, which is the whole failure this judge exists to avoid.
67 68 69 70 71 72 73 74 75 76 77 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 67 def transition_rows(node) lifecycle = node.respond_to?(:lifecycle) ? node.lifecycle : nil return [] unless lifecycle lifecycle.transitions.flat_map do |command, transition| froms = transition.constrained? ? Array(transition.from) : [nil] froms.map do |from| { command: command, from_state: from, to_state: transition.target } end end end |
#value_object_names(node) ⇒ Object
The language holds a value object's NAME here ; the IR holds the object.
43 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 43 def value_object_names(node) = node.value_objects.map { |shape| { name: shape.hecks_name } } |
#where_rows(node) ⇒ Object
A where-clause is read through its own to_h, which is where the IR spells a symbol argument as ":ceiling". Reading the OBJECT instead lost the colon, and nothing downstream could tell an argument from a literal of the same name.
40 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 40 def where_rows(node) = Array(node.wheres).map(&:to_h) |
#with_spec_rows(node) ⇒ Object
Through to_h, which is where Bluebook.render_value spells a symbol argument as ":source". The raw with_spec lost the colon, and a binding that reads an argument became indistinguishable from one carrying a literal string.
54 |
# File 'lib/hecks/bluebook/meta_validator/readings.rb', line 54 def with_spec_rows(node) = pair_rows(node.to_h[:with_spec]) |