hecks 1.0.2 → 1.1.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 (239) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +3 -3
  3. data/lib/hecks/adapters/driven/d1.rb +48 -28
  4. data/lib/hecks/adapters/driven/folder.rb +10 -3
  5. data/lib/hecks/adapters/driven/google_authentication.rb +2 -4
  6. data/lib/hecks/adapters/driven/heki/journal.rb +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +3 -5
  8. data/lib/hecks/adapters/driven/in_memory_ordering.rb +4 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +2 -2
  10. data/lib/hecks/adapters/driven/local_storage.adapter +3 -0
  11. data/lib/hecks/adapters/driven/local_storage.rb +120 -0
  12. data/lib/hecks/adapters/driven/memory.rb +48 -3
  13. data/lib/hecks/adapters/driven/postgres/codec.rb +3 -1
  14. data/lib/hecks/adapters/driven/postgres/outbox.rb +97 -0
  15. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +3 -2
  16. data/lib/hecks/adapters/driven/postgres.rb +25 -14
  17. data/lib/hecks/adapters/driven/prism.rb +11 -0
  18. data/lib/hecks/adapters/driven/sql_query_builder.rb +45 -20
  19. data/lib/hecks/adapters/driven/sqlite/codec.rb +3 -1
  20. data/lib/hecks/adapters/driven/sqlite/projection.rb +17 -1
  21. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +23 -1
  22. data/lib/hecks/adapters/driven/sqlite.rb +87 -8
  23. data/lib/hecks/adapters/driven.rb +4 -0
  24. data/lib/hecks/adapters.rb +4 -0
  25. data/lib/hecks/behaviors/dsl.rb +7 -0
  26. data/lib/hecks/behaviors/expectations.rb +32 -4
  27. data/lib/hecks/behaviors/rspec.rb +2 -0
  28. data/lib/hecks/behaviors/runner.rb +4 -0
  29. data/lib/hecks/bluebook/aggregate.rb +6 -5
  30. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +4 -4
  31. data/lib/hecks/bluebook/assembly/build.rb +6 -3
  32. data/lib/hecks/bluebook/assembly/contract.rb +5 -1
  33. data/lib/hecks/bluebook/assembly/contracts.rb +4 -0
  34. data/lib/hecks/bluebook/assembly/marks.rb +2 -1
  35. data/lib/hecks/bluebook/assembly.rb +1 -3
  36. data/lib/hecks/bluebook/attribute.rb +6 -0
  37. data/lib/hecks/bluebook/behaviour/policy.rb +18 -0
  38. data/lib/hecks/bluebook/behaviour/process_manager.rb +17 -1
  39. data/lib/hecks/bluebook/behaviour/traits.rb +1 -1
  40. data/lib/hecks/bluebook/command.rb +6 -5
  41. data/lib/hecks/bluebook/dsl/adapter_builder.rb +4 -1
  42. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +524 -0
  43. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +19 -472
  44. data/lib/hecks/bluebook/dsl/attribute_collector.rb +14 -6
  45. data/lib/hecks/bluebook/dsl/binding_proxy.rb +5 -0
  46. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +848 -0
  47. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +25 -784
  48. data/lib/hecks/bluebook/dsl/command_builder.rb +53 -13
  49. data/lib/hecks/bluebook/dsl/const_shim.rb +6 -0
  50. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +20 -7
  51. data/lib/hecks/bluebook/dsl/entity_builder.rb +27 -6
  52. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +12 -2
  53. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +8 -1
  54. data/lib/hecks/bluebook/dsl/identity_declaration.rb +24 -12
  55. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +40 -1
  56. data/lib/hecks/bluebook/dsl/policy_builder.rb +10 -1
  57. data/lib/hecks/bluebook/dsl/port_builder.rb +6 -1
  58. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +33 -12
  59. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +56 -12
  60. data/lib/hecks/bluebook/dsl/query_builder.rb +7 -1
  61. data/lib/hecks/bluebook/dsl/read_model_builder.rb +11 -3
  62. data/lib/hecks/bluebook/dsl/rule_reference.rb +3 -1
  63. data/lib/hecks/bluebook/dsl/translation_builder.rb +13 -2
  64. data/lib/hecks/bluebook/dsl/value_object_builder.rb +8 -1
  65. data/lib/hecks/bluebook/dsl/word_gate.rb +5 -0
  66. data/lib/hecks/bluebook/dsl/world_builder.rb +13 -3
  67. data/lib/hecks/bluebook/entity.rb +5 -3
  68. data/lib/hecks/bluebook/expression/ast_json.rb +88 -9
  69. data/lib/hecks/bluebook/expression/ast_reader.rb +107 -0
  70. data/lib/hecks/bluebook/expression/canonical_form.rb +7 -0
  71. data/lib/hecks/bluebook/expression/evaluator.rb +64 -11
  72. data/lib/hecks/bluebook/expression/projection.json +12 -0
  73. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +4 -3
  74. data/lib/hecks/bluebook/expression/resolver.rb +83 -11
  75. data/lib/hecks/bluebook/expression.rb +1 -0
  76. data/lib/hecks/bluebook/hexagon.rb +10 -0
  77. data/lib/hecks/bluebook/lifecycle.rb +9 -0
  78. data/lib/hecks/bluebook/meta_validator/judge.rb +10 -4
  79. data/lib/hecks/bluebook/meta_validator/plan.rb +4 -0
  80. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +1 -1
  81. data/lib/hecks/bluebook/meta_validator/shapes.rb +3 -3
  82. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +7 -4
  83. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +42 -3
  84. data/lib/hecks/bluebook/meta_validator/world_judge.rb +1 -1
  85. data/lib/hecks/bluebook/meta_validator.rb +5 -5
  86. data/lib/hecks/bluebook/model_check.rb +119 -82
  87. data/lib/hecks/bluebook/pattern_subset.rb +9 -0
  88. data/lib/hecks/bluebook/policy.rb +2 -1
  89. data/lib/hecks/bluebook/process_manager.rb +6 -0
  90. data/lib/hecks/bluebook/project_register.rb +45 -25
  91. data/lib/hecks/bluebook/query.rb +4 -0
  92. data/lib/hecks/bluebook/read_model.rb +2 -2
  93. data/lib/hecks/bluebook/smoke_test.rb +5 -0
  94. data/lib/hecks/bluebook/synthesizer.rb +2 -1
  95. data/lib/hecks/bluebook/translation.rb +3 -0
  96. data/lib/hecks/bluebook/value_object.rb +2 -2
  97. data/lib/hecks/codemod.rb +13 -0
  98. data/lib/hecks/doc/reference.rb +17 -11
  99. data/lib/hecks/facade/cli_runner.rb +2 -2
  100. data/lib/hecks/facade/command_request.rb +11 -11
  101. data/lib/hecks/facade/handle.rb +3 -3
  102. data/lib/hecks/facade/surface/aggregate_door.rb +52 -38
  103. data/lib/hecks/facade.rb +4 -0
  104. data/lib/hecks/forms/app.rb +10 -6
  105. data/lib/hecks/forms/command_form_renderer.rb +3 -3
  106. data/lib/hecks/forms/field_renderer.rb +14 -9
  107. data/lib/hecks/forms/field_shape.rb +7 -2
  108. data/lib/hecks/forms/html.rb +5 -2
  109. data/lib/hecks/forms/index_renderer.rb +1 -1
  110. data/lib/hecks/forms/page.rb +7 -3
  111. data/lib/hecks/forms/query_form_renderer.rb +3 -2
  112. data/lib/hecks/forms/record_renderer.rb +22 -16
  113. data/lib/hecks/forms.rb +4 -0
  114. data/lib/hecks/fqn.rb +21 -9
  115. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +30 -8
  116. data/lib/hecks/fuzzing/invalid_value_generator.rb +0 -1
  117. data/lib/hecks/fuzzing/isolated_boot.rb +14 -6
  118. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +264 -0
  119. data/lib/hecks/fuzzing/properties/guards.rb +251 -0
  120. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +351 -0
  121. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +99 -0
  122. data/lib/hecks/fuzzing/properties/querying.rb +206 -0
  123. data/lib/hecks/fuzzing/properties.rb +41 -1065
  124. data/lib/hecks/fuzzing/replay.rb +60 -16
  125. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +6 -2
  126. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +2 -1
  127. data/lib/hecks/fuzzing/sequence_generator.rb +0 -1
  128. data/lib/hecks/fuzzing/value_generator.rb +2 -2
  129. data/lib/hecks/grammar/evolve.rb +34 -10
  130. data/lib/hecks/grammar/expression_operators.json +124 -0
  131. data/lib/hecks/grammar.rb +15 -5
  132. data/lib/hecks/ir.rb +9 -0
  133. data/lib/hecks/language/bluebook/aggregate.bluebook +4 -2
  134. data/lib/hecks/language/bluebook/command.bluebook +2 -1
  135. data/lib/hecks/language/bluebook/entity.bluebook +2 -1
  136. data/lib/hecks/language/bluebook/projection.bluebook +2 -1
  137. data/lib/hecks/language/bluebook/query.bluebook +4 -2
  138. data/lib/hecks/language/bluebook/shape.bluebook +2 -1
  139. data/lib/hecks/language/bluebook/vocabulary.bluebook +2 -0
  140. data/lib/hecks/literal.rb +18 -8
  141. data/lib/hecks/naming.rb +6 -3
  142. data/lib/hecks/ports/access_control.rb +1 -1
  143. data/lib/hecks/ports/agent.rb +5 -3
  144. data/lib/hecks/ports/authentication.rb +3 -2
  145. data/lib/hecks/ports/authorization.rb +1 -1
  146. data/lib/hecks/ports/clock.rb +1 -1
  147. data/lib/hecks/ports/extraction.rb +5 -1
  148. data/lib/hecks/ports/identity_assignment.rb +1 -1
  149. data/lib/hecks/ports/identity_generation.rb +1 -1
  150. data/lib/hecks/ports/identity_resolution.rb +1 -1
  151. data/lib/hecks/ports/loading.rb +4 -1
  152. data/lib/hecks/ports/persistence/append_only.rb +40 -4
  153. data/lib/hecks/ports/persistence/binding_policy.rb +1 -1
  154. data/lib/hecks/ports/persistence/plugin.rb +3 -0
  155. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +3 -1
  156. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +5 -5
  157. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +14 -5
  158. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +4 -0
  159. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +1 -1
  160. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +80 -16
  161. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +23 -6
  162. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +43 -5
  163. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +25 -4
  164. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +49 -9
  165. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +2 -2
  166. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +10 -0
  167. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +49 -38
  168. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +23 -13
  169. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +19 -21
  170. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +27 -9
  171. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +9 -0
  172. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +25 -10
  173. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +2 -6
  174. data/lib/hecks/ports/persistence/plugins/era.rb +3 -1
  175. data/lib/hecks/ports/persistence/remote_runtime.rb +1 -1
  176. data/lib/hecks/ports/persistence.rb +7 -3
  177. data/lib/hecks/ports/projection.rb +13 -5
  178. data/lib/hecks/ports/query/in_memory.rb +8 -2
  179. data/lib/hecks/ports/query.rb +1 -3
  180. data/lib/hecks/ports.rb +2 -0
  181. data/lib/hecks/projections/diagrams.rb +11 -3
  182. data/lib/hecks/projections/ir.rb +2 -0
  183. data/lib/hecks/projections/model/deviations.rb +2 -1
  184. data/lib/hecks/projections/model.rb +15 -10
  185. data/lib/hecks/projections/vocabulary.rb +1 -3
  186. data/lib/hecks/projector/cli_projector.rb +49 -29
  187. data/lib/hecks/projector/docs_projector.rb +21 -12
  188. data/lib/hecks/projector/exporter.rb +10 -1
  189. data/lib/hecks/projector/narrate_projector.rb +83 -25
  190. data/lib/hecks/projector.rb +2 -2
  191. data/lib/hecks/query_ir.rb +60 -36
  192. data/lib/hecks/query_specification/common/authorization_spec.rb +1 -1
  193. data/lib/hecks/query_specification/common/comparators.rb +4 -0
  194. data/lib/hecks/query_specification/common/comparison.rb +8 -1
  195. data/lib/hecks/query_specification/common/dsl.rb +5 -0
  196. data/lib/hecks/query_specification/common/null_policy.rb +6 -2
  197. data/lib/hecks/query_specification/common/options.rb +17 -5
  198. data/lib/hecks/query_specification/field_path.rb +4 -4
  199. data/lib/hecks/query_specification/read_model/specification.rb +5 -2
  200. data/lib/hecks/router/namespace_installer.rb +22 -10
  201. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +57 -22
  202. data/lib/hecks/runtime/command_interpreter.rb +104 -65
  203. data/lib/hecks/runtime/command_rules/admissibility.rb +26 -8
  204. data/lib/hecks/runtime/command_rules/arithmetic.rb +35 -14
  205. data/lib/hecks/runtime/dependency_planning.rb +33 -21
  206. data/lib/hecks/runtime/dispatcher.rb +41 -7
  207. data/lib/hecks/runtime/entity_element.rb +25 -6
  208. data/lib/hecks/runtime/entity_interpreter.rb +28 -15
  209. data/lib/hecks/runtime/errors.rb +28 -2
  210. data/lib/hecks/runtime/instance.rb +9 -3
  211. data/lib/hecks/runtime/interpreting.rb +29 -2
  212. data/lib/hecks/runtime/loader.rb +21 -0
  213. data/lib/hecks/runtime/outbox.rb +365 -0
  214. data/lib/hecks/runtime/policy_interpreter.rb +32 -17
  215. data/lib/hecks/runtime/query_interpreter.rb +36 -16
  216. data/lib/hecks/runtime/reaction_invocation.rb +35 -22
  217. data/lib/hecks/runtime/read_model_interpreter.rb +26 -3
  218. data/lib/hecks/runtime/reference_hop.rb +1 -1
  219. data/lib/hecks/runtime/refusal_wording.rb +55 -49
  220. data/lib/hecks/runtime/registry/saga_persistence.rb +3 -1
  221. data/lib/hecks/runtime/registry/verification.rb +39 -2
  222. data/lib/hecks/runtime/registry.rb +21 -11
  223. data/lib/hecks/runtime/remote_dispatcher.rb +3 -1
  224. data/lib/hecks/runtime/routing.rb +52 -39
  225. data/lib/hecks/runtime/saga_interpreter/correlation.rb +7 -7
  226. data/lib/hecks/runtime/saga_interpreter.rb +119 -90
  227. data/lib/hecks/runtime/saga_pending_dispatch.rb +6 -3
  228. data/lib/hecks/runtime/tenant_scope.rb +7 -0
  229. data/lib/hecks/runtime/value/coercion.rb +207 -48
  230. data/lib/hecks/runtime/value.rb +1 -1
  231. data/lib/hecks/runtime.rb +7 -2
  232. data/lib/hecks/storehouse.rb +15 -8
  233. data/lib/hecks/version.rb +1 -1
  234. data/lib/hecks/vocabulary.rb +1 -0
  235. data/lib/hecks.rb +17 -13
  236. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +1 -1
  237. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +1 -1
  238. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +1 -1
  239. metadata +14 -2
@@ -2,8 +2,15 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # Parses a `process_manager "Name" do ... end` block into a
6
+ # `ProcessManager` — its own `starts_on`/`ends_on` events, what
7
+ # correlates its instances (`correlates_by`), and the `transition`-
8
+ # declared state machine whose `dispatch`es (each optionally paired
9
+ # with its own per-dispatch `compensates`) become its handlers. States
10
+ # are DERIVED from the transitions rather than declared separately
11
+ # (S7, ADR 0025).
5
12
  class ProcessManagerBuilder
6
- GRAMMAR_CONTEXT = "ProcessManager"
13
+ GRAMMAR_CONTEXT = "ProcessManager".freeze
7
14
 
8
15
  class InvalidProcessManager < StandardError; end
9
16
 
@@ -189,8 +196,10 @@ module Hecks
189
196
  end
190
197
 
191
198
  def validate!
192
- raise InvalidProcessManager, "#{@name} declares no correlates_by — " \
193
- "nothing would tie its events to one instance" unless @correlates_by
199
+ unless @correlates_by
200
+ raise InvalidProcessManager, "#{@name} declares no correlates_by " \
201
+ "nothing would tie its events to one instance"
202
+ end
194
203
 
195
204
  # THE FIELD, NAMED — never the value object that carries it. A bare
196
205
  # `correlates_by :end_to_end` reads whatever the payload holds under
@@ -202,19 +211,54 @@ module Hecks
202
211
  # holds a head to. This is a syntactic check, not a type check: it
203
212
  # does not know or care whether the field IS a value object, only
204
213
  # that the declaration cannot leave that question open.
205
- raise InvalidProcessManager, "#{@name} correlates_by #{@correlates_by.inspect}, which names a whole " \
206
- "field rather than one of its scalars say which one, e.g. " \
207
- "#{@correlates_by}.value" unless @correlates_by.to_s.include?(".")
214
+ unless @correlates_by.to_s.include?(".")
215
+ raise InvalidProcessManager, "#{@name} correlates_by #{@correlates_by.inspect}, which names a whole " \
216
+ "field rather than one of its scalars — say which one, e.g. " \
217
+ "#{@correlates_by}.value"
218
+ end
219
+
220
+ if @starts_on.to_s.empty?
221
+ raise InvalidProcessManager, "#{@name} declares no starts_on — " \
222
+ "nothing would ever begin it"
223
+ end
224
+
225
+ if @handlers.empty?
226
+ raise InvalidProcessManager, "#{@name} declares no transitions — " \
227
+ "it would start and then ignore every event"
228
+ end
208
229
 
209
- raise InvalidProcessManager, "#{@name} declares no starts_on — " \
210
- "nothing would ever begin it" if @starts_on.to_s.empty?
230
+ refuse_ambiguous_legs!
231
+ end
211
232
 
212
- raise InvalidProcessManager, "#{@name} declares no transitions " \
213
- "it would start and then ignore every event" if @handlers.empty?
233
+ # C10.3 a leg is selected by (event, current state), so two
234
+ # legs answering the SAME event from the SAME state would leave
235
+ # the runtime to pick by declaration order, silently. Refused
236
+ # here, where the declaration can still be read whole. (`from:
237
+ # [...]` fan-out counts: `transition E => "a", from: ["x", "y"]`
238
+ # and `transition E => "b", from: "y"` collide on ("E", "y").)
239
+ def refuse_ambiguous_legs!
240
+ return if MetaValidator.shadow_parsing? # frozen era text is history
241
+
242
+ seen = {}
243
+ @handlers.each do |handler|
244
+ key = [handler.event_type, handler.from_state]
245
+ if (earlier = seen[key])
246
+ raise InvalidProcessManager,
247
+ "#{@name} declares two transitions on #{handler.event_type.inspect} from " \
248
+ "#{handler.from_state.inspect} (=> #{earlier.to_state.inspect} and => " \
249
+ "#{handler.to_state.inspect}) — a leg is selected by (event, current state), so " \
250
+ "only one may answer"
251
+ end
252
+ seen[key] = handler
253
+ end
214
254
  end
215
255
 
256
+ # THE BODY OF ONE `transition ... do ... end` block — collects the
257
+ # `dispatch` calls (each optionally opening its own `compensates`
258
+ # via the nested `DispatchBuilder`) that fire when this transition
259
+ # is taken.
216
260
  class HandlerBuilder
217
- GRAMMAR_CONTEXT = "Handler"
261
+ GRAMMAR_CONTEXT = "Handler".freeze
218
262
 
219
263
  attr_reader :dispatches
220
264
 
@@ -282,7 +326,7 @@ module Hecks
282
326
  # any saga dispatch already does
283
327
  # (`SagaInterpreter#dispatch_args`).
284
328
  class DispatchBuilder
285
- GRAMMAR_CONTEXT = "Dispatch"
329
+ GRAMMAR_CONTEXT = "Dispatch".freeze
286
330
 
287
331
  include WordGate
288
332
 
@@ -2,8 +2,14 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # Parses a `query "Name" do ... end` block, declared on an aggregate
6
+ # or one of its entities, into a `Query` — its own parameters (plain
7
+ # attributes, including reference-typed ones) plus whatever `where`/
8
+ # `order_by`/`limit`/etc. `QuerySpecification::Common::DSL` contributes.
9
+ # A block parameter naming an already-declared owner attribute has its
10
+ # type derived from the owner rather than restated (`derive_from_owner!`).
5
11
  class QueryBuilder
6
- GRAMMAR_CONTEXT = "Query"
12
+ GRAMMAR_CONTEXT = "Query".freeze
7
13
 
8
14
  include AttributeCollector
9
15
  include QuerySpecification::Common::DSL
@@ -2,8 +2,14 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # Parses a `read_model "Name" do ... end` block into a `ReadModel` — a
6
+ # cross-aggregate projection built from an optional `reference_to`
7
+ # root (omitted, it's a rootless "bulk" read model) plus one or more
8
+ # `include`d aggregate heads, with `where`/`order_by`/`limit`/etc, and
9
+ # at most one of `group_by`, `count`, or `median` as its single
10
+ # reduction over the one eligible many-side collection.
5
11
  class ReadModelBuilder
6
- GRAMMAR_CONTEXT = "ReadModel"
12
+ GRAMMAR_CONTEXT = "ReadModel".freeze
7
13
 
8
14
  include QuerySpecification::Common::DSL
9
15
  include WordGate
@@ -107,8 +113,10 @@ module Hecks
107
113
  # field values) has no root to speak of. Still needs to describe
108
114
  # SOMETHING — zero includes AND no reference is refused.
109
115
  def build
110
- raise Malformed,
111
- "#{@name} needs an aggregate-head reference or at least one include" if !@reference_target && Array(@includes).empty?
116
+ if !@reference_target && Array(@includes).empty?
117
+ raise Malformed,
118
+ "#{@name} needs an aggregate-head reference or at least one include"
119
+ end
112
120
 
113
121
  Array(@includes).each do |target, as|
114
122
  add_aggregate_head(target, as, many: target != @reference_target)
@@ -59,7 +59,9 @@ module Hecks
59
59
  "extraction — #{extraction_failure}"
60
60
  end
61
61
 
62
- struct_class.new(description: description, canonical: canonical, predicate: predicate)
62
+ ast = Expression::AstJson.refuse_unshared_patterns!(Expression::AstJson.emit_predicate(canonical),
63
+ owner: owner_name, word: "#{word} #{description.inspect}")
64
+ struct_class.new(description: description, canonical: canonical, predicate: predicate, ast: ast)
63
65
  end
64
66
 
65
67
  # PRIMITIVE 1 — an ORDERED CHAIN of flat `Hash[description] =>
@@ -2,8 +2,14 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # Parses an `aggregate "Name" do ... end` block inside a `.translation`
6
+ # file into a `TranslationAggregate` — the per-aggregate rules
7
+ # (`rename`/`move`/`convert`/`retype`/`compute`/`rekey`/`backfill`/
8
+ # `drop`) that carry one era's stored data forward to the next, plus
9
+ # `unresolved` markers the scaffold writes wherever it cannot decide a
10
+ # rule for itself.
5
11
  class TranslationAggregateBuilder
6
- GRAMMAR_CONTEXT = "TranslationAggregate"
12
+ GRAMMAR_CONTEXT = "TranslationAggregate".freeze
7
13
 
8
14
  include WordGate
9
15
 
@@ -183,8 +189,13 @@ module Hecks
183
189
  end
184
190
  end
185
191
 
192
+ # Parses a whole `.translation` file into a `Translation` — the
193
+ # domain's own `from:`/`to:` era pair, its list of `aggregate`
194
+ # translation blocks (each built by `TranslationAggregateBuilder`
195
+ # above), and any `retired` aggregates that no longer exist in the
196
+ # destination era.
186
197
  class TranslationBuilder
187
- GRAMMAR_CONTEXT = "Translation"
198
+ GRAMMAR_CONTEXT = "Translation".freeze
188
199
 
189
200
  include WordGate
190
201
 
@@ -2,8 +2,15 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # Parses a `value_object "Name" do ... end` block into a
6
+ # `ValueObject` — its attributes, its own invariants (declared once
7
+ # and referenceable by name from sibling value objects on the same
8
+ # aggregate, `RuleReference`), and its `member` rows when it's a
9
+ # closed set (either bare `one_of` member lines, or the single-field
10
+ # `attribute ..., one_of: [...]` shorthand `install_inline_closed_set`
11
+ # installs).
5
12
  class ValueObjectBuilder
6
- GRAMMAR_CONTEXT = "ValueObject"
13
+ GRAMMAR_CONTEXT = "ValueObject".freeze
7
14
 
8
15
  include AttributeCollector
9
16
  include RuleReference
@@ -150,6 +150,11 @@ module Hecks
150
150
  # admitted-but-unimplemented, or admitted-somewhere-else-only —
151
151
  # those are real, useful refusals regardless of which
152
152
  # `method_missing` is asking.
153
+ # One ordered admission-then-dispatch pipeline (own context, then
154
+ # "Type" fallback, then admitted-elsewhere check, then dispatch) —
155
+ # see this method's own header comment above for the full,
156
+ # order-dependent account of why each check runs where it does.
157
+ # rubocop:disable-next Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
153
158
  def word_gate_dispatch(word, args, kwargs, block)
154
159
  context = self.class::GRAMMAR_CONTEXT
155
160
  rows = MetaValidator::SyntaxBoot.call
@@ -2,10 +2,15 @@ require_relative "word_gate"
2
2
  module Hecks
3
3
  module Bluebook
4
4
  module DSL
5
+ # A generic keyword-call sink for one bind's own settings block (e.g.
6
+ # `persisted_by "Heki" do dir :default end`) — every method call made
7
+ # on it inside the block is recorded verbatim by name, with no fixed
8
+ # vocabulary of its own; `WorldBuilder#record_binding` reads `#to_h`
9
+ # back out.
5
10
  class SettingsCollector
6
11
  def initialize = @values = {}
7
12
 
8
- def method_missing(key, *args, &_block)
13
+ def method_missing(key, *args, &)
9
14
  @values[key.to_sym] = args.size == 1 ? args.first : args
10
15
  end
11
16
 
@@ -38,8 +43,13 @@ module Hecks
38
43
  def respond_to_missing?(_name, _include_private = false) = true
39
44
  end
40
45
 
46
+ # Parses a `.world` file's top-level DSL block into a `World` — a
47
+ # domain's own `realm`/`latest` version markers plus its adapter bind
48
+ # SETTINGS, one entry per `verb("Adapter") do ... end` call (whether
49
+ # written bare or aggregate-qualified through `WorldConstProxy`'s
50
+ # visual mirror of a sibling `.hecksagon` file's own bind).
41
51
  class WorldBuilder
42
- GRAMMAR_CONTEXT = "World"
52
+ GRAMMAR_CONTEXT = "World".freeze
43
53
 
44
54
  include WordGate
45
55
 
@@ -107,7 +117,7 @@ module Hecks
107
117
 
108
118
  private
109
119
 
110
- def required(value, label)
120
+ def required(value, _label)
111
121
  # moved to the language: Realm / Latest invariants, in world.bluebook
112
122
  value.to_s
113
123
  end
@@ -1,4 +1,5 @@
1
1
  require_relative "behaviour/entity"
2
+ require_relative "expression/ast_json"
2
3
 
3
4
  module Hecks
4
5
  module Bluebook
@@ -51,7 +52,7 @@ module Hecks
51
52
  # documentation of what the piece itself declared, the same
52
53
  # relationship `Aggregate.preconditions` already has to its own
53
54
  # commands.
54
- preconditions: -> { preconditions.map { |rule| { description: rule.description, canonical: rule.canonical } } },
55
+ preconditions: -> { preconditions.map { |rule| Expression::AstJson.rule_row(rule) } },
55
56
  # A piece's OWN shape rule, checked against EVERY instance of
56
57
  # this piece the aggregate holds (Admissibility#enforce_
57
58
  # invariants' own recursive walk) — the SAME relationship
@@ -61,7 +62,7 @@ module Hecks
61
62
  # mutation, before save) the aggregate's own invariants always
62
63
  # were — see that method's own comment for why this does not
63
64
  # contradict "there is no separate entity invariant."
64
- invariants: -> { invariants.map { |rule| { description: rule.description, canonical: rule.canonical } } },
65
+ invariants: -> { invariants.map { |rule| Expression::AstJson.rule_row(rule) } },
65
66
  lifecycle: one(:lifecycle)
66
67
  )
67
68
 
@@ -84,7 +85,8 @@ module Hecks
84
85
  # Assigns what the language declares, then hands off to the
85
86
  # behaviour's own `settle` — derived identity and the name
86
87
  # indexes, neither of which the declaration states.
87
- def absorb(description:, identified_by:, attributes:, commands:, queries:, entities:, preconditions:, invariants:, lifecycle:)
88
+ def absorb(description:, identified_by:, attributes:, commands:, queries:, entities:, preconditions:, invariants:,
89
+ lifecycle:)
88
90
  @description = description
89
91
  @identified_by = identified_by
90
92
  @attributes = attributes
@@ -42,8 +42,8 @@ module Hecks
42
42
  # `expr_emitter.rb`'s own `emit_bool`/`emit_resolver` already hold
43
43
  # to — even though, as of this writing, no real corpus VALUE OBJECT
44
44
  # invariant exercises `Include`/`Modulo`/`BlockPredicate`/`Find`/
45
- # `Array`/`MatchesRegex`/`Presence`/`Split`/`StartsWith`/`EndsWith`/
46
- # `First`/`Last` (only `given`/`ensures` clauses do, elsewhere in
45
+ # `Array`/`MatchesRegex`/`Presence`/`Assignment`/`Split`/`StartsWith`/
46
+ # `EndsWith`/`First`/`Last` (only `given`/`ensures` clauses do, elsewhere in
47
47
  # the corpus — a different consumer of this same grammar).
48
48
  # `rust/host/src/expr_json.rs`'s own header names exactly which of
49
49
  # these its interpreter evaluates for real today versus refuses
@@ -56,10 +56,75 @@ module Hecks
56
56
  module AstJson
57
57
  module_function
58
58
 
59
+ # THE CLOSED OP ROSTER — every `"op"` tag the walkers below can
60
+ # emit, pinned so a reader (or a spec) can refuse a tag it does
61
+ # not know instead of guessing. A new node kind is a new entry
62
+ # here, a new arm below, and a new arm in every reader.
63
+ OPS = %w[
64
+ or and not compare include
65
+ int float str bool nil array lookup
66
+ add modulo sign_test empty size to_s
67
+ block_predicate find first last
68
+ matches_regex presence assignment split starts_with ends_with
69
+ ].freeze
70
+
71
+ # ONE RULE ROW, THE WAY EVERY RULE SITE EMITS IT — description and
72
+ # canonical text (what every reader has always had) plus the
73
+ # structured form, derived from the same text. `ast` is a pure
74
+ # function of `canonical`: the IR carries both so a reader that
75
+ # only displays keeps the text, and a reader that evaluates never
76
+ # re-parses it.
77
+ def rule_row(rule)
78
+ { description: rule.description, canonical: rule.canonical, ast: rule.ast || emit_predicate(rule.canonical) }
79
+ end
80
+
59
81
  def emit_predicate(canonical)
60
82
  emit_bool(Evaluator.parse(canonical))
61
83
  end
62
84
 
85
+ # C3.6 (docs/semantics/bluebook-semantics.md) — every `.match?`
86
+ # pattern a rule carries is held to `PatternSubset`, exactly as an
87
+ # attribute's own `pattern:` already is (`AttributeCollector#
88
+ # refuse_unshared_pattern`): a regex whose meaning depends on the
89
+ # engine reading it is a defect in the bluebook, refused at build.
90
+ # Walks the emitted AST, so every rule site (givens, ensures,
91
+ # invariants, preconditions, a policy's where) gets the one check.
92
+ def refuse_unshared_patterns!(ast, owner:, word:)
93
+ return ast if Hecks::Bluebook::MetaValidator.shadow_parsing? # frozen era text is history
94
+
95
+ each_node(ast) do |node|
96
+ next unless node["op"] == "matches_regex"
97
+
98
+ rejection = PatternSubset.validate(node["pattern"])
99
+ next unless rejection
100
+
101
+ raise DSL::Malformed,
102
+ "#{owner}'s #{word} matches against #{node['pattern'].inspect}, which uses a " \
103
+ "#{rejection.construct} — #{rejection.reason}"
104
+ end
105
+ ast
106
+ end
107
+
108
+ # Every name a rule resolves at its root — the first segment of
109
+ # each `lookup` path, unique, in first-seen order.
110
+ def lookup_heads(ast)
111
+ heads = []
112
+ each_node(ast) do |node|
113
+ heads << node["path"].first.to_s if node["op"] == "lookup" && node["path"].is_a?(::Array)
114
+ end
115
+ heads.uniq
116
+ end
117
+
118
+ def each_node(node, &block)
119
+ case node
120
+ when ::Hash
121
+ yield node
122
+ node.each_value { |child| each_node(child, &block) }
123
+ when ::Array
124
+ node.each { |child| each_node(child, &block) }
125
+ end
126
+ end
127
+
63
128
  def emit_bool(node)
64
129
  case node
65
130
  when Evaluator::Or then { "op" => "or", "left" => emit_bool(node.left), "right" => emit_bool(node.right) }
@@ -73,12 +138,13 @@ module Hecks
73
138
  when Evaluator::Resolve
74
139
  emit_resolver(node.expr)
75
140
  else
76
- raise "unhandled evaluator node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_bool)"
141
+ raise "unhandled evaluator node #{node.class} — no JSON rendering exists for it " \
142
+ "(lib/hecks/bluebook/expression/ast_json.rb#emit_bool)"
77
143
  end
78
144
  end
79
145
 
80
- def emit_comparison(op)
81
- { "less_than" => op.compares_less_than, "equal" => op.compares_equal, "negated" => op.negated }
146
+ def emit_comparison(comparator)
147
+ { "less_than" => comparator.compares_less_than, "equal" => comparator.compares_equal, "negated" => comparator.negated }
82
148
  end
83
149
 
84
150
  # The JSON-target sibling of `expr_emitter.rb`'s own
@@ -99,11 +165,18 @@ module Hecks
99
165
  return { "op" => "bool", "value" => false } if node.haystack.elements.empty?
100
166
 
101
167
  equalities = node.haystack.elements.map do |element|
102
- { "op" => "compare", "cmp" => emit_comparison(EQ), "left" => emit_resolver(node.needle), "right" => emit_resolver(element) }
168
+ { "op" => "compare", "cmp" => emit_comparison(EQ), "left" => emit_resolver(node.needle),
169
+ "right" => emit_resolver(element) }
103
170
  end
104
171
  equalities.reduce { |left, right| { "op" => "or", "left" => left, "right" => right } }
105
172
  end
106
173
 
174
+ # One case arm per Resolver node type — the class header above is
175
+ # explicit that this dispatch must stay COMPLETE and in one place
176
+ # ("every node this grammar admits gets a real arm"); splitting it
177
+ # into several methods would hide whether the set is still
178
+ # exhaustive instead of making that visible at a glance.
179
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength
107
180
  def emit_resolver(node)
108
181
  case node
109
182
  when Resolver::IntegerLiteral then { "op" => "int", "value" => node.value }
@@ -111,7 +184,9 @@ module Hecks
111
184
  when Resolver::StringLiteral then { "op" => "str", "value" => node.value }
112
185
  when Resolver::BoolLiteral then { "op" => "bool", "value" => node.value }
113
186
  when Resolver::NilLiteral then { "op" => "nil" }
114
- when Resolver::Lookup then { "op" => "lookup", "path" => node.path }
187
+ # `path` is the SAME shape `find.path` already has — segments, not
188
+ # a dotted string a reader would have to split by its own rule.
189
+ when Resolver::Lookup then { "op" => "lookup", "path" => node.path.split(".") }
115
190
  when Resolver::Addition then { "op" => "add", "left" => emit_resolver(node.left), "right" => emit_resolver(node.right) }
116
191
  when Resolver::SignTest
117
192
  { "op" => "sign_test", "cmp" => emit_comparison(node.operator), "receiver" => emit_resolver(node.receiver) }
@@ -128,9 +203,12 @@ module Hecks
128
203
  when Resolver::ArrayLiteral
129
204
  { "op" => "array", "elements" => node.elements.map { |element| emit_resolver(element) } }
130
205
  when Resolver::MatchesRegex
131
- { "op" => "matches_regex", "receiver" => emit_resolver(node.receiver), "pattern" => node.pattern, "flags" => node.flags }
206
+ { "op" => "matches_regex", "receiver" => emit_resolver(node.receiver), "pattern" => node.pattern,
207
+ "flags" => node.flags }
132
208
  when Resolver::Presence
133
209
  { "op" => "presence", "receiver" => emit_resolver(node.receiver), "negated" => node.negated }
210
+ when Resolver::Assignment
211
+ { "op" => "assignment", "receiver" => emit_resolver(node.receiver), "negated" => node.negated }
134
212
  when Resolver::Split
135
213
  { "op" => "split", "receiver" => emit_resolver(node.receiver), "separator" => node.separator }
136
214
  when Resolver::StartsWith
@@ -140,7 +218,8 @@ module Hecks
140
218
  when Resolver::First then { "op" => "first", "receiver" => emit_resolver(node.receiver) }
141
219
  when Resolver::Last then { "op" => "last", "receiver" => emit_resolver(node.receiver) }
142
220
  else
143
- raise "unhandled resolver node #{node.class} — no JSON rendering exists for it (lib/hecks/bluebook/expression/ast_json.rb#emit_resolver)"
221
+ raise "unhandled resolver node #{node.class} — no JSON rendering exists for it " \
222
+ "(lib/hecks/bluebook/expression/ast_json.rb#emit_resolver)"
144
223
  end
145
224
  end
146
225
  end
@@ -0,0 +1,107 @@
1
+ require_relative "evaluator"
2
+ require_relative "resolver"
3
+
4
+ module Hecks
5
+ module Bluebook
6
+ module Expression
7
+ # THE INVERSE OF `AstJson` — reads the `"op"`-tagged JSON a rule row
8
+ # carries as `ast` back into the SAME `Evaluator`/`Resolver` node
9
+ # Structs `Evaluator.parse` builds from `canonical`. This is how the
10
+ # runtime evaluates a rule without re-parsing its text: the one
11
+ # parse happened at DSL-build time, behind `AstJson`; dispatch walks
12
+ # the structured form (`Evaluator.call_rule`), and `canonical` stays
13
+ # what it always displayed as — text for humans and refusal wording.
14
+ #
15
+ # Written as the obvious mirror of `ast_json.rb`'s two walkers, arm
16
+ # for arm, so a new op there is a new arm here and
17
+ # spec/expression_ast_spec.rb's roster contract fails until it
18
+ # lands. Promoted from spec/support once the runtime switched —
19
+ # the spec proved the round trip first, then the runtime adopted it.
20
+ #
21
+ # One deliberate asymmetry, inherited: `AstJson` rewrites a
22
+ # LITERAL-array `.include?` into an OR of equalities (see
23
+ # `emit_include`), so reading never produces an `Include` over an
24
+ # `ArrayLiteral`. Evaluation is unchanged by that rewrite, which is
25
+ # exactly what the equivalence spec pins.
26
+ module AstReader
27
+ module_function
28
+
29
+ def read_predicate(json) = read_bool(json)
30
+
31
+ def read_bool(json)
32
+ case json.fetch("op")
33
+ when "or" then Evaluator::Or.new(left: read_bool(json["left"]), right: read_bool(json["right"]))
34
+ when "and" then Evaluator::And.new(left: read_bool(json["left"]), right: read_bool(json["right"]))
35
+ when "not" then Evaluator::Not.new(node: read_bool(json["expr"]))
36
+ when "compare"
37
+ Evaluator::Compare.new(operator: operator(json["cmp"]),
38
+ left: read_resolver(json["left"]), right: read_resolver(json["right"]))
39
+ when "include"
40
+ Evaluator::Include.new(haystack: read_resolver(json["haystack"]), needle: read_resolver(json["needle"]))
41
+ else Evaluator::Resolve.new(expr: read_resolver(json))
42
+ end
43
+ end
44
+
45
+ # The comparator algebra travels as its three-flag triple; the
46
+ # `Operator` carrying that exact triple is the one `parse` would
47
+ # have chosen, because the roster (`expression/projection.json`)
48
+ # holds one symbol per triple.
49
+ def operator(cmp)
50
+ Evaluator::OPERATORS.find do |op|
51
+ op.compares_less_than == cmp.fetch("less_than") &&
52
+ op.compares_equal == cmp.fetch("equal") &&
53
+ op.negated == cmp.fetch("negated")
54
+ end or raise "no comparison operator has the triple #{cmp.inspect}"
55
+ end
56
+
57
+ # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity -- one arm
58
+ # per AstJson op is the point; splitting the case would hide the roster.
59
+ def read_resolver(json)
60
+ recv = -> { read_resolver(json["receiver"]) }
61
+ case json.fetch("op")
62
+ when "int" then Resolver::IntegerLiteral.new(value: json["value"])
63
+ when "float" then Resolver::FloatLiteral.new(value: json["value"])
64
+ when "str" then Resolver::StringLiteral.new(value: json["value"])
65
+ when "bool" then Resolver::BoolLiteral.new(value: json["value"])
66
+ when "nil" then Resolver::NilLiteral.new
67
+ when "array" then Resolver::ArrayLiteral.new(elements: json["elements"].map { |e| read_resolver(e) })
68
+ when "lookup" then Resolver::Lookup.new(path: json["path"].join("."))
69
+ when "add" then Resolver::Addition.new(left: read_resolver(json["left"]), right: read_resolver(json["right"]))
70
+ when "sign_test"
71
+ op = operator(json["cmp"])
72
+ Resolver::SignTest.new(operator: op, test: sign_test_name(op), receiver: recv.call)
73
+ when "empty" then Resolver::Empty.new(receiver: recv.call)
74
+ when "to_s" then Resolver::ToS.new(receiver: recv.call)
75
+ when "modulo" then Resolver::Modulo.new(receiver: recv.call, divisor: read_resolver(json["divisor"]))
76
+ when "size" then Resolver::Size.new(receiver: recv.call)
77
+ when "first" then Resolver::First.new(receiver: recv.call)
78
+ when "last" then Resolver::Last.new(receiver: recv.call)
79
+ when "block_predicate"
80
+ Resolver::BlockPredicate.new(mode: json["mode"].to_sym, receiver: recv.call, param: json["param"],
81
+ predicate: read_bool(json["predicate"]))
82
+ when "find"
83
+ Resolver::Find.new(receiver: recv.call, param: json["param"],
84
+ predicate: read_bool(json["predicate"]), path: json["path"])
85
+ when "matches_regex" then Resolver::MatchesRegex.new(receiver: recv.call, pattern: json["pattern"],
86
+ flags: json["flags"])
87
+ when "presence" then Resolver::Presence.new(receiver: recv.call, negated: json["negated"])
88
+ when "assignment" then Resolver::Assignment.new(receiver: recv.call, negated: json["negated"])
89
+ when "split" then Resolver::Split.new(receiver: recv.call, separator: json["separator"])
90
+ when "starts_with" then Resolver::StartsWith.new(receiver: recv.call, substring: json["substring"])
91
+ when "ends_with" then Resolver::EndsWith.new(receiver: recv.call, substring: json["substring"])
92
+ else raise "no reader handles op #{json['op'].inspect} — add an arm before AstJson can emit it"
93
+ end
94
+ end
95
+ # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
96
+
97
+ # `SignTest#test` is only wording (the refusal names it); the
98
+ # triple is what evaluates. Recover the spelling from the
99
+ # vocabulary so a rebuilt node refuses with the same message the
100
+ # parsed one would.
101
+ def sign_test_name(operator)
102
+ Resolver::SIGN_TEST_OPERATORS.key(operator.symbol) || operator.symbol
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
@@ -4,6 +4,13 @@ require_relative "../../vocabulary"
4
4
  module Hecks
5
5
  module Bluebook
6
6
  module Expression
7
+ # Rewrites a predicate's SOURCE TEXT into one canonical spelling
8
+ # before it's ever parsed or hashed — collapsing whitespace and
9
+ # folding admitted synonyms (e.g. `.length` → `.size`) via the
10
+ # `RULES` table projected from the grammar chapter, so two byte-
11
+ # different but equivalent predicates compare and cache identically.
12
+ # Never touches text inside quoted string literals
13
+ # (`map_outside_strings`).
7
14
  module CanonicalForm
8
15
  Rule = Struct.new(:strategy, :source_token, :replacement, :boundary, :position, keyword_init: true)
9
16