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
@@ -1,17 +1,19 @@
1
+ require "hecks/vocabulary"
2
+
1
3
  module Hecks
2
4
  module Bluebook
3
5
  # Lightweight formal methods over the IR — the same family as TLA+/
4
6
  # Alloy/P: every lifecycle is a declared finite state machine and
5
- # every process manager a declared protocol, and BOTH are already
6
- # data, not code, so they can be MODEL-CHECKED rather than merely
7
+ # every process manager a declared protocol, and both are already
8
+ # data, not code, so they can be model-checked rather than merely
7
9
  # executed. Static analysis only — no bluebook boots twice, no
8
10
  # runtime is touched — over what meta_validator/judge.rb and the
9
11
  # builders' own `validate!` methods leave uncovered (an undeclared
10
12
  # transition target, a dispatch to nowhere, a compensation nothing
11
13
  # can ever reach).
12
14
  #
13
- # THE RARE PROPERTY THIS RESTS ON: the model IS the implementation.
14
- # A checker over TLA+ verifies a SPEC a human keeps in sync with
15
+ # **The rare property this rests on**: the model is the implementation.
16
+ # A checker over TLA+ verifies a spec a human keeps in sync with
15
17
  # code by hand ; this verifies the same IR the runtime dispatches
16
18
  # against, so there is no second copy to drift.
17
19
  module ModelCheck
@@ -19,8 +21,8 @@ module Hecks
19
21
  def to_s = "#{severity.to_s.upcase.ljust(7)} #{kind.to_s.ljust(20)} #{subject} — #{message}"
20
22
  end
21
23
 
22
- # A FINDING SHIPPED, NOT SILENCED — the coverage-gate idiom, empty
23
- # allowlists enforced BOTH directions (spec/model_check_spec.rb holds
24
+ # **A finding shipped, not silenced** — the coverage-gate idiom, empty
25
+ # allowlists enforced both directions (spec/model_check_spec.rb holds
24
26
  # this exact table: an error the checker reports and this does not
25
27
  # name is a regression, an entry the checker no longer reports is
26
28
  # stale and must be deleted). bin/model_check reads this same
@@ -29,45 +31,42 @@ module Hecks
29
31
  # "banking"/ExternalSettlement — found on the first real run:
30
32
  # ExternalSettlement declares `ends_on "ExternalTransferSent"`, and
31
33
  # ExternalTransfer.Send genuinely emits it — the event is real, and
32
- # the AGGREGATE reaches "sent" (its own, separate lifecycle) — but
33
- # the SAGA'S protocol has no `on "ExternalTransferSent"` handler, so
34
+ # the aggregate reaches "sent" (its own, separate lifecycle) — but
35
+ # the saga's protocol has no `on "ExternalTransferSent"` handler, so
34
36
  # its own `state "sent"` is unreachable through the chain the
35
37
  # checker walks, and the saga's own bookkeeping (saga_log, ends_on)
36
38
  # never closes it. Real domain activity is unaffected; the saga's
37
- # OWN tracking of it is not. Left named rather than redesigning a
39
+ # own tracking of it is not. Left named rather than redesigning a
38
40
  # corpus fixture that is not this checker's to redesign.
39
- # S7, ADR 0025 — the ExternalSettlement finding this used to
40
- # allowlist is GONE, not just quieted: its "sent" state was a
41
- # `state "x"` line never named by any handler's own from:/to:, a
42
- # pure declaration-drift artifact. States are DERIVED from the
41
+ # S7, ADR 0025 — an ExternalSettlement finding once lived here as an
42
+ # allowlist entry and is gone now, not just quieted: its "sent" state
43
+ # was a `state "x"` line never named by any handler's own from:/to:, a
44
+ # pure declaration-drift artifact. States are derived from the
43
45
  # transitions that name them now (ProcessManagerBuilder#derived_
44
46
  # states), so a state nothing ever transitions into or out of no
45
- # longer exists to be unreachable — the finding this allowlisted
47
+ # longer exists to be unreachable — the finding that entry allowlisted
46
48
  # cannot occur any more, by construction.
47
49
  #
48
- # "banking"/NotifyOnClosure, FlagKeyReturn — real, confirmed
49
- # findings, not bugs to fix. `across "Notifications"` names a
50
- # domain that does not exist anywhere in this repo — no
51
- # Notifications bluebook, no hecksagon, nothing to `uses_framework`
52
- # or `subscribe` to. This is deliberate: `spec/runtime/policy_spec.
53
- # rb` (a test literally named "records a reaction it cannot
54
- # deliver rather than swallowing it") and `lib/hecks/runtime/
55
- # errors.rb`'s own `UnknownVerb` comment both treat "target domain
56
- # not loaded" as the EXPECTED outcome for it — Notifications is
57
- # used on purpose to exercise the undelivered-reaction runtime
58
- # path, not left half-built. There is no real `subscribe` line to
59
- # add (no event of Notifications' own to name) and no real domain
60
- # to point `uses_framework` at.
50
+ # "banking"/NotifyOnClosure, FlagKeyReturn — gone from here, moved
51
+ # to banking. `across "Notifications"` names a domain that does not
52
+ # exist anywhere in this repo, deliberately (it exercises the
53
+ # undelivered-reaction runtime path — `spec/runtime/policy_spec.rb`,
54
+ # "records a reaction it cannot deliver rather than swallowing it").
55
+ # That expectation is now declared on the two policies themselves —
56
+ # `across "Notifications", expect_undelivered: true` — and
57
+ # `expected_undelivered_findings` below holds it in both directions:
58
+ # the unknown-target and unacknowledged-relationship findings are
59
+ # expected, and a declaration whose target turns out reachable is a
60
+ # `stale_undelivered_expectation` error. A domain's own allowance
61
+ # lives in its own source, never in a core table keyed by its name.
62
+ #
63
+ # Pinned empty (spec/model_check_spec.rb), the way `bin/fuzz`'s
64
+ # `KNOWN_FUZZ_FINDINGS` is: a finding a domain means to keep belongs
65
+ # in that domain's own declaration.
61
66
  ALLOWED_FINDINGS = {
62
- "banking" => [
63
- [:unacknowledged_relationship, "NotifyOnClosure"],
64
- [:unknown_target_domain, "NotifyOnClosure"],
65
- [:unacknowledged_relationship, "FlagKeyReturn"],
66
- [:unknown_target_domain, "FlagKeyReturn"]
67
- ]
68
- # QualityControl WAS the first domain in this corpus to trigger an
69
- # `asks`/`tells` PORT OPERATION from a `policy`, and used to carry
70
- # two entries here for it — both GONE now, not just quieted:
67
+ # QualityControl was the first domain in this corpus to trigger an
68
+ # `asks`/`tells` port operation from a `policy`, and once carried
69
+ # two entries here for it — both gone now, not just quieted:
71
70
  #
72
71
  # `deaf_policy` (ClearOnPass, RefuseOnFail, RecordTheIssue,
73
72
  # RecordTheRefusal) went first: `emitted_events` below now reads an
@@ -80,19 +79,19 @@ module Hecks
80
79
  #
81
80
  # `unknown_trigger` (FileWhenSubmitted, AskOnceMore) — BUG#23 — was
82
81
  # never actually a `Naming`/`PolicyBuilder` defect, confirmed by
83
- # tracing the real dispatch path rather than assuming the comment
84
- # that used to sit here: `Naming.command_ref`'s bare-constant
85
- # rewrite DOES leave `trigger Ticket::IssueTracker::File` (aggregate,
82
+ # tracing the real dispatch path rather than trusting a stale
83
+ # comment that once sat here: `Naming.command_ref`'s bare-constant
84
+ # rewrite does leave `trigger Ticket::IssueTracker::File` (aggregate,
86
85
  # port, operation) as "Ticket::IssueTracker.File", a leftover `::`
87
86
  # past the aggregate — but `PolicyInterpreter#deliver` re-qualifies
88
87
  # every trigger with this domain's own name before dispatch
89
88
  # ("QualityControl::Ticket::IssueTracker.File"), and `Naming.
90
89
  # split_verb` already folds that reintroduced `::` into the
91
- # dot-joined tail correctly (fixed for `ReactionInvocation#
92
- # resolve_target`, PR #520, predating this entry's own removal) —
93
- # confirmed live: a real dispatch through `Ticket.Submit` fires
90
+ # dot-joined tail correctly (the same fix `ReactionInvocation#
91
+ # resolve_target` already carries) — confirmed live: a real
92
+ # dispatch through `Ticket.Submit` fires
94
93
  # `IssueFiled`/`TicketFiled` exactly as declared. The actual gap was
95
- # entirely in THIS checker: `verbs_of` never enumerated a port
94
+ # entirely in this checker: `verbs_of` never enumerated a port
96
95
  # operation as a triggerable verb at all, and `policy_findings`
97
96
  # compared raw strings instead of `Naming.split_verb` triples the
98
97
  # way `handler_findings`'s own `unknown_dispatch` check already does
@@ -103,31 +102,156 @@ module Hecks
103
102
 
104
103
  module_function
105
104
 
105
+ # Runs every static model check over `bluebook` and returns what it
106
+ # finds.
107
+ #
106
108
  # `hecksagon:`/`known_domains:` — both optional, both `nil`-safe
107
109
  # (every existing caller with no sibling hecksagon, or checking one
108
110
  # domain in isolation, behaves exactly as before). `hecksagon` is
109
- # THIS bluebook's own sibling wiring file, if the caller loaded one
111
+ # this bluebook's own sibling wiring file, if the caller loaded one
110
112
  # (see `emitted_events`'s own comment on why a caller that didn't
111
113
  # simply finds none, correctly). `known_domains` is the caller's
112
- # OWN corpus-wide view — every bluebook/hecksagon name it has
114
+ # own corpus-wide view — every bluebook/hecksagon name it has
113
115
  # booted anywhere, across every domain it has looked at, not just
114
116
  # this one — used only to catch a typo'd `across`/`uses_framework`
115
117
  # target; see `cross_domain_policy_findings`'s own comment for why
116
118
  # this can only ever be a corpus-scoped heuristic, never a general
117
119
  # correctness guarantee.
118
- def call(bluebook, hecksagon: nil, known_domains: nil)
120
+ #
121
+ # `rust_target:`/`strict:` — both default false, both only change the
122
+ # severity of `rust_reserved_name` findings (see
123
+ # `rust_reserved_name_findings`); every other finding is unaffected.
124
+ #
125
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to check
126
+ # @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
127
+ # sibling wiring file, if the caller loaded one
128
+ # @param known_domains [Set<String>, nil] every bluebook/hecksagon
129
+ # name the caller has booted anywhere in this corpus scan
130
+ # @param global_emitted_events [Set<String>, nil] every bare event
131
+ # name any domain the caller has booted this run emits — lets a
132
+ # same-domain-target policy (e.g. one built by `translates`)
133
+ # reacting to a genuinely foreign event avoid a false `deaf_policy`
134
+ # finding; `nil` (every pre-existing call site) keeps today's
135
+ # single-domain-only check
136
+ # @param rust_target [Boolean] whether this domain has a real Rust
137
+ # target, raising `rust_reserved_name` findings to error
138
+ # @param strict [Boolean] whether to raise every `rust_reserved_name`
139
+ # finding to error regardless of `rust_target`
140
+ # @return [Array<Finding>] every finding this bluebook triggers,
141
+ # across its lifecycles, sagas, policies, and Rust-reserved names
142
+ def call(bluebook, hecksagon: nil, known_domains: nil, global_emitted_events: nil, rust_target: false, strict: false)
119
143
  findings = []
120
144
  bluebook.aggregates.each do |aggregate|
121
145
  findings.concat(lifecycle_findings(aggregate, aggregate))
122
146
  aggregate.entities.each { |entity| findings.concat(lifecycle_findings(aggregate, entity)) }
123
147
  end
124
148
  bluebook.process_managers.each { |process_manager| findings.concat(saga_findings(bluebook, process_manager)) }
125
- bluebook.policies.each { |policy| findings.concat(policy_findings(bluebook, policy, hecksagon, known_domains)) }
149
+ bluebook.policies.each do |policy|
150
+ findings.concat(policy_findings(bluebook, policy, hecksagon, known_domains, global_emitted_events))
151
+ end
152
+ findings.concat(rust_reserved_name_findings(domain_name: bluebook.name,
153
+ aggregate_names: bluebook.aggregates.map(&:hecks_name),
154
+ rust_target: rust_target, strict: strict))
155
+ findings
156
+ end
157
+
158
+ # ── Rust reserved names ───────────────────────────────────────────
159
+ #
160
+ # A name that becomes a bare Rust module identifier with no `r#`
161
+ # escape hatch: an aggregate (`pub mod <name.downcase>;` plus its
162
+ # `<name.downcase>.rs` file) and a domain (`pub mod <name>;` and a
163
+ # Cargo `[features]` key). Field names are not checked — both
164
+ # generators already raw-escape those (`rust_ident_field`).
165
+ #
166
+ # The words come from the `RustReservedWord`/`CargoReservedName`
167
+ # vocabularies, the same tables `rust/project/naming.rb` and
168
+ # hecks-codegen's generated `reserved_names.rs` read. Both Rust
169
+ # generators refuse through this check (`Projector.
170
+ # reserved_name_refusal`, and its hecks-codegen port in `naming.rs`).
171
+ #
172
+ # Severity: a domain that only ever runs in Ruby is fine with an
173
+ # aggregate named `Match`, so this warns by default. It is an error
174
+ # when the caller says the domain has a Rust target (`rust_target:` —
175
+ # `bin/model_check` reads it off the domain's Cargo feature, the
176
+ # generators always pass it) or asks for strictness (`strict:`,
177
+ # `bin/model_check --strict`).
178
+ #
179
+ # The module-name transform is `downcase`, the one both generators
180
+ # apply to an aggregate name and to an attached chapter's name.
181
+ #
182
+ # @param domain_name [String, Symbol, nil] the domain's own name, or
183
+ # `nil` to skip the domain-level check
184
+ # @param aggregate_names [Array<String, Symbol>] every aggregate name
185
+ # to check
186
+ # @param rust_target [Boolean] whether this domain has a real Rust
187
+ # target, raising severity to error
188
+ # @param strict [Boolean] whether to raise severity to error
189
+ # regardless of `rust_target`
190
+ # @return [Array<Finding>] one `:rust_reserved_name` finding per
191
+ # aggregate or domain name that collides with a Rust or Cargo
192
+ # reserved word
193
+ def rust_reserved_name_findings(domain_name: nil, aggregate_names: [], rust_target: false, strict: false)
194
+ severity = rust_target || strict ? :error : :warning
195
+ keywords = Hecks::Vocabulary.fetch("RustReservedWord")
196
+
197
+ findings = aggregate_names.filter_map do |name|
198
+ module_name = rust_module_name(name)
199
+ next unless keywords.include?(module_name)
200
+
201
+ Finding.new(kind: :rust_reserved_name, severity: severity, subject: name.to_s,
202
+ message: "the aggregate's Rust module `#{module_name}` is a Rust keyword (RustReservedWord) — " \
203
+ "`pub mod #{module_name};` has no raw-identifier escape; rename the aggregate")
204
+ end
205
+ findings.concat(domain_reserved_name_findings(domain_name, keywords, severity)) if domain_name
126
206
  findings
127
207
  end
128
208
 
129
- # ── lifecycles (aggregate AND entity — a piece may declare one too) ──
209
+ # Checks the domain's own module/Cargo-feature name, one level up
210
+ # from `rust_reserved_name_findings`'s own per-aggregate check.
211
+ #
212
+ # @param domain_name [String, Symbol] the domain's own name
213
+ # @param keywords [Array<String>] the admitted `RustReservedWord`
214
+ # vocabulary rows
215
+ # @param severity [Symbol] the finding severity to use, `:error` or
216
+ # `:warning`
217
+ # @return [Array<Finding>] a single-element array holding a
218
+ # `:rust_reserved_name` finding, or `[]` if `domain_name` collides
219
+ # with no reserved word or Cargo key
220
+ def domain_reserved_name_findings(domain_name, keywords, severity)
221
+ module_name = rust_module_name(domain_name)
222
+ table = if keywords.include?(module_name)
223
+ "a Rust keyword (RustReservedWord)"
224
+ elsif Hecks::Vocabulary.fetch("CargoReservedName").include?(module_name)
225
+ "a reserved Cargo.toml key (CargoReservedName)"
226
+ end
227
+ return [] unless table
228
+
229
+ [Finding.new(kind: :rust_reserved_name, severity: severity, subject: domain_name.to_s,
230
+ message: "the domain's Rust module and Cargo feature `#{module_name}` is #{table} — " \
231
+ "rename the domain")]
232
+ end
233
+
234
+ # Derives the Rust module identifier both generators derive from a
235
+ # name.
236
+ #
237
+ # @param name [String, Symbol] an aggregate or domain name
238
+ # @return [String] the Rust module identifier both generators derive
239
+ # from `name`
240
+ def rust_module_name(name) = name.to_s.downcase
241
+
242
+ # ── lifecycles (aggregate and entity — a piece may declare one too) ──
130
243
 
244
+ # Runs every lifecycle-shaped model check for one declaring
245
+ # construct's own lifecycle, if it has one.
246
+ #
247
+ # @param aggregate [Bluebook::Aggregate] the root aggregate `declaring`
248
+ # belongs to
249
+ # @param declaring [Bluebook::Aggregate, Bluebook::Entity] the
250
+ # construct whose own lifecycle to check — `aggregate` itself, or
251
+ # one of its entities
252
+ # @return [Array<Finding>] `[]` if `declaring` has no lifecycle;
253
+ # otherwise every `unknown_command`/`unreachable_state`/
254
+ # `dead_transition`/`stuck_state` finding its lifecycle triggers
131
255
  def lifecycle_findings(aggregate, declaring)
132
256
  lifecycle = declaring.lifecycle
133
257
  return [] unless lifecycle
@@ -146,6 +270,15 @@ module Hecks
146
270
  findings
147
271
  end
148
272
 
273
+ # Finds every declared state a lifecycle's own reachability walk
274
+ # never reaches.
275
+ #
276
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
277
+ # @param full [Array<String>] every state `full_states` declares
278
+ # @param reached [Set<String>] every state `reachable_states` reaches
279
+ # @param subject [String] the finding's own subject label
280
+ # @return [Array<Finding>] one `:unreachable_state` finding per
281
+ # declared state that `reached` does not cover
149
282
  def unreachable_state_findings(lifecycle, full, reached, subject)
150
283
  (full - reached.to_a).map do |state|
151
284
  Finding.new(kind: :unreachable_state, severity: :error, subject: subject,
@@ -154,6 +287,13 @@ module Hecks
154
287
  end
155
288
  end
156
289
 
290
+ # Finds every constrained transition that can never fire.
291
+ #
292
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
293
+ # @param reached [Set<String>] every state `reachable_states` reaches
294
+ # @param subject [String] the finding's own subject label
295
+ # @return [Array<Finding>] one `:dead_transition` finding per
296
+ # constrained transition whose `from:` states are all unreached
157
297
  def dead_transition_findings(lifecycle, reached, subject)
158
298
  lifecycle.transitions.filter_map do |command, transition|
159
299
  next unless transition.constrained?
@@ -165,6 +305,14 @@ module Hecks
165
305
  end
166
306
  end
167
307
 
308
+ # Finds every reached state with no transition ever leaving it.
309
+ #
310
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
311
+ # @param reached [Set<String>] every state `reachable_states` reaches
312
+ # @param subject [String] the finding's own subject label
313
+ # @return [Array<Finding>] one `:stuck_state` finding per reached,
314
+ # non-exempt state with no transition ever leaving it (skipped
315
+ # entirely when any transition is unconstrained)
168
316
  def stuck_state_findings(lifecycle, reached, subject)
169
317
  any_unconstrained = lifecycle.transitions.any? { |_, t| !t.constrained? }
170
318
  (reached - terminal_exempt(lifecycle)).filter_map do |state|
@@ -177,6 +325,15 @@ module Hecks
177
325
  end
178
326
  end
179
327
 
328
+ # Finds every transition named after a command this construct does
329
+ # not declare.
330
+ #
331
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
332
+ # @param commands [Array<String>] every command name the declaring
333
+ # construct actually declares
334
+ # @param subject [String] the finding's own subject label
335
+ # @return [Array<Finding>] one `:unknown_command` finding per
336
+ # transition named after a command `commands` does not list
180
337
  def unknown_transition_commands(lifecycle, commands, subject)
181
338
  lifecycle.transitions.filter_map do |command, _transition|
182
339
  next if commands.include?(command)
@@ -190,6 +347,10 @@ module Hecks
190
347
  # from-only state (declared nowhere as a target) is real and is
191
348
  # exactly the hole `Lifecycle#states` leaves: it answers default
192
349
  # plus targets only.
350
+ #
351
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
352
+ # @return [Array<String>] every state `lifecycle` declares, in any
353
+ # role (default, target, or from), unique
193
354
  def full_states(lifecycle)
194
355
  (
195
356
  [lifecycle.default] +
@@ -198,9 +359,13 @@ module Hecks
198
359
  ).uniq
199
360
  end
200
361
 
201
- # Least fixpoint from the default state: an UNCONSTRAINED
362
+ # Least fixpoint from the default state: an unconstrained
202
363
  # transition always fires, from wherever the machine is ; a
203
364
  # constrained one fires once any of its named sources is reached.
365
+ #
366
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
367
+ # @return [Set<String>] every state reachable from `lifecycle`'s own
368
+ # default state
204
369
  def reachable_states(lifecycle)
205
370
  reached = Set.new([lifecycle.default])
206
371
  loop do
@@ -222,8 +387,8 @@ module Hecks
222
387
  reached
223
388
  end
224
389
 
225
- # A state with no OUTGOING declared path at all is exempt from the
226
- # stuck-state WARNING for a different reason than "it fires an
390
+ # A state with no outgoing declared path at all is exempt from the
391
+ # stuck-state warning for a different reason than "it fires an
227
392
  # unconstrained transition" — the default state of a lifecycle
228
393
  # with only constrained transitions is legitimately allowed to sit
229
394
  # forever, since nothing about *entering* it via default implies
@@ -231,13 +396,17 @@ module Hecks
231
396
  # explicitly delivered somewhere.
232
397
  #
233
398
  # Scoped to `default` alone, and only when the lifecycle actually
234
- # declares real transitions elsewhere: an EMPTY lifecycle (no
399
+ # declares real transitions elsewhere: an empty lifecycle (no
235
400
  # transitions at all) doesn't get this exemption — that's not "a
236
401
  # machine whose entry point deliberately awaits external action,"
237
402
  # it's much more likely a lifecycle nobody finished wiring, and
238
403
  # should still warn (see spec/fixtures/model_check/lifecycle_
239
404
  # findings.bluebook's own Widget::Part, which stays warned on
240
405
  # purpose).
406
+ #
407
+ # @param lifecycle [Bluebook::Lifecycle] the lifecycle being checked
408
+ # @return [Array<String>] `[lifecycle.default]` when it is exempt from
409
+ # the stuck-state warning, `[]` otherwise
241
410
  def terminal_exempt(lifecycle)
242
411
  return [] if lifecycle.transitions.empty?
243
412
 
@@ -247,9 +416,18 @@ module Hecks
247
416
 
248
417
  # ── process managers / sagas ──────────────────────────────────────
249
418
 
419
+ # Runs every saga-shaped model check for one process manager.
420
+ #
421
+ # @param bluebook [Bluebook::Chapter] the assembled chapter
422
+ # `process_manager` belongs to
423
+ # @param process_manager [Bluebook::ProcessManager] the process
424
+ # manager to check
425
+ # @return [Array<Finding>] every `deaf_trigger`/`unreachable_pm_state`/
426
+ # `deaf_handler`/`unknown_dispatch`/`unarmed_compensation`/
427
+ # `dead_compensation` finding this process manager triggers
250
428
  def saga_findings(bluebook, process_manager)
251
429
  emitted = emitted_events(bluebook)
252
- # (domain, aggregate, command) TRIPLES, not raw strings — see
430
+ # (domain, aggregate, command) triples, not raw strings — see
253
431
  # `handler_findings`'s own comment on the dispatch side for why:
254
432
  # `Naming.split_verb` is what makes an entity verb's two legitimate
255
433
  # spellings (`Naming.command_ref`'s own `::`-then-`.` rewrite vs.
@@ -267,6 +445,15 @@ module Hecks
267
445
  findings
268
446
  end
269
447
 
448
+ # Finds every declared `starts_on`/`ends_on` event this domain never
449
+ # emits.
450
+ #
451
+ # @param process_manager [Bluebook::ProcessManager] the process
452
+ # manager being checked
453
+ # @param emitted [Array<String>] every bare event name this domain
454
+ # actually emits
455
+ # @return [Array<Finding>] one `:deaf_trigger` finding per declared
456
+ # `starts_on`/`ends_on` event this domain never emits
270
457
  def deaf_trigger_findings(process_manager, emitted)
271
458
  [process_manager.starts_on, process_manager.ends_on].compact.filter_map do |event|
272
459
  next if emitted.include?(bare(event))
@@ -277,6 +464,14 @@ module Hecks
277
464
  end
278
465
  end
279
466
 
467
+ # Finds every declared state no handler chain ever reaches.
468
+ #
469
+ # @param process_manager [Bluebook::ProcessManager] the process
470
+ # manager being checked
471
+ # @param reached [Set<String>] every state `pm_reachable_states`
472
+ # reaches
473
+ # @return [Array<Finding>] one `:unreachable_pm_state` finding per
474
+ # declared state that `reached` does not cover
280
475
  def unreachable_pm_state_findings(process_manager, reached)
281
476
  (Array(process_manager.states) - reached.to_a).map do |state|
282
477
  Finding.new(kind: :unreachable_pm_state, severity: :error, subject: process_manager.name,
@@ -285,17 +480,31 @@ module Hecks
285
480
  end
286
481
  end
287
482
 
288
- # ONE HANDLER'S OWN FINDINGS — deaf_handler, unknown_dispatch (one
483
+ # One handler's own findings — deaf_handler, unknown_dispatch (one
289
484
  # per dispatch), and unarmed_compensation (one per compensating
290
485
  # dispatch), pulled out of saga_findings' own handler loop; each
291
486
  # check reads only this handler plus the domain-wide emitted/verbs
292
- # sets saga_findings already resolved once, no state shared BETWEEN
487
+ # sets saga_findings already resolved once, no state shared between
293
488
  # handlers.
489
+ #
490
+ # @param bluebook [Bluebook::Chapter] the assembled chapter
491
+ # `process_manager` belongs to
492
+ # @param process_manager [Bluebook::ProcessManager] the process
493
+ # manager `handler` belongs to
494
+ # @param emitted [Array<String>] every bare event name this domain
495
+ # actually emits
496
+ # @param verbs [Array<Array(String, String, String)>] every command
497
+ # this domain declares, parsed as `Naming.split_verb`
498
+ # `[domain, aggregate, command]` triples
499
+ # @param handler [Bluebook::ProcessManagerHandler] the handler to
500
+ # check
501
+ # @return [Array<Finding>] every `deaf_handler`/`unknown_dispatch`/
502
+ # `unarmed_compensation` finding this handler triggers
294
503
  def handler_findings(bluebook, process_manager, emitted, verbs, handler)
295
504
  findings = []
296
505
 
297
- # The compensating leg answers REFUSED, a synthetic trigger no
298
- # command ever emits by name (ProcessManager::REFUSED) — not
506
+ # The compensating leg answers `REFUSED`, a synthetic trigger no
507
+ # command ever emits by name (`ProcessManager::REFUSED`) — not
299
508
  # a deaf handler, the one handler this domain's own events can
300
509
  # never satisfy on purpose.
301
510
  if handler.event_type != ProcessManager::REFUSED && !emitted.include?(bare(handler.event_type))
@@ -305,22 +514,22 @@ module Hecks
305
514
  end
306
515
 
307
516
  handler.dispatches.each do |dispatch|
308
- # ALWAYS THIS DOMAIN — same fix, same reason, as `SagaInterpreter
309
- # #qualified` (BUG#6). This used to guess: a dispatch whose own
310
- # `command_name` still carried a leftover `::` after `Naming.
311
- # command_ref`'s own rewrite was read as "already qualified" and
312
- # left alone — the exact same string-shape ambiguity that
517
+ # **Always this domain** — same fix, same reason, as `SagaInterpreter
518
+ # #qualified` (BUG#6). Guessing instead — reading a dispatch whose
519
+ # own `command_name` still carries a leftover `::` after `Naming.
520
+ # command_ref`'s own rewrite as "already qualified" and leaving it
521
+ # alone — would hit the exact same string-shape ambiguity that
313
522
  # `SagaInterpreter#qualified`'s own comment explains at length
314
523
  # (a same-domain entity command reference and a genuinely
315
524
  # cross-domain one are textually indistinguishable after that
316
525
  # rewrite). Confirmed against the entire corpus, same as that
317
526
  # fix: no saga anywhere ever dispatches genuinely cross-domain,
318
- # so this checker now qualifies exactly the way the runtime
527
+ # so this checker qualifies exactly the way the runtime
319
528
  # actually dispatches — unconditionally against `bluebook.name`
320
529
  # — instead of maintaining its own, independently-wrong copy of
321
530
  # the same guess.
322
531
  #
323
- # COMPARED AS A TRIPLE, NOT A STRING — `Naming.command_ref`'s
532
+ # Compared as a triple, not a string — `Naming.command_ref`'s
324
533
  # own rewrite of an entity reference (`Manifest::Slot::Fill`)
325
534
  # collapses to "Manifest::Slot.Fill" (`::` between aggregate and
326
535
  # entity, `.` before the command); `verbs_of`'s own entity
@@ -345,11 +554,11 @@ module Hecks
345
554
  "checker's scope, same as CommandRules#resolve_references")
346
555
  end
347
556
 
348
- # A `compensates` DECLARED WITH NOWHERE TO EVER FIRE — the exact
557
+ # A `compensates` declared with nowhere to ever fire — the exact
349
558
  # shape of the real bug this whole feature closes ("the
350
559
  # reversal was written and never armed"), caught at build/
351
560
  # model-check time instead of discovered in production. No
352
- # handler anywhere answers REFUSED (`process_manager.saga?` false) means
561
+ # handler anywhere answers `REFUSED` (`process_manager.saga?` false) means
353
562
  # `SagaInterpreter#unwind` never runs for this process
354
563
  # manager at all, so a declared `compensates` is structurally
355
564
  # unreachable — not a warning about style, a dead declaration.
@@ -365,6 +574,16 @@ module Hecks
365
574
  findings
366
575
  end
367
576
 
577
+ # Checks whether a saga's own compensation leaves an unreachable
578
+ # state.
579
+ #
580
+ # @param process_manager [Bluebook::ProcessManager] the process
581
+ # manager being checked
582
+ # @param reached [Set<String>] every state `pm_reachable_states`
583
+ # reaches
584
+ # @return [Array<Finding>] a single-element array holding a
585
+ # `:dead_compensation` finding, or `[]` if `process_manager` has no
586
+ # saga or its own compensation leaves a reached state
368
587
  def dead_compensation_findings(process_manager, reached)
369
588
  return [] unless process_manager.saga? && !reached.include?(process_manager.saga.from_state)
370
589
 
@@ -374,13 +593,21 @@ module Hecks
374
593
  end
375
594
 
376
595
  # A handler edge is only usable in the closure if it can actually
377
- # FIRE — REFUSED always can (it is a compensation trigger, not an
596
+ # fire — `REFUSED` always can (it is a compensation trigger, not an
378
597
  # event), and any other handler needs its event genuinely emitted.
379
598
  # Without this, a deaf handler's declared from_state -> to_state
380
599
  # pair reads as connected even though nothing can ever traverse
381
600
  # it, which would hide exactly the states this walk exists to
382
601
  # catch (a state only "reachable" through a handler that itself
383
602
  # never fires).
603
+ #
604
+ # @param process_manager [Bluebook::ProcessManager] the process
605
+ # manager being checked
606
+ # @param emitted [Array<String>] every bare event name this domain
607
+ # actually emits
608
+ # @return [Set<String>] every state reachable from
609
+ # `process_manager`'s own first declared state, through handlers
610
+ # that can actually fire
384
611
  def pm_reachable_states(process_manager, emitted)
385
612
  return Set.new if Array(process_manager.states).empty?
386
613
 
@@ -402,14 +629,28 @@ module Hecks
402
629
 
403
630
  # ── policies ───────────────────────────────────────────────────────
404
631
 
405
- def policy_findings(bluebook, policy, hecksagon, known_domains)
632
+ # Runs every same-domain policy check, or defers to
633
+ # `cross_domain_policy_findings` for a policy that targets another
634
+ # domain.
635
+ #
636
+ # @param bluebook [Bluebook::Chapter] the assembled chapter `policy`
637
+ # belongs to
638
+ # @param policy [Bluebook::Policy] the policy to check
639
+ # @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
640
+ # sibling wiring file, if the caller loaded one
641
+ # @param known_domains [Set<String>, nil] every bluebook/hecksagon
642
+ # name the caller has booted anywhere in this corpus scan
643
+ # @return [Array<Finding>] every `deaf_policy`/`unknown_trigger`
644
+ # finding this policy triggers, or `cross_domain_policy_findings`'s
645
+ # own return for a cross-domain policy
646
+ def policy_findings(bluebook, policy, hecksagon, known_domains, global_emitted_events = nil)
406
647
  return cross_domain_policy_findings(policy, hecksagon, known_domains) if policy.target_domain
407
648
 
408
649
  emitted = emitted_events(bluebook)
409
650
  findings = []
410
651
 
411
- # `policy.event_name` (Naming.unqualified) — NOT `bare`, which only
412
- # strips a "::" domain qualifier. An AGGREGATE-scoped policy's
652
+ # `policy.event_name` (Naming.unqualified) — not `bare`, which only
653
+ # strips a "::" domain qualifier. An aggregate-scoped policy's
413
654
  # `on_event` carries a "." aggregate qualifier instead (PolicyBuilder
414
655
  # stores whatever was typed, verbatim — see `on "Account.
415
656
  # AccountFrozen"`), and `bare` left it untouched, silently comparing
@@ -419,7 +660,18 @@ module Hecks
419
660
  # prior aggregate-scoped policy (ReviewOnFreeze) is also cross-domain
420
661
  # (`across "Compliance"`), which exits this method one line above
421
662
  # before the mismatch is ever reached.
422
- unless emitted.include?(policy.event_name)
663
+ # `global_emitted_events` — a `translates` (hecksagon-level)
664
+ # reaction is BUILT as the exact same same-domain-target `Policy`
665
+ # an ordinary `policy` block is (no `target_domain`, so it never
666
+ # reaches `cross_domain_policy_findings` above), but its whole
667
+ # point is to react to a FOREIGN domain's own event — this
668
+ # domain's own `emitted_events(bluebook)` was never going to
669
+ # contain it. Checked only as a FALLBACK, after the local check
670
+ # already failed, so a real same-domain typo still gets flagged
671
+ # exactly as before whenever the caller has no cross-domain set
672
+ # to offer (nil — every existing call site, unchanged) or the
673
+ # event genuinely isn't emitted anywhere this run examined.
674
+ unless emitted.include?(policy.event_name) || global_emitted_events&.include?(policy.event_name)
423
675
  findings << Finding.new(kind: :deaf_policy, severity: :error, subject: policy.name,
424
676
  message: "on #{policy.on_event.inspect}, which no command in this domain emits")
425
677
  end
@@ -429,13 +681,13 @@ module Hecks
429
681
  # "#{domain}::#{trigger_command}" — the same join `verbs_of` builds
430
682
  # independently, so the two spellings have to be compared as FQNs.
431
683
  #
432
- # COMPARED AS A TRIPLE, NOT A STRING — `handler_findings`'s own
684
+ # Compared as a triple, not a string — `handler_findings`'s own
433
685
  # `unknown_dispatch` check (BUG#6) already applies this fix for a
434
686
  # saga's dispatch; a policy's `trigger` needed the identical one. A
435
- # policy triggering an `asks`/`tells` PORT OPERATION (`Aggregate::
687
+ # policy triggering an `asks`/`tells` port operation (`Aggregate::
436
688
  # Port::Operation`, three colon-joined segments — `Naming.command_ref`'s
437
689
  # bare-constant rewrite turns this into "Aggregate::Port.Operation",
438
- # a leftover `::` past the aggregate) is a real, WORKING dispatch —
690
+ # a leftover `::` past the aggregate) is a real, working dispatch —
439
691
  # `PolicyInterpreter#deliver` qualifies it with this domain's own
440
692
  # name before `Naming.split_verb` ever sees it, and `split_verb`
441
693
  # already folds that leftover `::` into the dot-joined tail
@@ -458,32 +710,44 @@ module Hecks
458
710
 
459
711
  # ── cross-domain policies (Context Mapping) ───────────────────────
460
712
  #
461
- # `uses_framework "X"` already IS a Shared Kernel relationship — it
462
- # merges X's own bluebook into THIS registry, no boundary. A cross-
463
- # domain `policy ... across: "X"` already IS a Customer/Supplier
713
+ # `uses_framework "X"` already is a Shared Kernel relationship — it
714
+ # merges X's own bluebook into this registry, no boundary. A cross-
715
+ # domain `policy ... across: "X"` already is a Customer/Supplier
464
716
  # relationship — it dispatches into X over real cross-Lambda RPC in
465
717
  # the Rust host (`rust/host/src/lambda_client.rs`). Neither is a new
466
- # word; this makes the CHOICE between them checked instead of a
718
+ # word; this makes the choice between them checked instead of a
467
719
  # prose comment nobody enforces (`examples/banking/bluebook/
468
720
  # banking.hecksagon`'s own hand-written note explaining why
469
721
  # Compliance is reached via `across`, never `uses_framework`).
470
722
  #
471
- # NO NEW KEYWORD ANYWHERE — ADR 0025 principle 1 ("one idea, one
723
+ # No new keyword anywhere — ADR 0025 principle 1 ("one idea, one
472
724
  # spelling") refuses a `relationship:`/`as:` argument that would
473
725
  # just restate, as a string, the fact the chosen keyword (
474
726
  # `uses_framework` vs `across`) already states completely. The
475
727
  # DDD vocabulary (Shared Kernel, Customer/Supplier) lives here, in
476
728
  # the finding's own name and this comment, and in prose docs — not
477
729
  # in the grammar.
730
+ #
731
+ # @param policy [Bluebook::Policy] the cross-domain policy to check
732
+ # @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
733
+ # sibling wiring file, if the caller loaded one
734
+ # @param known_domains [Set<String>, nil] every bluebook/hecksagon
735
+ # name the caller has booted anywhere in this corpus scan
736
+ # @return [Array<Finding>] `expected_undelivered_findings`'s own
737
+ # return for a policy declared `expect_undelivered`; otherwise every
738
+ # `contradictory_relationship`/`unacknowledged_relationship`/
739
+ # `unknown_target_domain` finding this policy triggers
478
740
  def cross_domain_policy_findings(policy, hecksagon, known_domains)
479
- return [] unless hecksagon # no sibling hecksagon loaded — nothing to check a relationship against.
741
+ return expected_undelivered_findings(policy, hecksagon, known_domains) if policy.expect_undelivered
742
+ # No sibling hecksagon loaded — nothing to check a relationship against.
743
+ return [] unless hecksagon
480
744
 
481
745
  target = policy.target_domain
482
746
  findings = []
483
747
 
484
748
  if hecksagon.framework_members.include?(target)
485
- # SHARED KERNEL AND CUSTOMER/SUPPLIER ARE MUTUALLY EXCLUSIVE
486
- # CLAIMS about the SAME target — `uses_framework` means "X is
749
+ # Shared kernel and customer/supplier are mutually exclusive
750
+ # claims about the same target — `uses_framework` means "X is
487
751
  # loaded in-process, right here"; `across` means "X is a
488
752
  # separate deployment, reached only by RPC." Declaring both is
489
753
  # either a pointless RPC to a domain already local, or a
@@ -495,7 +759,7 @@ module Hecks
495
759
  "relationship declarations contradict each other for the same " \
496
760
  "target domain")
497
761
  elsif hecksagon.subscriptions.none? { |subscribed| Naming.qualifier(subscribed) == target }
498
- # THIS IS WHAT FINALLY GIVES `subscribe` REAL TEETH — checked
762
+ # This is what finally gives `subscribe` real teeth — checked
499
763
  # here, at model-check time, still never routed at runtime
500
764
  # (nothing dispatches off a `subscribe` line; see hecksagon.md's
501
765
  # own "checked, not routed" section). ADR 0025 names `subscribe`
@@ -508,43 +772,77 @@ module Hecks
508
772
  "attach it in-process instead")
509
773
  end
510
774
 
511
- # TYPO DETECTION, DELIBERATELY WEAKER — `known_domains` can only
512
- # ever be a MONOREPO-SCOPED heuristic: a real external hecks
775
+ # **Typo detection, deliberately weaker** — `known_domains` can only
776
+ # ever be a monorepo-scoped heuristic: a real external hecks
513
777
  # consumer's own domain (this repo's own embryonaut/lifeadelics-
514
778
  # shaped case) lives in a genuinely separate repository this
515
779
  # corpus scan can never see, so a target this check cannot find
516
- # is "unknown to THIS corpus," never proof of a typo. Two real,
517
- # legitimate reasons a target is unresolvable — genuinely
518
- # undefined by design (the corpus's own "Notifications," used
780
+ # is "unknown to THIS corpus," never proof of a typo. A target
781
+ # undefined by design (the corpus's own "Notifications", used
519
782
  # deliberately to exercise the undelivered-reaction runtime path)
520
- # and real-but-external (a separate repository) — both go in
521
- # `ALLOWED_FINDINGS`, the same judged-exception mechanism this
522
- # file already uses for ExternalSettlement, rather than a new
523
- # keyword invented to declare "this one's fine."
783
+ # declares so on its own policy — `expect_undelivered: true`,
784
+ # checked by `expected_undelivered_findings` — rather than being
785
+ # named in a core allowlist.
524
786
  if known_domains && !known_domains.include?(target)
525
787
  findings << Finding.new(kind: :unknown_target_domain, severity: :error, subject: policy.name,
526
788
  message: "across #{target.inspect} names a domain nowhere in the corpus " \
527
- "this check has booted — a typo, or a real domain intentionally " \
528
- "outside this corpus (undefined by design, or living in a " \
529
- "separate repository) belongs in ALLOWED_FINDINGS, named and " \
530
- "explained, not silently assumed correct")
789
+ "this check has booted — a typo, or a target intentionally never " \
790
+ "reached, which the policy itself declares with " \
791
+ "across #{target.inspect}, expect_undelivered: true")
531
792
  end
532
793
 
533
794
  findings
534
795
  end
535
796
 
797
+ # A declared undelivered target, held to its declaration. The two
798
+ # findings an unreachable `across` target raises (unknown target,
799
+ # unacknowledged relationship) are what the policy declared it
800
+ # expects, so they are not raised. What is raised is the declaration
801
+ # going stale: the target is a domain this corpus actually booted,
802
+ # or the sibling hecksagon attaches or subscribes to it — either way
803
+ # the reaction can be delivered, and the declaration is now a lie.
804
+ # `known_domains` is nil for a single-target run, which can then only
805
+ # check the hecksagon half.
806
+ #
807
+ # @param policy [Bluebook::Policy] the `expect_undelivered` policy to
808
+ # check
809
+ # @param hecksagon [Bluebook::Hecksagon, nil] this bluebook's own
810
+ # sibling wiring file, if the caller loaded one
811
+ # @param known_domains [Set<String>, nil] every bluebook/hecksagon
812
+ # name the caller has booted anywhere in this corpus scan
813
+ # @return [Array<Finding>] a single-element array holding a
814
+ # `:stale_undelivered_expectation` finding, or `[]` if the target
815
+ # remains genuinely unreachable
816
+ def expected_undelivered_findings(policy, hecksagon, known_domains)
817
+ target = policy.target_domain
818
+ reached = []
819
+ reached << "#{target} is a domain this corpus boots" if known_domains&.include?(target)
820
+ if hecksagon
821
+ reached << "this hecksagon uses_framework #{target.inspect}" if hecksagon.framework_members.include?(target)
822
+ if hecksagon.subscriptions.any? { |subscribed| Naming.qualifier(subscribed) == target }
823
+ reached << "this hecksagon subscribes to #{target}"
824
+ end
825
+ end
826
+ return [] if reached.empty?
827
+
828
+ [Finding.new(kind: :stale_undelivered_expectation, severity: :error, subject: policy.name,
829
+ message: "across #{target.inspect}, expect_undelivered: true — but #{reached.join(' and ')}, " \
830
+ "so the reaction can be delivered after all; drop expect_undelivered: or remove " \
831
+ "what reaches #{target}")]
832
+ end
833
+
536
834
  # ── shared enumeration ────────────────────────────────────────────
537
835
 
538
- # A PORT OPERATION EMITS TOO — the primary/driving port an adapter
836
+ # A port operation emits too — the primary/driving port an adapter
539
837
  # outside the bluebook calls through (see hecksagon_builder.rb) is a
540
838
  # second, real source of events, alongside a command's own `emits`.
541
- # Ports attach to the aggregate/bluebook from the SIBLING `.hecksagon`
839
+ # Ports attach to the aggregate/bluebook from the sibling `.hecksagon`
542
840
  # file, not this one — a caller that boots only the `.bluebook` (as
543
841
  # the fixtures under spec/fixtures/model_check/ do, having no
544
842
  # hecksagon at all) simply finds none, which is correct : nothing
545
843
  # can be deaf to an event that isn't even wired up yet.
546
844
  #
547
- # AN OUTBOUND OPERATION (`asks`) EMITS THROUGH A DIFFERENT DOOR — it
845
+ # An outbound operation (`asks`) emits through a different door — it
548
846
  # declares no `.emits` at all (`PortOperationBuilder#refuse_wrong_
549
847
  # words!` refuses one that tries), naming its two real endings
550
848
  # `.answers`/`.refuses` instead (`PortOperation#initialize`). Reading
@@ -552,9 +850,14 @@ module Hecks
552
850
  # method — real, live events a policy genuinely reacts to
553
851
  # (`Clearance.SuitePassed`/`SuiteFailed`, `Ticket.IssueFiled`/
554
852
  # `IssueFilingRefused`), reported as `deaf_policy` findings until this
555
- # read both. `.compact` because an INBOUND operation's `.answers`/
853
+ # read both. `.compact` because an inbound operation's `.answers`/
556
854
  # `.refuses` are always nil (there is no channel back to tell), which
557
855
  # would otherwise seed every emitted-events set with a stray nil.
856
+ #
857
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to
858
+ # enumerate
859
+ # @return [Array<String>] every bare event name this domain's
860
+ # commands and port operations emit, answer, or refuse, unique
558
861
  def emitted_events(bluebook)
559
862
  aggregate_emits = bluebook.aggregates.flat_map do |aggregate|
560
863
  aggregate.commands.map(&:emits) +
@@ -566,13 +869,18 @@ module Hecks
566
869
  (aggregate_emits + chapter_emits).flatten.compact.uniq
567
870
  end
568
871
 
569
- # ONE OPERATION, EITHER OF ITS OWN SOURCES OF EVENTS — an inbound
872
+ # One operation, either of its own sources of events — an inbound
570
873
  # `tells` names its own via `.emits`; an outbound `asks` has none
571
874
  # (`PortOperationBuilder#refuse_wrong_words!` refuses one that
572
875
  # tries) and names its two real endings `.answers`/`.refuses`
573
876
  # instead. Pulled out of `emitted_events` above purely to keep that
574
877
  # method's own branching low enough to read at a glance — every
575
878
  # port, aggregate-owned or chapter-level, asks this the same way.
879
+ #
880
+ # @param ports [Array<Bluebook::DomainPort>] the ports to enumerate
881
+ # @return [Array<String, nil>] every event name each operation emits,
882
+ # answers, or refuses; `nil` entries included (compacted by the
883
+ # caller)
576
884
  def port_operation_events(ports)
577
885
  ports.flat_map { |port| port.operations.flat_map { |op| [*op.emits, op.answers, op.refuses] } }
578
886
  end
@@ -581,6 +889,12 @@ module Hecks
581
889
  # carries and fuzzing/sequence_generator/catalog.rb builds
582
890
  # independently for the same reason: a saga dispatch and a fuzzer
583
891
  # step both have to name a verb the same way the door does.
892
+ #
893
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to
894
+ # enumerate
895
+ # @return [Array<String>] every command's own fully-qualified verb,
896
+ # `"Domain::Aggregate.Command"` or
897
+ # `"Domain::Aggregate.Entity.Command"`
584
898
  def verbs_of(bluebook)
585
899
  bluebook.aggregates.flat_map do |aggregate|
586
900
  verbs = aggregate.commands.map { |command| "#{bluebook.name}::#{aggregate.hecks_name}.#{command.hecks_name}" }
@@ -592,15 +906,20 @@ module Hecks
592
906
  end
593
907
  end
594
908
 
595
- # AN AGGREGATE-OWNED PORT OPERATION IS A TRIGGERABLE VERB TOO —
909
+ # An aggregate-owned port operation is a triggerable verb too —
596
910
  # `ReactionInvocation#resolve_target`'s own port-operation branch
597
911
  # resolves one by the exact same two-segment tail shape ("Aggregate::
598
912
  # Port.Operation", the aggregate then the port then the operation,
599
913
  # dot-joined past the domain) an entity command uses, checked first,
600
914
  # same order `Dispatcher#dispatch` already resolves a live verb in.
601
- # Only an aggregate's OWN ports (`aggregate.ports`) are in scope here
915
+ # Only an aggregate's own ports (`aggregate.ports`) are in scope here
602
916
  # — a policy's `trigger` always names one aggregate, never a chapter-
603
917
  # level port with no owner to address through.
918
+ #
919
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to
920
+ # enumerate
921
+ # @return [Array<String>] every aggregate-owned port operation's own
922
+ # fully-qualified verb, `"Domain::Aggregate.Port.Operation"`
604
923
  def port_verbs_of(bluebook)
605
924
  bluebook.aggregates.flat_map do |aggregate|
606
925
  aggregate.ports.flat_map do |port|
@@ -611,16 +930,27 @@ module Hecks
611
930
  end
612
931
  end
613
932
 
614
- # EVERY TRIGGERABLE VERB, AS A TRIPLE — `verbs_of` (ordinary/entity
933
+ # Every triggerable verb, as a triple — `verbs_of` (ordinary/entity
615
934
  # commands) plus `port_verbs_of` (port operations), each parsed
616
935
  # through `Naming.split_verb` so a caller never has to compare two
617
936
  # spellings of the same verb as strings (see `policy_findings`'s own
618
937
  # `unknown_trigger` check for why that comparison has to happen this
619
938
  # way, not as `include?` on a raw string).
939
+ #
940
+ # @param bluebook [Bluebook::Chapter] the assembled chapter to
941
+ # enumerate
942
+ # @return [Set<Array(String, String, String)>] every triggerable
943
+ # verb, parsed as `Naming.split_verb` `[domain, aggregate,
944
+ # command]` triples
620
945
  def triggerable_verbs(bluebook)
621
946
  (verbs_of(bluebook) + port_verbs_of(bluebook)).to_set { |verb| Naming.split_verb(verb) }
622
947
  end
623
948
 
949
+ # Strips a domain qualifier off an event name.
950
+ #
951
+ # @param event [String, Symbol] an event name, qualified
952
+ # (`"Domain::Event"`) or bare
953
+ # @return [String] `event`'s own bare name, past the last `::`
624
954
  def bare(event) = event.to_s.split("::").last
625
955
  end
626
956
  end