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
@@ -0,0 +1,871 @@
1
+ require "fileutils"
2
+ require "json"
3
+ require_relative "form_census"
4
+
5
+ module Hecks
6
+ module Fuzzing
7
+ # A small, valid bluebook, written from a seed — so the QA loop can test
8
+ # construct combinations nobody has hand-authored yet.
9
+ #
10
+ # ## Why this exists
11
+ #
12
+ # The ledger's most productive moments were a new stress domain's first
13
+ # sweep: `corrections` found four bugs, `referral_chain` four,
14
+ # `tenant_ledger` two. Every one of those domains was written by hand,
15
+ # one angle at a time, and `bin/qa_domain_novelty` exists precisely
16
+ # because the bugs live where two declared forms meet on one aggregate
17
+ # for the first time (`spec/combination_coverage_spec.rb`'s header).
18
+ # This writes those meetings mechanically: a seed picks two
19
+ # `FormCensus::FORMS` to force onto one aggregate, adds whatever other
20
+ # aggregates those forms need (a reference target, a two-hop chain),
21
+ # sprinkles extras (a lifecycle, a closed set, an entity, a query, a
22
+ # policy, a role), and renders the whole thing as ordinary bluebook
23
+ # source any runtime boots.
24
+ #
25
+ # ## Blueprint, not source, is the unit
26
+ #
27
+ # `generate` answers a plain, JSON-shaped Hash (string keys) — a small
28
+ # IR of its own — and `render` turns it into source. That split is what
29
+ # makes a finding shrinkable at the domain level: `shrink_candidates`
30
+ # removes one element at a time (a query, a command, an entity, an
31
+ # aggregate, a given…) and `prune` drops whatever that removal left
32
+ # dangling, using the explicit `requires` tokens every dependent
33
+ # element carries. Nothing here parses Ruby back.
34
+ #
35
+ # ## One fixed domain name
36
+ #
37
+ # Every generated domain is the same domain name — `QaGenerated`, in a
38
+ # `qa_generated/bluebook/qa_generated.bluebook` directory — because the
39
+ # directory basename doubles as a Rust module and Cargo feature name
40
+ # (`bin/project_rust`'s own landmine guard), and one fixed feature is
41
+ # what lets `bin/qa_generated_domains --rust` rebuild incrementally.
42
+ # Each domain is checked in its own child process for the same reason:
43
+ # two different shapes under one constant name never share a process.
44
+ module DomainGenerator
45
+ DOMAIN_NAME = "QaGenerated".freeze
46
+ DIRECTORY = "qa_generated".freeze
47
+ # `FORMS` — what this generator can build, defined at the bottom of
48
+ # this module, beside the `Builder::FORM_STEPS` table it reads.
49
+
50
+ # **No Rust-reserved snake names**. `Crate` was here first and every
51
+ # domain holding it failed to compile under `--rust`: its snake form
52
+ # is the keyword `crate`, which Rust cannot escape even as a raw
53
+ # identifier, and the projection emits it as a module and field name
54
+ # unguarded (`bin/project_rust` only guards the domain name). That is
55
+ # a real finding, reported rather than generated into every run.
56
+ AGGREGATE_NAMES = %w[Ticket Desk Parcel Venue Kiosk Hangar].freeze
57
+ ENTITY_NAMES = %w[Line Stamp].freeze
58
+ CLOSED_SETS = [%w[low high], %w[red amber green], %w[draft final]].freeze
59
+ ROLES = %w[Clerk Manager].freeze
60
+
61
+ # Forms that need another aggregate to reference, and the ones that
62
+ # need a two-hop chain ending in a lifecycle.
63
+ REFERENCE_FORMS = %w[reference_attr revalued_reference].freeze
64
+ CHAIN_FORMS = %w[two_hop_given multi_hop_where].freeze
65
+
66
+ module_function
67
+
68
+ # Builds a random blueprint from a seed and prunes it to a fixpoint,
69
+ # ready for `write` to render into a fresh `QaGenerated` domain.
70
+ #
71
+ # @param seed [Integer] the random seed; the same seed and `forms` always
72
+ # produce the same blueprint
73
+ # @param forms [Array<String, Symbol>, nil] the `FormCensus::FORMS` names to
74
+ # force onto the primary aggregate; two random forms from `FORMS` when nil
75
+ # @return [Hash{String => Object}] the pruned blueprint, with `"seed"` and
76
+ # `"forms"` merged in
77
+ # @raise [ArgumentError] if `forms` names anything outside `FORMS`
78
+ def generate(seed:, forms: nil)
79
+ random = Random.new(seed)
80
+ forms = Array(forms || FORMS.sample(2, random: random)).map(&:to_s)
81
+ unknown = forms - FORMS
82
+ raise ArgumentError, "unknown form(s) #{unknown.join(', ')} — FormCensus::FORMS names #{FORMS.join(', ')}" if unknown.any?
83
+
84
+ blueprint = Builder.new(random, forms).build
85
+ prune(blueprint.merge("seed" => seed, "forms" => forms))
86
+ end
87
+
88
+ # `<root>/qa_generated/bluebook/qa_generated.bluebook`, plus the
89
+ # blueprint beside the domain directory (never inside it — a stray
90
+ # file under `bluebook/` would be loaded as a chapter).
91
+ #
92
+ # A blueprint carrying `"source"` is bluebook text someone else wrote
93
+ # (`bin/qa_mine_combinations`' agent): it is adopted, not rendered,
94
+ # and its empty `aggregates`/`policies` leave nothing to shrink.
95
+ #
96
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers,
97
+ # or one carrying a `"source"` key instead of `"aggregates"`/`"policies"`
98
+ # @param root [String] the directory to write the domain and blueprint under
99
+ # @return [String] the written domain's directory (`<root>/qa_generated`)
100
+ def write(blueprint, root)
101
+ domain = File.join(root, DIRECTORY)
102
+ FileUtils.rm_rf(domain)
103
+ FileUtils.mkdir_p(File.join(domain, "bluebook"))
104
+ text = blueprint["source"] ? adopt(blueprint["source"]) : render(blueprint)
105
+ File.write(File.join(domain, "bluebook", "#{DIRECTORY}.bluebook"), text)
106
+ File.write(File.join(root, "blueprint.json"), JSON.pretty_generate(blueprint))
107
+ domain
108
+ end
109
+
110
+ # Renames an outside bluebook's own chapter to `QaGenerated`, the one
111
+ # name the scratch crate and child processes are built around.
112
+ #
113
+ # @param source [String] bluebook source text declaring one chapter
114
+ # @return [String] `source` with its `Hecks.bluebook "..."` chapter name
115
+ # replaced by `"QaGenerated"`
116
+ def adopt(source)
117
+ source.sub(/Hecks\.bluebook\s*\(?\s*(["'])[^"']+\1/) { "Hecks.bluebook #{DOMAIN_NAME.inspect}" }
118
+ end
119
+
120
+ # ── rendering ───────────────────────────────────────────────────
121
+
122
+ # Renders a whole blueprint into one bluebook source file.
123
+ #
124
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
125
+ # @return [String] the complete `Hecks.bluebook "QaGenerated" do ... end` source,
126
+ # ending in a newline
127
+ def render(blueprint)
128
+ out = []
129
+ out << "# GENERATED by Hecks::Fuzzing::DomainGenerator — seed #{blueprint['seed']}, forms " \
130
+ "#{blueprint['forms'].join(', ')}. Not hand-written; see bin/qa_generated_domains."
131
+ out << "Hecks.bluebook #{DOMAIN_NAME.inspect} do"
132
+ out << " vision #{"A generated domain that puts #{blueprint['forms'].join(' and ')} together.".inspect}"
133
+ out << ""
134
+ out << " core"
135
+ blueprint["aggregates"].each { |aggregate| out.concat(render_aggregate(aggregate)) }
136
+ blueprint["policies"].each { |policy| out.concat(render_policy(policy)) }
137
+ out << "end"
138
+ "#{out.join("\n")}\n"
139
+ end
140
+
141
+ # Renders one aggregate's whole `aggregate ... end` block, including
142
+ # every value object, entity, command and query it declares.
143
+ #
144
+ # @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
145
+ # @return [Array<String>] source lines, unindented at the block's own `aggregate`/`end`
146
+ def render_aggregate(aggregate)
147
+ out = aggregate_header(aggregate)
148
+ aggregate["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
149
+ aggregate["references"].each { |target| out << " reference_to #{target}" }
150
+ aggregate["vos"].each { |name, vo| out.concat(render_value_object(name, vo, " ")) }
151
+ out.concat(render_lifecycle(aggregate["lifecycle"], " ")) if aggregate["lifecycle"]
152
+ aggregate["invariants"].each { |inv| out << " invariant(#{inv['label'].inspect}) { #{inv['expr']} }" }
153
+ aggregate["entities"].each { |entity| out.concat(render_entity(entity)) }
154
+ aggregate["commands"].each { |command| out.concat(render_command(command, aggregate["name"], " ")) }
155
+ aggregate["queries"].each { |query| out.concat(render_query(query)) }
156
+ out << " end"
157
+ end
158
+
159
+ # Renders an aggregate block's opening lines, up through `identified_by`.
160
+ #
161
+ # @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
162
+ # @return [Array<String>] the `aggregate ... do`, `description` and
163
+ # `identified_by` lines
164
+ def aggregate_header(aggregate)
165
+ ["", " aggregate #{aggregate['name'].inspect} do",
166
+ " description #{"A generated #{aggregate['name'].downcase}.".inspect}", "",
167
+ " identified_by #{aggregate['identity'].map { |part| ":#{part}" }.join(', ')}"]
168
+ end
169
+
170
+ # Renders one entity's whole `entity ... end` block.
171
+ #
172
+ # @param entity [Hash{String => Object}] one entity's blueprint entry, as built
173
+ # by `Builder#entity`
174
+ # @return [Array<String>] source lines, unindented at the block's own `entity`/`end`
175
+ def render_entity(entity)
176
+ out = ["", " entity #{entity['name'].inspect} do",
177
+ " description #{"A generated #{entity['name'].downcase}.".inspect}"]
178
+ out << " identified_by #{entity['identity'].map { |part| ":#{part}" }.join(', ')}"
179
+ entity["attributes"].each { |attribute| out << " #{render_attribute(attribute)}" }
180
+ out.concat(render_lifecycle(entity["lifecycle"], " ")) if entity["lifecycle"]
181
+ entity["commands"].each { |command| out.concat(render_command(command, nil, " ")) }
182
+ out << " end"
183
+ end
184
+
185
+ # Renders one `attribute` declaration line.
186
+ #
187
+ # @param attribute [Hash{String => Object}] `"name"`, `"type"`, and optional
188
+ # `"list"`, `"optional"`, `"default"` keys
189
+ # @return [String] one `attribute :name, Type` line, with `list_of`, `optional:`
190
+ # and `default:` added as the Hash calls for
191
+ def render_attribute(attribute)
192
+ type = attribute["list"] ? "list_of(#{attribute['type']})" : attribute["type"]
193
+ line = "attribute :#{attribute['name']}, #{type}"
194
+ line += ", optional: true" if attribute["optional"]
195
+ line += ", default: { value: #{attribute['default'].inspect} }" if attribute.key?("default")
196
+ line
197
+ end
198
+
199
+ # Renders one `value_object ... end` block.
200
+ #
201
+ # @param name [String] the value object's declared name
202
+ # @param value_object [Hash{String => Object}] `"kind"` (`"string"`, `"positive"`,
203
+ # `"integer"` or `"closed"`) and, for `"closed"`, a `"members"` array
204
+ # @param indent [String] leading whitespace for every rendered line
205
+ # @return [Array<String>] source lines, indented by `indent`
206
+ def render_value_object(name, value_object, indent)
207
+ body =
208
+ case value_object["kind"]
209
+ when "string"
210
+ ["attribute :value, String, pattern: '[^ \\t\\n\\r]'",
211
+ "invariant(#{"a #{name} is not blank".inspect}) { !value.to_s.empty? }"]
212
+ when "positive"
213
+ ["attribute :value, Integer", "invariant(#{"a #{name} is positive".inspect}) { value.positive? }"]
214
+ when "integer"
215
+ ["attribute :value, Integer"]
216
+ when "closed"
217
+ ["attribute :value, String, one_of: #{value_object['members'].inspect}"]
218
+ end
219
+ ["", "#{indent}value_object #{name.inspect} do", *body.map { |line| "#{indent} #{line}" }, "#{indent}end"]
220
+ end
221
+
222
+ # Renders one `lifecycle ... end` block.
223
+ #
224
+ # @param lifecycle [Hash{String => Object}] `"field"`, `"default"` and
225
+ # `"transitions"` (each `"command"`, `"to"`, `"from"`)
226
+ # @param indent [String] leading whitespace for every rendered line
227
+ # @return [Array<String>] source lines, indented by `indent`
228
+ def render_lifecycle(lifecycle, indent)
229
+ out = ["", "#{indent}lifecycle :#{lifecycle['field']}, default: #{lifecycle['default'].inspect} do"]
230
+ lifecycle["transitions"].each do |transition|
231
+ from = transition["from"].size == 1 ? transition["from"].first.inspect : transition["from"].inspect
232
+ out << "#{indent} transition #{transition['command'].inspect} => #{transition['to'].inspect}, from: #{from}"
233
+ end
234
+ out << "#{indent}end"
235
+ end
236
+
237
+ # Renders one `command ... end` block.
238
+ #
239
+ # @param command [Hash{String => Object}] `"name"`, `"creates"`, `"role"`,
240
+ # `"references"`, `"args"`, `"givens"`, `"sets"` and `"emits"`
241
+ # @param self_name [String, nil] the owning aggregate's name, added as a
242
+ # `reference_to` on a non-creating entity command; nil for an
243
+ # aggregate-level command, which needs no reference to itself
244
+ # @param indent [String] leading whitespace for every rendered line
245
+ # @return [Array<String>] source lines, indented by `indent`
246
+ def render_command(command, self_name, indent)
247
+ out = ["", "#{indent}command #{command['name'].inspect} do"]
248
+ out << "#{indent} role #{command['role'].inspect}" if command["role"]
249
+ out << "#{indent} goal #{"#{command['name']} it".inspect}"
250
+ out << "#{indent} reference_to #{self_name}" if self_name && !command["creates"]
251
+ command["references"].each { |target| out << "#{indent} reference_to #{target}" }
252
+ command["args"].each { |arg| out << "#{indent} #{render_attribute(arg)}" }
253
+ command["givens"].each { |given| out << "#{indent} given(#{given['label'].inspect}) { #{given['expr']} }" }
254
+ command["sets"].each { |set| out << "#{indent} #{render_set(set)}" }
255
+ command["emits"].each { |event| out << "#{indent} emits #{event.inspect}" }
256
+ out << "#{indent}end"
257
+ end
258
+
259
+ # Renders one `sets` line inside a command block.
260
+ #
261
+ # @param set [Hash{String => Object}] `"target"`, plus either `"to"` (rename) or
262
+ # `"append"` (a Hash of field => value token to append with); neither means a
263
+ # bare positional set
264
+ # @return [String] one `sets :target[, to: :value | append: { ... }]` line
265
+ def render_set(set)
266
+ return "sets :#{set['target']}, to: :#{set['to']}" if set["to"]
267
+ return "sets :#{set['target']}, append: { #{set['append'].map { |k, v| "#{k}: :#{v}" }.join(', ')} }" if set["append"]
268
+
269
+ "sets :#{set['target']}"
270
+ end
271
+
272
+ # Renders one `query ... end` block.
273
+ #
274
+ # @param query [Hash{String => Object}] `"name"`, `"wheres"` (each `"field"`,
275
+ # `"value"`) and `"order_by"`
276
+ # @return [Array<String>] source lines, unindented at the block's own `query`/`end`
277
+ def render_query(query)
278
+ out = ["", " query #{query['name'].inspect} do", " description #{"Generated #{query['name']}.".inspect}"]
279
+ query["wheres"].each do |where|
280
+ out << " where(:#{where['field'].inspect} => #{where['value'].inspect})"
281
+ end
282
+ out << " order_by :#{query['order_by']}"
283
+ out << " end"
284
+ end
285
+
286
+ # Renders one `policy ... end` block.
287
+ #
288
+ # @param policy [Hash{String => Object}] `"name"`, `"on"` and `"trigger"`
289
+ # @return [Array<String>] source lines, unindented at the block's own `policy`/`end`
290
+ def render_policy(policy)
291
+ ["", " policy #{policy['name'].inspect} do", " on #{policy['on']}", " trigger #{policy['trigger']}", " end"]
292
+ end
293
+
294
+ # ── domain-level shrinking ──────────────────────────────────────
295
+
296
+ # Every blueprint one removal smaller, each already pruned. Identity
297
+ # attributes and creating commands are never offered: without them
298
+ # there is no domain left to dispatch against.
299
+ #
300
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
301
+ # @return [Array<Hash{String => Object}>] one pruned blueprint per candidate removal,
302
+ # deduplicated and never including `blueprint` itself
303
+ def shrink_candidates(blueprint)
304
+ removals(blueprint).map { |path| prune(remove_at(blueprint, path)) }.uniq.reject { |candidate| candidate == blueprint }
305
+ end
306
+
307
+ # Every path into `blueprint` naming one element a shrink could remove.
308
+ #
309
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
310
+ # @return [Array<Array<String, Integer>>] one path per removable element, each
311
+ # a sequence of Hash keys and Array indices `Hash#dig` can follow
312
+ def removals(blueprint)
313
+ paths = blueprint["policies"].each_index.map { |i| ["policies", i] }
314
+ blueprint["aggregates"].each_with_index do |aggregate, a|
315
+ at = ["aggregates", a]
316
+ paths << at if a.positive?
317
+ %w[queries invariants entities references].each { |key| aggregate[key].each_index { |i| paths << [*at, key, i] } }
318
+ paths << [*at, "lifecycle"] if aggregate["lifecycle"]
319
+ aggregate["attributes"].each_with_index do |attribute, i|
320
+ paths << [*at, "attributes", i] unless aggregate["identity"].include?(attribute["name"])
321
+ end
322
+ aggregate["commands"].each_with_index { |command, i| paths.concat(command_removals(command, [*at, "commands", i])) }
323
+ paths.concat(entity_removals(aggregate, at))
324
+ end
325
+ paths
326
+ end
327
+
328
+ # `removals`'s own entity half — one path per entity's lifecycle (if any) and
329
+ # per entity command.
330
+ #
331
+ # @param aggregate [Hash{String => Object}] one aggregate's blueprint entry
332
+ # @param at [Array<String, Integer>] the path prefix to `aggregate` itself
333
+ # @return [Array<Array<String, Integer>>] one path per removable entity element
334
+ def entity_removals(aggregate, at)
335
+ aggregate["entities"].each_with_index.flat_map do |entity, e|
336
+ paths = entity["lifecycle"] ? [[*at, "entities", e, "lifecycle"]] : []
337
+ paths + entity["commands"].each_index.map { |i| [*at, "entities", e, "commands", i] }
338
+ end
339
+ end
340
+
341
+ # `removals`'s own command half — one path for the whole command (unless it
342
+ # creates the aggregate), one per given, and one for its role or its last
343
+ # extra emit.
344
+ #
345
+ # @param command [Hash{String => Object}] one command's blueprint entry
346
+ # @param at [Array<String, Integer>] the path prefix to `command` itself
347
+ # @return [Array<Array<String, Integer>>] one path per removable command element
348
+ def command_removals(command, at)
349
+ paths = command["creates"] ? [] : [at]
350
+ command["givens"].each_index { |g| paths << [*at, "givens", g] }
351
+ paths << [*at, "role"] if command["role"]
352
+ paths << [*at, "emits", command["emits"].size - 1] if command["emits"].size > 1
353
+ paths
354
+ end
355
+
356
+ # Deep-copies `blueprint` and deletes whatever `path` names.
357
+ #
358
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
359
+ # @param path [Array<String, Integer>] a path as `removals` returns, its last
360
+ # element the Hash key or Array index to delete
361
+ # @return [Hash{String => Object}] a deep copy of `blueprint` with that one
362
+ # element removed
363
+ def remove_at(blueprint, path)
364
+ copy = JSON.parse(JSON.generate(blueprint))
365
+ *parents, last = path
366
+ holder = parents.empty? ? copy : copy.dig(*parents)
367
+ holder.is_a?(Array) ? holder.delete_at(last) : holder.delete(last)
368
+ copy
369
+ end
370
+
371
+ # Drop whatever a removal left dangling, to a fixpoint. Every element
372
+ # that depends on another carries `requires` — tokens naming exactly
373
+ # what must still exist (`attribute:Ticket.score`, `lifecycle:Desk`,
374
+ # `reference:Ticket->Desk`, `command:Ticket.Close`, …) — so this is
375
+ # set arithmetic over those tokens, never a reading of the source.
376
+ #
377
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
378
+ # @return [Hash{String => Object}] a deep copy of `blueprint` with every element
379
+ # whose `requires` tokens are not all still present removed, repeated until
380
+ # nothing more can be dropped
381
+ def prune(blueprint)
382
+ current = JSON.parse(JSON.generate(blueprint))
383
+ loop do
384
+ available = tokens(current)
385
+ before = JSON.generate(current)
386
+ prune_once!(current, available)
387
+ break if JSON.generate(current) == before
388
+ end
389
+ current
390
+ end
391
+
392
+ # One pass of `prune` — drops every policy, then every aggregate element,
393
+ # whose `requires` tokens are not all in `available`.
394
+ #
395
+ # @param blueprint [Hash{String => Object}] the blueprint being pruned, mutated
396
+ # in place
397
+ # @param available [Set<String>] every token `tokens` finds still declared
398
+ # @return [void]
399
+ def prune_once!(blueprint, available)
400
+ keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
401
+ blueprint["policies"].select!(&keep)
402
+ blueprint["aggregates"].each { |aggregate| prune_aggregate!(aggregate, available, keep) }
403
+ end
404
+
405
+ # `prune_once!`'s own per-aggregate pass — drops attributes, invariants,
406
+ # entities, unsatisfied references and empty queries, then recurses into
407
+ # each remaining lifecycle and command.
408
+ #
409
+ # @param aggregate [Hash{String => Object}] one aggregate's blueprint entry,
410
+ # mutated in place
411
+ # @param available [Set<String>] every token `tokens` finds still declared
412
+ # @param keep [Proc] the `requires`-satisfied predicate `prune_once!` built
413
+ # @return [void]
414
+ def prune_aggregate!(aggregate, available, keep)
415
+ aggregate["references"].select! { |target| available.include?("aggregate:#{target}") }
416
+ %w[attributes invariants entities].each { |key| aggregate[key].select!(&keep) }
417
+ aggregate["queries"].each { |query| query["wheres"].select!(&keep) }
418
+ aggregate["queries"].select! { |query| query["wheres"].any? }
419
+ [aggregate, *aggregate["entities"]].each do |owner|
420
+ prune_lifecycle!(owner, available)
421
+ owner["commands"].each { |command| prune_command!(command, available) }
422
+ end
423
+ end
424
+
425
+ # Drops transitions whose own command no longer exists, then whatever that
426
+ # leaves unreachable from the default state; clears `"lifecycle"` outright
427
+ # once no transition survives.
428
+ #
429
+ # @param owner [Hash{String => Object}] an aggregate or entity blueprint entry,
430
+ # mutated in place
431
+ # @param available [Set<String>] every token `tokens` finds still declared
432
+ # @return [void]
433
+ def prune_lifecycle!(owner, available)
434
+ return unless owner["lifecycle"]
435
+
436
+ transitions = owner["lifecycle"]["transitions"]
437
+ transitions.select! { |t| available.include?(t["requires"].first) }
438
+ reachable = reachable_states(owner["lifecycle"]["default"], transitions)
439
+ transitions.select! { |t| t["from"].any? { |state| reachable.include?(state) } }
440
+ owner["lifecycle"] = nil if transitions.empty?
441
+ end
442
+
443
+ # Every state a path from the default reaches. A transition out of a
444
+ # state nothing reaches can never fire, so a removal that orphans one
445
+ # goes too — without this, shrinking away `Close` leaves `Reopen from
446
+ # closed` behind, the shape `qa/stress_domains/generated_revalued_shape`
447
+ # was promoted with, which `bin/model_check` reports as a dead transition.
448
+ #
449
+ # @param default [String] the lifecycle's default state name
450
+ # @param transitions [Array<Hash{String => Object}>] the lifecycle's surviving
451
+ # transitions, each with `"from"` (an Array of state names) and `"to"`
452
+ # @return [Array<String>] every state reachable from `default`, `default` included
453
+ def reachable_states(default, transitions)
454
+ reachable = [default]
455
+ loop do
456
+ reached = transitions.select { |t| t["from"].any? { |state| reachable.include?(state) } }.map { |t| t["to"] }
457
+ return reachable if (reached - reachable).empty?
458
+
459
+ reachable |= reached
460
+ end
461
+ end
462
+
463
+ # Drops a command's own unsatisfied references, args, givens and sets.
464
+ #
465
+ # @param command [Hash{String => Object}] one command's blueprint entry,
466
+ # mutated in place
467
+ # @param available [Set<String>] every token `tokens` finds still declared
468
+ # @return [void]
469
+ def prune_command!(command, available)
470
+ keep = ->(item) { Array(item["requires"]).all? { |token| available.include?(token) } }
471
+ command["references"].select! { |target| available.include?("aggregate:#{target}") }
472
+ %w[args givens sets].each { |key| command[key].select!(&keep) }
473
+ end
474
+
475
+ # Every declaration token `blueprint` currently makes available — what a
476
+ # `requires` entry elsewhere in the blueprint can point at.
477
+ #
478
+ # @param blueprint [Hash{String => Object}] a blueprint as `generate` answers
479
+ # @return [Set<String>] tokens such as `"aggregate:Ticket"`,
480
+ # `"attribute:Ticket.score"`, `"lifecycle:Desk"`, `"reference:Ticket->Desk"`,
481
+ # `"command:Ticket.Close"` and `"event:Ticket.TicketOpened"`
482
+ def tokens(blueprint)
483
+ blueprint["aggregates"].each_with_object(Set.new) do |aggregate, set|
484
+ name = aggregate["name"]
485
+ set << "aggregate:#{name}"
486
+ set << "lifecycle:#{name}" if aggregate["lifecycle"]
487
+ aggregate["attributes"].each { |attribute| set << "attribute:#{name}.#{attribute['name']}" }
488
+ aggregate["references"].each { |target| set << "reference:#{name}->#{target}" }
489
+ aggregate["commands"].each do |command|
490
+ set << "command:#{name}.#{command['name']}"
491
+ command["references"].each { |target| set << "command_reference:#{name}.#{command['name']}->#{target}" }
492
+ command["emits"].each { |event| set << "event:#{name}.#{event}" }
493
+ end
494
+ aggregate["entities"].each do |entity|
495
+ set << "entity:#{name}.#{entity['name']}"
496
+ set << "lifecycle:#{name}.#{entity['name']}" if entity["lifecycle"]
497
+ entity["commands"].each { |command| set << "command:#{name}.#{entity['name']}.#{command['name']}" }
498
+ end
499
+ end
500
+ end
501
+
502
+ # Converts a rendered `CamelCase` aggregate/entity name to the `snake_case`
503
+ # form its own `reference_to`/`sets` field uses.
504
+ #
505
+ # @param name [String] a `CamelCase` name
506
+ # @return [String] `name` in `snake_case`
507
+ def snake(name) = name.gsub(/([a-z])([A-Z])/, '\1_\2').downcase
508
+
509
+ # ── building ────────────────────────────────────────────────────
510
+
511
+ # One seeded pass: the aggregates the forms need, the forms forced
512
+ # onto the first ("primary") aggregate, then extras everywhere.
513
+ class Builder
514
+ # @param random [Random] the seeded generator every random choice this
515
+ # builder makes draws from
516
+ # @param forms [Array<String>] the `FormCensus::FORMS` names to force onto
517
+ # the primary aggregate
518
+ def initialize(random, forms)
519
+ @random = random
520
+ @forms = forms
521
+ @names = AGGREGATE_NAMES.shuffle(random: random)
522
+ @policies = []
523
+ end
524
+
525
+ # Builds the aggregates the forced forms need, applies each forced form to
526
+ # the primary aggregate, then sprinkles extras onto every aggregate.
527
+ #
528
+ # @return [Hash{String => Object}] `"aggregates"` and `"policies"`, the two
529
+ # keys `DomainGenerator.generate` merges `"seed"`/`"forms"` into
530
+ def build
531
+ chain = @forms.intersect?(CHAIN_FORMS)
532
+ count = if chain then 3
533
+ elsif @forms.intersect?(REFERENCE_FORMS) then 2
534
+ else 1
535
+ end
536
+ count += 1 if count < 3 && @random.rand < 0.3
537
+ aggregates = @names.first(count).map { |name| base(name) }
538
+ primary = aggregates.first
539
+
540
+ link_chain(aggregates.first(3)) if chain
541
+ @forms.each { |form| apply_form(form, primary, aggregates) }
542
+ aggregates.each { |aggregate| extras(aggregate, aggregates) }
543
+ { "aggregates" => aggregates, "policies" => @policies }
544
+ end
545
+
546
+ # One step per `FormCensus::FORMS` entry, each run against the
547
+ # builder with the primary aggregate and every aggregate in play.
548
+ FORM_STEPS = {
549
+ "composite_id" => ->(primary, _) { composite_id(primary) },
550
+ "has_entity" => ->(primary, _) { entity(primary) },
551
+ "two_entities" => ->(primary, _) { 2.times { entity(primary) } },
552
+ "composite_piece" => ->(primary, _) { entity(primary, composite: true) },
553
+ "multi_emit" => ->(primary, _) { creating(primary)["emits"] << "#{primary['name']}Logged" },
554
+ "lifecycle" => ->(primary, _) { lifecycle(primary) },
555
+ "piece_lifecycle" => ->(primary, _) { entity(primary, lifecycle: true) },
556
+ "has_query" => ->(primary, _) { query(primary) },
557
+ "list_attr" => ->(primary, _) { list_attr(primary) },
558
+ "reference_attr" => ->(primary, all) { reference_attr(primary, all[1]) },
559
+ "closed_set" => ->(primary, _) { closed_set(primary) },
560
+ "has_default" => ->(primary, _) { default_attr(primary) },
561
+ "has_optional" => ->(primary, _) { optional_arg(primary) },
562
+ "two_hop_given" => ->(primary, all) { two_hop_given(primary, all[1], all[2]) },
563
+ "multi_hop_where" => ->(primary, all) { multi_hop_where(primary, all[1], all[2]) },
564
+ "revalued_reference" => ->(primary, all) { revalued_reference(primary, all[1]) }
565
+ }.freeze
566
+
567
+ private
568
+
569
+ def chance?(probability) = @random.rand < probability
570
+
571
+ def base(name)
572
+ aggregate = { "name" => name, "identity" => ["code"], "vos" => {}, "attributes" => [], "references" => [],
573
+ "lifecycle" => nil, "invariants" => [], "entities" => [], "commands" => [], "queries" => [] }
574
+ vo(aggregate, "#{name}Code", "string")
575
+ aggregate["attributes"] << { "name" => "code", "type" => "#{name}Code" }
576
+ aggregate["commands"] << { "name" => "Open", "creates" => true, "references" => [],
577
+ "args" => [{ "name" => "code", "type" => "#{name}Code" }],
578
+ "givens" => [], "sets" => [{ "target" => "code" }], "emits" => ["#{name}Opened"] }
579
+ aggregate
580
+ end
581
+
582
+ def vo(aggregate, name, kind, members: nil)
583
+ aggregate["vos"][name] ||= { "kind" => kind }.merge(members ? { "members" => members } : {})
584
+ end
585
+
586
+ def creating(aggregate) = aggregate["commands"].find { |command| command["creates"] }
587
+
588
+ def command(aggregate, name, args: [], sets: [], givens: [], emits: nil)
589
+ existing = aggregate["commands"].find { |candidate| candidate["name"] == name }
590
+ return existing if existing
591
+
592
+ entry = { "name" => name, "creates" => false, "references" => [], "args" => args, "givens" => givens,
593
+ "sets" => sets, "emits" => emits || ["#{aggregate['name']}#{past(name)}"] }
594
+ aggregate["commands"] << entry
595
+ entry
596
+ end
597
+
598
+ def past(verb) = verb.end_with?("e") ? "#{verb}d" : "#{verb}ed"
599
+
600
+ # ── the forms ────────────────────────────────────────────────
601
+
602
+ def apply_form(form, primary, aggregates) = instance_exec(primary, aggregates, &FORM_STEPS.fetch(form))
603
+
604
+ def composite_id(aggregate)
605
+ return if aggregate["identity"].size > 1
606
+
607
+ name = aggregate["name"]
608
+ vo(aggregate, "#{name}Branch", "string")
609
+ vo(aggregate, "#{name}Number", "positive")
610
+ aggregate["identity"] = %w[branch number]
611
+ aggregate["attributes"].reject! { |attribute| attribute["name"] == "code" }
612
+ aggregate["attributes"].unshift({ "name" => "branch", "type" => "#{name}Branch" },
613
+ { "name" => "number", "type" => "#{name}Number" })
614
+ open = creating(aggregate)
615
+ open["args"] = [{ "name" => "branch", "type" => "#{name}Branch" }, { "name" => "number", "type" => "#{name}Number" }]
616
+ open["sets"] = [{ "target" => "branch" }, { "target" => "number" }]
617
+ aggregate["vos"].delete("#{name}Code")
618
+ end
619
+
620
+ def entity(aggregate, composite: false, lifecycle: false)
621
+ taken = aggregate["entities"].map { |entity| entity["name"] }
622
+ name = (ENTITY_NAMES - taken).first
623
+ return unless name
624
+
625
+ owner = aggregate["name"]
626
+ list = "#{name.downcase}s"
627
+ parts = composite ? %w[batch sequence] : ["sequence"]
628
+ vo(aggregate, "#{name}Sequence", "positive")
629
+ vo(aggregate, "#{name}Batch", "string") if composite
630
+ vo(aggregate, "#{name}Label", "string")
631
+ types = { "sequence" => "#{name}Sequence", "batch" => "#{name}Batch" }
632
+
633
+ aggregate["attributes"] << { "name" => list, "type" => name, "list" => true, "requires" => ["entity:#{owner}.#{name}"] }
634
+ # Event names are qualified by the owner aggregate, not just the
635
+ # entity type — `ENTITY_NAMES` is a small pool (`Line`, `Stamp`)
636
+ # shared across every aggregate in a domain, and `extras` can pick
637
+ # the same entity name on a different aggregate than a form forced
638
+ # it onto (composite there, plain here, or vice versa). Aggregate
639
+ # names are always unique within one generated domain, so this is
640
+ # the same qualification ordinary commands already get by default
641
+ # (`"#{aggregate['name']}#{past(name)}"`) — without it, two
642
+ # aggregates can both emit a bare "LineAdded" with different
643
+ # shapes, which `validate_event_shapes!` correctly refuses.
644
+ piece = { "name" => name, "identity" => parts, "requires" => [],
645
+ "attributes" => parts.map { |part| { "name" => part, "type" => types[part] } } +
646
+ [{ "name" => "label", "type" => "#{name}Label", "optional" => true }],
647
+ "lifecycle" => nil,
648
+ "commands" => [{ "name" => "Label", "creates" => false, "references" => [],
649
+ "args" => [{ "name" => "label", "type" => "#{name}Label" }], "givens" => [],
650
+ "sets" => [{ "target" => "label" }], "emits" => ["#{owner}#{name}Labeled"] }] }
651
+ if lifecycle || chance?(0.2)
652
+ piece["commands"] << { "name" => "Settle", "creates" => false, "references" => [], "args" => [], "givens" => [],
653
+ "sets" => [], "emits" => ["#{owner}#{name}Settled"] }
654
+ piece["lifecycle"] = { "field" => "state", "default" => "pending",
655
+ "transitions" => [{ "command" => "Settle", "to" => "settled", "from" => ["pending"],
656
+ "requires" => ["command:#{owner}.#{name}.Settle"] }] }
657
+ end
658
+ aggregate["entities"] << piece
659
+ command(aggregate, "Add#{name}",
660
+ args: parts.map { |part| { "name" => part, "type" => types[part] } },
661
+ sets: [{ "target" => list, "append" => parts.to_h { |part| [part, part] },
662
+ "requires" => ["attribute:#{owner}.#{list}", "entity:#{owner}.#{name}"] }],
663
+ emits: ["#{owner}#{name}Added"])
664
+ end
665
+
666
+ def lifecycle(aggregate)
667
+ return if aggregate["lifecycle"]
668
+
669
+ name = aggregate["name"]
670
+ command(aggregate, "Close")
671
+ command(aggregate, "Reopen")
672
+ aggregate["lifecycle"] = { "field" => "status", "default" => "open", "transitions" => [
673
+ { "command" => "Close", "to" => "closed", "from" => ["open"], "requires" => ["command:#{name}.Close"] },
674
+ { "command" => "Reopen", "to" => "open", "from" => ["closed"], "requires" => ["command:#{name}.Reopen"] }
675
+ ] }
676
+ end
677
+
678
+ def query(aggregate)
679
+ return if aggregate["queries"].any? { |query| query["name"] == "Listed" }
680
+
681
+ name = aggregate["name"]
682
+ where = if aggregate["lifecycle"] || chance?(0.5)
683
+ lifecycle(aggregate)
684
+ { "field" => "status", "value" => "open", "requires" => ["lifecycle:#{name}"] }
685
+ else
686
+ members = closed_set(aggregate)
687
+ { "field" => "priority.value", "value" => members.first, "requires" => ["attribute:#{name}.priority"] }
688
+ end
689
+ aggregate["queries"] << { "name" => "Listed", "wheres" => [where], "order_by" => aggregate["identity"].first }
690
+ end
691
+
692
+ def list_attr(aggregate)
693
+ name = aggregate["name"]
694
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "tags" }
695
+
696
+ vo(aggregate, "#{name}Tag", "string")
697
+ aggregate["attributes"] << { "name" => "tags", "type" => "#{name}Tag", "list" => true }
698
+ command(aggregate, "Retag", args: [{ "name" => "tags", "type" => "#{name}Tag", "list" => true }],
699
+ sets: [{ "target" => "tags", "requires" => ["attribute:#{name}.tags"] }])
700
+ end
701
+
702
+ # Answers the closed set's members, so a query can filter on one.
703
+ def closed_set(aggregate)
704
+ name = aggregate["name"]
705
+ existing = aggregate["vos"]["#{name}Priority"]
706
+ return existing["members"] if existing
707
+
708
+ members = CLOSED_SETS.sample(random: @random)
709
+ vo(aggregate, "#{name}Priority", "closed", members: members)
710
+ aggregate["attributes"] << { "name" => "priority", "type" => "#{name}Priority", "optional" => true }
711
+ command(aggregate, "Prioritize", args: [{ "name" => "priority", "type" => "#{name}Priority" }],
712
+ sets: [{ "target" => "priority", "requires" => ["attribute:#{name}.priority"] }])
713
+ members
714
+ end
715
+
716
+ def default_attr(aggregate)
717
+ name = aggregate["name"]
718
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "score" }
719
+
720
+ vo(aggregate, "#{name}Score", "integer")
721
+ aggregate["attributes"] << { "name" => "score", "type" => "#{name}Score", "default" => 0 }
722
+ givens = []
723
+ if chance?(0.5)
724
+ givens << { "label" => "a score never drops", "expr" => "amount.value >= score.value",
725
+ "requires" => ["attribute:#{name}.score"] }
726
+ end
727
+ command(aggregate, "Rescore", args: [{ "name" => "amount", "type" => "#{name}Score" }], givens: givens,
728
+ sets: [{ "target" => "score", "to" => "amount",
729
+ "requires" => ["attribute:#{name}.score"] }])
730
+ return unless chance?(0.5)
731
+
732
+ aggregate["invariants"] << { "label" => "a score is never negative", "expr" => "score.value >= 0",
733
+ "requires" => ["attribute:#{name}.score"] }
734
+ end
735
+
736
+ def optional_arg(aggregate)
737
+ name = aggregate["name"]
738
+ return if aggregate["attributes"].any? { |attribute| attribute["name"] == "note" }
739
+
740
+ vo(aggregate, "#{name}Note", "string")
741
+ aggregate["attributes"] << { "name" => "note", "type" => "#{name}Note", "optional" => true }
742
+ command(aggregate, "Annotate", args: [{ "name" => "note", "type" => "#{name}Note", "optional" => true }],
743
+ sets: [{ "target" => "note", "requires" => ["attribute:#{name}.note"] }])
744
+ end
745
+
746
+ # An aggregate-level `reference_to`, set by the owner's own creating
747
+ # command (`Member.Join`'s shape, qa/stress_domains/referral_chain).
748
+ def reference_attr(owner, target)
749
+ return unless target
750
+ return if owner["references"].include?(target["name"])
751
+
752
+ owner["references"] << target["name"]
753
+ open = creating(owner)
754
+ open["references"] << target["name"]
755
+ open["sets"] << { "target" => snake(target["name"]),
756
+ "requires" => ["reference:#{owner['name']}->#{target['name']}"] }
757
+ return unless target["lifecycle"] && chance?(0.5)
758
+
759
+ open["givens"] << { "label" => "the #{snake(target['name'])} is open",
760
+ "expr" => "#{snake(target['name'])}.status == \"open\"",
761
+ "requires" => ["lifecycle:#{target['name']}",
762
+ "command_reference:#{owner['name']}.Open->#{target['name']}"] }
763
+ end
764
+
765
+ # primary -> middle -> root, root with a lifecycle.
766
+ def link_chain(chain)
767
+ primary, middle, root = chain
768
+ lifecycle(root)
769
+ reference_attr(middle, root)
770
+ reference_attr(primary, middle)
771
+ end
772
+
773
+ def two_hop_given(primary, middle, root)
774
+ return unless middle && root
775
+
776
+ path = "#{snake(middle['name'])}.#{snake(root['name'])}.status"
777
+ creating(primary)["givens"] << {
778
+ "label" => "the #{snake(middle['name'])}'s #{snake(root['name'])} is open", "expr" => "#{path} == \"open\"",
779
+ "requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
780
+ "command_reference:#{primary['name']}.Open->#{middle['name']}"]
781
+ }
782
+ end
783
+
784
+ def multi_hop_where(primary, middle, root)
785
+ return unless middle && root
786
+
787
+ field = "#{snake(middle['name'])}/#{snake(root['name'])}/status"
788
+ primary["queries"] << {
789
+ "name" => "ThroughOpen#{root['name']}", "order_by" => primary["identity"].first,
790
+ "wheres" => [{ "field" => field, "value" => "open",
791
+ "requires" => ["lifecycle:#{root['name']}", "reference:#{middle['name']}->#{root['name']}",
792
+ "reference:#{primary['name']}->#{middle['name']}"] }]
793
+ }
794
+ end
795
+
796
+ # A command redeclaring the aggregate's own reference field under a
797
+ # plain value object — `Referral.Reassign`'s shape (ADR 0037 F5).
798
+ def revalued_reference(owner, target)
799
+ return unless target
800
+
801
+ reference_attr(owner, target)
802
+ field = snake(target["name"])
803
+ vo(owner, "#{target['name']}Handle", "string")
804
+ requires = ["reference:#{owner['name']}->#{target['name']}"]
805
+ command(owner, "Repoint", args: [{ "name" => field, "type" => "#{target['name']}Handle" }],
806
+ sets: [{ "target" => field, "requires" => requires }])
807
+ end
808
+
809
+ # ── extras, on every aggregate ───────────────────────────────
810
+
811
+ def extras(aggregate, aggregates)
812
+ extra_shape(aggregate)
813
+ extra_wiring(aggregate, aggregates)
814
+ end
815
+
816
+ def extra_shape(aggregate)
817
+ lifecycle(aggregate) if chance?(0.4)
818
+ closed_set(aggregate) if chance?(0.25)
819
+ default_attr(aggregate) if chance?(0.25)
820
+ optional_arg(aggregate) if chance?(0.25)
821
+ entity(aggregate) if chance?(0.2)
822
+ query(aggregate) if chance?(0.3)
823
+ emits = creating(aggregate)["emits"]
824
+ emits << "#{aggregate['name']}Logged" if chance?(0.15) && emits.size == 1
825
+ end
826
+
827
+ def extra_wiring(aggregate, aggregates)
828
+ other = (aggregates - [aggregate]).sample(random: @random)
829
+ reference_attr(aggregate, other) if other && chance?(0.15) && !creates_cycle?(aggregate, other, aggregates)
830
+ aggregate["commands"].each { |command| command["role"] = ROLES.sample(random: @random) if chance?(0.3) }
831
+ policy(aggregate) if aggregate["lifecycle"] && chance?(0.25)
832
+ end
833
+
834
+ def creates_cycle?(from, to, aggregates)
835
+ seen = Set.new
836
+ stack = [to["name"]]
837
+ until stack.empty?
838
+ name = stack.pop
839
+ return true if name == from["name"]
840
+ next unless seen.add?(name)
841
+
842
+ stack.concat(aggregates.find { |aggregate| aggregate["name"] == name }["references"])
843
+ end
844
+ false
845
+ end
846
+
847
+ def policy(aggregate)
848
+ name = aggregate["name"]
849
+ event = creating(aggregate)["emits"].first
850
+ @policies << { "name" => "On#{event}Close", "on" => "#{name}::#{event}", "trigger" => "#{name}::Close",
851
+ "requires" => ["event:#{name}.#{event}", "command:#{name}.Close", "lifecycle:#{name}"] }
852
+ end
853
+
854
+ def snake(name) = DomainGenerator.snake(name)
855
+ end
856
+
857
+ # What this generator can build, not everything the census names.
858
+ # This read `FormCensus::FORMS.keys`, which quietly assumed the two
859
+ # tables would always agree — and they stopped agreeing the moment
860
+ # the census learned a form (`corrects`, `role_gated`) that
861
+ # `Builder::FORM_STEPS` has no recipe for: `generate` raised
862
+ # `KeyError` for any seed that happened to draw one. The census
863
+ # measures what a domain has; this names what a generator can
864
+ # write, and a form in the first without the second simply is not
865
+ # generated — the rotation still meets it, and
866
+ # `spec/combination_coverage_spec.rb`'s own `HELD_OUTSIDE_THE_GOLDENS`
867
+ # names where. Kept honest by `spec/fuzzing/domain_generator_spec.rb`.
868
+ FORMS = Builder::FORM_STEPS.keys.freeze
869
+ end
870
+ end
871
+ end