hecks 1.3.0 → 1.4.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 (355) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +7 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +10 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +8 -8
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +31 -13
  7. data/lib/hecks/adapters/driven/heki/journal.rb +5 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +5 -5
  9. data/lib/hecks/adapters/driven/heki.rb +13 -7
  10. data/lib/hecks/adapters/driven/identity_registry.rb +2 -2
  11. data/lib/hecks/adapters/driven/in_memory_ordering.rb +3 -3
  12. data/lib/hecks/adapters/driven/lambda/client.rb +34 -9
  13. data/lib/hecks/adapters/driven/lambda.rb +39 -33
  14. data/lib/hecks/adapters/driven/local_storage.rb +17 -10
  15. data/lib/hecks/adapters/driven/memory.rb +205 -9
  16. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +1 -1
  17. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  18. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  19. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  20. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  21. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  22. data/lib/hecks/adapters/driven/prism.rb +4 -4
  23. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  24. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  25. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  26. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  27. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  28. data/lib/hecks/adapters/driven.rb +4 -4
  29. data/lib/hecks/adapters/driving/github_webhook.rb +18 -18
  30. data/lib/hecks/behaviors/dsl.rb +2 -2
  31. data/lib/hecks/behaviors/expectations.rb +23 -23
  32. data/lib/hecks/behaviors/ir.rb +1 -1
  33. data/lib/hecks/behaviors/rspec.rb +1 -1
  34. data/lib/hecks/behaviors/runner.rb +2 -2
  35. data/lib/hecks/behaviors.rb +1 -1
  36. data/lib/hecks/bluebook/aggregate.rb +13 -13
  37. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  38. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  39. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  40. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  41. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  42. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  43. data/lib/hecks/bluebook/assembly.rb +9 -9
  44. data/lib/hecks/bluebook/attribute.rb +9 -9
  45. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  46. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  47. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  48. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  49. data/lib/hecks/bluebook/behaviour/domain_port.rb +7 -7
  50. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  51. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  52. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  53. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  54. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  55. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  56. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  57. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  58. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  59. data/lib/hecks/bluebook/capabilities.rb +27 -0
  60. data/lib/hecks/bluebook/chapter.rb +28 -9
  61. data/lib/hecks/bluebook/command.rb +12 -12
  62. data/lib/hecks/bluebook/domain_port.rb +9 -9
  63. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  64. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  65. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  66. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  67. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  68. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  69. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  70. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  71. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  72. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  73. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  74. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  75. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  76. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  77. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  78. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  79. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  80. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  81. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  82. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  83. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  84. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  85. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  86. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  87. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  88. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  89. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  90. data/lib/hecks/bluebook/entity.rb +11 -11
  91. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  92. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  93. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  94. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  95. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  97. data/lib/hecks/bluebook/hexagon.rb +1 -1
  98. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  99. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/judge.rb +101 -87
  101. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  102. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  103. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  104. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  105. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  106. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  107. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  108. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  109. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  110. data/lib/hecks/bluebook/model_check.rb +170 -84
  111. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  112. data/lib/hecks/bluebook/policy.rb +15 -13
  113. data/lib/hecks/bluebook/process_manager.rb +14 -14
  114. data/lib/hecks/bluebook/project_register.rb +6 -6
  115. data/lib/hecks/bluebook/query.rb +4 -4
  116. data/lib/hecks/bluebook/read_model.rb +14 -14
  117. data/lib/hecks/bluebook/reference.rb +8 -8
  118. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  119. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  120. data/lib/hecks/bluebook/translation.rb +4 -4
  121. data/lib/hecks/bluebook/value_object.rb +6 -6
  122. data/lib/hecks/bluebook.rb +2 -2
  123. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  124. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  125. data/lib/hecks/codemod.rb +36 -35
  126. data/lib/hecks/construct.rb +6 -6
  127. data/lib/hecks/corpus.rb +317 -0
  128. data/lib/hecks/deprecation.rb +95 -0
  129. data/lib/hecks/doc/reference.rb +19 -19
  130. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  131. data/lib/hecks/facade/cli_door.rb +69 -10
  132. data/lib/hecks/facade/cli_runner.rb +105 -24
  133. data/lib/hecks/facade/command_request.rb +23 -0
  134. data/lib/hecks/facade/handle.rb +79 -32
  135. data/lib/hecks/facade/json_door.rb +106 -25
  136. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  137. data/lib/hecks/facade/surface/chapter.rb +26 -17
  138. data/lib/hecks/facade/surface.rb +16 -3
  139. data/lib/hecks/facade.rb +15 -4
  140. data/lib/hecks/forms/app.rb +46 -30
  141. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  142. data/lib/hecks/forms/field_renderer.rb +142 -6
  143. data/lib/hecks/forms/field_shape.rb +180 -19
  144. data/lib/hecks/forms/html.rb +51 -7
  145. data/lib/hecks/forms/index_renderer.rb +14 -2
  146. data/lib/hecks/forms/params.rb +120 -23
  147. data/lib/hecks/forms/port_argument.rb +2 -2
  148. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  149. data/lib/hecks/forms/record_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_table.rb +1 -1
  151. data/lib/hecks/forms/value_object_shape.rb +3 -3
  152. data/lib/hecks/forms.rb +24 -4
  153. data/lib/hecks/fqn.rb +1 -1
  154. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  155. data/lib/hecks/framework.rb +48 -17
  156. data/lib/hecks/freezer.rb +11 -11
  157. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  158. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  159. data/lib/hecks/fuzzing/concurrent_dispatch.rb +140 -45
  160. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  161. data/lib/hecks/fuzzing/differential.rb +158 -0
  162. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  163. data/lib/hecks/fuzzing/era_boundary.rb +37 -18
  164. data/lib/hecks/fuzzing/form_census.rb +35 -20
  165. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  166. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  167. data/lib/hecks/fuzzing/isolated_boot.rb +62 -62
  168. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  169. data/lib/hecks/fuzzing/persistence_parity.rb +19 -21
  170. data/lib/hecks/fuzzing/properties/corrections.rb +9 -9
  171. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +154 -49
  172. data/lib/hecks/fuzzing/properties/guards.rb +42 -42
  173. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  174. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +22 -35
  175. data/lib/hecks/fuzzing/properties/outbox.rb +24 -24
  176. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  177. data/lib/hecks/fuzzing/properties.rb +64 -40
  178. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  179. data/lib/hecks/fuzzing/replay.rb +114 -114
  180. data/lib/hecks/fuzzing/rotation_priority.rb +10 -10
  181. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  182. data/lib/hecks/fuzzing/self_consistency.rb +141 -112
  183. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +35 -35
  184. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  185. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  186. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  187. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +58 -31
  188. data/lib/hecks/fuzzing/sequence_generator.rb +111 -23
  189. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  190. data/lib/hecks/fuzzing/structural_skips.rb +23 -130
  191. data/lib/hecks/fuzzing/sweep_depth.rb +4 -4
  192. data/lib/hecks/fuzzing/target_capabilities.rb +49 -18
  193. data/lib/hecks/fuzzing/value_generator.rb +14 -14
  194. data/lib/hecks/fuzzing.rb +5 -0
  195. data/lib/hecks/grammar/evolve.rb +10 -10
  196. data/lib/hecks/grammar.rb +7 -7
  197. data/lib/hecks/ir.rb +13 -13
  198. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  199. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  200. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  201. data/lib/hecks/language/oidc.json +5 -0
  202. data/lib/hecks/literal.rb +9 -9
  203. data/lib/hecks/naming.rb +25 -25
  204. data/lib/hecks/ports/access_control.rb +58 -2
  205. data/lib/hecks/ports/agent/answers.rb +83 -6
  206. data/lib/hecks/ports/agent.rb +119 -35
  207. data/lib/hecks/ports/authentication.rb +44 -4
  208. data/lib/hecks/ports/authorization.rb +53 -11
  209. data/lib/hecks/ports/clock.rb +42 -23
  210. data/lib/hecks/ports/extraction.rb +16 -0
  211. data/lib/hecks/ports/identity_assignment.rb +24 -2
  212. data/lib/hecks/ports/identity_generation.rb +17 -3
  213. data/lib/hecks/ports/identity_resolution.rb +18 -1
  214. data/lib/hecks/ports/loading.rb +4 -0
  215. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  216. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  217. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  218. data/lib/hecks/ports/persistence/execution.rb +4 -0
  219. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  220. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  221. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  222. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  224. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  225. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  226. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  241. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  242. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  247. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  248. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  249. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  252. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  253. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  254. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  255. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  256. data/lib/hecks/ports/persistence.rb +36 -1
  257. data/lib/hecks/ports/projection.rb +61 -7
  258. data/lib/hecks/ports/query/in_memory.rb +3 -3
  259. data/lib/hecks/ports/query/ordering.rb +6 -6
  260. data/lib/hecks/ports/query.rb +35 -0
  261. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  262. data/lib/hecks/projections/diagrams.rb +75 -75
  263. data/lib/hecks/projections/glossary/html.rb +3 -3
  264. data/lib/hecks/projections/glossary/markdown.rb +3 -3
  265. data/lib/hecks/projections/glossary/mermaid.rb +1 -1
  266. data/lib/hecks/projections/glossary/sections.rb +1 -1
  267. data/lib/hecks/projections/glossary/sentences.rb +5 -5
  268. data/lib/hecks/projections/glossary.rb +15 -15
  269. data/lib/hecks/projections/ir.rb +1 -1
  270. data/lib/hecks/projections/model/deviations.rb +18 -17
  271. data/lib/hecks/projections/model.rb +25 -21
  272. data/lib/hecks/projections/oidc.rb +7 -7
  273. data/lib/hecks/projections/parser_table.rb +5 -5
  274. data/lib/hecks/projections/reference.rb +3 -3
  275. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  276. data/lib/hecks/projections/shape.rb +2 -2
  277. data/lib/hecks/projections/statements.rb +11 -11
  278. data/lib/hecks/projections/vocabulary.rb +9 -9
  279. data/lib/hecks/projections.rb +5 -3
  280. data/lib/hecks/projector/cli_projector.rb +29 -29
  281. data/lib/hecks/projector/docs_projector.rb +13 -13
  282. data/lib/hecks/projector/exporter.rb +42 -21
  283. data/lib/hecks/projector/ir_projector.rb +1 -1
  284. data/lib/hecks/projector/narrate_projector.rb +11 -11
  285. data/lib/hecks/projector/target.rb +13 -13
  286. data/lib/hecks/projector.rb +15 -15
  287. data/lib/hecks/query_ir.rb +47 -47
  288. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  289. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  290. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  291. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  292. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  293. data/lib/hecks/query_specification/common/options.rb +25 -0
  294. data/lib/hecks/query_specification/field_path.rb +69 -15
  295. data/lib/hecks/query_specification/hop_path.rb +57 -20
  296. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  297. data/lib/hecks/rendering.rb +3 -3
  298. data/lib/hecks/router/namespace_installer.rb +3 -3
  299. data/lib/hecks/router.rb +1 -1
  300. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  301. data/lib/hecks/runtime/boot_gates.rb +3 -3
  302. data/lib/hecks/runtime/caller.rb +8 -8
  303. data/lib/hecks/runtime/capability_graph.rb +2 -2
  304. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  305. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +42 -63
  306. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  307. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  308. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  309. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  310. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  311. data/lib/hecks/runtime/command_rules/references.rb +35 -35
  312. data/lib/hecks/runtime/command_rules.rb +3 -0
  313. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  314. data/lib/hecks/runtime/dispatcher.rb +283 -90
  315. data/lib/hecks/runtime/entity_element.rb +151 -67
  316. data/lib/hecks/runtime/entity_interpreter.rb +108 -82
  317. data/lib/hecks/runtime/errors.rb +19 -19
  318. data/lib/hecks/runtime/event.rb +6 -6
  319. data/lib/hecks/runtime/identity.rb +22 -22
  320. data/lib/hecks/runtime/instance.rb +39 -14
  321. data/lib/hecks/runtime/interpreting.rb +12 -12
  322. data/lib/hecks/runtime/invocation.rb +276 -0
  323. data/lib/hecks/runtime/loader.rb +14 -14
  324. data/lib/hecks/runtime/outbox.rb +23 -23
  325. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  326. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  327. data/lib/hecks/runtime/query_interpreter.rb +55 -64
  328. data/lib/hecks/runtime/reaction_invocation.rb +20 -20
  329. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  330. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  331. data/lib/hecks/runtime/reference_hop.rb +6 -6
  332. data/lib/hecks/runtime/refusal_wording.rb +92 -115
  333. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  334. data/lib/hecks/runtime/registry/verification.rb +36 -26
  335. data/lib/hecks/runtime/registry.rb +45 -28
  336. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  337. data/lib/hecks/runtime/routing.rb +10 -153
  338. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  339. data/lib/hecks/runtime/saga_interpreter.rb +64 -64
  340. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  341. data/lib/hecks/runtime/tenant_check.rb +9 -9
  342. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  343. data/lib/hecks/runtime/value/admission.rb +75 -30
  344. data/lib/hecks/runtime/value/coercion.rb +318 -127
  345. data/lib/hecks/runtime/value/entity_list_coercion.rb +34 -34
  346. data/lib/hecks/runtime/value.rb +21 -21
  347. data/lib/hecks/runtime.rb +7 -7
  348. data/lib/hecks/storehouse.rb +64 -64
  349. data/lib/hecks/version.rb +3 -3
  350. data/lib/hecks/vocabulary.rb +205 -4
  351. data/lib/hecks.rb +13 -11
  352. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  353. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  354. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  355. metadata +22 -2
@@ -20,17 +20,17 @@ module Hecks
20
20
  { "query" => entry[:verb], "args" => args }
21
21
  end
22
22
 
23
- # A REPORT ASK — the bare domain form `entry[:verb]` already carries
23
+ # A report ask — the bare domain form `entry[:verb]` already carries
24
24
  # ("Domain.report_name", no "::"), so `Dispatcher#query` routes it
25
25
  # to the read model rather than an aggregate query. A `ReadModel`
26
26
  # has no declared `.attributes` the way a `Query` does — its own
27
- # argument surface is exactly ONE key, `reference_name`, and ONLY
27
+ # argument surface is exactly one key, `reference_name`, and only
28
28
  # for a rooted model (`read_model_actionable?` already gated a
29
29
  # rootless one straight into eligibility with nothing to supply).
30
- # A BARE scalar, not `identity_shaped` — `ReadModelInterpreter#
31
- # refuse_object_reference` explicitly REJECTS a Hash/Value offered
30
+ # A bare scalar, not `identity_shaped` — `ReadModelInterpreter#
31
+ # refuse_object_reference` explicitly rejects a Hash/Value offered
32
32
  # here (this is the one place in the whole generator where the
33
- # subject's own identity must NOT be wrapped the way a command
33
+ # subject's own identity must not be wrapped the way a command
34
34
  # argument's would be).
35
35
  def build_read_model_step(runtime, entry)
36
36
  model = entry[:model]
@@ -40,20 +40,20 @@ module Hecks
40
40
  { "query" => entry[:verb], "args" => args }
41
41
  end
42
42
 
43
- # THE ADVERSARIAL LAYER SITS HERE, AND ONLY HERE — after the step's
44
- # arguments and identity are fully built, BEFORE the one real
43
+ # The adversarial layer sits here, and only here — after the step's
44
+ # arguments and identity are fully built, before the one real
45
45
  # dispatch this generator makes to learn what the step did, and
46
46
  # before the step is returned as corpus data. That ordering is
47
47
  # the whole contract: the mutated `args` are what this generator's
48
48
  # own inline dispatch sees (so `known_ids` tracking reflects what
49
- # actually happened), AND they are the bytes `Fuzzing::Replay`
49
+ # actually happened), and they are the bytes `Fuzzing::Replay`
50
50
  # later hands Ruby's runtime and the bytes `JSON.generate({steps:
51
51
  # ...})` hands the compiled Rust binary — one step, one payload,
52
52
  # both engines. Nothing downstream of this method can tell a
53
53
  # mutated step from an ordinary one except by reading the
54
54
  # `"adversarial"` metadata it carries (see adversary.rb).
55
55
  #
56
- # TWO MORE DRAWS SIT RIGHT AFTER THE MUTATION, IN A FIXED ORDER
56
+ # Two more draws sit right after the mutation, in a fixed order
57
57
  # a caller (`caller_draw!`, adversary.rb: `role:`/`actor_id:` on
58
58
  # the step, bound around this one dispatch exactly the way
59
59
  # `Fuzzing::Replay` and `kernel/cli.rs` will later bind it) and
@@ -70,13 +70,14 @@ module Hecks
70
70
  mutations = adversarial_mutations!(args, entry, catalog)
71
71
  caller, caller_note = caller_draw!(entry, catalog)
72
72
  mutations << caller_note if caller_note
73
+ @state_before = state_before(runtime, entry, args)
73
74
 
74
75
  step =
75
76
  if dry_run_draw?
76
77
  safe_call { as_caller(caller) { runtime.dry_run?(entry[:verb], **symbolize(args)) } }
77
78
  { "dry_run" => entry[:verb], "args" => args }
78
79
  else
79
- outcome = safe_call { as_caller(caller) { runtime.dispatch(entry[:verb], **symbolize(args)) } }
80
+ outcome = safe_call { as_caller(caller) { runtime.dispatch_flat(entry[:verb], symbolize(args)) } }
80
81
  if outcome
81
82
  record_outcome(catalog, entry, args)
82
83
  @event_count += outcome.events.length
@@ -93,7 +94,7 @@ module Hecks
93
94
  def dry_run_draw? = @dry_run.positive? && @random.rand < @dry_run
94
95
 
95
96
  # `Hecks.as_caller` for exactly this block, or a bare yield — the
96
- # SAME binding `Fuzzing::Replay` makes from the step's own keys
97
+ # same binding `Fuzzing::Replay` makes from the step's own keys
97
98
  # later, so the generator's inline dispatch and both replays see
98
99
  # one caller.
99
100
  def as_caller(caller, &)
@@ -104,7 +105,7 @@ module Hecks
104
105
 
105
106
  def args_for(attributes, aggregate)
106
107
  args = attributes.each_with_object({}) do |attribute, built|
107
- # AN OPTIONAL ARGUMENT IS SOMETIMES NOT GIVEN, and that is an
108
+ # An optional argument is sometimes not given, and that is an
108
109
  # ordinary payload rather than a damaged one — see
109
110
  # OPTIONAL_OMITTED_PROBABILITY for why this cannot live in
110
111
  # `malform` below and what it was costing while it did not
@@ -113,13 +114,13 @@ module Hecks
113
114
 
114
115
  if attribute.list?
115
116
  value = list_value_for(attribute, aggregate)
116
- # A list-of-ENTITY command attribute has no real example
117
+ # A list-of-entity command attribute has no real example
117
118
  # anywhere in this repo's domains — every entity-owned list is
118
119
  # populated via a per-element append command instead, never a
119
120
  # whole-list command argument — so `list_value_for` (below)
120
121
  # answers `nil` for one rather than guessing at an entity's own
121
122
  # shape, and this step still skips it exactly as it always
122
- # has. A list-of-VALUE-OBJECT attribute (`ConsoleSettings::
123
+ # has. A list-of-value-object attribute (`ConsoleSettings::
123
124
  # Collection.ReplaceColumns`' own `columns`, `list_of(Column)`)
124
125
  # is the real, previously-unfuzzable case this now covers —
125
126
  # `sets :columns` imports the owner aggregate's own declared
@@ -137,9 +138,9 @@ module Hecks
137
138
  malform(args, attributes, aggregate)
138
139
  end
139
140
 
140
- # A `list_of` ATTRIBUTE'S OWN VALUE — an array of independently
141
+ # A `list_of` attribute's own value — an array of independently
141
142
  # generated elements, each shaped exactly the way a bare (non-list)
142
- # attribute of the SAME declared element type already is
143
+ # attribute of the same declared element type already is
143
144
  # (`ValueGenerator.value_for`), since `list_of(X)`'s own element
144
145
  # coercion is `X`'s ordinary shape repeated, not a different one
145
146
  # (`Attribute#type` is already unwrapped from `list_of(...)` at
@@ -157,12 +158,12 @@ module Hecks
157
158
  Array.new(@random.rand(0..3)) { ValueGenerator.value_for(attribute, aggregate, random: @random, known_ids: @known_ids) }
158
159
  end
159
160
 
160
- # ONE MALFORMATION AT A TIME, and usually none. A step whose payload is
161
+ # One malformation at a time, and usually none. A step whose payload is
161
162
  # wrong in three ways only ever proves which check runs first ; wrong in
162
163
  # exactly one way names the check that fired. And the rate stays low on
163
164
  # purpose — a corrupted step is almost always refused, a sequence of
164
165
  # refusals reaches no state at all, and bin/fuzz already counts those as
165
- # SILENT rather than scoring them.
166
+ # silent rather than scoring them.
166
167
  def malform(args, attributes, aggregate)
167
168
  return args if args.empty? || @random.rand >= MALFORMED_ARGUMENT_PROBABILITY
168
169
 
@@ -173,12 +174,12 @@ module Hecks
173
174
  end
174
175
  end
175
176
 
176
- # NEVER THE IDENTITY. A creating command with no id auto-mints one, and
177
+ # Never the identity. A creating command with no id auto-mints one, and
177
178
  # a minted id is deliberately unreproducible — a random hex, never a
178
179
  # guessable counter — so dropping it manufactures a step whose outcome
179
180
  # cannot be replayed and says nothing about the runtime's behaviour.
180
181
  # Every step in the hand-written corpus
181
- # supplies an id for the same reason. Whether an auto-minted id OUGHT to
182
+ # supplies an id for the same reason. Whether an auto-minted id ought to
182
183
  # be reproducible is a real question, but it is not one a payload fuzzer
183
184
  # can ask.
184
185
  def drop_one(args, aggregate)
@@ -204,11 +205,11 @@ module Hecks
204
205
  if entry[:entity]
205
206
  parent_scalar = pick_known(aggregate.hecks_name)
206
207
  args[parent_key] = identity_shaped(aggregate, aggregate.identified_by, parent_scalar, aggregate)
207
- # ONE IDENTITY PER HOP, each drawn from the pool its OWN
208
+ # One identity per hop, each drawn from the pool its own
208
209
  # parent-plus-hops landed elements in (`entity_pool_key`) —
209
210
  # a depth-1 chain draws exactly what it always did; a
210
211
  # `Board.Card` chain draws a Board under this Workspace, then
211
- # a Card under THAT Board. Flat args, one head per hop, is
212
+ # a Card under that Board. Flat args, one head per hop, is
212
213
  # the legacy addressing `EntityElement#locate_chain` reads
213
214
  # (`args[head]` per identity path); the routed `to: {
214
215
  # aggregate:, entities: [...] }` spelling is an adversarial
@@ -222,19 +223,19 @@ module Hecks
222
223
  scalars << scalar
223
224
  end
224
225
  elsif entry[:command].creates?
225
- # A COMPOSITE IDENTITY (`identified_by` answering nil with MORE
226
- # THAN ONE declared path — Behaviour::Identified's own "a
226
+ # A composite identity (`identified_by` answering nil with more
227
+ # than one declared path — Behaviour::Identified's own "a
227
228
  # composite has no single head" comment) supplies every one of
228
229
  # its parts as its own ordinary, individually-declared command
229
230
  # attribute already — `RoleAssignment::Assign` takes actor_id/
230
231
  # role_name/starts_at directly, `args_for` (above) already
231
232
  # generated all three. Forcing a synthetic top-level `id` here
232
- # too — this codebase's own fallback for the SINGLE-key and the
233
+ # too — this codebase's own fallback for the single-key and the
233
234
  # genuinely untyped (`identity_paths.empty?`, no `identified_by`
234
235
  # declared at all) cases — hands a composite creating command
235
236
  # an argument it never declared at all, refused every time as
236
237
  # unknown before this check existed (a creating `Assign`/`Grant`
237
- # step was never anything BUT refused). `identity_paths.empty?`
238
+ # step was never anything but refused). `identity_paths.empty?`
238
239
  # is the untyped default (falls all the way back to a minted
239
240
  # `:id` the runtime itself never declared as an attribute
240
241
  # either), which still needs exactly the old minting behavior.
@@ -248,9 +249,9 @@ module Hecks
248
249
  end
249
250
  end
250
251
 
251
- # TRUE ONLY FOR A GENUINE MULTI-FIELD IDENTITY — `identified_by`
252
+ # True only for a genuine multi-field identity — `identified_by`
252
253
  # returns nil both for a real composite (`identity_paths.size > 1`)
253
- # and for the untyped default with NO identity declared at all
254
+ # and for the untyped default with no identity declared at all
254
255
  # (`identity_paths.size == 0`, Behaviour::Identified's own
255
256
  # `Array(@identified_by)` fallback) ; only the first of those two
256
257
  # has its own parts already sitting in `args` as real, individually-
@@ -294,7 +295,7 @@ module Hecks
294
295
 
295
296
  # A step the runtime declines is not a generator failure — it simply did
296
297
  # not take effect, so nothing is recorded and the sequence carries on. The
297
- # step still goes into the corpus, because a REFUSAL IS AN ANSWER, and
298
+ # step still goes into the corpus, because a refusal is an answer, and
298
299
  # its wording is pinned by the corpus.
299
300
  #
300
301
  # EvaluationError sits alongside the declared refusals deliberately : a
@@ -305,10 +306,36 @@ module Hecks
305
306
  # generator built a step that breaks the interpreter for reasons that have
306
307
  # nothing to do with the domain declining a payload.
307
308
  def safe_call
308
- yield
309
- rescue *Hecks::Runtime::DOMAIN_REFUSALS, Hecks::Bluebook::Expression::EvaluationError
309
+ result = yield
310
+ @last_outcome = "ok"
311
+ result
312
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS, Hecks::Bluebook::Expression::EvaluationError => e
313
+ @last_outcome = e.class.name.split("::").last
310
314
  nil
311
315
  end
316
+
317
+ # Where the addressed aggregate stood just before this dispatch — its
318
+ # lifecycle value, or `exists`/`absent` when it declares none. Read
319
+ # straight off the repository, never through dispatch, and draws
320
+ # nothing from the RNG, so it changes no generated byte. An identity
321
+ # an adversarial mutation mangled past resolving is `?`.
322
+ def state_before(runtime, entry, args)
323
+ aggregate = entry[:aggregate]
324
+ symbolic = symbolize(args)
325
+ id = Runtime::Identity.of(aggregate, symbolic) || Runtime::Identity.from(aggregate, symbolic, :id)
326
+ return "absent" unless id
327
+
328
+ record = runtime.registry.repository(entry[:verb].split("::").first, aggregate).find(id)
329
+ return "absent" unless record
330
+
331
+ lifecycle = aggregate.lifecycle
332
+ return "exists" unless lifecycle
333
+
334
+ key = record.state.key?(lifecycle.field) ? lifecycle.field : lifecycle.field.to_s
335
+ record.state[key].to_s
336
+ rescue StandardError
337
+ "?"
338
+ end
312
339
  end
313
340
  end
314
341
  end
@@ -15,13 +15,13 @@ module Hecks
15
15
  # `{name, note, steps}` shape `spec/corpus/*.json` already uses — so a
16
16
  # generated sequence replays as a corpus member, completely unchanged.
17
17
  # Boots a throwaway copy of
18
- # the domain and DISPATCHES each candidate step for real as it builds the
18
+ # the domain and dispatches each candidate step for real as it builds the
19
19
  # sequence (not just synthesizing plausible-looking JSON) : the only way to
20
20
  # know whether a step actually reached a new state, or which id an
21
21
  # auto-minted entity landed on, is to run it and watch what happened.
22
22
  #
23
23
  # Single-call fuzzing mostly misses the bugs this project has actually
24
- # found — they needed STATE first (a saga leg acting on a transfer that
24
+ # found — they needed state first (a saga leg acting on a transfer that
25
25
  # already exists, a reference pointing at a customer already registered).
26
26
  # So this tracks what it has created as it goes, the same way
27
27
  # spec/banking_state_machine_spec.rb's hand-written generator does, and
@@ -47,18 +47,18 @@ module Hecks
47
47
  CREATING_WEIGHT = 2
48
48
 
49
49
  # How often a payload is deliberately the wrong shape. Low, because a
50
- # refused step reaches no new state and a sequence of them is SILENT.
50
+ # refused step reaches no new state and a sequence of them is silent.
51
51
  MALFORMED_ARGUMENT_PROBABILITY = 0.12
52
52
 
53
- # HOW OFTEN AN OPTIONAL ARGUMENT IS SIMPLY NOT GIVEN — a fair coin,
53
+ # How often an optional argument is simply not given — a fair coin,
54
54
  # because that is exactly what `optional:` means: present or absent,
55
55
  # both legal, neither the interesting case.
56
56
  #
57
- # This is NOT a malformation and must not be filed as one. `malform`
58
- # drops an argument too, but a dropped REQUIRED argument is refused,
59
- # a refusal writes no record, and bin/fuzz counts it SILENT — so the
57
+ # This is not a malformation and must not be filed as one. `malform`
58
+ # drops an argument too, but a dropped required argument is refused,
59
+ # a refusal writes no record, and bin/fuzz counts it silent — so the
60
60
  # one outcome worth reaching (a stored record carrying a null, then
61
- # QUERIED) was unreachable from that path by construction. Until
61
+ # queried) was unreachable from that path by construction. Until
62
62
  # this existed no generated history contained a null at all, which
63
63
  # meant `query_answers_match_reference` — the differential that
64
64
  # diffs every native adapter against the reference interpreter — had
@@ -72,11 +72,11 @@ module Hecks
72
72
  # How strongly an unexercised verb is preferred over one this sequence has
73
73
  # already dispatched. Random picking revisits the same handful of verbs and
74
74
  # leaves whole commands untouched for a whole run — which is the same
75
- # "reached no interesting state" problem the SILENT count reports, seen from
75
+ # "reached no interesting state" problem the silent count reports, seen from
76
76
  # the generating end rather than the scoring end.
77
77
  UNEXERCISED_WEIGHT = 4
78
78
 
79
- # `adversarial:` — the fraction of generated COMMAND steps (0.0..1.0)
79
+ # `adversarial:` — the fraction of generated command steps (0.0..1.0)
80
80
  # that get one deliberately adversarial argument mutation
81
81
  # (adversary.rb — the shapes BUG#7–#16 were found through). `0.0`,
82
82
  # the default, draws nothing extra from the seeded RNG, so a seed's
@@ -89,22 +89,49 @@ module Hecks
89
89
  # `dry_run_draw?`): `0.0` draws nothing, so every pinned seed is
90
90
  # byte-for-byte what it was; `bin/qa_sweep` reads them from
91
91
  # `QualityControlDials::ROLE_DRAW_PROBABILITY`/`DRY_RUN_FRACTION`.
92
- def self.generate(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
93
- new(domain_path, seed: seed, steps: steps, adapter: adapter, adversarial: adversarial,
94
- role_draw: role_draw, dry_run: dry_run).call
92
+ #
93
+ # `prefix:` / `favor:` the two levers `CoverageCampaign` pulls
94
+ # (coverage_campaign.rb has the why). `prefix:` is `{ "seed", "steps",
95
+ # "favor", "prefix" }`: re-generate that seed's first `steps` attempts
96
+ # (itself recursively prefixed) before this seed's own randomness
97
+ # starts, so a seed can begin from state an earlier seed reached.
98
+ # `favor:` names verbs the picker weights up. `nil`/`[]`, the
99
+ # defaults, draw nothing extra and change nothing: every pinned seed
100
+ # is byte-for-byte what it was.
101
+ def self.generate(domain_path, seed:, steps:, **)
102
+ new(domain_path, seed: seed, steps: steps, **).call
103
+ end
104
+
105
+ # The same generation, with what it reached — `coverage` is
106
+ # `[[attempt_index, tuple], ...]` (`coverage_tuple`), `verbs` every
107
+ # verb the booted catalog offered, so a campaign can tell a verb it
108
+ # never hit from one that does not exist.
109
+ Trace = Struct.new(:steps, :coverage, :verbs, keyword_init: true)
110
+
111
+ def self.trace(domain_path, seed:, steps:, **)
112
+ generator = new(domain_path, seed: seed, steps: steps, **)
113
+ Trace.new(steps: generator.call, coverage: generator.coverage, verbs: generator.verbs)
95
114
  end
96
115
 
97
- # How many EVENTS the generated sequence actually produced not
116
+ # How strongly a `favor:` verb is preferred when it is eligible
117
+ # the same order of magnitude as an unexercised verb, so favor
118
+ # steers without drowning out what this sequence has not touched.
119
+ FAVOR_WEIGHT = 4
120
+
121
+ attr_reader :coverage, :verbs
122
+
123
+ # How many events the generated sequence actually produced — not
98
124
  # steps, not successful dispatches, but the sum of every Result#events
99
125
  # length across the run. This is the count bin/fuzz declares as the
100
126
  # script's own `expectations.events` claim: whatever was achieved
101
- # DURING generation becomes the claim a fresh replay of the same
127
+ # during generation becomes the claim a fresh replay of the same
102
128
  # script is held to. Zero means the sequence never
103
129
  # reached an interesting state — a fuzzer-effectiveness fact, not a
104
130
  # replay one.
105
131
  attr_reader :event_count
106
132
 
107
- def initialize(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0)
133
+ def initialize(domain_path, seed:, steps:, adapter: :memory, adversarial: 0.0, role_draw: 0.0, dry_run: 0.0,
134
+ prefix: nil, favor: [])
108
135
  { adversarial: adversarial, role_draw: role_draw, dry_run: dry_run }.each do |name, fraction|
109
136
  next if fraction.is_a?(Numeric) && fraction.between?(0, 1)
110
137
 
@@ -122,41 +149,102 @@ module Hecks
122
149
  @known_ids = Hash.new { |h, k| h[k] = [] }
123
150
  @entity_known_ids = Hash.new { |h, k| h[k] = [] }
124
151
  @appended_identities = Hash.new { |h, k| h[k] = [] }
125
- # ROLE => [actor ids] this sequence's own successful
152
+ # Role => [actor ids] this sequence's own successful
126
153
  # `Governance::RoleAssignment.Assign` steps granted — what the
127
154
  # `actor_known` caller shape draws from (adversary.rb).
128
155
  @granted = Hash.new { |h, k| h[k] = [] }
129
156
  @precedence_caller = nil
130
157
  @exercised = Set.new
131
158
  @event_count = 0
159
+ @prefix = prefix
160
+ @favor = Array(favor)
161
+ @own_favor = @favor
162
+ @coverage = []
163
+ @verbs = []
164
+ @attempt = 0
132
165
  end
133
166
 
134
167
  def call
135
168
  # Real leftover data from ordinary use (bin/console, whatever) lives
136
169
  # under the example's data/ — a generator that boots against it
137
170
  # starts from state its own known_ids tracking doesn't know about.
138
- # IsolatedBoot resets that AND rebinds persistence to Memory, since
171
+ # IsolatedBoot resets that and rebinds persistence to Memory, since
139
172
  # a Postgres-bound domain's real store lives outside the copied
140
173
  # directory entirely and `rm_rf`ing data/ alone cannot reach it —
141
174
  # see isolated_boot.rb's own header.
142
175
  IsolatedBoot.call(@domain_path, adapter: @adapter) do |copy|
143
176
  runtime = Hecks.boot(copy)
144
177
  catalog = build_catalog(runtime)
145
- Array.new(@step_count) { attempt_step(runtime, catalog) }.compact
178
+ @verbs = catalog.values_at(:creating, :instance, :entity_commands, :queries, :entity_queries, :read_models)
179
+ .flatten.map { |entry| entry[:verb] }.uniq
180
+
181
+ steps = []
182
+ if @prefix
183
+ realize_prefix(runtime, catalog, @prefix, prefix_limit(@prefix), steps)
184
+ @random = Random.new(@seed)
185
+ @favor = @own_favor
186
+ end
187
+ @step_count.times { steps << attempt_step(runtime, catalog) }
188
+ steps.compact
146
189
  end
147
190
  end
148
191
 
149
192
  private
150
193
 
194
+ # A prefix is the first `limit` attempts of another seed's generation,
195
+ # re-run for real: its own nested prefix first (capped the same way it
196
+ # was capped when that seed was generated), then that seed's own
197
+ # `Random.new(seed)` and favor for the rest. Same inputs, same
198
+ # catalog, same draws — the same steps, and the same known ids and
199
+ # exercised verbs carried forward into this seed.
200
+ #
201
+ # The prefix is on top of this seed's own budget, not out of it. A
202
+ # spliced seed still makes all `steps` attempts of its own after the
203
+ # prefix; a prefix is capped at `steps` attempts, so a spliced
204
+ # sequence is at most twice as long as an unspliced one. Taking the
205
+ # prefix out of the budget (the first version of this) left a spliced
206
+ # seed replaying state already seen with almost nothing left to
207
+ # explore from it — measured: fewer distinct tuples than unguided.
208
+ def realize_prefix(runtime, catalog, spec, limit, steps)
209
+ return 0 unless limit.positive?
210
+
211
+ inner = spec["prefix"]
212
+ used = inner ? realize_prefix(runtime, catalog, inner, [prefix_limit(inner), limit].min, steps) : 0
213
+ @random = Random.new(Integer(spec.fetch("seed")))
214
+ @favor = Array(spec["favor"])
215
+ (limit - used).times { steps << attempt_step(runtime, catalog) }
216
+ limit
217
+ end
218
+
219
+ def prefix_limit(spec) = Integer(spec.fetch("steps")).clamp(0, @step_count)
220
+
151
221
  def attempt_step(runtime, catalog)
222
+ index = @attempt
223
+ @attempt += 1
152
224
  entry = pick(catalog)
153
225
  return nil unless entry
154
226
 
155
227
  @exercised << entry[:verb]
156
- if entry[:query] then build_query_step(runtime, entry)
157
- elsif entry[:model] then build_read_model_step(runtime, entry)
158
- else build_command_step(runtime, catalog, entry)
159
- end
228
+ @state_before = "-"
229
+ step =
230
+ if entry[:query] then build_query_step(runtime, entry)
231
+ elsif entry[:model] then build_read_model_step(runtime, entry)
232
+ else build_command_step(runtime, catalog, entry)
233
+ end
234
+ @coverage << [index, coverage_tuple(entry, step)]
235
+ step
236
+ end
237
+
238
+ # `verb | kind | state before | mutation | outcome` — see
239
+ # CoverageCampaign's header for why the unit is this and not the
240
+ # verb. `state` is the addressed aggregate's lifecycle value (or
241
+ # `exists`/`absent` for one without a lifecycle) read just before
242
+ # dispatch; `mutation` names every adversarial mutation and its shape;
243
+ # `outcome` is `ok` or the refusal class `safe_call` rescued.
244
+ def coverage_tuple(entry, step)
245
+ kind = %w[verb query dry_run].find { |key| step.key?(key) }
246
+ mutation = Array(step["adversarial"]).map { |m| [m["mutation"], m["shape"]].compact.join(":") }.join("+")
247
+ [entry[:verb], kind, @state_before, mutation.empty? ? "-" : mutation, @last_outcome].join(" | ")
160
248
  end
161
249
  end
162
250
  end
@@ -0,0 +1,197 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # A failing step list, made small enough to read.
4
+ #
5
+ # `bin/fuzz` always shrank its findings; `bin/qa_sweep` — the loop that
6
+ # actually finds the bugs — never did, so the ledger filled with
7
+ # demonstrations like "seed 25, 25 steps, step 16" and a human cut
8
+ # BUG#40 down to its four real steps by hand. This is `bin/fuzz`'s
9
+ # shrinker lifted out of that script so both callers share it, and
10
+ # made cheaper at the same time: a sweep's candidate check can cost a
11
+ # Rust subprocess (or a real Postgres round trip), where `bin/fuzz`'s
12
+ # only ever cost an in-process replay.
13
+ #
14
+ # The caller owns "same finding". `call` is handed a block that
15
+ # answers true when a candidate step list still reproduces what the
16
+ # original did — `bin/fuzz` compares its own verdict signature,
17
+ # `bin/qa_sweep` compares `Shrinker.signature` of one mode's
18
+ # divergences. This module never replays anything itself, so it has
19
+ # no idea which engine, adapter or comparison it is minimizing for.
20
+ #
21
+ # **Two passes, in order**:
22
+ #
23
+ # 1. Steps, chunks first. Removing one step at a time (what
24
+ # `bin/fuzz` did) costs O(n²) candidate checks on a sequence
25
+ # where most steps are irrelevant. Delta-debugging style: try
26
+ # removing halves, then quarters, … then single steps, keeping
27
+ # any removal that still reproduces; a single-step pass repeats
28
+ # until it changes nothing, so the result is 1-minimal (no one
29
+ # remaining step can be dropped).
30
+ # 2. Arguments, inside each surviving step. Unchanged from `bin/fuzz`
31
+ # (spec/bin_fuzz_spec.rb pins its accumulation contract): drop
32
+ # one key at a time from the step's current args, keep it dropped
33
+ # only while the finding still reproduces.
34
+ #
35
+ # A budget, because a sweep has other targets waiting. `budget:` caps
36
+ # how many candidate checks one call may spend (nil = unbounded, the
37
+ # `bin/fuzz` behaviour). When it runs out the best candidate found so
38
+ # far is returned — every accepted candidate reproduced, so a partial
39
+ # shrink is still a correct, just less small, demonstration.
40
+ module Shrinker
41
+ Result = Struct.new(:steps, :attempts, :exhausted, keyword_init: true)
42
+
43
+ module_function
44
+
45
+ def call(steps, budget: nil, &reproduces)
46
+ raise ArgumentError, "Shrinker.call needs a block answering whether a candidate reproduces" unless reproduces
47
+
48
+ meter = Meter.new(budget)
49
+ current = drop_steps(steps.dup, meter, &reproduces)
50
+ current = drop_arguments(current, meter, &reproduces)
51
+ Result.new(steps: current, attempts: meter.used, exhausted: meter.exhausted?)
52
+ end
53
+
54
+ def drop_steps(steps, meter, &reproduces)
55
+ current = steps
56
+ chunk = [current.length / 2, 1].max
57
+ loop do
58
+ changed = false
59
+ index = 0
60
+ while index < current.length
61
+ return current if meter.exhausted?
62
+
63
+ candidate = current[0...index] + (current[(index + chunk)..] || [])
64
+ if candidate.empty?
65
+ index += chunk
66
+ next
67
+ end
68
+
69
+ if meter.try { reproduces.call(candidate) }
70
+ current = candidate
71
+ changed = true
72
+ else
73
+ index += chunk
74
+ end
75
+ end
76
+
77
+ if chunk > 1
78
+ chunk = [chunk / 2, 1].max
79
+ elsif !changed
80
+ break
81
+ end
82
+ end
83
+ current
84
+ end
85
+
86
+ # See the module header's pass 2 — `args` is read by whichever
87
+ # spelling the step actually carries (`key?` first, never `||`,
88
+ # which cannot tell a stored `false` from an absent key).
89
+ def drop_arguments(steps, meter, &reproduces)
90
+ steps.each_index do |position|
91
+ original = args_of(steps[position])
92
+ next unless original.is_a?(Hash)
93
+
94
+ original.each_key do |key|
95
+ return steps if meter.exhausted?
96
+
97
+ step = steps[position]
98
+ trimmed = args_of(step).reject { |name, _| name == key }
99
+ candidate = steps.map(&:dup)
100
+ candidate[position] = step.merge("args" => trimmed)
101
+ steps = candidate if meter.try { reproduces.call(candidate) }
102
+ end
103
+ end
104
+ steps
105
+ end
106
+
107
+ def args_of(step)
108
+ step.key?("args") ? step["args"] : step[:args]
109
+ end
110
+
111
+ # Which finding this is, as a set of stable strings — the identity a
112
+ # shrink candidate has to keep. The `field` alone is too loose for
113
+ # the comparisons that carry lists: "refusals differ" on a 3-step
114
+ # candidate could be a different refusal split than the one the
115
+ # original seed found, and a shrinker that accepted it would hand
116
+ # back a demonstration of the wrong bug. So:
117
+ #
118
+ # refusals/queries/dry_runs/reactions — the field plus every verb
119
+ # (or query) named in the symmetric difference of the two sides;
120
+ # instances — the field plus the aggregate of every top-level key
121
+ # whose two sides disagree (the `#id` suffix dropped);
122
+ # a crash/process finding — the field plus the exception class
123
+ # leading its detail;
124
+ # anything else (a property name, a self-consistency axis) — the
125
+ # field, which already names the finding.
126
+ #
127
+ # `reproduces?` holds when a candidate's signature contains the
128
+ # original's: removing steps may add a second divergence, but it
129
+ # must never lose the one being demonstrated.
130
+ LIST_FIELDS = %w[refusals queries dry_runs reactions].freeze
131
+
132
+ CRASH_FIELDS = %w[crash process generator_crash].freeze
133
+
134
+ def signature(divergences)
135
+ divergences.each_with_object(Set.new) do |divergence, keys|
136
+ field = divergence[:field].to_s
137
+ keys << field
138
+ keys.merge(detail_keys(field, divergence))
139
+ end
140
+ end
141
+
142
+ def detail_keys(field, divergence)
143
+ left, right = divergence.except(:field, :detail).values.select { |v| v.is_a?(Array) || v.is_a?(Hash) }
144
+ if LIST_FIELDS.include?(field) then list_keys(field, left, right)
145
+ elsif field == "instances" then instance_keys(left, right)
146
+ elsif CRASH_FIELDS.include?(field) && divergence[:detail]
147
+ ["#{field}:#{divergence[:detail].to_s[/\A\w+(?:::\w+)*/]}"]
148
+ else []
149
+ end
150
+ end
151
+
152
+ def list_keys(field, left, right)
153
+ return [] unless left.is_a?(Array) && right.is_a?(Array)
154
+
155
+ ((left - right) + (right - left)).map { |row| "#{field}:#{named(row)}" }
156
+ end
157
+
158
+ # Instance keys are `Aggregate#id` on the wire — the id is whatever
159
+ # the generator minted, so it names the record, not the finding;
160
+ # keeping it would pin every creating step.
161
+ def instance_keys(left, right)
162
+ return [] unless left.is_a?(Hash) && right.is_a?(Hash)
163
+
164
+ (left.keys | right.keys).reject { |key| left[key] == right[key] }
165
+ .map { |key| "instances:#{key.to_s.split('#').first}" }
166
+ end
167
+
168
+ def reproduces?(original_signature, divergences)
169
+ !divergences.empty? && original_signature.subset?(signature(divergences))
170
+ end
171
+
172
+ def named(row)
173
+ return row.to_s unless row.is_a?(Hash)
174
+
175
+ key = [%w[verb query policy], %i[verb query policy]].flatten.find { |name| row.key?(name) }
176
+ (key ? row[key] : row).to_s
177
+ end
178
+
179
+ # Counts candidate checks against the budget.
180
+ class Meter
181
+ attr_reader :used
182
+
183
+ def initialize(budget)
184
+ @budget = budget
185
+ @used = 0
186
+ end
187
+
188
+ def exhausted? = !@budget.nil? && @used >= @budget
189
+
190
+ def try
191
+ @used += 1
192
+ yield
193
+ end
194
+ end
195
+ end
196
+ end
197
+ end