hecks 0.3.0 → 1.0.0

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 (194) 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/sqlite/codec.rb +17 -0
  14. data/lib/hecks/adapters/driven/sqlite/projection.rb +76 -9
  15. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +17 -5
  16. data/lib/hecks/adapters/driven/sqlite.rb +30 -9
  17. data/lib/hecks/adapters/driven.rb +19 -1
  18. data/lib/hecks/behaviors/dsl.rb +29 -0
  19. data/lib/hecks/behaviors/expectations.rb +62 -2
  20. data/lib/hecks/bluebook/assembly/contracts.rb +36 -7
  21. data/lib/hecks/bluebook/assembly/marks.rb +4 -3
  22. data/lib/hecks/bluebook/assembly.rb +14 -1
  23. data/lib/hecks/bluebook/behaviour/lifecycle.rb +18 -1
  24. data/lib/hecks/bluebook/behaviour/process_manager.rb +14 -1
  25. data/lib/hecks/bluebook/chapter.rb +21 -11
  26. data/lib/hecks/bluebook/command.rb +1 -1
  27. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +117 -5
  28. data/lib/hecks/bluebook/dsl/attribute_collector.rb +21 -0
  29. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +71 -2
  30. data/lib/hecks/bluebook/dsl/command_builder.rb +91 -3
  31. data/lib/hecks/bluebook/dsl/entity_builder.rb +129 -4
  32. data/lib/hecks/bluebook/dsl/policy_builder.rb +18 -3
  33. data/lib/hecks/bluebook/dsl/port_builder.rb +12 -3
  34. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +109 -10
  35. data/lib/hecks/bluebook/dsl/rule_reference.rb +1 -0
  36. data/lib/hecks/bluebook/dsl/word_gate.rb +9 -2
  37. data/lib/hecks/bluebook/dsl/world_builder.rb +44 -4
  38. data/lib/hecks/bluebook/expression/canonical_form.rb +71 -3
  39. data/lib/hecks/bluebook/expression/evaluator.rb +50 -7
  40. data/lib/hecks/bluebook/expression/projection.json +48 -0
  41. data/lib/hecks/bluebook/expression/resolver.rb +161 -10
  42. data/lib/hecks/bluebook/hexagon.rb +1 -1
  43. data/lib/hecks/bluebook/meta_validator/judge.rb +78 -16
  44. data/lib/hecks/bluebook/meta_validator/port_judge.rb +4 -0
  45. data/lib/hecks/bluebook/meta_validator/readings.rb +14 -4
  46. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +42 -3
  47. data/lib/hecks/bluebook/meta_validator/shapes.rb +30 -10
  48. data/lib/hecks/bluebook/meta_validator.rb +103 -13
  49. data/lib/hecks/bluebook/model_check.rb +132 -5
  50. data/lib/hecks/bluebook/pattern_subset.rb +66 -2
  51. data/lib/hecks/bluebook/process_manager.rb +53 -11
  52. data/lib/hecks/bluebook/value_object.rb +9 -1
  53. data/lib/hecks/doc/reference.rb +22 -1
  54. data/lib/hecks/facade/cli_door.rb +6 -3
  55. data/lib/hecks/facade/json_door.rb +16 -4
  56. data/lib/hecks/forms/app.rb +47 -6
  57. data/lib/hecks/forms/command_form_renderer.rb +1 -1
  58. data/lib/hecks/forms/field_renderer.rb +11 -4
  59. data/lib/hecks/forms/html.rb +31 -0
  60. data/lib/hecks/forms/params.rb +30 -1
  61. data/lib/hecks/forms/port_argument.rb +46 -0
  62. data/lib/hecks/forms/record_renderer.rb +6 -2
  63. data/lib/hecks/forms/record_table.rb +6 -1
  64. data/lib/hecks/framework/bluebook/console_settings.bluebook +19 -19
  65. data/lib/hecks/framework/bluebook/governance.bluebook +26 -11
  66. data/lib/hecks/framework/bluebook/identity.bluebook +2 -2
  67. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  68. data/lib/hecks/fuzzing/isolated_boot.rb +212 -18
  69. data/lib/hecks/fuzzing/properties.rb +52 -6
  70. data/lib/hecks/fuzzing/replay.rb +51 -18
  71. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +28 -2
  72. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +71 -6
  73. data/lib/hecks/fuzzing/sequence_generator.rb +5 -4
  74. data/lib/hecks/fuzzing/value_generator.rb +25 -2
  75. data/lib/hecks/grammar/evolve.rb +33 -0
  76. data/lib/hecks/grammar/expression.bluebook +8 -5
  77. data/lib/hecks/grammar/expression_operators.json +497 -1
  78. data/lib/hecks/language/bluebook/aggregate.bluebook +12 -12
  79. data/lib/hecks/language/bluebook/bluebook.bluebook +3 -3
  80. data/lib/hecks/language/bluebook/command.bluebook +42 -8
  81. data/lib/hecks/language/bluebook/entity.bluebook +86 -10
  82. data/lib/hecks/language/bluebook/policy.bluebook +21 -4
  83. data/lib/hecks/language/bluebook/process_manager.bluebook +135 -18
  84. data/lib/hecks/language/bluebook/projection.bluebook +6 -6
  85. data/lib/hecks/language/bluebook/query.bluebook +4 -4
  86. data/lib/hecks/language/bluebook/shape.bluebook +6 -6
  87. data/lib/hecks/language/bluebook/syntax.bluebook +12 -11
  88. data/lib/hecks/language/bluebook/vocabulary.bluebook +22 -7
  89. data/lib/hecks/language/oidc.json +20 -0
  90. data/lib/hecks/language/port.bluebook +30 -2
  91. data/lib/hecks/naming.rb +54 -1
  92. data/lib/hecks/ports/access_control.port +7 -2
  93. data/lib/hecks/ports/access_control.rb +1 -1
  94. data/lib/hecks/ports/agent.port +6 -2
  95. data/lib/hecks/ports/agent.rb +1 -1
  96. data/lib/hecks/ports/authentication.port +4 -2
  97. data/lib/hecks/ports/authentication.rb +1 -1
  98. data/lib/hecks/ports/authorization.port +5 -2
  99. data/lib/hecks/ports/authorization.rb +14 -11
  100. data/lib/hecks/ports/clock.port +3 -2
  101. data/lib/hecks/ports/clock.rb +1 -1
  102. data/lib/hecks/ports/extraction.port +3 -2
  103. data/lib/hecks/ports/extraction.rb +1 -1
  104. data/lib/hecks/ports/identity_assignment.port +3 -2
  105. data/lib/hecks/ports/identity_assignment.rb +1 -1
  106. data/lib/hecks/ports/identity_generation.port +3 -2
  107. data/lib/hecks/ports/identity_generation.rb +1 -1
  108. data/lib/hecks/ports/identity_resolution.port +3 -2
  109. data/lib/hecks/ports/identity_resolution.rb +1 -1
  110. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  111. data/lib/hecks/ports/persistence/execution.rb +6 -1
  112. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  113. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_check.rb +41 -8
  114. data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard.rb +24 -56
  115. data/lib/hecks/ports/persistence/{lineage.rb → plugins/era/lineage.rb} +31 -4
  116. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/era_store.rb +3 -3
  117. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/head_compiler.rb +59 -10
  118. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/mint_transaction.rb +2 -2
  119. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/provisioning.rb +29 -1
  120. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/tail_merge.rb +11 -4
  121. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/transform_installer.rb +20 -0
  122. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage.rb +1 -1
  123. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/coverage_check.rb +5 -5
  124. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/era_resolver.rb +5 -2
  125. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/merge_coordinator.rb +2 -2
  126. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager/minter.rb +4 -4
  127. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage_manager.rb +2 -2
  128. data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era.rb +116 -29
  129. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/approval_digest.rb +1 -1
  130. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_one.rb +14 -5
  131. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/layer_two.rb +31 -6
  132. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit/unfed_report.rb +5 -1
  133. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/reattest.rb +3 -3
  134. data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/differ.rb +1 -1
  135. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  136. data/lib/hecks/ports/persistence.rb +1 -1
  137. data/lib/hecks/ports/projection.rb +20 -0
  138. data/lib/hecks/projections/diagrams.rb +230 -1
  139. data/lib/hecks/projections/shape.rb +15 -2
  140. data/lib/hecks/projector/cli_projector.rb +10 -1
  141. data/lib/hecks/projector/exporter.rb +19 -2
  142. data/lib/hecks/query_ir.rb +19 -0
  143. data/lib/hecks/query_specification/common/null_policy.rb +13 -1
  144. data/lib/hecks/query_specification/field_path.rb +20 -2
  145. data/lib/hecks/query_specification/hop_path.rb +7 -5
  146. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  147. data/lib/hecks/runtime/boot_gates.rb +41 -0
  148. data/lib/hecks/runtime/caller.rb +19 -3
  149. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +13 -2
  150. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -0
  151. data/lib/hecks/runtime/command_interpreter.rb +97 -13
  152. data/lib/hecks/runtime/command_rules/admissibility.rb +64 -14
  153. data/lib/hecks/runtime/command_rules/arithmetic.rb +7 -1
  154. data/lib/hecks/runtime/command_rules/authorization.rb +2 -1
  155. data/lib/hecks/runtime/command_rules/references.rb +27 -19
  156. data/lib/hecks/runtime/dependency_planning.rb +14 -0
  157. data/lib/hecks/runtime/dispatcher.rb +19 -4
  158. data/lib/hecks/runtime/entity_interpreter.rb +85 -14
  159. data/lib/hecks/runtime/errors.rb +22 -0
  160. data/lib/hecks/runtime/identity.rb +30 -2
  161. data/lib/hecks/runtime/instance.rb +59 -4
  162. data/lib/hecks/runtime/interpreting.rb +21 -0
  163. data/lib/hecks/runtime/loader.rb +59 -18
  164. data/lib/hecks/runtime/query_interpreter.rb +36 -4
  165. data/lib/hecks/runtime/reaction_invocation.rb +9 -1
  166. data/lib/hecks/runtime/read_model_interpreter.rb +76 -1
  167. data/lib/hecks/runtime/refusal_wording.rb +2 -0
  168. data/lib/hecks/runtime/registry/saga_persistence.rb +75 -3
  169. data/lib/hecks/runtime/registry/verification.rb +88 -0
  170. data/lib/hecks/runtime/registry.rb +69 -8
  171. data/lib/hecks/runtime/saga_interpreter.rb +215 -13
  172. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  173. data/lib/hecks/runtime/value/admission.rb +19 -1
  174. data/lib/hecks/runtime/value/coercion.rb +75 -10
  175. data/lib/hecks/runtime.rb +17 -5
  176. data/lib/hecks/storehouse.rb +632 -0
  177. data/lib/hecks/version.rb +1 -1
  178. data/lib/hecks/vocabulary.rb +6 -1
  179. data/lib/hecks.rb +7 -2
  180. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  181. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  182. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  183. metadata +48 -37
  184. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_guard/shape_diff.rb +0 -0
  185. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/era_tamper.rb +0 -0
  186. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/field_cache.rb +0 -0
  187. /data/lib/hecks/{adapters/driven → ports/persistence/plugins/era}/postgres_era/lineage/resumable_backfill.rb +0 -0
  188. /data/lib/hecks/{runtime → ports/persistence/plugins/era}/storage_shape.rb +0 -0
  189. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/audit.rb +0 -0
  190. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/rule_compiler.rb +0 -0
  191. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/renderer.rb +0 -0
  192. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold/writer.rb +0 -0
  193. /data/lib/hecks/{translation → ports/persistence/plugins/era/translation}/scaffold.rb +0 -0
  194. /data/lib/hecks/{translation.rb → ports/persistence/plugins/era/translation.rb} +0 -0
@@ -8,15 +8,24 @@ module Hecks
8
8
  include WordGate
9
9
 
10
10
  def initialize(name)
11
- @name = name
12
- @signal = :reply
11
+ @name = name
12
+ @signal = :reply
13
+ @answers = []
13
14
  end
14
15
 
15
16
  def verb(value) = @verb = value.to_s
16
17
  def signal(value) = @signal = value.to_sym
17
18
 
19
+ # THE METHOD CONTRACT — the fact a `.port` file's `verb`/`signal`
20
+ # never carried: what an adapter must actually RESPOND TO for a
21
+ # dispatch to reach it without a bare `NoMethodError`. Declared the
22
+ # same repeatable way `AdapterBuilder#field`/`#secret` already are,
23
+ # so `verify!` can check it with `respond_to?` at boot instead of
24
+ # the runtime discovering it live.
25
+ def answers(name) = @answers << name.to_sym
26
+
18
27
  def build
19
- MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal))
28
+ MetaValidator.call_port(Port.new(name: @name, verb: @verb, signal: @signal, answers: @answers))
20
29
  end
21
30
 
22
31
  def self.build(name, &block)
@@ -14,10 +14,38 @@ module Hecks
14
14
  @handlers = []
15
15
  end
16
16
 
17
- # `correlates_by`/`starts_on`/`ends_on` — item #13's full
18
- # metaprogrammed dispatch, slice 1 (whole-project table-
19
- # unification survey): each a bare, kind-driven coerce-and-
20
- # assign with nothing else, now executed by `GenericDispatch`.
17
+ # `starts_on Transfer::TransferRequested` — BARE CONSTANT
18
+ # ACCEPTED (ADR 0025, S6 "events first-class"), resolved
19
+ # through `ConstShim` the same way `on_impl`/`transition_impl`
20
+ # resolve an event reference but through `Naming.
21
+ # event_name_ref`, NOT `Naming.event_ref` (that method's own
22
+ # header has the full account: `SagaInterpreter` matches
23
+ # `pm.starts_on`/`pm.ends_on` against a BARE `event.name`, never
24
+ # a "." qualified one). A plain String still passes through
25
+ # unchanged, both for `shadow_parse` and for any corpus site a
26
+ # future pass hasn't migrated yet.
27
+ def starts_on_impl(event_ref)
28
+ @starts_on = Naming.event_name_ref(event_ref)
29
+ end
30
+
31
+ # `ends_on` — same reasoning as `starts_on_impl`, above.
32
+ def ends_on_impl(event_ref)
33
+ @ends_on = Naming.event_name_ref(event_ref)
34
+ end
35
+
36
+ # `correlates_by` — item #13's full metaprogrammed dispatch,
37
+ # slice 1 (whole-project table-unification survey): a bare,
38
+ # kind-driven coerce-and-assign with nothing else, executed by
39
+ # `GenericDispatch`.
40
+ #
41
+ # `starts_on`/`ends_on` USED TO be table-driven the same way,
42
+ # until ADR 0025 S6 gave each a second, `kind: "constant"`
43
+ # ArgumentSeed row (`starts_on Transfer::TransferRequested`) —
44
+ # `GenericDispatch::COERCE_BY_KIND` only knows `"text"`/
45
+ # `"symbol"`, and `shape_for` refuses to pick a `single_fill`
46
+ # shape at all once two Argument rows share a `fills:` target
47
+ # (`arguments.size == 1` below), so both words are hand-written
48
+ # again, `calls:`-routed like `transition` already is.
21
49
 
22
50
  # ONE STATE-MACHINE VOCABULARY (S7, ADR 0025 — "events and
23
51
  # reactions"): the SAME word `Lifecycle#transition` already
@@ -85,8 +113,25 @@ module Hecks
85
113
  handler.instance_eval(&block) if block
86
114
 
87
115
  mapping.each do |event_type, target|
116
+ # BARE CONSTANT ACCEPTED (ADR 0025, S6 — "events first-
117
+ # class"), `transition Account::AccountDebited => "state"` —
118
+ # `Naming.event_name_ref`, NOT the DOTTED `Naming.event_ref`
119
+ # transform `PolicyBuilder#on_impl` uses (that method's own
120
+ # header has the full account, found live wiring a real
121
+ # migrated corpus site into `bin/model_check` for the first
122
+ # time: `SagaInterpreter#advance_saga` matches `handler.
123
+ # event_type` against a BARE `event.name`, never a "."
124
+ # qualified one — a policy's own cross-aggregate match
125
+ # works differently, splitting the qualifier apart from the
126
+ # name rather than comparing the whole string). Writing the
127
+ # qualifier is still worth it (the same provenance `trigger
128
+ # Account::Debit` gives a reader) — `event_name_ref` keeps
129
+ # only the final segment, so `Account::AccountDebited` and a
130
+ # bare `AccountDebited` store identically. A plain String
131
+ # still passes through unchanged, both for `shadow_parse`
132
+ # and for every corpus site this pass didn't migrate.
88
133
  state_transition = StateTransition.new(target: target, from: from)
89
- expand(event_type.to_s, state_transition, handler.dispatches).each { |row| @handlers << row }
134
+ expand(Naming.event_name_ref(event_type), state_transition, handler.dispatches).each { |row| @handlers << row }
90
135
  end
91
136
  end
92
137
 
@@ -185,19 +230,73 @@ module Hecks
185
230
  #
186
231
  # RENAMED FROM `dispatch` — item #13's full metaprogrammed
187
232
  # dispatch (slice 4), same reasoning as trigger_impl above.
188
- def dispatch_impl(command_ref, with: nil)
233
+ #
234
+ # AN OPTIONAL BLOCK OPENS `compensates` ON THIS DISPATCH
235
+ # SPECIFICALLY — per-dispatch saga compensation, replacing a
236
+ # hand-written list at the saga's own `on :refused` leg. Real,
237
+ # live bug this closes: `examples/banking/bluebook/transfers_
238
+ # and_payments.bluebook`'s own `Settlement` saga wrote a
239
+ # compensating reversal by hand that depended on an event only
240
+ # fired if someone dispatched it manually — "the reversal was
241
+ # written and never armed" (that file's own comment). The
242
+ # runtime now tracks which legs actually completed
243
+ # (`SagaInterpreter`'s own `completed_compensations`) and
244
+ # compensates only those, newest first, instead of trusting an
245
+ # author's static list to be complete and correctly ordered.
246
+ def dispatch_impl(command_ref, with: nil, &block)
189
247
  if command_ref.is_a?(::String) && !MetaValidator.shadow_parsing?
190
248
  raise InvalidProcessManager,
191
249
  "dispatch #{command_ref.inspect} is quoted text — give the bare command constant " \
192
250
  "instead, e.g. dispatch Account::Debit"
193
251
  end
194
252
 
195
- DispatchSpec.new(
253
+ spec = DispatchSpec.new(
196
254
  command_name: Naming.command_ref(command_ref),
197
255
  with_spec: (with || {}).to_a
198
- ).tap do |dispatch|
199
- dispatch.instance_variable_set(:@projection_declared, !with.nil?)
200
- @dispatches << dispatch
256
+ )
257
+ spec.instance_variable_set(:@projection_declared, !with.nil?)
258
+
259
+ if block
260
+ builder = DispatchBuilder.new
261
+ builder.instance_eval(&block)
262
+ # `instance_variable_get`, not a public `compensates_spec`
263
+ # reader — a public one would be a method the grammar
264
+ # never declares, exactly what `spec/syntax_conformance_
265
+ # spec.rb`'s "answers only words the language declares"
266
+ # check exists to catch.
267
+ spec.compensates = builder.instance_variable_get(:@compensates_spec)
268
+ end
269
+
270
+ @dispatches << spec
271
+ spec
272
+ end
273
+
274
+ # THE NESTED SCOPE `dispatch ... do ... end` OPENS — one word
275
+ # only (`compensates`), the compensating half of the dispatch it
276
+ # sits inside. Its own `compensates_impl` builds a SECOND
277
+ # `DispatchSpec`, shape-identical to `HandlerBuilder#dispatch_
278
+ # impl`'s own — a compensation takes the exact same two
279
+ # arguments (a bare command constant, an optional `with:`)
280
+ # because it resolves through the identical scope (current
281
+ # event payload, opening event memory, correlation binding)
282
+ # any saga dispatch already does
283
+ # (`SagaInterpreter#dispatch_args`).
284
+ class DispatchBuilder
285
+ GRAMMAR_CONTEXT = "Dispatch"
286
+
287
+ include WordGate
288
+
289
+ def compensates_impl(command_ref, with: nil)
290
+ if command_ref.is_a?(::String) && !MetaValidator.shadow_parsing?
291
+ raise InvalidProcessManager,
292
+ "compensates #{command_ref.inspect} is quoted text — give the bare command constant " \
293
+ "instead, e.g. compensates Account::Credit"
294
+ end
295
+
296
+ @compensates_spec = DispatchSpec.new(
297
+ command_name: Naming.command_ref(command_ref),
298
+ with_spec: (with || {}).to_a
299
+ )
201
300
  end
202
301
  end
203
302
  end
@@ -129,6 +129,7 @@ module Hecks
129
129
  # the real table, every time, no exception.
130
130
  BOOTSTRAP_FALLBACK = {
131
131
  %w[given Aggregate] => { resolves_via: "owner_keyed", disambiguator: "declared_by" },
132
+ %w[given Entity] => { resolves_via: "owner_keyed", disambiguator: "declared_by" },
132
133
  %w[given Command] => { resolves_via: "hash_chain" },
133
134
  %w[invariant ValueObject] => { resolves_via: "sibling_scan" }
134
135
  }.freeze
@@ -110,8 +110,15 @@ module Hecks
110
110
  # below checks them in, for the same reason: `list_of`/
111
111
  # `one_of` used in an attribute's own type position never
112
112
  # arrive with `self.class::GRAMMAR_CONTEXT == "Type"`.
113
- target = fallback[[self.class::GRAMMAR_CONTEXT, word.to_s]] || fallback[["Type", word.to_s]]
114
- return send(target, *args, **kwargs, &block) if target
113
+ own_key = [self.class::GRAMMAR_CONTEXT, word.to_s]
114
+ type_key = ["Type", word.to_s]
115
+ # key? first, never `||` — a target method name is never
116
+ # actually stored as `false`, but the fallback lookup itself
117
+ # must not silently prefer "Type" over this class's own
118
+ # context on the strength of a falsy-looking value.
119
+ found = fallback.key?(own_key) || fallback.key?(type_key)
120
+ target = fallback.key?(own_key) ? fallback[own_key] : fallback[type_key]
121
+ return send(target, *args, **kwargs, &block) if found
115
122
 
116
123
  return super
117
124
  end
@@ -14,6 +14,30 @@ module Hecks
14
14
  def to_h = @values
15
15
  end
16
16
 
17
+ # THE AGGREGATE-QUALIFIED MIRROR (#143) — a `.world` file's own
18
+ # `Pizzas::Order.charged_by("Stripe") do ... end` visually mirrors the
19
+ # SAME bind line the sibling `.hecksagon` file already writes
20
+ # (`HecksagonBuilder`'s own `BindingProxy`), but `IR::World#for_verb`/
21
+ # `#for_binding` key purely by verb and adapter name — the aggregate
22
+ # qualifier is never read back out, it exists only for that visual
23
+ # mirroring. So unlike `BindingProxy`, nothing here needs to hold onto
24
+ # the resolved constant chain at all: every verb call, qualified or
25
+ # not, has to land in the exact same `@settings` write path
26
+ # (`WorldBuilder#record_binding`).
27
+ class WorldConstProxy
28
+ def self.namespace(builder)
29
+ Module.new do
30
+ define_singleton_method(:const_missing) { |_aggregate| WorldConstProxy.new(builder) }
31
+ end
32
+ end
33
+
34
+ def initialize(builder) = @builder = builder
35
+
36
+ def method_missing(verb, *args, **kwargs, &block) = @builder.record_binding(verb, args, kwargs, block)
37
+
38
+ def respond_to_missing?(_name, _include_private = false) = true
39
+ end
40
+
17
41
  class WorldBuilder
18
42
  GRAMMAR_CONTEXT = "World"
19
43
 
@@ -44,6 +68,17 @@ module Hecks
44
68
  result = word_gate_dispatch(verb, args, kwargs, block)
45
69
  return result unless result.equal?(WordGate::NOT_ADMITTED)
46
70
 
71
+ record_binding(verb, args, kwargs, block)
72
+ end
73
+
74
+ def respond_to_missing?(_name, _include_private = false) = true
75
+
76
+ # EXTRACTED from the old `method_missing` body (#143) so
77
+ # `WorldConstProxy`'s own aggregate-qualified verb calls
78
+ # (`Pizzas::Order.charged_by(...)`) write into the exact same
79
+ # place the bare top-level spelling (`charged_by(...)`) already
80
+ # does — one write path, two spellings.
81
+ def record_binding(verb, args, kwargs, block)
47
82
  collector = SettingsCollector.new
48
83
  collector.instance_eval(&block) if block
49
84
  value = { adapter: args.first.to_s }.merge(kwargs).merge(collector.to_h)
@@ -51,17 +86,22 @@ module Hecks
51
86
  @settings["#{verb}:#{args.first.to_s.downcase}"] = value
52
87
  end
53
88
 
54
- def respond_to_missing?(_name, _include_private = false) = true
55
-
56
89
  def build
57
90
  MetaValidator.call_world(
58
91
  World.new(domain: @domain, realm: @realm, latest: @latest, settings: @settings)
59
92
  )
60
93
  end
61
94
 
95
+ # `ConstShim`'s resolver, the same bridge `HecksagonBuilder`/
96
+ # `BluebookBuilder` already wrap their own `instance_eval` in
97
+ # (#143) — without it, `Pizzas::Order.charged_by(...)` raised
98
+ # `NameError: uninitialized constant Pizzas` for every `.world`
99
+ # file using the aggregate-qualified mirror form, since a bare
100
+ # `Pizzas` has no real constant to resolve to.
62
101
  def self.build(domain, &block)
63
- builder = new(domain)
64
- builder.instance_eval(&block) if block
102
+ builder = new(domain)
103
+ resolver = ->(_domain) { WorldConstProxy.namespace(builder) }
104
+ ConstShim.with(resolver) { builder.instance_eval(&block) } if block
65
105
  builder.build
66
106
  end
67
107
 
@@ -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": [