hecks 1.0.0 → 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9d056aad218f4fe26394319c5ce486211ac9790022de8159ba23f050a634f5df
4
- data.tar.gz: d6fa0c2f693152be740b0c5f658008a6ba71ae9bd80e175e8da78b142f00f133
3
+ metadata.gz: 5d13701a926b6be040b496d5e582a9d4fd99ad878372f5345636ef074aa6e4e8
4
+ data.tar.gz: 95fc7593502393bd0d698aeaac1b9419dc6e00b78e12f11ad6740a2008a0df0e
5
5
  SHA512:
6
- metadata.gz: 4ae526f5a34b194a9b3d23751fae0bb6bdee1bcd8011653904d2b324bf4478ee7b4049d96f0b2a74d005a810ac7023d6efa5a8d0639fb079ed8691da93a5f217
7
- data.tar.gz: c2b1a7be12ebfbd52ad5cb13ddd1aa7693a7d903d1402c580baad40414a7f3318d7263379658debc502492986956f97b5377681ac93cf5cb36e058c3e1ccfb46
6
+ metadata.gz: 9b65062cd42566cb0f2e4d5c45b12b1990133850964e117a6827b917d5b293245855a292ccd698c12d982de268a1554029a7a079a03c2cd7e99148eba1f7af47
7
+ data.tar.gz: 9b98481d47cfcbd09e72e5d53226287fbed298d2d1ae77ebbd48b133c7fb9100be1531daca0be92728976ef11eb87979e5847306b4eed628308dfebfdf83014f
@@ -43,7 +43,15 @@ module Hecks
43
43
  vo = aggregate.value_object(attribute.type)
44
44
  return field.to_s unless vo
45
45
 
46
- member = vo.attributes.find { |a| %w[Integer Float].include?(a.type) }&.name || "value"
46
+ # Numeric member first, then the SOLE attribute whatever it is
47
+ # named (single-attribute value objects strictly answer `.value`
48
+ # — the same generalization `SqlQueryBuilder#query_expression`
49
+ # makes for the column side, kept in lockstep so Memory and SQL
50
+ # order the identical rows identically), and only then the bare
51
+ # `value` convention — now purely a backstop for the multi-field
52
+ # non-numeric shape neither rule can honestly pick a field for.
53
+ member = vo.attributes.find { |a| %w[Integer Float].include?(a.type) }&.name ||
54
+ vo.sole_attribute&.name || "value"
47
55
  "#{name}.#{member}"
48
56
  end
49
57
  end
@@ -151,7 +151,18 @@ module Hecks
151
151
  end
152
152
  member ||= if path.empty? && attribute && value_object?(attribute)
153
153
  object = @aggregate.value_object(attribute.type)
154
- Forms::ValueObjectShape.numeric_member(object)&.name
154
+ # A SOLE attribute is the fallback when no member is
155
+ # numeric — a single-attribute value object IS its one
156
+ # field whatever that field is named (`Behaviour::
157
+ # ValueObject#sole_attribute`, the same strict rule
158
+ # `Runtime::Value`'s own `.value` alias enforces), so
159
+ # `EmailAddress{address}` compiles to `$.address`
160
+ # rather than falling through to the dialects' own
161
+ # `member || "value"` convention and silently matching
162
+ # nothing. Multi-field non-numeric shapes still fall
163
+ # through to that convention, unchanged — there is no
164
+ # single field to honestly pick for them here either.
165
+ (Forms::ValueObjectShape.numeric_member(object) || object.sole_attribute)&.name
155
166
  end
156
167
  nested_expression(name, path, member)
157
168
  end
@@ -204,7 +215,7 @@ module Hecks
204
215
 
205
216
  object = @aggregate.value_object(attribute.type)
206
217
  return "" unless object
207
- return object.attributes.first.name.to_s if object.attributes.size == 1
218
+ return object.sole_attribute.name.to_s if object.sole_attribute
208
219
 
209
220
  raise ArgumentError,
210
221
  "#{dialect_name} query adapter cannot compile contains on #{field} — " \
@@ -17,13 +17,19 @@ module Hecks
17
17
 
18
18
  # A single-attribute value object (EmailAddress{address},
19
19
  # CustomerNumber{value}) is a NAME for a scalar, not a genuine
20
- # group — [[feedback_name_the_scalar_field]]. Four call sites
21
- # already inline this exact check (`attributes.size == 1` /
22
- # `attributes.first`) `forms/field_shape.rb` (twice),
23
- # `adapters/driven/sql_query_builder.rb`,
24
- # `fuzzing/invalid_value_generator.rb` kept as-is for now
25
- # (not migrated to this reader), but any NEW caller should
26
- # reach for this rather than add a fifth inline copy.
20
+ # group — [[feedback_name_the_scalar_field]]. `adapters/driven/
21
+ # sql_query_builder.rb` and `fuzzing/invalid_value_generator.rb`
22
+ # now read through this rather than inlining the check.
23
+ #
24
+ # `forms/field_shape.rb`'s own two `attributes.first.name` sites
25
+ # (`closed_set_options`/`closed_set_field`) look identical but are
26
+ # NOT the same question they pick a closed set's DISCRIMINANT
27
+ # column, and a closed set can be genuinely multi-attribute
28
+ # (`Runtime::Value::Admission#member_matches?`'s own comment
29
+ # names a real one: `StatementFrequency`'s `cadence`/
30
+ # `retention_months`/`paper_fee_cents`). `sole_attribute` would
31
+ # return `nil` for that shape and break the discriminant lookup —
32
+ # left as `.first` on purpose, not a missed migration.
27
33
  def sole_attribute
28
34
  attributes.first if attributes.size == 1
29
35
  end
@@ -242,8 +242,37 @@ module Hecks
242
242
  # into THIS aggregate's own `@value_objects`, the identical move
243
243
  # `@value_objects + closed_sets` already makes for the aggregate's
244
244
  # own direct attributes (see this file's other 5 call sites).
245
- def value_object(name, &block)
245
+ # `type` — THE BARE SHORTHAND (single-attribute value objects):
246
+ # `value_object :Price, Integer` declares a value object with
247
+ # exactly one attribute, NAMED `value`, of that type — pure sugar
248
+ # for `value_object("Price") { attribute :value, Integer }`,
249
+ # routed through the SAME `attribute_impl` the block form's own
250
+ # `attribute` line reaches (so the quoted-text-type refusal,
251
+ # `one_of(...)`/`list_of(...)` synthesis, everything an attribute
252
+ # line already does, applies unchanged rather than being
253
+ # re-derived here). The name `value` is not arbitrary: a
254
+ # single-attribute value object is a NAME for a scalar, not a
255
+ # genuine group ([[feedback_name_the_scalar_field]], `Behaviour::
256
+ # ValueObject#sole_attribute`), and `value` is what the language
257
+ # guarantees EVERY sole field answers to at runtime regardless of
258
+ # its declared name (`Runtime::Value#method_missing`'s alias) —
259
+ # so the shorthand simply declares it under the canonical name
260
+ # directly. Type AND block together are refused: the block exists
261
+ # to say what the fields are, and the type just said it — two
262
+ # answers to one question is an authoring error, never a merge.
263
+ # NEITHER type NOR block keeps its historical behavior untouched
264
+ # (an empty attribute list — judged, or not, by the language
265
+ # downstream, the same as before this parameter existed).
266
+ def value_object(name, type = nil, &block)
267
+ if type && block
268
+ raise Malformed,
269
+ "#{name} declares both a type (#{type.inspect}) and a block — " \
270
+ "value_object #{name.inspect}, Type is sugar for a block declaring " \
271
+ "exactly one attribute named :value; write one form or the other, never both"
272
+ end
273
+
246
274
  builder = ValueObjectBuilder.new(name, owner_value_objects: @value_objects + closed_sets)
275
+ builder.attribute_impl(:value, type) if type
247
276
  builder.instance_eval(&block) if block
248
277
  @value_objects << builder.build
249
278
  @value_objects.concat(builder.closed_sets)
@@ -554,6 +554,59 @@ module Hecks
554
554
  end
555
555
  refuse_unknown_state_sources!(mutation)
556
556
  end
557
+ # A SECOND, SEPARATE pass — not folded into the loop above — so
558
+ # every mutation's own self-referential import (resolve_bare_set!)
559
+ # has already landed before any mutation's SOURCE is checked
560
+ # against the final `attributes` list. `sets :a, to: :b` declared
561
+ # before `sets :b` (bare, importing :b from the owner) is real
562
+ # and legal; checking inline, mutation by mutation, would refuse
563
+ # it for an ordering accident this language has never required
564
+ # authors to avoid. rubocop's Style/CombinableLoops can't see
565
+ # that dependency — it only sees two `@mutations.each` calls
566
+ # back to back — so it's disabled here, not satisfied.
567
+ # rubocop:disable-next Style/CombinableLoops
568
+ @mutations.each { |mutation| refuse_unknown_argument_sources!(mutation) }
569
+ end
570
+
571
+ # THE ONES `resolve_source` (CommandRules::Arithmetic) ONLY EVER
572
+ # READS FROM `args` — never a fallback to the record's own current
573
+ # state the way `append`'s own per-field resolution legitimately
574
+ # can (`MutationApplier#resolve_append_source`'s own `instance
575
+ # [source]` fallback, a real, intentional second meaning this
576
+ # check must not foreclose). For exactly these five ops, a bare
577
+ # Symbol source has exactly one legitimate reading — the name of
578
+ # a declared argument — so anything else is unreachable at
579
+ # runtime, not merely optional: no caller can ever supply a value
580
+ # under a name the command never declared (ArgumentGate's own
581
+ # `refuse_unknown_arguments` already refuses that), so the source
582
+ # resolves to nil FOREVER, indistinguishable from a legitimately
583
+ # absent OPTIONAL argument until this check existed to tell them
584
+ # apart. Mirrors `AggregateBuilder#seal_query_argument`'s
585
+ # identical shape for a query's own where-clause argument —
586
+ # same mistake, one construct over.
587
+ CHECKED_SYMBOL_SOURCE_OPS = %i[set increment decrement multiply remove].freeze
588
+ private_constant :CHECKED_SYMBOL_SOURCE_OPS
589
+
590
+ def refuse_unknown_argument_sources!(mutation)
591
+ return unless CHECKED_SYMBOL_SOURCE_OPS.include?(mutation.op)
592
+ return unless mutation.source.is_a?(Symbol)
593
+ # The bare self-referential shape (`sets :field` alone, `source
594
+ # == target`) is `resolve_bare_set!`'s own territory, not this
595
+ # check's — when neither the command nor the owner declares
596
+ # that name, the MORE SPECIFIC, pre-existing refusal one level
597
+ # up (`AggregateBuilder#seal_mutation_targets`, checking the
598
+ # mutation's TARGET against the aggregate's own fields) is the
599
+ # one that should fire, naming the field as a target problem,
600
+ # not — confusingly — as a source problem this check would
601
+ # otherwise misreport it as.
602
+ return if mutation.source.to_s == mutation.target.to_s
603
+ return if attributes.any? { |attr| attr.name == mutation.source }
604
+
605
+ raise Malformed,
606
+ "#{@name}'s sets :#{mutation.target} resolves :#{mutation.source} from its " \
607
+ "arguments, but #{@name} declares no #{mutation.source} attribute — an " \
608
+ "argument that does not exist resolves to nil, always, never what the " \
609
+ "caller actually sent"
557
610
  end
558
611
 
559
612
  # `state(:name)` names one of the OWNER'S OWN fields — a snapshot
@@ -7,10 +7,28 @@ module Hecks
7
7
 
8
8
  include WordGate
9
9
 
10
- def initialize(name, owner: nil)
11
- @name = name
12
- @owner = owner
13
- @operations = []
10
+ # `legacy_bare_port:` ONLY `Hecks.port`'s own top-level method
11
+ # (lib/hecks.rb) passes `true`. `PortBuilder#build` never refused
12
+ # an empty build (no verb, no signal, nothing) — `Port.new(verb:
13
+ # nil, signal: :reply)` is a real, allowed shape dsl_spec.rb's own
14
+ # "a port" tests rely on (`signal`-only, no `verb` at all). The
15
+ # AGGREGATE-scoped (`BindingProxy#port`) and hecksagon-ROOT
16
+ # (`HecksagonBuilder#port_impl`) callers both reach this SAME
17
+ # class with `owner: nil` too when they're building the bare-verb
18
+ # shape (`port_impl`'s own root-level port can be EITHER shape,
19
+ # decided only after `build` returns) — so `owner.nil?` cannot be
20
+ # the discriminator between "old Hecks.port semantics" and "real
21
+ # DomainPort semantics"; those two callers correctly want the
22
+ # stricter "declares no verb and no operations" refusal `build`
23
+ # already raises below, unchanged. Only the literal top-level
24
+ # `.port` file caller wants the older, looser rule.
25
+ def initialize(name, owner: nil, legacy_bare_port: false)
26
+ @name = name
27
+ @owner = owner
28
+ @operations = []
29
+ @signal = :reply
30
+ @answers = []
31
+ @legacy_bare_port = legacy_bare_port
14
32
  end
15
33
 
16
34
  # WHAT THE OUTSIDE TELLS US — an external fact arriving, translated
@@ -58,18 +76,42 @@ module Hecks
58
76
  # coerce-and-assign with nothing else, now executed by
59
77
  # `GenericDispatch`.
60
78
 
79
+ # `Hecks.port "x" do verb "y"; signal :effect end`'s own two words,
80
+ # reachable here too — a bare-verb `DomainPortBuilder.build` falls
81
+ # back to the SAME `Port` object `PortBuilder` produces (`build`,
82
+ # below), so any `.port` file can migrate to being parsed by this
83
+ # builder with zero change to its own text, or to any caller that
84
+ # reads `.verb`/`.signal` off the `Port` it gets back. Ordinary
85
+ # `def`s, exactly like `PortBuilder`'s own — `WordGate`'s own
86
+ # header is explicit that a word answered this way never reaches
87
+ # its `method_missing`, so no new self-hosted grammar row is
88
+ # needed for either word under this context.
89
+ def verb(value) = @verb = value.to_s
90
+ def signal(value) = @signal = value.to_sym
91
+
92
+ # THE METHOD CONTRACT — `PortBuilder#answers`'s own twin, added
93
+ # here after the fact: a `.port` file migrated to parse through
94
+ # this builder (the repoint `lib/hecks.rb#port`'s own comment
95
+ # describes) can still declare one (`extraction.port`'s own
96
+ # `answers :canonical`, real, live corpus text) — this builder's
97
+ # bare-verb fallback needs to carry it through to the same `Port`
98
+ # object `PortBuilder` itself would have built, or the migration
99
+ # would silently drop a method-contract check for any `.port`
100
+ # file that uses this word.
101
+ def answers(name) = @answers << name.to_sym
102
+
61
103
  def build
62
104
  raise Malformed, "#{@name} declares both a verb and operations — a port is one or the other, not both" if @verb && !@operations.empty?
63
105
 
64
- return Port.new(name: @name, verb: @verb, signal: :reply) if @verb
106
+ return MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal, answers: @answers)) if @verb || (@legacy_bare_port && @operations.empty?)
65
107
 
66
108
  raise Malformed, "#{@name} declares no verb and no operations" if @operations.empty?
67
109
 
68
110
  DomainPort.new(name: @name, operations: @operations)
69
111
  end
70
112
 
71
- def self.build(name, owner: nil, &block)
72
- builder = new(name, owner: owner)
113
+ def self.build(name, owner: nil, legacy_bare_port: false, &block)
114
+ builder = new(name, owner: owner, legacy_bare_port: legacy_bare_port)
73
115
  builder.instance_eval(&block) if block
74
116
  builder.build
75
117
  end
@@ -0,0 +1,149 @@
1
+ require_relative "evaluator"
2
+ require_relative "resolver"
3
+
4
+ module Hecks
5
+ module Bluebook
6
+ module Expression
7
+ # ── AST → JSON — walks the REAL Evaluator/Resolver AST (the same
8
+ # objects a live dispatch parses `given`/`ensures`/invariant text
9
+ # into — see docs/implemented/guides/running-a-runtime.md's "The
10
+ # expression grammar") and emits plain, JSON-serializable Ruby
11
+ # Hashes, tagged by `"op"`.
12
+ #
13
+ # THE SAME TWO-METHOD WALK `rust/project/expr_emitter.rb`'s own
14
+ # `emit_bool`/`emit_resolver` already do, over the SAME AST — that
15
+ # file's own methods build RUST SOURCE-CODE STRINGS for `rust/
16
+ # project`'s codegen (`rust/src/kernel::expr::Expr` literals, baked
17
+ # into a generated domain's own compiled binary); this builds DATA
18
+ # instead, for a genuinely different consumer with a genuinely
19
+ # different constraint: `rust/host` can never link the `rust`
20
+ # (kernel) crate at all (a real, load-bearing build constraint —
21
+ # `reference_validate.rs`'s own header has the full reasoning: one
22
+ # path dependency would statically bake every domain's generated
23
+ # dispatch code into every Lambda binary), so a value object's own
24
+ # `invariant` predicate has to travel as something `rust/host` can
25
+ # deserialize and interpret itself, at RUNTIME, from `ir.json` — the
26
+ # exact same relationship `rust/project`'s own `Expr` literals
27
+ # already have to the compiled kernel, one layer further out.
28
+ #
29
+ # LIVES IN CORE `lib/hecks`, NOT `rust/project/` — `rust/project.rb`
30
+ # is a separate, downstream toolchain
31
+ # (`lib/hecks/projector.rb`'s own header: "a whole separate Ruby
32
+ # program"), never `require`d by core `lib/hecks/bluebook/*.rb`
33
+ # files (confirmed: no core file does). `value_object.rb`'s own
34
+ # `invariants:` IR emission needs this for EVERY domain's ordinary
35
+ # `to_h`/`ir.json` export — golden fixtures, `hecks-parse`'s parity
36
+ # comparisons, and any deploy artifact, not only a `bin/project_rust`
37
+ # run — so it belongs beside `Evaluator`/`Resolver` themselves, not
38
+ # bolted onto a tool that only sometimes runs.
39
+ #
40
+ # COMPLETE, not corpus-scoped: every node this grammar admits gets
41
+ # a real arm, the identical "raise, don't silently drop" discipline
42
+ # `expr_emitter.rb`'s own `emit_bool`/`emit_resolver` already hold
43
+ # to — even though, as of this writing, no real corpus VALUE OBJECT
44
+ # invariant exercises `Include`/`Modulo`/`BlockPredicate`/`Find`/
45
+ # `Array`/`MatchesRegex`/`Presence`/`Split`/`StartsWith`/`EndsWith`/
46
+ # `First`/`Last` (only `given`/`ensures` clauses do, elsewhere in
47
+ # the corpus — a different consumer of this same grammar).
48
+ # `rust/host/src/expr_json.rs`'s own header names exactly which of
49
+ # these its interpreter evaluates for real today versus refuses
50
+ # cleanly — a narrower, deliberate, documented boundary on the
51
+ # INTERPRETING side, not on this EMITTING side: an author is free
52
+ # to write ANY real expression in a value object's own `invariant`,
53
+ # and this always emits it faithfully; whether `rust/host` can yet
54
+ # CHECK it at mint time is that file's own question to answer, not
55
+ # this one's to pre-empt by refusing to even try.
56
+ module AstJson
57
+ module_function
58
+
59
+ def emit_predicate(canonical)
60
+ emit_bool(Evaluator.parse(canonical))
61
+ end
62
+
63
+ def emit_bool(node)
64
+ case node
65
+ when Evaluator::Or then { "op" => "or", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
66
+ when Evaluator::And then { "op" => "and", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
67
+ when Evaluator::Not then { "op" => "not", "expr" => emit_bool(node.node) }
68
+ when Evaluator::Compare
69
+ { "op" => "compare", "cmp" => emit_comparison(node.operator),
70
+ "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
71
+ when Evaluator::Include
72
+ emit_include(node)
73
+ when Evaluator::Resolve
74
+ emit_resolver(node.expr)
75
+ else
76
+ raise "unhandled evaluator node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_bool)"
77
+ end
78
+ end
79
+
80
+ def emit_comparison(op)
81
+ { "less_than" => op.compares_less_than, "equal" => op.compares_equal, "negated" => op.negated }
82
+ end
83
+
84
+ # The JSON-target sibling of `expr_emitter.rb`'s own
85
+ # `emit_include` — see that method's own comment for the full
86
+ # reasoning (a LITERAL array haystack has no `Expr::Include`-
87
+ # representable shape on EITHER target, kernel or host, so both
88
+ # rewrite it identically into an OR-of-equalities at emission
89
+ # time rather than carrying a shape neither interpreter could
90
+ # evaluate). A non-literal haystack still emits `include`
91
+ # unchanged.
92
+ EQ = Evaluator::OPERATORS.find { |op| op.symbol == "==" }
93
+ private_constant :EQ
94
+
95
+ def emit_include(node)
96
+ return { "op" => "include", "haystack" => emit_resolver(node.haystack), "needle" => emit_resolver(node.needle) } \
97
+ unless node.haystack.is_a?(Resolver::ArrayLiteral)
98
+
99
+ return { "op" => "bool", "value" => false } if node.haystack.elements.empty?
100
+
101
+ equalities = node.haystack.elements.map do |element|
102
+ { "op" => "compare", "cmp" => emit_comparison(EQ), "left" => emit_resolver(node.needle), "right" => emit_resolver(element) }
103
+ end
104
+ equalities.reduce { |left, right| { "op" => "or", "left" => left, "right" => right } }
105
+ end
106
+
107
+ def emit_resolver(node)
108
+ case node
109
+ when Resolver::IntegerLiteral then { "op" => "int", "value" => node.value }
110
+ when Resolver::FloatLiteral then { "op" => "float", "value" => node.value }
111
+ when Resolver::StringLiteral then { "op" => "str", "value" => node.value }
112
+ when Resolver::BoolLiteral then { "op" => "bool", "value" => node.value }
113
+ when Resolver::NilLiteral then { "op" => "nil" }
114
+ when Resolver::Lookup then { "op" => "lookup", "path" => node.path }
115
+ when Resolver::Addition then { "op" => "add", "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
116
+ when Resolver::SignTest
117
+ { "op" => "sign_test", "cmp" => emit_comparison(node.operator), "receiver" => emit_resolver(node.receiver) }
118
+ when Resolver::Empty then { "op" => "empty", "receiver" => emit_resolver(node.receiver) }
119
+ when Resolver::ToS then { "op" => "to_s", "receiver" => emit_resolver(node.receiver) }
120
+ when Resolver::Modulo then { "op" => "modulo", "receiver" => emit_resolver(node.receiver), "divisor" => emit_resolver(node.divisor) }
121
+ when Resolver::Size then { "op" => "size", "receiver" => emit_resolver(node.receiver) }
122
+ when Resolver::BlockPredicate
123
+ { "op" => "block_predicate", "mode" => node.mode.to_s, "receiver" => emit_resolver(node.receiver),
124
+ "param" => node.param.to_s, "predicate" => emit_bool(node.predicate) }
125
+ when Resolver::Find
126
+ { "op" => "find", "receiver" => emit_resolver(node.receiver), "param" => node.param.to_s,
127
+ "predicate" => emit_bool(node.predicate), "path" => node.path.map(&:to_s) }
128
+ when Resolver::ArrayLiteral
129
+ { "op" => "array", "elements" => node.elements.map { |element| emit_resolver(element) } }
130
+ when Resolver::MatchesRegex
131
+ { "op" => "matches_regex", "receiver" => emit_resolver(node.receiver), "pattern" => node.pattern, "flags" => node.flags }
132
+ when Resolver::Presence
133
+ { "op" => "presence", "receiver" => emit_resolver(node.receiver), "negated" => node.negated }
134
+ when Resolver::Split
135
+ { "op" => "split", "receiver" => emit_resolver(node.receiver), "separator" => node.separator }
136
+ when Resolver::StartsWith
137
+ { "op" => "starts_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
138
+ when Resolver::EndsWith
139
+ { "op" => "ends_with", "receiver" => emit_resolver(node.receiver), "substring" => node.substring }
140
+ when Resolver::First then { "op" => "first", "receiver" => emit_resolver(node.receiver) }
141
+ when Resolver::Last then { "op" => "last", "receiver" => emit_resolver(node.receiver) }
142
+ else
143
+ raise "unhandled resolver node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_resolver)"
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -744,9 +744,34 @@ module Hecks
744
744
  # `#to_h`), so this is safe for the existing `field.value`-
745
745
  # shaped dotted lookups too -- they already returned a raw
746
746
  # scalar and are unaffected.
747
+ # UPDATE (single-element value objects strictly answer `.value`):
748
+ # the unwrap used to gate on the sole key being literally NAMED
749
+ # `:value` — correct for the shorthand/closed-set shapes that
750
+ # motivated it, but a lie of omission for `Money{amount}` and
751
+ # every other single-field value object whose author picked a
752
+ # domain name for the field: the SAME "this VO IS its scalar"
753
+ # reading ([[feedback_name_the_scalar_field]], `Behaviour::
754
+ # ValueObject#sole_attribute`) applies regardless of what the
755
+ # sole field happens to be called, and the name gate made a bare
756
+ # `balance > 0` work for a `Balance{value}` while silently
757
+ # comparing a whole VO for a `Balance{amount}`. Now the COUNT is
758
+ # the gate, never the name. A declared `Runtime::Value` reads
759
+ # its own `sole_attribute` (the declaration's answer, not the
760
+ # stored hash's); any OTHER to_h-able (a Struct, a bespoke
761
+ # wrapper with no declaration to consult) keeps the original
762
+ # `{value: X}`-only unwrap, so nothing that never was a value
763
+ # object gains a surprise unwrapping. `rust/src/kernel/json.rs`'s
764
+ # `impl Fielded for Json` mirrors the count-only reading on the
765
+ # Rust side — change them in lockstep or rust_conformance
766
+ # diverges.
747
767
  def unwrap_scalar(value)
748
768
  return value unless value.respond_to?(:to_h) && !value.is_a?(Hash) && !value.is_a?(Array)
749
769
 
770
+ if value.respond_to?(:value_object)
771
+ sole = value.value_object.sole_attribute
772
+ return sole ? value[sole.name] : value
773
+ end
774
+
750
775
  hash = value.to_h
751
776
  hash.size == 1 && hash.key?(:value) ? hash[:value] : value
752
777
  end
@@ -11,3 +11,4 @@ end
11
11
  require_relative "expression/canonical_form"
12
12
  require_relative "expression/resolver"
13
13
  require_relative "expression/evaluator"
14
+ require_relative "expression/ast_json"
@@ -184,7 +184,21 @@ module Hecks
184
184
  # forever, since nothing about *entering* it via default implies
185
185
  # anything must eventually move it, unlike a state a transition
186
186
  # explicitly delivered somewhere.
187
- def terminal_exempt(_lifecycle) = []
187
+ #
188
+ # Scoped to `default` alone, and only when the lifecycle actually
189
+ # declares real transitions elsewhere: an EMPTY lifecycle (no
190
+ # transitions at all) doesn't get this exemption — that's not "a
191
+ # machine whose entry point deliberately awaits external action,"
192
+ # it's much more likely a lifecycle nobody finished wiring, and
193
+ # should still warn (see spec/fixtures/model_check/lifecycle_
194
+ # findings.bluebook's own Widget::Part, which stays warned on
195
+ # purpose).
196
+ def terminal_exempt(lifecycle)
197
+ return [] if lifecycle.transitions.empty?
198
+
199
+ outgoing_sources = lifecycle.transitions.flat_map { |_, t| Array(t.from) }.to_set
200
+ outgoing_sources.include?(lifecycle.default) ? [] : [lifecycle.default]
201
+ end
188
202
 
189
203
  # ── process managers / sagas ──────────────────────────────────────
190
204
 
@@ -16,6 +16,7 @@ module Hecks
16
16
 
17
17
  def initialize
18
18
  @entries = {}
19
+ @tenant_directories = Hash.new { |hash, key| hash[key] = [] }
19
20
  end
20
21
 
21
22
  def fetch(address) = entries.fetch(address.to_s)
@@ -25,6 +26,7 @@ module Hecks
25
26
 
26
27
  def register(bluebooks, registry, dispatcher, directory)
27
28
  bluebooks.each do |bluebook|
29
+ refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
28
30
  world = registry.world(bluebook.name)
29
31
  realm = world&.realm
30
32
  raise MissingRealm, "#{bluebook.name} in #{directory} has no world realm" if realm.to_s.empty?
@@ -62,6 +64,29 @@ module Hecks
62
64
 
63
65
  private
64
66
 
67
+ # THE ACTUAL "more than one tenant" MOMENT — Runtime::TenantCheck's
68
+ # own header names this table as the one place real multitenancy
69
+ # happens: the SAME on-disk directory (one domain, one
70
+ # `persisted_by` binding) registering a SECOND time, under a
71
+ # different realm, into this SAME shared route table. A directory's
72
+ # FIRST registration is never refused here — nothing shares its
73
+ # data yet, so a plain single-tenant deployment on an ordinary
74
+ # adapter (Postgres, no schema story) still boots exactly as
75
+ # before. Only the SECOND (and any later) registration of that
76
+ # same directory is refused, and refused before this call adds its
77
+ # routes to the table — so a leaking tenant's requests never
78
+ # become reachable through `Router#resolve` in the first place.
79
+ #
80
+ # Keyed on `directory` + `bluebook.name` only, not realm or
81
+ # `dispatcher` — two tenants share the identical on-disk domain,
82
+ # differing only by which realm/environment overlay booted it.
83
+ def refuse_unless_safe_for_second_tenant!(bluebook, registry, directory)
84
+ key = [directory, bluebook.name]
85
+ seen_before = @tenant_directories.key?(key) && @tenant_directories[key].any?
86
+ Runtime::TenantCheck.refuse_unless_tenant_capable!(registry, bluebook.name) if seen_before
87
+ @tenant_directories[key] << registry
88
+ end
89
+
65
90
  def current?(bluebook, world)
66
91
  bluebook.version.nil? || world.latest == bluebook.version
67
92
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "behaviour/value_object"
2
+ require_relative "expression/ast_json"
2
3
 
3
4
  module Hecks
4
5
  module Bluebook
@@ -28,7 +29,15 @@ module Hecks
28
29
  emits_ir(
29
30
  name: :hecks_name,
30
31
  attributes: many(:attributes),
31
- invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical } } },
32
+ # `ast:` a JSON-serializable rendering of the SAME predicate
33
+ # `canonical` already spells as text, alongside it rather than
34
+ # replacing it (`canonical` stays the human-facing/doctest-facing
35
+ # form; parsing it back would just re-derive what `ast` already
36
+ # states directly). Ground truth and the full reasoning:
37
+ # `Expression::AstJson`'s own header — built for `rust/host`'s
38
+ # own mint-time invariant check (`reference_validate.rs`), which
39
+ # has no kernel crate to parse `canonical` with.
40
+ invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical, ast: Expression::AstJson.emit_predicate(rule.canonical) } } },
32
41
  closed_set: :closed_set?,
33
42
  # THE FIELD NAME IS STRINGIFIED, NEVER THE VALUE. A `member` row can
34
43
  # hold any of the scalar types an attribute declares — `Integer 84`
@@ -67,11 +67,12 @@ module Hecks
67
67
  # scalar cannot stand for anything and the refusal has to say so.
68
68
  def scalar_for(attribute, aggregate, random:)
69
69
  value_object = aggregate.value_object(attribute.type.to_s)
70
- return "a bare scalar" unless value_object && value_object.attributes.size == 1
70
+ sole = value_object&.sole_attribute
71
+ return "a bare scalar" unless sole
71
72
 
72
73
  # One field, so a scalar is legal — corrupt the FIELD's own type instead,
73
74
  # which is still a shape the attribute cannot accept.
74
- { value_object.attributes.first.name.to_s => ["nested", "array"] }
75
+ { sole.name.to_s => ["nested", "array"] }
75
76
  end
76
77
 
77
78
  # An attribute the command never declared. `refuse_unknown_arguments` is a