hecks 1.3.0 → 1.5.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 (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -2,8 +2,8 @@ module Hecks
2
2
  module Bluebook
3
3
  module DSL
4
4
  class BluebookBuilder
5
- # THE self.validate_*/self.correlation_*/self.event_*/self.walk_*
6
- # CLUSTER — every whole-chapter, cross-aggregate check `#build`
5
+ # The self.validate_*/self.correlation_*/self.event_*/self.walk_*
6
+ # cluster — every whole-chapter, cross-aggregate check `#build`
7
7
  # runs once a chapter is fully assembled (reference targets, event
8
8
  # shapes, correlation keys, query hops, projected fields,
9
9
  # bidirectional-reference cycles), plus each one's own private
@@ -17,25 +17,34 @@ module Hecks
17
17
  # one of these is a pure function of its own explicit arguments
18
18
  # (an assembled `Bluebook::Chapter`, an aggregate list, ...), never
19
19
  # builder-instance state (no `@ivar` read anywhere in this
20
- # module — checked before splitting it out), and every caller
21
- # reaches these as `BluebookBuilder.validate_assembled!(...)`
22
- # class-level calls, `#build`'s own `self.class.validate_assembled!`
23
- # included. `extend` is `include`'s class-level counterpart: the
24
- # same "one class, its methods, filed across files" pattern
20
+ # module), and every caller reaches these as
21
+ # `BluebookBuilder.validate_assembled!(...)` class-level calls,
22
+ # `#build`'s own `self.class.validate_assembled!` included.
23
+ # `extend` is `include`'s class-level counterpart: the same "one
24
+ # class, its methods, filed across files" pattern
25
25
  # `Runtime::Registry` already uses (`include Verification`), just
26
26
  # turning each method into a class (singleton) method instead of
27
- # an instance method, matching what `def self.foo` already made
28
- # every one of these before the split.
27
+ # an instance method, the same role `def self.foo` fills on a
28
+ # class that keeps its methods in one file.
29
29
  module Validation
30
- # EVERY WHOLE-CHAPTER CHECK, IN ONE PLACE — the battery `#build`
31
- # used to run inline, now a pure function of an assembled
32
- # `Bluebook::Chapter` so `MetaValidator.judge_deferred!` can run
33
- # it too, once, on a chapter whose files have ALL loaded (see
34
- # `#build`'s own comment for why that split exists at all).
35
- # Public, not `private_class_method`'d, for exactly that second
36
- # caller — `MetaValidator` needs to reach this with no builder
37
- # instance in hand, only the chapter `judge_deferred!` already
38
- # read back out of the registry.
30
+ # Runs every whole-chapter check against one fully assembled chapter.
31
+ #
32
+ # **Every whole-chapter check, in one place** — a pure function of an
33
+ # assembled `Bluebook::Chapter`, callable both from `#build` (right
34
+ # after assembly) and from `MetaValidator.judge_deferred!` (once, on
35
+ # a chapter whose files have all loaded — see `#build`'s own comment
36
+ # for why that split exists at all). Public, not
37
+ # `private_class_method`'d, for exactly that second caller —
38
+ # `MetaValidator` needs to reach this with no builder instance in
39
+ # hand, only the chapter `judge_deferred!` already read back out of
40
+ # the registry.
41
+ #
42
+ # @param bluebook [Bluebook::Chapter] the fully assembled chapter to validate
43
+ # @return [void]
44
+ # @raise [Bluebook::DSL::Malformed] if any of the reference, event-shape, `with:`,
45
+ # query-hop, projected-field or provision checks below finds a violation
46
+ # @raise [Bluebook::DSL::ProcessManagerBuilder::InvalidProcessManager] if a process
47
+ # manager's `correlates_by` resolves to something other than a scalar field
39
48
  def validate_assembled!(bluebook)
40
49
  # moved to the language: an attribute type is a reference to its Shape,
41
50
  # so an undeclared value object fails reference resolution
@@ -49,12 +58,12 @@ module Hecks
49
58
 
50
59
  # Every hop AggregateBuilder#seal_query_field recognised and
51
60
  # deferred gets checked for real here — the earliest point a
52
- # hop CAN be checked, for exactly the reason
61
+ # hop can be checked, for exactly the reason
53
62
  # validate_no_bidirectional_references! above already gives:
54
63
  # `Bluebook.new` just stamped `hecks_owner` on every
55
64
  # aggregate, so `Reference#resolve` finally has a chapter to
56
65
  # walk. Before this line every target in the file (including
57
- # ones declared ABOVE the aggregate doing the asking) would
66
+ # ones declared above the aggregate doing the asking) would
58
67
  # have resolved to nil.
59
68
  infer_hop_query_arguments!(bluebook)
60
69
  validate_query_hops!(bluebook)
@@ -63,29 +72,99 @@ module Hecks
63
72
  # own reference cannot resolve until every aggregate in the
64
73
  # chapter is real and owner-stamped (S12, ADR 0025).
65
74
  validate_projected_fields!(bluebook)
75
+
76
+ validate_provisions!(bluebook)
77
+ end
78
+
79
+ # Checks every `provides` row against `Capabilities::CONTRACTS`.
80
+ #
81
+ # **What a declared capability must name**. A `provides` row is only
82
+ # worth trusting in place of a name check if it is checked: an
83
+ # unknown capability, a missing or extra key, or a verb that is
84
+ # not this chapter's own command/query of the right kind would
85
+ # otherwise wire a role check to nothing, in silence.
86
+ #
87
+ # @param bluebook [Bluebook::Chapter] the chapter whose `provides` rows to check
88
+ # @return [void]
89
+ # @raise [Bluebook::DSL::Malformed] if a row names a capability
90
+ # `Capabilities::CONTRACTS` does not know, its keys do not match the
91
+ # capability's contract exactly, or a verb names no real command/query
92
+ # of the declared kind
93
+ def validate_provisions!(bluebook)
94
+ bluebook.provides.group_by(&:capability).each do |capability, rows|
95
+ contract = Capabilities::CONTRACTS.fetch(capability) do
96
+ raise Malformed, "#{bluebook.name} provides #{capability.inspect}, which is no capability the " \
97
+ "language knows — known: #{Capabilities::CONTRACTS.keys.sort.join(', ')}"
98
+ end
99
+
100
+ keys = rows.map { |row| row.key.to_sym }
101
+ unless keys.sort == contract.keys.sort
102
+ raise Malformed, "#{bluebook.name} provides #{capability.inspect} with #{keys.join(', ')}, but " \
103
+ "#{capability} needs exactly #{contract.keys.join(', ')}"
104
+ end
105
+
106
+ rows.each { |row| validate_provided_verb!(bluebook, capability, row, contract.fetch(row.key.to_sym)) }
107
+ end
66
108
  end
67
109
 
68
- # AN ENTITY COMMAND MAY NOT NAME ITSELF AS ITS ROOT.
110
+ # Checks that one `provides` row's verb names a real command or query.
69
111
  #
70
- # That is the whole of what is left here, and it needs saying plainly
71
- # because the sentence this used to raise — "references must target
72
- # aggregate heads" — was never what it checked.
112
+ # @param bluebook [Bluebook::Chapter] the chapter the row was declared on
113
+ # @param capability [String] the capability the row belongs to, such as
114
+ # `"authorization"`
115
+ # @param row [Bluebook::Chapter::Provision] the one declared row to check
116
+ # @param kind [Symbol] `:command` or `:query`, the kind of verb this row's key
117
+ # must name (from `Capabilities::CONTRACTS`)
118
+ # @return [void]
119
+ # @raise [Bluebook::DSL::Malformed] if `row.verb` does not split into a real
120
+ # aggregate and a member of the right kind that aggregate declares
121
+ def validate_provided_verb!(bluebook, capability, row, kind)
122
+ aggregate_name, member = row.verb.split(".", 2)
123
+ aggregate = bluebook.aggregate(aggregate_name)
124
+ return if aggregate && member && provided_member_names(aggregate, kind).include?(member)
125
+
126
+ raise Malformed, "#{bluebook.name} provides #{capability.inspect} #{row.key}: #{row.verb.inspect}, " \
127
+ "which names no #{kind} this chapter declares (spelled \"Aggregate.#{kind.capitalize}\")"
128
+ end
129
+
130
+ # Names of the members a `provides` verb of the given kind may address.
73
131
  #
74
- # `CommandBuilder#reference_to` sets `references` ONLY when the target's
132
+ # @param aggregate [Bluebook::Aggregate] the aggregate the verb is scoped to
133
+ # @param kind [Symbol] `:command` or `:query`
134
+ # @return [Array<String>] every command's `hecks_name` when `kind` is `:command`,
135
+ # else every query's declared `name`
136
+ def provided_member_names(aggregate, kind)
137
+ kind == :command ? aggregate.commands.map(&:hecks_name) : aggregate.queries.map(&:name)
138
+ end
139
+
140
+ # Refuses an entity command that names itself as its own root.
141
+ #
142
+ # That is the whole of what is left here, and it needs saying plainly:
143
+ # the refusal message once read "references must target aggregate
144
+ # heads," a claim broader than what this actually checks.
145
+ #
146
+ # `CommandBuilder#reference_to` sets `references` only when the target's
75
147
  # bare name equals the owner's ; anything else becomes a reference
76
- # ATTRIBUTE. So on an aggregate command `references` is always a copy of
148
+ # attribute. So on an aggregate command `references` is always a copy of
77
149
  # that aggregate's own name, and looking it up in an index of aggregates
78
- # is a TAUTOLOGY — that branch never refused anything and structurally
150
+ # is a tautology — that branch never refused anything and structurally
79
151
  # could not. Verified across all eight golden chapters before deleting it.
80
152
  #
81
- # On a PIECE's command the owner is the entity, and an entity is not a
153
+ # On a piece's command the owner is the entity, and an entity is not a
82
154
  # head, so what this actually refuses is `reference_to <its own name>`
83
- # written inside `entity do … end`. A piece is reached THROUGH its
155
+ # written inside `entity do … end`. A piece is reached through its
84
156
  # aggregate ; a command on one addresses the aggregate, never the piece.
85
157
  #
86
- # Reference ATTRIBUTES are the language's business now — offered as the
158
+ # Reference attributes are the language's business now — offered as the
87
159
  # head's own id and resolved as references, so `Aggregate.Reference` and
88
160
  # `Command.Reference` refuse an undeclared head with no predicate at all.
161
+ #
162
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
163
+ # @return [void]
164
+ # @raise [Bluebook::DSL::Malformed] if an entity command's `references` names
165
+ # something other than one of this chapter's own aggregate heads — which, given
166
+ # how `CommandBuilder#reference_to` sets it, only happens when the entity command
167
+ # names its own entity as its root
89
168
  def validate_reference_value_objects!(aggregates)
90
169
  heads = aggregates.map(&:hecks_name)
91
170
 
@@ -106,22 +185,27 @@ module Hecks
106
185
  "an entity command is addressed through its aggregate; #{violations.uniq.join('; ')}"
107
186
  end
108
187
 
109
- # EVENTS ARE FIRST-CLASS BY CONVENTION, NOT BY DECLARATION (ADR
188
+ # Events are first-class by convention, not by declaration (ADR
110
189
  # 0025, "events and reactions" — "a domain event is a value
111
190
  # object with its own attributes, not a label"). No new `event
112
191
  # do ... end` construct exists to hand-author and keep in step
113
192
  # with every emitting command by hand — an event's own known
114
- # shape IS whichever command(s) declare `emits` for its name, and
115
- # this is the ONE thing that has to hold for that convention to
193
+ # shape is whichever command(s) declare `emits` for its name, and
194
+ # this is the one thing that has to hold for that convention to
116
195
  # mean anything: every command that emits a given name has to
117
196
  # agree on what it carries. An event is one fact; a fact does not
118
197
  # carry two different truths depending on who is telling it.
119
198
  #
120
- # STRUCTURAL fields only (name/type/list/optional) — `pattern:`/
121
- # `admits:`/`default:` are refinements ON a field, not a second
199
+ # Structural fields only (name/type/list/optional) — `pattern:`/
200
+ # `admits:`/`default:` are refinements on a field, not a second
122
201
  # claim about what the payload holds, so two emitting commands
123
202
  # are free to differ there without actually disagreeing about
124
203
  # the event's own shape.
204
+ #
205
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
206
+ # @return [void]
207
+ # @raise [Bluebook::DSL::Malformed] if two or more commands `emits` the same event
208
+ # name with different structural shapes
125
209
  def validate_event_shapes!(aggregates)
126
210
  event_emitters(aggregates).each do |event_name, pairs|
127
211
  next if pairs.size == 1
@@ -136,29 +220,39 @@ module Hecks
136
220
  end
137
221
  end
138
222
 
139
- # THE "EXPENSIVE HALF" the ADR names: "with: { account: :account }
223
+ # The "expensive half" the ADR names: "with: { account: :account }
140
224
  # projecting into a reaction that has no declared contract ...
141
225
  # breaks at dispatch rather than at load." Checked here, now that
142
226
  # `validate_event_shapes!` (above) guarantees at most one real
143
227
  # shape per event name, and command references being first-class
144
- # (`Naming.command_ref`) means the TARGET side is a real
228
+ # (`Naming.command_ref`) means the target side is a real
145
229
  # resolvable command, not a string that might be a typo.
146
230
  #
147
- # SAME-CHAPTER ONLY, ON PURPOSE — a `with:` whose source event or
231
+ # **Same-chapter only, on purpose** — a `with:` whose source event or
148
232
  # target command lives outside this chapter (an `across` policy
149
233
  # reacting to another domain's event entirely) is silently left
150
234
  # unchecked rather than refused: there is nothing here yet to
151
235
  # check it against, and "unresolvable" is not the same claim as
152
236
  # "wrong."
153
237
  #
154
- # A FOR_EACH POLICY'S SOURCE ISN'T THE EVENT AT ALL — a fan-out
155
- # `with:`'s symbols read the QUERY ROW `for_each` answers
238
+ # A FOR_EACH policy's source isn't the event at all — a fan-out
239
+ # `with:`'s symbols read the query row `for_each` answers
156
240
  # (FreezeAccountsOnSuspension's own comment: "`account` is the
157
241
  # key the fan-out merges for each row"), which this has no shape
158
- # for; the SOURCE half is skipped for those, the TARGET half
242
+ # for; the source half is skipped for those, the target half
159
243
  # (does the dispatched command actually declare the field) still
160
244
  # runs, since that half is true regardless of where the value
161
245
  # came from.
246
+ #
247
+ # @param policies [Array<Bluebook::Policy>] every reaction hoisted onto the
248
+ # chapter's own aggregates
249
+ # @param process_managers [Array<Bluebook::ProcessManager>] the chapter's declared
250
+ # process managers
251
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
252
+ # @return [void]
253
+ # @raise [Bluebook::DSL::Malformed] if a policy's or a saga dispatch's `with:`
254
+ # names a field its target command does not declare, or reads a source field
255
+ # its event does not carry (see `check_with_spec!` for the full rule)
162
256
  def validate_with_projections!(policies, process_managers, aggregates)
163
257
  lookup = command_lookup(aggregates)
164
258
  heads = correlation_heads(process_managers)
@@ -184,11 +278,11 @@ module Hecks
184
278
  end
185
279
 
186
280
  # `process_manager:` is present only for a process manager's own dispatch — a
187
- # saga leg's source symbol resolves against the CURRENT triggering
281
+ # saga leg's source symbol resolves against the current triggering
188
282
  # event first, same as a policy, but falls all the way back to the
189
- # saga's own MEMORY when the current event does not carry it
283
+ # saga's own memory when the current event does not carry it
190
284
  # (`SagaInterpreter#dispatch_args`, its own last `else`) — and
191
- # memory starts as the OPENING event's payload
285
+ # memory starts as the opening event's payload
192
286
  # (`SagaInterpreter#instance = { ..., memory: event.payload }`,
193
287
  # never updated after), never the leg's own. Settlement's own
194
288
  # comment names exactly this: "the credit leg reads a destination
@@ -203,6 +297,27 @@ module Hecks
203
297
  # source_shape, memory_shape, correlation, identity_sources, ...)
204
298
  # into a new method for no gain: each `next`/`raise` already
205
299
  # reads as its own rule at its own site.
300
+ #
301
+ # @param command_ref [String] the dotted name of the command `with:` dispatches,
302
+ # such as `"Account.Debit"` or `"Account.Piece.Move"`
303
+ # @param event_name [String, nil] the triggering event's name, or `nil` for a
304
+ # FOR_EACH policy (whose source is a query row, not an event)
305
+ # @param with_spec [Array<Array(Symbol, Object)>] the declared `with:` pairs,
306
+ # field name to source — a Symbol naming a field to read, or a literal value
307
+ # @param lookup [Hash{String => Class}] every command in the chapter, keyed by
308
+ # `"owner.hecks_name"` (`command_lookup`'s own shape)
309
+ # @param label [String] a human-readable description of what declared this
310
+ # `with:`, used only in a raised message
311
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
312
+ # @param correlation_heads [Array<Symbol>] every saga's own `correlates_by` head
313
+ # in this chapter, a legal addressing key alongside a command's own attributes
314
+ # @param process_manager [Bluebook::ProcessManager, nil] the process manager this
315
+ # `with:` dispatches from, or `nil` for a policy's own trigger
316
+ # @return [void]
317
+ # @raise [Bluebook::DSL::Malformed] if `with:` names a field the target command
318
+ # does not declare, or reads a Symbol source that resolves to neither the
319
+ # correlation key, the emitter's identity, nor a field the triggering event or
320
+ # (for a saga leg) the starting event's shape carries
206
321
  # rubocop:disable-next Metrics/CyclomaticComplexity
207
322
  # rubocop:disable-next Metrics/PerceivedComplexity
208
323
  def check_with_spec!(command_ref, event_name, with_spec, lookup, label, aggregates, correlation_heads,
@@ -211,7 +326,7 @@ module Hecks
211
326
  source_shape = event_name && event_shape_for(event_name, aggregates)
212
327
  memory_shape = process_manager && event_shape_for(process_manager.starts_on, aggregates)
213
328
  correlation = process_manager&.correlates_by && process_manager.correlation_head
214
- # A POLICY'S SOURCE ALSO CARRIES THE EMITTER'S OWN IDENTITY —
329
+ # A policy's source also carries the emitter's own identity —
215
330
  # `PolicyInterpreter#emitter_identity`, the runtime half of this.
216
331
  # An entity command's event never declares its aggregate's
217
332
  # identity (it arrives through `reference_to`, not an
@@ -243,15 +358,17 @@ module Hecks
243
358
  end
244
359
  end
245
360
 
246
- # A command's OWN `reference_to` (bare, no `as:`) never lands in
361
+ # Decides whether `field` is a legal `with:` target on `command`.
362
+ #
363
+ # A command's own `reference_to` (bare, no `as:`) never lands in
247
364
  # `attributes` — `CommandBuilder#reference_to`'s self-reference
248
365
  # branch sets `command.references` instead (S2), and mints no new
249
- # field at all. What addresses it is not one name but the SAME
250
- # SET `CommandInterpreter::ArgumentGate#refuse_unknown_arguments`
366
+ # field at all. What addresses it is not one name but the same
367
+ # set `CommandInterpreter::ArgumentGate#refuse_unknown_arguments`
251
368
  # already accepts at dispatch time — `:id`, the owning aggregate's
252
369
  # own `identity_heads` (real corpus proof — `Account.Debit`
253
370
  # dispatched everywhere as `number: ...`, `Account`'s own
254
- # `identified_by`), AND `Naming.reference_key(command.references)`
371
+ # `identified_by`), and `Naming.reference_key(command.references)`
255
372
  # (real corpus proof — `FreezeAccountsOnSuspension`'s `for_each`
256
373
  # fan-out, whose own comment reads "`account` is the key the
257
374
  # fan-out merges for each row it answers"). Both are simultaneously
@@ -259,6 +376,16 @@ module Hecks
259
376
  # legal here : this mirrors that gate rather than re-deriving a
260
377
  # narrower rule that would refuse one of two real, already-shipped
261
378
  # dispatch conventions.
379
+ #
380
+ # @param command [Class<Bluebook::Command>] the command `with:` dispatches
381
+ # @param field [Symbol] the `with:` key to check
382
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates,
383
+ # searched when `field` might name `command`'s own reference key
384
+ # @param correlation_heads [Array<Symbol>] every saga's own `correlates_by` head
385
+ # in this chapter
386
+ # @return [Boolean] `true` when `field` is one of `command`'s own attributes,
387
+ # `:id`, a correlation head, or the identity/reference key of the aggregate
388
+ # `command.references` names
262
389
  def command_declares?(command, field, aggregates, correlation_heads)
263
390
  return true if command.attributes.any? { |a| a.name == field }
264
391
  return true if field == :id
@@ -271,23 +398,38 @@ module Hecks
271
398
  referenced.identity_heads.include?(field) || Naming.reference_key(command.references) == field
272
399
  end
273
400
 
274
- # THE FOURTH addressing key `ArgumentGate#refuse_unknown_arguments`
401
+ # The fourth addressing key `ArgumentGate#refuse_unknown_arguments`
275
402
  # accepts, alongside `:id`/`identity_heads`/`reference_key` — every
276
- # saga in THIS domain's own `correlates_by` head, carried through
403
+ # saga in this domain's own `correlates_by` head, carried through
277
404
  # every dispatch as pure passthrough (Settlement's own comment:
278
405
  # "`reference:` carries the correlation forward... this is pure
279
406
  # passthrough, not an addressing key"). A command declaring none of
280
407
  # its attributes named this is not a gap; the correlation key rides
281
408
  # through commands that never read it, same as it does at runtime.
409
+ #
410
+ # @param process_managers [Array<Bluebook::ProcessManager>] the chapter's declared
411
+ # process managers
412
+ # @return [Array<Symbol>] the `correlation_head` of every process manager that
413
+ # declares a `correlates_by`
282
414
  def correlation_heads(process_managers)
283
415
  process_managers.filter_map { |pm| pm.correlates_by && pm.correlation_head }
284
416
  end
285
417
 
286
- # Every command this chapter declares, an aggregate's own AND
418
+ # Yields every command this chapter declares, paired with its owner's name.
419
+ #
420
+ # Every command this chapter declares, an aggregate's own and
287
421
  # every entity nested inside one, paired with a name for what
288
422
  # declares it — shared by `validate_event_shapes!` and
289
423
  # `validate_with_projections!`'s own command lookup, the same
290
424
  # reach `HecksagonBuilder#commands_in` needs one level up (S8).
425
+ #
426
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
427
+ # @yieldparam owner [String] the declaring aggregate's `hecks_name`, or
428
+ # `"Aggregate.Entity"` for a command declared on a nested entity
429
+ # @yieldparam command [Class<Bluebook::Command>] the command class
430
+ # @return [Enumerator<Array(String, Class)>] an enumerator over the same pairs
431
+ # when no block is given; otherwise the value of the underlying `each`, unused
432
+ # by any caller
291
433
  def each_command(aggregates)
292
434
  return enum_for(:each_command, aggregates) unless block_given?
293
435
 
@@ -299,70 +441,116 @@ module Hecks
299
441
  end
300
442
  end
301
443
 
302
- # NOT MEMOISED — this used to be `@event_emitters ||=` on the
303
- # builder instance, which is safe for a one-file chapter but
304
- # wrong for one split across several: the FIRST file's build()
305
- # call would compute and cache it from whatever `@aggregates`
306
- # held at that moment, and every later file's own validation
307
- # would keep reading that same stale snapshot, silently missing
308
- # any command a later file adds. Recomputed fresh every call
309
- # instead — this walks the whole chapter once per `#build`, not
310
- # a hot path worth memoising at that cost.
444
+ # Groups every command in the chapter by the event name(s) it emits.
445
+ #
446
+ # **Not memoised** — caching this as `@event_emitters ||=` on the builder
447
+ # instance would be safe for a one-file chapter but wrong for one split
448
+ # across several: the first file's `build()` call would compute and cache
449
+ # it from whatever `@aggregates` held at that moment, and every later
450
+ # file's own validation would keep reading that same stale snapshot,
451
+ # silently missing any command a later file adds. Recomputed fresh every
452
+ # call instead — this walks the whole chapter once per `#build`, not a
453
+ # hot path worth memoising at that cost.
454
+ #
455
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
456
+ # @return [Hash{String => Array<Array(String, Class)>}] every event name mapped
457
+ # to the `[owner, command]` pairs (`each_command`'s own shape) that `emits` it;
458
+ # an unqueried event name answers `[]`
311
459
  def event_emitters(aggregates)
312
460
  each_command(aggregates).with_object(Hash.new { |h, k| h[k] = [] }) do |(owner, command), index|
313
461
  command.emits.each { |event_name| index[event_name] << [owner, command] }
314
462
  end
315
463
  end
316
464
 
317
- # STRUCTURAL, NOT NOMINAL. Two commands on two different
465
+ # The comparable shape of one command's attributes, value objects unwrapped.
466
+ #
467
+ # **Structural, not nominal**. Two commands on two different
318
468
  # aggregates that both `emits "SameEvent"` are free to type a
319
- # field through two DIFFERENT, locally-scoped wrapper value
469
+ # field through two different, locally-scoped wrapper value
320
470
  # objects (e.g. one aggregate's own `value: SomeText` vs
321
471
  # another's `value: OtherText`, exactly the per-aggregate "own
322
472
  # text VO" convention every aggregate in this grammar already
323
473
  # follows for everything from `RuleText` to `FieldRef`) without
324
474
  # actually disagreeing about the event's shape — comparing
325
- # `a.type` by NAME would flag that as a violation for no real
475
+ # `a.type` by name would flag that as a violation for no real
326
476
  # reason: an event is one fact, and two isomorphic wrapper types
327
477
  # tell an identical one. So a value-object type is unwrapped to
328
- # its OWN attribute shape (recursively — a wrapper could itself
478
+ # its own attribute shape (recursively — a wrapper could itself
329
479
  # wrap another) before comparing, and only a primitive type
330
480
  # (nothing left to unwrap) or two VOs that truly differ once
331
481
  # unwrapped still counts as a real mismatch. `owner` carries the
332
482
  # type's `value_object` lookup — a command's own attributes only
333
- # know their type's NAME, never the aggregate that declared it,
483
+ # know their type's name, never the aggregate that declared it,
334
484
  # and two sibling aggregates in one chapter each keep a
335
485
  # same-named VO private to themselves, so the unwrap has to ask
336
- # the SAME aggregate the field's own command belongs to, never a
486
+ # the same aggregate the field's own command belongs to, never a
337
487
  # neighbor's.
488
+ #
489
+ # @param command [Class<Bluebook::Command>] the command whose attribute shape
490
+ # to compute
491
+ # @param owner [Bluebook::Aggregate, nil] the aggregate whose `value_object`
492
+ # lookup resolves any wrapper type by name; `nil` when the owner could not
493
+ # be resolved, in which case nothing is unwrapped
494
+ # @return [Array<Array>] one `[name, unwrapped_type, list, optional]` tuple per
495
+ # attribute, sorted; `unwrapped_type` is a type name String for a primitive
496
+ # or an unresolved type, or a recursively-unwrapped Array of the same shape
497
+ # for a value object
338
498
  def event_shape(command, owner)
339
499
  command.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s), a.list?, a.optional?] }.sort
340
500
  end
341
501
 
502
+ # Recursively unwraps a type name to a primitive or a value object's own shape.
503
+ #
504
+ # @param owner [Bluebook::Aggregate, nil] the aggregate whose `value_object`
505
+ # lookup resolves `type_name`; `nil` answers `type_name` unchanged
506
+ # @param type_name [String] the attribute type to unwrap
507
+ # @param seen [Array<String>] type names already unwrapped on this path, so a
508
+ # self-referential value object bottoms out on its own name instead of
509
+ # recursing forever
510
+ # @return [String, Array<Array>] `type_name` itself when `owner` is `nil`, when
511
+ # it is already a primitive, when it is already in `seen`, or when it names no
512
+ # value object `owner` declares; otherwise the same `[name, unwrapped_type,
513
+ # list, optional]` shape `event_shape` returns, one tuple per member
342
514
  def unwrap_shape(owner, type_name, seen = [])
343
- return type_name if owner.nil? # owner couldn't be resolved -- compare by name, same as before this unwrap existed
515
+ # owner couldn't be resolved, so compare by name: nothing left to unwrap
516
+ return type_name if owner.nil?
344
517
  return type_name if Attribute::PRIMITIVES.include?(type_name)
345
518
  # a self-referential VO bottoms out on its own name, not an infinite unwrap
346
519
  return type_name if seen.include?(type_name)
347
520
 
348
521
  shape = owner.value_object(type_name)
349
- return type_name unless shape # not this owner's own VO (a reference type, say) -- nothing further to unwrap
522
+ # not this owner's own VO (a reference type, say): nothing further to unwrap
523
+ return type_name unless shape
350
524
 
351
525
  shape.attributes.map { |a| [a.name, unwrap_shape(owner, a.type.to_s, seen + [type_name]), a.list?, a.optional?] }.sort
352
526
  end
353
527
 
354
- # `owner` (from `each_command`) is a plain STRING — the aggregate's
528
+ # Finds the aggregate an `each_command` owner string names.
529
+ #
530
+ # `owner` (from `each_command`) is a plain string — the aggregate's
355
531
  # `hecks_name` alone, or `"Aggregate.Entity"` for an entity's own
356
- # command. Either way the VALUE OBJECTS a command's fields can be
357
- # typed with are the AGGREGATE's own (`Entity` carries no
532
+ # command. Either way the value objects a command's fields can be
533
+ # typed with are the aggregate's own (`Entity` carries no
358
534
  # `value_object` lookup of its own — the whole rest of this file
359
535
  # already resolves hop/type lookups only at the aggregate level,
360
536
  # e.g. `validate_hop_tail!`'s `target.value_object(type)`), so only
361
537
  # the first segment ever matters here.
538
+ #
539
+ # @param owner [String] an `each_command` owner string, such as `"Game"` or
540
+ # `"Game.Knight"`
541
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
542
+ # @return [Bluebook::Aggregate, nil] the aggregate whose `hecks_name` matches
543
+ # `owner`'s first dotted segment, or `nil` if none does
362
544
  def owner_aggregate(owner, aggregates)
363
545
  aggregates.find { |a| a.hecks_name == owner.to_s.split(".").first }
364
546
  end
365
547
 
548
+ # The comparable attribute shape of whichever command first emits `event_name`.
549
+ #
550
+ # @param event_name [String, Symbol] the event name to look up
551
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
552
+ # @return [Array<Array>, nil] the first emitting command's `event_shape`, or `nil`
553
+ # if no command in the chapter emits this event
366
554
  def event_shape_for(event_name, aggregates)
367
555
  pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
368
556
  return nil if pairs.empty?
@@ -372,9 +560,15 @@ module Hecks
372
560
  end
373
561
 
374
562
  # The identity heads of the aggregate that emits `event_name` — an
375
- # entity's event is stamped with its OWNING aggregate's identity
563
+ # entity's event is stamped with its owning aggregate's identity
376
564
  # (`Event#id` is the parent's), so an owner spelled "Game.Knight"
377
565
  # answers Game's heads.
566
+ #
567
+ # @param event_name [String, Symbol] the event name to look up
568
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
569
+ # @return [Array<Symbol>] the emitting aggregate's own `identity_heads`, plus the
570
+ # emitting entity's own `identity_heads` when the event is an entity's; `[]` if
571
+ # no command in the chapter emits this event, or its owner cannot be resolved
378
572
  def event_identity_heads_for(event_name, aggregates)
379
573
  pairs = event_emitters(aggregates).fetch(event_name.to_s, [])
380
574
  return [] if pairs.empty?
@@ -384,7 +578,7 @@ module Hecks
384
578
  return [] unless aggregate
385
579
 
386
580
  heads = aggregate.identity_heads.map(&:to_sym)
387
- # AN ENTITY'S EVENT ALSO CARRIES THE PIECE'S OWN IDENTITY — the
581
+ # An entity's event also carries the piece's own identity — the
388
582
  # args a piece was addressed by are the args its event announces
389
583
  # (`Emission#emit`: `payload: args`), so `id`-shaped heads are
390
584
  # genuinely there at runtime even though no `attribute` line on
@@ -394,15 +588,20 @@ module Hecks
394
588
  heads + (entity ? entity.identity_heads.map(&:to_sym) : [])
395
589
  end
396
590
 
591
+ # Every command in the chapter, keyed by its fully dotted address.
592
+ #
593
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
594
+ # @return [Hash{String => Class}] `"owner.hecks_name"` (`each_command`'s owner
595
+ # string joined with the command's own `hecks_name`) mapped to the command class
397
596
  def command_lookup(aggregates)
398
597
  each_command(aggregates).with_object({}) do |(owner, command), index|
399
598
  index["#{owner}.#{command.hecks_name}"] = command
400
599
  end
401
600
  end
402
601
 
403
- # A REFERENCE RING IS NOT A MODELLING CHOICE, IT IS A MISSING ONE
602
+ # A reference ring is not a modelling choice, it is a missing one
404
603
  # — a DDD aggregate is a consistency boundary precisely because
405
- # something outside it can only ever point IN, by id, never the
604
+ # something outside it can only ever point in, by id, never the
406
605
  # other way. A caller must be able to reason about one aggregate
407
606
  # alone ; a ring back to where it started means no aggregate in
408
607
  # it is a boundary anyone can reason about without the rest of
@@ -414,18 +613,22 @@ module Hecks
414
613
  # aggregate finishes building long before it can know whether
415
614
  # some later aggregate in the same file points back at it.
416
615
  #
417
- # ACYCLIC WITHIN A CHAPTER (ADR 0025, "References") — widened
418
- # from the direct pair (A -> B -> A) this used to catch alone to
419
- # any ring, however long (A -> B -> C -> A), the same DFS
420
- # coloring a reference graph needs for any cycle. A cross-chapter
421
- # reference is UNREACHABLE here rather than unchecked:
422
- # `Reference#resolve` is scoped to its own chapter by
423
- # construction, so a target this chapter never declares is a
424
- # dangling name, not an edge — `edges.key?` below is what keeps
425
- # the walk from ever leaving this chapter's own aggregates.
426
- # Self-reference stays legal (`parent.parent.name` for a
427
- # hierarchy is real and safe) — excluded the same way the
428
- # direct-pair check already excluded it.
616
+ # Acyclic within a chapter (ADR 0025, "References") — catches any
617
+ # ring, however long (A -> B -> C -> A), not only a direct pair
618
+ # (A -> B -> A), the same DFS coloring a reference graph needs
619
+ # for any cycle. A cross-chapter reference is unreachable here
620
+ # rather than unchecked: `Reference#resolve` is scoped to its
621
+ # own chapter by construction, so a target this chapter never
622
+ # declares is a dangling name, not an edge — `edges.key?` below
623
+ # is what keeps the walk from ever leaving this chapter's own
624
+ # aggregates. Self-reference stays legal (`parent.parent.name`
625
+ # for a hierarchy is real and safe) — excluded the same way a
626
+ # direct pair is.
627
+ #
628
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
629
+ # @return [void]
630
+ # @raise [Bluebook::DSL::Malformed] if the chapter's own aggregate-to-aggregate
631
+ # reference graph contains a cycle
429
632
  def validate_no_bidirectional_references!(aggregates)
430
633
  edges = aggregates.to_h do |aggregate|
431
634
  [aggregate.hecks_name, aggregate.reference_targets.uniq.reject { |target| target == aggregate.hecks_name }]
@@ -443,8 +646,13 @@ module Hecks
443
646
  end
444
647
 
445
648
  # Plain DFS with a visiting/done coloring, over the reference
446
- # graph THIS chapter's own aggregates declare. Returns the ring
649
+ # graph this chapter's own aggregates declare. Returns the ring
447
650
  # itself (in the order it closes), or nil.
651
+ #
652
+ # @param edges [Hash{String => Array<String>}] each aggregate's `hecks_name`
653
+ # mapped to the names of the aggregates it references
654
+ # @return [Array<String>, nil] the aggregate names forming a cycle, in the order
655
+ # the walk closes it, or `nil` if the graph has none
448
656
  def find_reference_cycle(edges)
449
657
  state = {}
450
658
 
@@ -456,6 +664,17 @@ module Hecks
456
664
  nil
457
665
  end
458
666
 
667
+ # One DFS step of `find_reference_cycle`, from `node`.
668
+ #
669
+ # @param node [String] the aggregate name to walk from
670
+ # @param edges [Hash{String => Array<String>}] the whole reference graph, as
671
+ # `find_reference_cycle` receives it
672
+ # @param state [Hash{String => Symbol}] each visited node's `:visiting` or
673
+ # `:done` color, mutated in place across the whole walk
674
+ # @param path [Array<String>] the nodes visited on the current path, mutated
675
+ # in place as the walk descends and backtracks
676
+ # @return [Array<String>, nil] the cycle closing back on `node`, or `nil` if
677
+ # no cycle is reachable from here
459
678
  def reference_cycle_from(node, edges, state, path)
460
679
  return nil if state[node] == :done
461
680
  return path[path.index(node)..] if state[node] == :visiting
@@ -464,7 +683,8 @@ module Hecks
464
683
  path.push(node)
465
684
 
466
685
  edges[node].each do |target|
467
- next unless edges.key?(target) # a name this chapter never declares is dangling, not an edge
686
+ # a name this chapter never declares is dangling, not an edge
687
+ next unless edges.key?(target)
468
688
 
469
689
  found = reference_cycle_from(target, edges, state, path)
470
690
  return found if found
@@ -475,31 +695,37 @@ module Hecks
475
695
  nil
476
696
  end
477
697
 
478
- # THE OTHER HALF OF A HOP — AggregateBuilder#seal_query_field
479
- # recognised the HEAD of a dotted where-field that names one of
698
+ # Resolves and checks every `where` hop deferred at aggregate-seal time.
699
+ #
700
+ # **The other half of a hop** — AggregateBuilder#seal_query_field
701
+ # recognised the head of a dotted where-field that names one of
480
702
  # its own references and deferred it here, unable to check
481
- # further: it cannot yet resolve what the reference points AT.
703
+ # further: it cannot yet resolve what the reference points at.
482
704
  # This runs once every aggregate exists in one chapter, so it
483
705
  # can.
484
706
  #
485
- # Only WHERE clauses ever reach here — a hop on ORDER BY is
707
+ # Only `where` clauses ever reach here — a hop on ORDER BY is
486
708
  # refused outright, immediately, back in seal_query_field
487
709
  # itself (that answer never needed the target's shape).
488
710
  #
489
- # AN ENTITY'S OWN QUERIES DID reach `EntityBuilder#reference_to`
490
- # (added after this comment first claimed otherwise — S9, ADR
491
- # 0025) without ever reaching HERE: tier-1 sealing
492
- # (`AggregateBuilder#query_surfaces`) already recognises a hop
493
- # on an entity's own field and DEFERS it exactly like an
494
- # aggregate's, but nothing ever walked entity queries at tier 2
495
- # to check the deferral — a bad hop, or even a well-formed one,
496
- # built silently and then matched nothing at runtime
711
+ # An entity's own queries do reach `EntityBuilder#reference_to` —
712
+ # tier-1 sealing (`AggregateBuilder#query_surfaces`) already
713
+ # recognises a hop on an entity's own field and defers it exactly
714
+ # like an aggregate's — but nothing walks entity queries at tier 2
715
+ # to check the deferral, so a bad hop, or even a well-formed one,
716
+ # would otherwise build silently and then match nothing at runtime
497
717
  # (`QueryInterpreter#entity_rows` reads an element's fields by
498
718
  # literal hash key, never follows a reference). Refused outright
499
719
  # here instead of taught to follow the hop for real: no corpus
500
720
  # member needs an entity query to cross a reference yet, and a
501
721
  # named refusal beats a runtime that resolves nothing while
502
722
  # looking like it might.
723
+ #
724
+ # @param bluebook [Bluebook::Chapter] the fully assembled chapter to check
725
+ # @return [void]
726
+ # @raise [Bluebook::DSL::Malformed] if an aggregate's `where` hop resolves to
727
+ # nothing, chains too deep, or lands somewhere illegal, or if an entity's own
728
+ # query hops through a reference at all
503
729
  def validate_query_hops!(bluebook)
504
730
  bluebook.aggregates.each do |aggregate|
505
731
  aggregate.queries.each do |query|
@@ -514,11 +740,19 @@ module Hecks
514
740
  end
515
741
  end
516
742
 
743
+ # Mints an implicit query attribute for every unresolved symbolic hop comparison.
744
+ #
517
745
  # The chapter-wide half of AggregateBuilder's local query-argument
518
746
  # inference. A hop cannot resolve while its aggregate is still being
519
747
  # built; here every Reference has an owner and target, so a symbolic
520
748
  # comparison can inherit the type of the scalar it compares without a
521
749
  # duplicate query-local declaration.
750
+ #
751
+ # @param bluebook [Bluebook::Chapter] the fully assembled chapter to infer
752
+ # query arguments over
753
+ # @return [void]
754
+ # @raise [Bluebook::DSL::Malformed] if a hop's reference has no `declared_in`
755
+ # aggregate to resolve its target through
522
756
  def infer_hop_query_arguments!(bluebook)
523
757
  bluebook.aggregates.each do |aggregate|
524
758
  aggregate.queries.each do |query|
@@ -540,13 +774,23 @@ module Hecks
540
774
  end
541
775
  end
542
776
 
543
- # THE LEAF `infer_hop_query_arguments!` INFERS for one resolved
777
+ # Builds the query attribute a resolved hop plan implies for `name`.
778
+ #
779
+ # The leaf `infer_hop_query_arguments!` infers for one resolved
544
780
  # hop plan — a pure function of `plan` and the symbolic `name`
545
781
  # it is naming, pulled out because it is a self-contained
546
782
  # computation with no dependency on the enclosing loop's own
547
783
  # iteration state (it neither reads nor mutates anything about
548
784
  # `bluebook`/`aggregate`/`query` beyond what `plan` already
549
785
  # carries).
786
+ #
787
+ # @param name [Symbol] the symbolic comparison value the query names, and the
788
+ # attribute name to mint
789
+ # @param plan [QuerySpecification::HopPath::Plan] the resolved hop chain whose
790
+ # target's shape `name` is inferred against
791
+ # @return [Bluebook::Attribute, nil] a `String`-typed attribute when the tail
792
+ # lands on the target's lifecycle field; otherwise an attribute copying the
793
+ # resolved leaf's type and list-ness; `nil` when the tail resolves to nothing
550
794
  def inferred_hop_leaf(name, plan)
551
795
  target = plan.hops.last.target
552
796
  head, *nested = plan.tail.to_s.split(".")
@@ -561,6 +805,14 @@ module Hecks
561
805
  end
562
806
  end
563
807
 
808
+ # Refuses any `where` clause on an entity's own query that hops through a reference.
809
+ #
810
+ # @param aggregate [Bluebook::Aggregate] the aggregate `entity` is nested under,
811
+ # named only in a raised message
812
+ # @param entity [Class<Bluebook::Entity>] the entity whose queries to check
813
+ # @return [void]
814
+ # @raise [Bluebook::DSL::Malformed] if any of `entity`'s queries has a `where`
815
+ # clause that hops through one of the entity's own references
564
816
  def refuse_entity_query_hops!(aggregate, entity)
565
817
  entity.queries.each do |query|
566
818
  query.wheres.each do |clause|
@@ -575,6 +827,18 @@ module Hecks
575
827
  end
576
828
  end
577
829
 
830
+ # Resolves one `where` hop's target and checks where its tail lands.
831
+ #
832
+ # @param aggregate [Bluebook::Aggregate] the querying aggregate, named in a
833
+ # raised message
834
+ # @param query [Bluebook::Query] the query `clause` belongs to, named in a
835
+ # raised message
836
+ # @param clause [QuerySpecification::Common::WhereClause] the `where` clause whose
837
+ # `field` hops through one of `aggregate`'s own references
838
+ # @return [void]
839
+ # @raise [Bluebook::DSL::Malformed] if the hop's target is not declared in this
840
+ # chapter, the hop chain exceeds `QuerySpecification::HopPath::MAX_HOPS`, or
841
+ # the resolved tail fails `validate_hop_tail!`'s own checks
578
842
  def validate_hop_clause!(aggregate, query, clause)
579
843
  plan = QuerySpecification::HopPath.plan(clause.field, aggregate.attributes)
580
844
 
@@ -602,11 +866,26 @@ module Hecks
602
866
  validate_hop_tail!(aggregate, query, clause, target, plan.tail)
603
867
  end
604
868
 
605
- # The same three-way answer seal_query_field gives for its OWN
869
+ # Checks that a resolved hop's tail lands on a real scalar field of its target.
870
+ #
871
+ # The same three-way answer seal_query_field gives for its own
606
872
  # aggregate's fields — landing on a real scalar (fine), landing
607
873
  # on a value object (refused by name), or naming nothing at all
608
- # (refused by name) — asked instead of the hop's TARGET aggregate,
874
+ # (refused by name) — asked instead of the hop's target aggregate,
609
875
  # since that is whose shape the tail actually has to answer for.
876
+ #
877
+ # @param aggregate [Bluebook::Aggregate] the querying aggregate, named in a
878
+ # raised message
879
+ # @param query [Bluebook::Query] the query `clause` belongs to, named in a
880
+ # raised message
881
+ # @param clause [QuerySpecification::Common::WhereClause] the `where` clause the
882
+ # hop was resolved from, named in a raised message
883
+ # @param target [Bluebook::Aggregate] the aggregate the hop resolved to
884
+ # @param tail [String] the dotted field path remaining after the hop, such as
885
+ # `"status"` or `"amount.currency"`
886
+ # @return [void]
887
+ # @raise [Bluebook::DSL::Malformed] if `tail` lands on a value object rather
888
+ # than a scalar, or names no field `target` declares
610
889
  def validate_hop_tail!(aggregate, query, clause, target, tail)
611
890
  name, *nested = tail.to_s.split(".")
612
891
  attribute = target.attributes.find { |candidate| candidate.name.to_s == name }
@@ -632,11 +911,30 @@ module Hecks
632
911
  "not exist matches nothing and refuses nothing"
633
912
  end
634
913
 
635
- # A WHERE hop with an ordered comparator is legitimate ("client
914
+ # Checks that an ordered comparator over a hopped field lands on a number.
915
+ #
916
+ # A where hop with an ordered comparator is legitimate ("client
636
917
  # whose balance > 500") — AggregateBuilder#seal_ordered_comparator
637
918
  # already deferred this exact check for the same reason every
638
919
  # other hop check is deferred, and this is where it gets asked,
639
- # against the hop's TARGET instead of the querying aggregate.
920
+ # against the hop's target instead of the querying aggregate.
921
+ #
922
+ # @param aggregate [Bluebook::Aggregate] the querying aggregate, named in a
923
+ # raised message
924
+ # @param query [Bluebook::Query] the query `clause` belongs to, named in a
925
+ # raised message
926
+ # @param clause [QuerySpecification::Common::WhereClause] the `where` clause
927
+ # whose `op` and `field` are named in a raised message
928
+ # @param target [Bluebook::Aggregate] the aggregate the hop resolved to, named
929
+ # in a raised message
930
+ # @param attribute [Bluebook::Attribute, nil] the tail's root attribute on
931
+ # `target`, or `nil` when the tail lands on the lifecycle field
932
+ # @param nested [Array<String>] the tail's remaining dotted segments past
933
+ # `attribute`, `[]` for a bare field
934
+ # @return [void]
935
+ # @raise [Bluebook::DSL::Malformed] if `clause.op` is one of
936
+ # `AggregateBuilder::ORDERED_COMPARATORS` and the field it lands on, after
937
+ # `attribute`/`nested`, is not numeric
640
938
  def validate_hop_comparator!(aggregate, query, clause, target, attribute, nested)
641
939
  return unless AggregateBuilder::ORDERED_COMPARATORS.include?(clause.op.to_s.to_sym)
642
940
  return if attribute &&
@@ -650,10 +948,10 @@ module Hecks
650
948
  "anything else the adapters answer differently or not at all"
651
949
  end
652
950
 
653
- # THE TARGET HALF of `projects` validation (S12, ADR 0025) —
951
+ # The target half of `projects` validation (S12, ADR 0025) —
654
952
  # `AggregateBuilder#seal_projected_fields` already checked the
655
- # LOCAL half at declare time (the reference names a real
656
- # `reference_to` on THIS aggregate); this checks the reference
953
+ # local half at declare time (the reference names a real
954
+ # `reference_to` on this aggregate); this checks the reference
657
955
  # actually resolves to a real aggregate in this chapter, and
658
956
  # that aggregate really declares `remote_field` as a scalar.
659
957
  #
@@ -665,13 +963,20 @@ module Hecks
665
963
  # one resolution primitive. A single hop can never reach
666
964
  # HopPath::MAX_HOPS, so :too_deep is structurally unreachable
667
965
  # here and is not special-cased.
966
+ #
967
+ # @param bluebook [Bluebook::Chapter] the fully assembled chapter to check
968
+ # @return [void]
969
+ # @raise [Bluebook::DSL::Malformed] if any aggregate's `projects` field fails
970
+ # `validate_projected_field!`'s own checks
668
971
  def validate_projected_fields!(bluebook)
669
972
  bluebook.aggregates.each do |aggregate|
670
973
  aggregate.projected_fields.each { |field| validate_projected_field!(aggregate, field) }
671
974
  end
672
975
  end
673
976
 
674
- # A linear decision tree of validation rules over ONE resolved
977
+ # Checks that one `projects` field's reference resolves to a real scalar.
978
+ #
979
+ # A linear decision tree of validation rules over one resolved
675
980
  # hop plan, each already explained by its own comment above
676
981
  # (the lifecycle fallback, the chained-projection fallback, the
677
982
  # final scalar check) — a fixed, closed sequence "resolve, then
@@ -679,6 +984,13 @@ module Hecks
679
984
  # land," not several unrelated concerns. Splitting it would
680
985
  # scatter `plan`/`target`/`remote_attribute` across new methods
681
986
  # that would each need most of them anyway.
987
+ #
988
+ # @param aggregate [Bluebook::Aggregate] the aggregate that declared `field`
989
+ # @param field [Bluebook::ProjectedField] the declared `projects` field to check
990
+ # @return [void]
991
+ # @raise [Bluebook::DSL::Malformed] if `field.reference` hops to an aggregate
992
+ # this chapter never declares, or `field.remote_field` names nothing on the
993
+ # target, or names something other than a scalar
682
994
  # rubocop:disable-next Metrics/AbcSize
683
995
  def validate_projected_field!(aggregate, field)
684
996
  plan = QuerySpecification::HopPath.plan("#{field.reference}/#{field.remote_field}", aggregate.attributes)
@@ -693,17 +1005,17 @@ module Hecks
693
1005
  target = plan.hops.last.target
694
1006
  remote_attribute = target.attributes.find { |candidate| candidate.name.to_s == plan.tail }
695
1007
 
696
- # THE WORKED EXAMPLE ITSELF (ADR 0025) reads through a
697
- # LIFECYCLE field — banking's Customer.status is `lifecycle
1008
+ # The worked example itself (ADR 0025) reads through a
1009
+ # lifecycle field — banking's Customer.status is `lifecycle
698
1010
  # :status`, never a plain `attribute` — the same fallback
699
1011
  # validate_hop_tail! already gives a query's own hop tail. A
700
1012
  # lifecycle field is always a plain string by construction ;
701
1013
  # nothing further to check once it matches by name.
702
1014
  return if remote_attribute.nil? && target.lifecycle&.field.to_s == plan.tail
703
1015
 
704
- # A PROJECTION MAY CHAIN THROUGH ANOTHER PROJECTION (S12, ADR
1016
+ # A projection may chain through another projection (S12, ADR
705
1017
  # 0025's own boundary rule, followed through) — `target`'s
706
- # OWN projected fields live in `projected_fields`, a
1018
+ # own projected fields live in `projected_fields`, a
707
1019
  # separate list from `attributes`, so a match there is
708
1020
  # invisible to the check above even though it names a real,
709
1021
  # always-current, stored field. `Transfer.projects
@@ -733,14 +1045,21 @@ module Hecks
733
1045
  "value, never a reference, a value object, or a list"
734
1046
  end
735
1047
 
1048
+ # Decides whether a declared attribute is a plain, single scalar value.
1049
+ #
1050
+ # @param target [Bluebook::Aggregate] the aggregate `attribute` is declared on,
1051
+ # whose `value_object` lookup resolves `attribute`'s type
1052
+ # @param attribute [Bluebook::Attribute] the attribute to check
1053
+ # @return [Boolean] `true` when `attribute` is not a list, not a reference, and
1054
+ # its type names no value object `target` declares
736
1055
  def projectable_scalar?(target, attribute)
737
1056
  !attribute.list? && !attribute.reference? && target.value_object(attribute.type).nil?
738
1057
  end
739
1058
 
740
- # `correlates_by` NAMES A SCALAR, NOW CHECKED RATHER THAN TRUSTED.
1059
+ # `correlates_by` names a scalar, now checked rather than trusted.
741
1060
  #
742
1061
  # ProcessManagerBuilder#validate! already refuses a bare, undotted
743
- # spelling — a SYNTACTIC guarantee that the declaration cannot leave
1062
+ # spelling — a syntactic guarantee that the declaration cannot leave
744
1063
  # the question open. It cannot go further: a process manager is built
745
1064
  # in isolation, before this chapter's aggregates exist to check
746
1065
  # against. Here, with the whole document assembled, the dotted path
@@ -757,6 +1076,14 @@ module Hecks
757
1076
  # aggregate's own reference key; saga_interpreter/correlation.rb), so
758
1077
  # an absent field is not this check's business. Only a field that
759
1078
  # resolves, and resolves to something other than a scalar, is.
1079
+ #
1080
+ # @param process_managers [Array<Bluebook::ProcessManager>] the chapter's
1081
+ # declared process managers
1082
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
1083
+ # @return [void]
1084
+ # @raise [Bluebook::DSL::ProcessManagerBuilder::InvalidProcessManager] if a
1085
+ # process manager's `correlates_by` path resolves, on any command that emits
1086
+ # an event it reacts to, to something other than a scalar
760
1087
  def validate_correlation_keys!(process_managers, aggregates)
761
1088
  process_managers.each do |pm|
762
1089
  next unless pm.correlates_by
@@ -769,6 +1096,15 @@ module Hecks
769
1096
  end
770
1097
  end
771
1098
 
1099
+ # Finds why a process manager's `correlates_by` path is not a scalar, if it isn't.
1100
+ #
1101
+ # @param process_manager [Bluebook::ProcessManager] the process manager whose
1102
+ # `correlates_by` to check
1103
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
1104
+ # @return [String, nil] the reason the path is not a scalar, on the first
1105
+ # emitting command whose first segment resolves; `nil` if every command that
1106
+ # emits a reacted-to event either omits the first segment, or resolves to
1107
+ # a scalar
772
1108
  def correlation_key_violation(process_manager, aggregates)
773
1109
  head, *rest = process_manager.correlates_by.to_s.split(".")
774
1110
  events = reacted_events(process_manager)
@@ -784,6 +1120,12 @@ module Hecks
784
1120
  nil
785
1121
  end
786
1122
 
1123
+ # The demodulised names of every event a process manager reacts to.
1124
+ #
1125
+ # @param process_manager [Bluebook::ProcessManager] the process manager whose
1126
+ # `starts_on`, `ends_on` and handler event types to collect
1127
+ # @return [Array<String>] the unique demodulised event names, `REFUSED` (the
1128
+ # compensation trigger, not a real event) excluded
787
1129
  def reacted_events(process_manager)
788
1130
  ([process_manager.starts_on, process_manager.ends_on] + process_manager.handlers.map(&:event_type))
789
1131
  .compact
@@ -792,6 +1134,13 @@ module Hecks
792
1134
  .uniq
793
1135
  end
794
1136
 
1137
+ # Every command across the chapter that emits one of the given events.
1138
+ #
1139
+ # @param events [Array<String>] the demodulised event names to match against
1140
+ # each command's own `emits`
1141
+ # @param aggregates [Array<Bluebook::Aggregate>] the chapter's declared aggregates
1142
+ # @return [Array<Array(Bluebook::Aggregate, Class)>] one `[aggregate, command]`
1143
+ # pair per matching command, an aggregate's own or one of its entities'
795
1144
  def emitting_commands(events, aggregates)
796
1145
  aggregates.flat_map do |aggregate|
797
1146
  commands = aggregate.commands + aggregate.entities.flat_map(&:commands)
@@ -800,6 +1149,16 @@ module Hecks
800
1149
  end
801
1150
  end
802
1151
 
1152
+ # Decides whether one command attribute can serve as a correlation key.
1153
+ #
1154
+ # @param owner [Bluebook::Aggregate] the aggregate whose `value_object` lookup
1155
+ # resolves `attribute`'s type, for `walk_scalar`
1156
+ # @param attribute [Bluebook::Attribute] the command attribute the correlation
1157
+ # path's first segment named
1158
+ # @param segments [Array<String>] the path's remaining dotted segments, `[]` for
1159
+ # a bare field
1160
+ # @return [String, nil] why `attribute` cannot serve as a correlation key, or
1161
+ # `nil` if it can — see `walk_scalar` for what a non-`nil` reason means
803
1162
  def list_or_scalar_violation(owner, attribute, segments)
804
1163
  if attribute.list?
805
1164
  return "#{attribute.name} is a list — a correlation key must name one instance's own field, " \
@@ -816,6 +1175,16 @@ module Hecks
816
1175
  # a value object this domain never declared, a field that value
817
1176
  # object does not have, or a segment left over after already
818
1177
  # reaching a scalar.
1178
+ #
1179
+ # @param owner [Bluebook::Aggregate] the aggregate whose `value_object` lookup
1180
+ # resolves each stepped-into type name
1181
+ # @param type_name [String] the type to check or step into
1182
+ # @param segments [Array<String>] the dotted path segments remaining to walk
1183
+ # @return [String, nil] `nil` when the walk bottoms out cleanly on a scalar;
1184
+ # otherwise why it cannot: `type_name` is a value object with no segments
1185
+ # left, `type_name` is already a scalar with segments left over, `type_name`
1186
+ # names no value object `owner` declares, the next segment names no field of
1187
+ # that value object, or that field is a list
819
1188
  def walk_scalar(owner, type_name, segments)
820
1189
  if segments.empty?
821
1190
  return nil if Attribute::PRIMITIVES.include?(type_name)