hecks 0.3.0 → 1.0.1

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.
Files changed (202) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/d1.rb +38 -14
  3. data/lib/hecks/adapters/driven/governance_authorization.rb +41 -2
  4. data/lib/hecks/adapters/driven/heki/journal.rb +7 -2
  5. data/lib/hecks/adapters/driven/heki/saga_store.rb +0 -0
  6. data/lib/hecks/adapters/driven/heki/snapshot.rb +31 -4
  7. data/lib/hecks/adapters/driven/heki.rb +40 -9
  8. data/lib/hecks/adapters/driven/lambda.rb +16 -2
  9. data/lib/hecks/adapters/driven/postgres/codec.rb +8 -0
  10. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +45 -6
  11. data/lib/hecks/adapters/driven/postgres.rb +76 -29
  12. data/lib/hecks/adapters/driven/postgres_era.adapter +11 -0
  13. data/lib/hecks/adapters/driven/sql_query_builder.rb +1 -1
  14. data/lib/hecks/adapters/driven/sqlite/codec.rb +17 -0
  15. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  16. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  17. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  18. data/lib/hecks/adapters/driven.rb +19 -1
  19. data/lib/hecks/behaviors/dsl.rb +29 -0
  20. data/lib/hecks/behaviors/expectations.rb +62 -2
  21. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  22. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  23. data/lib/hecks/bluebook/assembly.rb +14 -1
  24. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  25. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  26. data/lib/hecks/bluebook/behaviour/value_object.rb +13 -7
  27. data/lib/hecks/bluebook/chapter.rb +21 -11
  28. data/lib/hecks/bluebook/command.rb +1 -1
  29. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  30. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  31. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  32. data/lib/hecks/bluebook/dsl/command_builder.rb +144 -3
  33. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +49 -7
  34. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  35. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  36. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  37. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  38. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  39. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  40. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  41. data/lib/hecks/bluebook/expression/ast_json.rb +149 -0
  42. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  43. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  44. data/lib/hecks/bluebook/expression/projection.json +48 -0
  45. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  46. data/lib/hecks/bluebook/expression.rb +1 -0
  47. data/lib/hecks/bluebook/hexagon.rb +1 -1
  48. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  49. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  50. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  51. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  52. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  53. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  54. data/lib/hecks/bluebook/model_check.rb +147 -6
  55. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  56. data/lib/hecks/bluebook/process_manager.rb +53 -11
  57. data/lib/hecks/bluebook/project_register.rb +25 -0
  58. data/lib/hecks/bluebook/value_object.rb +19 -2
  59. data/lib/hecks/doc/reference.rb +22 -1
  60. data/lib/hecks/facade/cli_door.rb +6 -3
  61. data/lib/hecks/facade/json_door.rb +16 -4
  62. data/lib/hecks/forms/app.rb +47 -6
  63. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  64. data/lib/hecks/forms/field_renderer.rb +11 -4
  65. data/lib/hecks/forms/html.rb +31 -0
  66. data/lib/hecks/forms/params.rb +30 -1
  67. data/lib/hecks/forms/port_argument.rb +46 -0
  68. data/lib/hecks/forms/record_renderer.rb +6 -2
  69. data/lib/hecks/forms/record_table.rb +6 -1
  70. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  71. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  72. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  73. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  74. data/lib/hecks/fuzzing/invalid_value_generator.rb +3 -2
  75. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  76. data/lib/hecks/fuzzing/properties.rb +52 -6
  77. data/lib/hecks/fuzzing/replay.rb +51 -18
  78. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  79. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  80. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  81. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  82. data/lib/hecks/grammar/evolve.rb +33 -0
  83. data/lib/hecks/grammar/expression.bluebook +8 -5
  84. data/lib/hecks/grammar/expression_operators.json +497 -1
  85. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  86. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  87. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  88. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  89. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  90. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  91. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  92. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  93. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  94. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  95. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  96. data/lib/hecks/language/hecksagon/domain_port.bluebook +25 -1
  97. data/lib/hecks/language/oidc.json +20 -0
  98. data/lib/hecks/language/port.bluebook +30 -2
  99. data/lib/hecks/naming.rb +54 -1
  100. data/lib/hecks/ports/access_control.port +7 -2
  101. data/lib/hecks/ports/access_control.rb +1 -1
  102. data/lib/hecks/ports/agent.port +6 -2
  103. data/lib/hecks/ports/agent.rb +1 -1
  104. data/lib/hecks/ports/authentication.port +4 -2
  105. data/lib/hecks/ports/authentication.rb +1 -1
  106. data/lib/hecks/ports/authorization.port +5 -2
  107. data/lib/hecks/ports/authorization.rb +14 -11
  108. data/lib/hecks/ports/clock.port +3 -2
  109. data/lib/hecks/ports/clock.rb +1 -1
  110. data/lib/hecks/ports/extraction.port +3 -2
  111. data/lib/hecks/ports/extraction.rb +1 -1
  112. data/lib/hecks/ports/identity_assignment.port +3 -2
  113. data/lib/hecks/ports/identity_assignment.rb +1 -1
  114. data/lib/hecks/ports/identity_generation.port +3 -2
  115. data/lib/hecks/ports/identity_generation.rb +1 -1
  116. data/lib/hecks/ports/identity_resolution.port +3 -2
  117. data/lib/hecks/ports/identity_resolution.rb +1 -1
  118. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  119. data/lib/hecks/ports/persistence/execution.rb +6 -1
  120. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  121. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  122. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  123. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  129. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  130. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  131. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  132. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  133. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  134. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  135. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  136. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  137. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  138. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  139. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  140. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  141. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  142. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  143. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  144. data/lib/hecks/ports/persistence.rb +1 -1
  145. data/lib/hecks/ports/projection.rb +20 -0
  146. data/lib/hecks/projections/diagrams.rb +230 -1
  147. data/lib/hecks/projections/shape.rb +15 -2
  148. data/lib/hecks/projector/cli_projector.rb +10 -1
  149. data/lib/hecks/projector/exporter.rb +19 -2
  150. data/lib/hecks/query_ir.rb +19 -0
  151. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  152. data/lib/hecks/query_specification/field_path.rb +20 -2
  153. data/lib/hecks/query_specification/hop_path.rb +7 -5
  154. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  155. data/lib/hecks/runtime/boot_gates.rb +41 -0
  156. data/lib/hecks/runtime/caller.rb +19 -3
  157. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  158. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  159. data/lib/hecks/runtime/command_interpreter.rb +165 -34
  160. data/lib/hecks/runtime/command_rules/admissibility.rb +101 -16
  161. data/lib/hecks/runtime/command_rules/arithmetic.rb +40 -4
  162. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  163. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  164. data/lib/hecks/runtime/dependency_planning.rb +66 -5
  165. data/lib/hecks/runtime/dispatcher.rb +19 -4
  166. data/lib/hecks/runtime/entity_interpreter.rb +92 -14
  167. data/lib/hecks/runtime/errors.rb +22 -0
  168. data/lib/hecks/runtime/identity.rb +30 -2
  169. data/lib/hecks/runtime/instance.rb +59 -4
  170. data/lib/hecks/runtime/interpreting.rb +21 -0
  171. data/lib/hecks/runtime/loader.rb +59 -18
  172. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  173. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  174. data/lib/hecks/runtime/read_model_interpreter.rb +89 -5
  175. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  176. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  177. data/lib/hecks/runtime/registry/verification.rb +88 -0
  178. data/lib/hecks/runtime/registry.rb +69 -8
  179. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  180. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  181. data/lib/hecks/runtime/value/admission.rb +19 -1
  182. data/lib/hecks/runtime/value/coercion.rb +75 -10
  183. data/lib/hecks/runtime.rb +17 -5
  184. data/lib/hecks/storehouse.rb +632 -0
  185. data/lib/hecks/version.rb +1 -1
  186. data/lib/hecks/vocabulary.rb +6 -1
  187. data/lib/hecks.rb +28 -3
  188. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  189. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  190. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  191. metadata +49 -37
  192. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  193. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  194. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  195. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  196. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  197. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  198. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  199. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  200. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  201. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  202. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -37,7 +37,7 @@ module Hecks
37
37
 
38
38
  def step(text, rule)
39
39
  case rule.strategy
40
- when "collapse_whitespace" then text.gsub(/\s+/, " ")
40
+ when "collapse_whitespace" then map_outside_strings(text) { |segment| segment.gsub(/\s+/, " ") }
41
41
  when "replace" then replace(text, rule)
42
42
  else
43
43
  raise ArgumentError, "#{rule.strategy.inspect} is not a linked normalisation strategy"
@@ -45,9 +45,77 @@ module Hecks
45
45
  end
46
46
 
47
47
  def replace(text, rule)
48
- return text.gsub(rule.source_token, rule.replacement) if rule.boundary == "none"
48
+ map_outside_strings(text) do |segment|
49
+ if rule.boundary == "none"
50
+ segment.gsub(rule.source_token, rule.replacement)
51
+ else
52
+ segment.gsub(/#{Regexp.escape(rule.source_token)}(?![[:alnum:]_])/, rule.replacement)
53
+ end
54
+ end
55
+ end
56
+
57
+ # Applies a normalisation rule to the text OUTSIDE quoted string
58
+ # literals only, copying every quoted run through byte-for-byte.
59
+ # Every rule here (collapse_whitespace, the `.length`→`.size` fold)
60
+ # used to run quote-blind — `"a b"` collapsed to `"a b"` and
61
+ # `"a.length"` folded to `"a.size"` just as readily as the real
62
+ # source outside the quotes, silently rewriting what a predicate
63
+ # compares a string attribute against, not merely how the
64
+ # predicate itself is spelled. A canonical string literal's
65
+ # CONTENTS are data, never syntax to normalise.
66
+ #
67
+ # Handles both `"` and `'` delimiters (this grammar's own
68
+ # `Resolver.quoted?` admits either), quote-aware exactly the way
69
+ # `Evaluator.top_level_index`/`Resolver.array_elements` already are
70
+ # elsewhere in this sublanguage. An unterminated quote (malformed
71
+ # input) is passed through raw rather than risk mangling it further.
72
+ def map_outside_strings(text)
73
+ result = +""
74
+ buffer = +""
75
+ quote = nil
76
+
77
+ text.each_char do |char|
78
+ if quote
79
+ buffer << char
80
+ if char == quote
81
+ result << buffer
82
+ buffer = +""
83
+ quote = nil
84
+ end
85
+ elsif ['"', "'"].include?(char)
86
+ result << yield(buffer)
87
+ # `char.dup`, not `char.to_s` (a no-op on a String — always
88
+ # returns self, never a copy) and not `+char` either
89
+ # (`String#+@` only dups a FROZEN receiver; `each_char`'s
90
+ # yielded strings aren't frozen, so `+char` is just as
91
+ # much a no-op here). Without a REAL copy, `buffer` and
92
+ # `quote` alias the same mutable object: the very next
93
+ # `buffer << char` grows `quote` right along with it, so
94
+ # `char == quote` can only ever compare a single character
95
+ # against an ever-lengthening string and never closes the
96
+ # literal — everything after a predicate's first quoted
97
+ # string silently skipped normalisation for the rest of
98
+ # the text, undetected because passing text through
99
+ # unnormalised is silent. MASKED by every existing spec
100
+ # here, which only checks that quoted CONTENTS survive
101
+ # untouched (the M7 fix this method exists for) — that
102
+ # still holds by accident once the bug makes the "outside"
103
+ # branch unreachable. Found live: a multi-line `given`/
104
+ # `ensures` block whose ONLY quoted literal closes before a
105
+ # later line — the newline and that later line's own
106
+ # indentation went uncollapsed, diverging from
107
+ # `hecks-parse`'s own (correct) single-space join.
108
+ # `spec/parser_parity_spec.rb`, roster's "a front-row seat
109
+ # takes a member of age".
110
+ buffer = char.dup
111
+ quote = char.dup
112
+ else
113
+ buffer << char
114
+ end
115
+ end
49
116
 
50
- text.gsub(/#{Regexp.escape(rule.source_token)}(?![[:alnum:]_])/, rule.replacement)
117
+ result << (quote ? buffer : yield(buffer))
118
+ result
51
119
  end
52
120
  end
53
121
  end
@@ -67,6 +67,23 @@ module Hecks
67
67
  left, right = split_top_level(expr, "&&")
68
68
  return And.new(left: parse(left), right: parse(right)) if left
69
69
 
70
+ # Tried BEFORE `.include?`/comparisons, not after — `!` negates
71
+ # the WHOLE boolean expression that follows it (`!names.include?(x)`
72
+ # means `!(names.include?(x))`, never "call .include? on the negated
73
+ # receiver"), so the leading marker has to be stripped and the
74
+ # remainder re-parsed before anything downstream gets a chance to
75
+ # mis-scan across it. It used to sit after `match_include`, whose
76
+ # naive `rindex(".include?(")` has no concept of a leading `!` —
77
+ # for `!names.include?(x)` it swallowed the `!` straight into the
78
+ # haystack text ("!names"), which `Resolver.parse` cannot resolve,
79
+ # so every spelling of negated membership raised instead of
80
+ # evaluating. Moving the check here fixes both the bare prefix
81
+ # (`!names.include?(x)`) and the parenthesized form
82
+ # (`!(names.include?(x))`) — the recursive `parse` call sees the
83
+ # clean remainder and correctly finds the `.include?` (or `&&`/`||`)
84
+ # inside it.
85
+ return Not.new(node: parse(Regexp.last_match(1))) if expr =~ /\A!(.+)\z/
86
+
70
87
  membership = match_include(expr)
71
88
  return Include.new(haystack: Resolver.parse(membership[0]), needle: Resolver.parse(membership[1])) if membership
72
89
 
@@ -75,8 +92,6 @@ module Hecks
75
92
  return Compare.new(operator: op, left: Resolver.parse(left), right: Resolver.parse(right)) if left
76
93
  end
77
94
 
78
- return Not.new(node: parse(Regexp.last_match(1))) if expr =~ /\A!(.+)\z/
79
-
80
95
  Resolve.new(expr: Resolver.parse(expr))
81
96
  end
82
97
 
@@ -145,11 +160,28 @@ module Hecks
145
160
  value.nil? ? "nil" : value.class.name
146
161
  end
147
162
 
163
+ # THE SAME MIS-SPLIT `Resolver.match_call` had (its own comment
164
+ # has the full story), found here too by the same generator: a
165
+ # `.include?` needle can itself be — or contain — ANOTHER
166
+ # `.include?` call (`"".include?(arr.all? { |el| "".include?("")
167
+ # }.to_s)`, a String built via `.to_s` off a block predicate
168
+ # whose own body happens to include one) — `rindex` finds the
169
+ # INNERMOST occurrence, not the outermost this split actually
170
+ # needs. Fixed identically: try each occurrence left to right,
171
+ # keep the first whose own balanced-paren match reaches the
172
+ # string's last character — `Resolver.matching_paren` is reused
173
+ # directly rather than duplicated, the same depth-tracking rule
174
+ # either grammar layer needs here.
148
175
  def match_include(expr)
149
- index = expr.rindex(".include?(")
150
- return nil unless index && expr.end_with?(")")
176
+ start = 0
177
+ marker = ".include?("
178
+ while (index = expr.index(marker, start))
179
+ close = Resolver.matching_paren(expr, index + marker.length)
180
+ return [expr[0...index], expr[(index + marker.length)...close]] if close == expr.length - 1
151
181
 
152
- [expr[0...index], expr[(index + ".include?(".length)...-1]]
182
+ start = index + 1
183
+ end
184
+ nil
153
185
  end
154
186
 
155
187
  # Declared the same way in Vocabulary::IncludeHaystack
@@ -243,9 +275,20 @@ module Hecks
243
275
  # built to close. `{`/`}` cannot legitimately appear inside
244
276
  # a quoted literal either, so this sits beside the existing
245
277
  # paren-depth branch, not instead of it.
246
- elsif char == "(" || char == "{"
278
+ #
279
+ # `[`/`]` -- the identical lesson a THIRD time (found live via
280
+ # the type-directed bounded-exhaustive expression generator,
281
+ # Phase 7 of the equivalence-gap plan): `Resolver::ArrayLiteral`
282
+ # (`[a, b]`) can appear as a general sub-expression, not only
283
+ # as `.include?`'s own haystack, the moment an array-typed
284
+ # attribute or a synthesized literal is embedded anywhere else
285
+ # -- and an element containing a top-level `+`/comparison of
286
+ # its own (`[0, 0 + 0]`) used to read as a split point for
287
+ # THIS expression's own boolean/comparison grammar, exactly
288
+ # the way an un-tracked `{`/`}` once did for block predicates.
289
+ elsif char == "(" || char == "{" || char == "["
247
290
  depth += 1
248
- elsif char == ")" || char == "}"
291
+ elsif char == ")" || char == "}" || char == "]"
249
292
  depth -= 1
250
293
  elsif depth.zero? && expr[index, operator.length] == operator
251
294
  return index if !block_given? || yield(index)
@@ -149,6 +149,54 @@
149
149
  "category": "enumeration",
150
150
  "precedence": 10,
151
151
  "arity": 2
152
+ },
153
+ {
154
+ "symbol": ".match?",
155
+ "category": "pattern_match",
156
+ "precedence": 7,
157
+ "arity": 2
158
+ },
159
+ {
160
+ "symbol": ".present?",
161
+ "category": "presence",
162
+ "precedence": 7,
163
+ "arity": 1
164
+ },
165
+ {
166
+ "symbol": ".blank?",
167
+ "category": "presence",
168
+ "precedence": 7,
169
+ "arity": 1
170
+ },
171
+ {
172
+ "symbol": ".split",
173
+ "category": "text",
174
+ "precedence": 7,
175
+ "arity": 2
176
+ },
177
+ {
178
+ "symbol": ".start_with?",
179
+ "category": "text",
180
+ "precedence": 7,
181
+ "arity": 2
182
+ },
183
+ {
184
+ "symbol": ".end_with?",
185
+ "category": "text",
186
+ "precedence": 7,
187
+ "arity": 2
188
+ },
189
+ {
190
+ "symbol": ".first",
191
+ "category": "positional",
192
+ "precedence": 7,
193
+ "arity": 1
194
+ },
195
+ {
196
+ "symbol": ".last",
197
+ "category": "positional",
198
+ "precedence": 7,
199
+ "arity": 1
152
200
  }
153
201
  ],
154
202
  "normalisations": [
@@ -58,6 +58,26 @@ module Hecks
58
58
  Size = Struct.new(:receiver, keyword_init: true)
59
59
  Lookup = Struct.new(:path, keyword_init: true)
60
60
 
61
+ # UPDATE 2026-08-27: every "vendored addition, not (yet) upstream
62
+ # hecks" comment on this file's own MatchesRegex/Presence/Split/
63
+ # First/Last/StartsWith/EndsWith below (plus ArrayLiteral above)
64
+ # described a REAL gap, found the hard way, in the history each
65
+ # comment tells — and that history stays exactly as written,
66
+ # on purpose. What changed is the PRESENT TENSE claim "not (yet)
67
+ # upstream": a review of this exact migration found these eight
68
+ # symbols had working Ruby parse/interpret arms but had NEVER
69
+ # gone through Propose -> Render -> Admit
70
+ # (lib/hecks/grammar/expression_operators.json) the way every
71
+ # other operator here has — a closed-vocabulary guard
72
+ # (spec/operator_conformance_spec.rb) built entirely over TABLES
73
+ # structurally could not see hand-coded Struct/parse/interpret
74
+ # additions, so eight operators ran in Ruby, admitted nowhere,
75
+ # invisible to the one guard whose whole job was "reads in every
76
+ # target." All eight are now ledger-admitted for real, with full
77
+ # Rust kernel parity (rust/src/kernel/expression_operators/
78
+ # {pattern_match,presence,text,positional}.rs) — the two-tier
79
+ # gap is closed, not merely tracked.
80
+ #
61
81
  # `receiver.match?(/pattern/)` -- vendored addition, not (yet)
62
82
  # upstream hecks (migration plan task 8): confirmed the
63
83
  # SINGLE most impactful corpus-wide dispatch-time gap of the
@@ -332,6 +352,15 @@ module Hecks
332
352
  options |= Regexp::EXTENDED if flags.include?("x")
333
353
 
334
354
  Regexp.new(pattern, options).match?(text)
355
+ rescue RegexpError => e
356
+ # M9: a malformed pattern between the slashes (an unclosed
357
+ # character class, say) is a defect in the EXPRESSION TEXT
358
+ # itself, exactly the same category of author mistake an
359
+ # unresolvable attribute name already refuses for — `Regexp.new`
360
+ # raising a raw `RegexpError` crossed this sublanguage's own
361
+ # refusal boundary the same way the `ZeroDivisionError`/
362
+ # `TypeError` cases elsewhere in this file did.
363
+ raise EvaluationError, "match? given an invalid pattern #{pattern.inspect} — #{e.message}"
335
364
  end
336
365
 
337
366
  # The elements of a bracketed literal, or nil if this isn't one.
@@ -392,9 +421,19 @@ module Hecks
392
421
  quote = nil if char == quote
393
422
  elsif ['"', "'"].include?(char)
394
423
  quote = char
395
- elsif ["(", "{"].include?(char)
424
+ # `[`/`]` -- the identical lesson this method's own `(`/`{`
425
+ # comment already names, a third time (found live via the
426
+ # type-directed bounded-exhaustive expression generator,
427
+ # Phase 7 of the equivalence-gap plan): `ArrayLiteral` can
428
+ # appear as a general sub-expression now, not only as
429
+ # `.include?`'s own haystack, so an array element containing
430
+ # its own top-level `+` (`[0, 0 + 0]`) used to read as THIS
431
+ # expression's own addition split point -- the whole
432
+ # receiver before `.all?`/`.any?`/etc. torn in half before
433
+ # `parse_block_opener` ever saw it as one atomic leaf.
434
+ elsif ["(", "{", "["].include?(char)
396
435
  depth += 1
397
- elsif [")", "}"].include?(char)
436
+ elsif [")", "}", "]"].include?(char)
398
437
  depth -= 1
399
438
  elsif char == "+" && depth.zero?
400
439
  return [expr[0...index].strip, expr[(index + 1)..].strip]
@@ -518,18 +557,109 @@ module Hecks
518
557
  Evaluator.apply(node.operator, number, 0)
519
558
  end
520
559
 
560
+ # FOUND LIVE via the type-directed bounded-exhaustive expression
561
+ # generator (Phase 7, equivalence-gap plan — spec/
562
+ # bounded_exhaustive_expression_spec.rb): `.modulo(`'s own
563
+ # argument position accepts any numeric sub-expression, including
564
+ # ANOTHER `.modulo(...)` call — `0.modulo(num_b.modulo(-1))` is
565
+ # perfectly well-typed — but `expr.rindex(marker)` finds the
566
+ # RIGHTMOST (innermost) `.modulo(` in the whole string, not the
567
+ # OUTERMOST one a nested call needs split at. For that expression
568
+ # it found the INNER `.modulo(` (inside `num_b.modulo(-1)`) and
569
+ # split there, producing a receiver of `"0.modulo(num_b"` and a
570
+ # divisor of `"-1)"` — both garbage, both re-parsed as bogus
571
+ # `Lookup` paths, both then refusing with "cannot resolve" — a
572
+ # SILENT MISPARSE that happened to fail safe into a real
573
+ # `EvaluationError` rather than a raw crash, which is exactly why
574
+ # this had gone unnoticed: nothing before this generator existed
575
+ # ever fed `.modulo` a nested `.modulo` call, random fuzzing
576
+ # essentially never manufactures that specific shape by chance,
577
+ # and the resulting refusal LOOKS like an ordinary, correct one
578
+ # unless you already know every name this generator's own
579
+ # synthetic state declares (real corpus authors would see this as
580
+ # a mysterious "cannot resolve" on text they never wrote).
581
+ #
582
+ # Fixed the same way `split_addition`/`Evaluator.top_level_index`
583
+ # already handle nested `(`/`{` elsewhere in this exact file:
584
+ # find the FIRST (leftmost, outermost) occurrence of the marker,
585
+ # then track paren/quote depth from there to find ITS OWN
586
+ # matching close — not just strip the string's own trailing `)`
587
+ # and hope it belongs to this call.
588
+ # Not just the FIRST occurrence, either — `.modulo` also CHAINS
589
+ # (`x.modulo(a).modulo(b)`, the receiver of the OUTER call itself
590
+ # ending in a `.modulo(...)` call), a second real shape the
591
+ # leftmost-occurrence-only version of this fix still mis-parsed:
592
+ # the first `.modulo(`'s own matching close paren lands mid-
593
+ # string (right after `a)`, before the second `.modulo(b)`), so
594
+ # it correctly fails the "reaches the end" check below and must
595
+ # be tried again at the NEXT occurrence rather than giving up.
596
+ # Trying occurrences strictly left to right and taking the FIRST
597
+ # one whose matching close reaches the string's last character
598
+ # handles both shapes with the same rule: for NESTING
599
+ # (`.modulo(x.modulo(y))`), the leftmost (outer) occurrence's own
600
+ # paren-depth tracking already walks straight through the inner
601
+ # call to the true final `)`; for CHAINING, the leftmost
602
+ # occurrence's close lands short and is rejected, so the next
603
+ # occurrence (the true outermost call) is tried instead.
521
604
  def match_call(expr, marker)
522
- index = expr.rindex(marker)
523
- return nil unless index && expr.end_with?(")")
605
+ start = 0
606
+ while (index = expr.index(marker, start))
607
+ close = matching_paren(expr, index + marker.length)
608
+ return [expr[0...index], expr[(index + marker.length)...close]] if close == expr.length - 1
524
609
 
525
- [expr[0...index], expr[(index + marker.length)...-1]]
610
+ start = index + 1
611
+ end
612
+ nil
526
613
  end
527
614
 
615
+ # `matching_brace` (resolver/block_predicates.rb)'s own twin, one
616
+ # bracket pair over: `start` is the index just past the OPENING
617
+ # `(` already consumed by the caller (depth starts at 1, not 0,
618
+ # for the same reason).
619
+ def matching_paren(expr, start)
620
+ depth = 1
621
+ quote = nil
622
+ index = start
623
+ while index < expr.length
624
+ char = expr[index]
625
+ if quote
626
+ quote = nil if char == quote
627
+ elsif ['"', "'"].include?(char)
628
+ quote = char
629
+ elsif char == "("
630
+ depth += 1
631
+ elsif char == ")"
632
+ depth -= 1
633
+ return index if depth.zero?
634
+ end
635
+ index += 1
636
+ end
637
+ nil
638
+ end
639
+
640
+ # Both operands are coerced to a real Integer/Float BEFORE the
641
+ # zero-check, and the check reads the COERCED divisor — not the
642
+ # raw `divisor_value` (which might not even respond to `.zero?`,
643
+ # a String for instance) and not a `.to_i`-truncated stand-in for
644
+ # it either. The old order checked a truncated `divisor.to_i`
645
+ # AFTER already validating the untruncated value wasn't zero, so
646
+ # a divisor merely small (`0.3`, truncating to `0`) sailed past
647
+ # the guard and then blew up `Integer#%` with a raw
648
+ # `ZeroDivisionError` the moment it reached zero anyway.
649
+ #
650
+ # The modulo itself is plain `%` on the coerced values, matching
651
+ # `add`'s own no-truncation precedent just above — Ruby's native
652
+ # `%` already handles every Integer/Float combination correctly
653
+ # (promoting to Float when either side is one), so rounding both
654
+ # operands down to Integer first was pure data loss with no
655
+ # purpose: `7.5.modulo(2.5)` silently became `7 % 2` (`1`)
656
+ # instead of the real `0.0`.
528
657
  def apply_modulo(receiver_value, divisor_value)
529
- divisor = require_number(divisor_value, "modulo")
658
+ receiver = require_number(receiver_value, "modulo")
659
+ divisor = require_number(divisor_value, "modulo")
530
660
  raise EvaluationError, "divided by 0" if divisor.zero?
531
661
 
532
- require_number(receiver_value, "modulo").to_i % divisor.to_i
662
+ receiver % divisor
533
663
  end
534
664
 
535
665
  def lookup(expr, state, attrs)
@@ -544,13 +674,34 @@ module Hecks
544
674
  # `found_of` (the `Find` node's own path projection) can walk a
545
675
  # `.find { ... }`-produced element the identical way `lookup`
546
676
  # walks a plain attribute path, rather than duplicating the
547
- # `value[segment.to_sym] || value[segment]` step twice in this
548
- # file.
677
+ # symbol-or-string key step twice in this file. `key?` decides
678
+ # which spelling answers — a bare `||` between the two would
679
+ # treat a genuinely-held `false` the same as an absent key and
680
+ # fall through to the other spelling, landing on `nil`.
549
681
  def walk_path(value, segments)
550
682
  segments.reduce(value) do |current, segment|
551
683
  break nil unless current.respond_to?(:[])
552
684
 
553
- current[segment.to_sym] || current[segment]
685
+ if current.is_a?(Hash)
686
+ sym = segment.to_sym
687
+ current.key?(sym) ? current[sym] : current[segment]
688
+ else
689
+ begin
690
+ current[segment]
691
+ rescue TypeError
692
+ # M9 (docs/audits/2026-08-10-main-bug-audit.md): a dotted
693
+ # path can walk onto an Array (e.g. the result of `.split`,
694
+ # or a `list_of` attribute) — Array#[] demands an
695
+ # Integer/Range and raises a raw TypeError for a String
696
+ # segment ("no implicit conversion of String into
697
+ # Integer"), which used to cross straight past this
698
+ # sublanguage's own refusal boundary and crash the
699
+ # runtime instead of reading as "this predicate doesn't
700
+ # apply here."
701
+ raise EvaluationError,
702
+ "cannot read #{segment.inspect} from #{describe(current)}"
703
+ end
704
+ end
554
705
  end
555
706
  end
556
707
 
@@ -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"
@@ -3,7 +3,7 @@ require_relative "../ir"
3
3
 
4
4
  module Hecks
5
5
  module Bluebook
6
- Port = Struct.new(:name, :verb, :signal, keyword_init: true) do
6
+ Port = Struct.new(:name, :verb, :signal, :answers, keyword_init: true) do
7
7
  def reply? = signal == :reply
8
8
  def effect? = signal == :effect
9
9
  end
@@ -25,10 +25,23 @@ module Hecks
25
25
  class Judge
26
26
  include Readings
27
27
 
28
- # Children offered BEFORE the parent's own lists. An attribute's type is
29
- # offered as the id of the thing it names, so both the value objects and the
30
- # entities have to exist before any attribute names one.
31
- EAGER_CHILDREN = { "Aggregate" => %w[Entity ValueObject] }.freeze
28
+ # Children offered BEFORE the parent's own lists, IN THIS ORDER. An
29
+ # attribute's type is offered as the id of the thing it names, so the
30
+ # value objects have to exist before anything that can name one — an
31
+ # aggregate's own attributes, AND an entity's own (M13: an entity is its
32
+ # own root, repeating the aggregate's whole shape one level down, so its
33
+ # attributes resolve against the SAME value-object pool). ValueObject
34
+ # first, Entity second, so an entity's own attributes are never offered
35
+ # before the value objects they may reference exist — a self-hosting
36
+ # casualty found live: the meta-grammar's own Handler/Dispatch/Member/
37
+ # Keyword/Argument entities (S17, ADR 0026) failed reference resolution
38
+ # on their own plain value-object-typed attributes (`HandlerText`,
39
+ # `MemberPosition`, ...) the moment entity attributes started being
40
+ # checked at all, because `@plan.names`' own (incidental) declaration
41
+ # order happened to walk Entity first. The order is stated here, not
42
+ # left to whatever order the plan's own category table iterates in —
43
+ # see `detail_node`'s own use of this constant, below.
44
+ EAGER_CHILDREN = { "Aggregate" => %w[ValueObject Entity] }.freeze
32
45
 
33
46
  # Categories an ENTITY declares as well as an aggregate. The IR reuses
34
47
  # Command and Query for a piece's own commands and queries, so the
@@ -157,13 +170,32 @@ module Hecks
157
170
  identity = extra.merge(node_identity(plan, category, node, index, parent_id))
158
171
  receiver ||= { aggregate: id, entities: [] }
159
172
  eager, later = children_of(category).partition { |child| eager?(category, child) }
173
+ # ORDERED AS `EAGER_CHILDREN` DECLARES, not as `children_of` happens to
174
+ # list them — `children_of` reads `@plan.names`, whose own order is an
175
+ # accident of which .bluebook file registered which category first,
176
+ # never a promise about which of two eager children exists before the
177
+ # other. `EAGER_CHILDREN`'s own array IS that promise (ValueObject
178
+ # before Entity), so the walk keeps only what this parent actually
179
+ # has, in the order the constant states — see that constant's own
180
+ # comment for the bug this exact reordering fixes.
181
+ eager = Array(EAGER_CHILDREN[category]) & eager
160
182
 
161
183
  eager.each { |child| walk_all(child, node, id, entity_child_extra(child, identity), receiver: receiver) }
162
184
  setters(plan, category, node, receiver)
163
- appends(plan, category, node, receiver)
185
+ # BEFORE `appends`, not after — the same reason `EAGER_CHILDREN`
186
+ # walks an aggregate's OWN entities before its OWN attributes
187
+ # (M13): a piece nested inside a piece (Handler's own
188
+ # `dispatches, list_of(Dispatch)` — S17, ADR 0026) must exist
189
+ # before this piece's own attribute list can reference it as a
190
+ # HELD entity, the same way `Account#ledger` needs Account's own
191
+ # entities walked eagerly. `nest_entities` is a no-op for every
192
+ # category but "Entity" (its own early return), so reordering it
193
+ # ahead of `appends` costs nothing for anything else that walks
194
+ # through here.
195
+ nest_entities(category, node, id, parent_id)
196
+ appends(plan, category, node, receiver, parent_id)
164
197
  later.each { |child| walk_all(child, node, id, entity_child_extra(child, identity), receiver: receiver) }
165
198
  within_entity(category, node, id, parent_id)
166
- nest_entities(category, node, id, parent_id)
167
199
  sealers(plan, category, receiver)
168
200
  end
169
201
 
@@ -353,8 +385,9 @@ module Hecks
353
385
  end
354
386
  end
355
387
 
356
- def appends(plan, category, node, receiver)
388
+ def appends(plan, category, node, receiver, parent_id)
357
389
  id = receiver[:entities].last || receiver[:aggregate]
390
+ owner_id = owning_aggregate_ref(category, id, parent_id)
358
391
  plan.appends.each do |list_name, append|
359
392
  rows_for(category, list_name, node).each_with_index do |row, index|
360
393
  chosen = append_for(category, list_name, append, row, node)
@@ -367,7 +400,7 @@ module Hecks
367
400
  v(index)
368
401
  else
369
402
  carried(@plan.category(category), chosen.verb, argument,
370
- cell(category, list_name, row, field, id, chosen))
403
+ cell(category, list_name, row, field, id, chosen, owner_id))
371
404
  end
372
405
  [argument.to_sym, value]
373
406
  end
@@ -378,21 +411,41 @@ module Hecks
378
411
  end
379
412
  end
380
413
 
414
+ # WHICH AGGREGATE OWNS THE VALUE OBJECTS an attribute's TYPE may
415
+ # resolve against. An aggregate owns its own — `id` already names
416
+ # it. An entity never has value objects of its own (Entity
417
+ # deliberately never answers `value_object` — see entity.rb's own
418
+ # comment on why); its attributes read the SAME pool its
419
+ # enclosing aggregate declares, one level up the construct tree
420
+ # no matter how many entities deep this attribute is nested —
421
+ # `parent_id` names it because `detail_node`/`nest_entities`
422
+ # thread the ROOT aggregate's id down as `parent_id` at every
423
+ # entity level, never the direct (possibly entity) parent.
424
+ def owning_aggregate_ref(category, id, parent_id)
425
+ category == "Entity" ? parent_id : id
426
+ end
427
+
381
428
  def sealers(plan, category, receiver)
382
429
  id = receiver[:entities].last || receiver[:aggregate]
383
430
  plan.sealers.each { |verb| send_to("Bluebook::#{verb_for(plan, verb)}", id, to: receiver) }
384
431
  end
385
432
 
386
- # An aggregate's attribute names its value object by TYPE, and the language
387
- # models that as a reference — so the type is offered as the value object's
388
- # own id. This is the rule "attributes must use value-object types",
389
- # enforced by reference resolution rather than by a predicate.
433
+ # An aggregate's or an entity's attribute names its value object by TYPE,
434
+ # and the language models that as a reference — so the type is offered as
435
+ # the value object's own id. This is the rule "attributes must use
436
+ # value-object types", enforced by reference resolution rather than by a
437
+ # predicate — for a HEAD's own attributes, aggregate or entity alike: an
438
+ # entity is its own root, repeating the aggregate's whole shape one level
439
+ # down (entity.rb's own words), and an undeclared type on an entity's
440
+ # attribute must fail the same reference resolution an aggregate's own
441
+ # does, not go unchecked because only "Aggregate.attributes" was ever
442
+ # asked.
390
443
  # An attribute's type is offered as the ID OF THE THING IT NAMES, so the
391
444
  # language resolves it as a reference and "the type is declared" costs no
392
445
  # predicate. Three kinds, three ids: a value object and an entity both hang
393
446
  # off this aggregate, so they share its prefix; another aggregate's head
394
447
  # hangs off the chapter.
395
- def cell(category, list_name, row, field, id, append)
448
+ def cell(category, list_name, row, field, id, append, aggregate_id)
396
449
  value = row_value(row, field)
397
450
  # A default keeps its TYPE by being written as a literal — 0.0 rather than
398
451
  # "0.0" — because the language holds it as text and text alone forgets.
@@ -400,13 +453,22 @@ module Hecks
400
453
  return value unless field == :type
401
454
  # A REFERENCE names another head WHEREVER it is written — on a head, on
402
455
  # a command, on a piece, on an ask — so it is offered as that head's
403
- # id in all four. Only the head's own attributes additionally qualify
456
+ # id in all four. Only a HEAD's own attributes additionally qualify
404
457
  # an ordinary type into a value object's id ; a command argument's
405
458
  # type is text and stays text.
406
459
  return points_at(row, id) if append.verb == "Reference"
407
- return value unless "#{category}.#{list_name}" == "Aggregate.attributes"
460
+ return value unless attribute_list?(category, list_name)
461
+
462
+ Naming.identity([owning_aggregate_id(aggregate_id, value), value])
463
+ end
408
464
 
409
- Naming.identity([owning_aggregate_id(id, value), value])
465
+ # A HEAD'S OWN ATTRIBUTES — an aggregate's, or an entity's (its own root,
466
+ # one level down). Every other "attributes" list belongs to something that
467
+ # is not a head at all (a command's arguments, a value object's own
468
+ # fields), and a type written there is a name, not a reference — the same
469
+ # distinction `cell`'s own comment draws.
470
+ def attribute_list?(category, list_name)
471
+ list_name.to_s == "attributes" && %w[Aggregate Entity].include?(category)
410
472
  end
411
473
 
412
474
  # `id` NAMES THE ATTRIBUTE'S OWN AGGREGATE, not necessarily the
@@ -40,6 +40,10 @@ module Hecks
40
40
  def judge!
41
41
  send_to("Port::Port.Declare", @port.name, name: v(@port.name),
42
42
  verb: v(@port.verb), signal: v(@port.signal))
43
+
44
+ Array(@port.answers).each do |answer|
45
+ send_to("Port::Port.AddAnswer", @port.name, name: @port.name, value: v(answer))
46
+ end
43
47
  end
44
48
  end
45
49
  end