hecks 1.2.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 (358) 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 +60 -2
  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/postgres_era.adapter +5 -0
  23. data/lib/hecks/adapters/driven/prism.rb +4 -4
  24. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  25. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  26. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  27. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  28. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  29. data/lib/hecks/adapters/driven.rb +4 -4
  30. data/lib/hecks/adapters/driving/github_webhook.rb +145 -0
  31. data/lib/hecks/behaviors/dsl.rb +2 -2
  32. data/lib/hecks/behaviors/expectations.rb +51 -23
  33. data/lib/hecks/behaviors/ir.rb +1 -1
  34. data/lib/hecks/behaviors/rspec.rb +1 -1
  35. data/lib/hecks/behaviors/runner.rb +2 -2
  36. data/lib/hecks/behaviors.rb +1 -1
  37. data/lib/hecks/bluebook/aggregate.rb +13 -13
  38. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +10 -10
  39. data/lib/hecks/bluebook/assembly/build.rb +1 -1
  40. data/lib/hecks/bluebook/assembly/contract.rb +39 -16
  41. data/lib/hecks/bluebook/assembly/contracts.rb +40 -38
  42. data/lib/hecks/bluebook/assembly/marks.rb +18 -18
  43. data/lib/hecks/bluebook/assembly/specializer.rb +19 -21
  44. data/lib/hecks/bluebook/assembly.rb +9 -9
  45. data/lib/hecks/bluebook/attribute.rb +9 -9
  46. data/lib/hecks/bluebook/behaviour/aggregate.rb +11 -11
  47. data/lib/hecks/bluebook/behaviour/attribute.rb +5 -5
  48. data/lib/hecks/bluebook/behaviour/chapter.rb +23 -5
  49. data/lib/hecks/bluebook/behaviour/command.rb +23 -23
  50. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -3
  51. data/lib/hecks/bluebook/behaviour/entity.rb +8 -8
  52. data/lib/hecks/bluebook/behaviour/hexagon.rb +4 -4
  53. data/lib/hecks/bluebook/behaviour/lifecycle.rb +5 -5
  54. data/lib/hecks/bluebook/behaviour/policy.rb +12 -12
  55. data/lib/hecks/bluebook/behaviour/process_manager.rb +7 -7
  56. data/lib/hecks/bluebook/behaviour/query.rb +1 -1
  57. data/lib/hecks/bluebook/behaviour/read_model.rb +8 -8
  58. data/lib/hecks/bluebook/behaviour/traits.rb +12 -12
  59. data/lib/hecks/bluebook/behaviour/value_object.rb +6 -6
  60. data/lib/hecks/bluebook/capabilities.rb +27 -0
  61. data/lib/hecks/bluebook/chapter.rb +28 -9
  62. data/lib/hecks/bluebook/command.rb +12 -12
  63. data/lib/hecks/bluebook/domain_port.rb +9 -9
  64. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  65. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  66. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +96 -96
  67. data/lib/hecks/bluebook/dsl/attribute_collector.rb +41 -41
  68. data/lib/hecks/bluebook/dsl/binding_proxy.rb +22 -2
  69. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +111 -74
  70. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +48 -30
  71. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  72. data/lib/hecks/bluebook/dsl/command_builder.rb +103 -103
  73. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  74. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  75. data/lib/hecks/bluebook/dsl/entity_builder.rb +56 -56
  76. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  77. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +89 -30
  78. data/lib/hecks/bluebook/dsl/identity_declaration.rb +17 -17
  79. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  80. data/lib/hecks/bluebook/dsl/policy_builder.rb +30 -21
  81. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  82. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  83. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +35 -35
  84. data/lib/hecks/bluebook/dsl/query_builder.rb +5 -5
  85. data/lib/hecks/bluebook/dsl/read_model_builder.rb +34 -34
  86. data/lib/hecks/bluebook/dsl/rule_reference.rb +41 -39
  87. data/lib/hecks/bluebook/dsl/translation_builder.rb +9 -9
  88. data/lib/hecks/bluebook/dsl/value_object_builder.rb +16 -16
  89. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  90. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  91. data/lib/hecks/bluebook/entity.rb +11 -11
  92. data/lib/hecks/bluebook/expression/ast_json.rb +20 -20
  93. data/lib/hecks/bluebook/expression/ast_reader.rb +3 -3
  94. data/lib/hecks/bluebook/expression/canonical_form.rb +9 -9
  95. data/lib/hecks/bluebook/expression/evaluator.rb +18 -18
  96. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +18 -18
  97. data/lib/hecks/bluebook/expression/resolver.rb +60 -62
  98. data/lib/hecks/bluebook/hexagon.rb +1 -1
  99. data/lib/hecks/bluebook/lifecycle.rb +1 -1
  100. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -1
  101. data/lib/hecks/bluebook/meta_validator/judge.rb +122 -86
  102. data/lib/hecks/bluebook/meta_validator/plan.rb +39 -39
  103. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -2
  104. data/lib/hecks/bluebook/meta_validator/readings.rb +43 -43
  105. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +44 -37
  106. data/lib/hecks/bluebook/meta_validator/shapes.rb +25 -21
  107. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +145 -31
  108. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +6 -6
  109. data/lib/hecks/bluebook/meta_validator/world_judge.rb +5 -5
  110. data/lib/hecks/bluebook/meta_validator.rb +70 -70
  111. data/lib/hecks/bluebook/model_check.rb +301 -84
  112. data/lib/hecks/bluebook/pattern_subset.rb +9 -9
  113. data/lib/hecks/bluebook/policy.rb +15 -13
  114. data/lib/hecks/bluebook/process_manager.rb +14 -14
  115. data/lib/hecks/bluebook/project_register.rb +6 -6
  116. data/lib/hecks/bluebook/query.rb +4 -4
  117. data/lib/hecks/bluebook/read_model.rb +14 -14
  118. data/lib/hecks/bluebook/reference.rb +8 -8
  119. data/lib/hecks/bluebook/smoke_test.rb +19 -19
  120. data/lib/hecks/bluebook/synthesizer.rb +12 -12
  121. data/lib/hecks/bluebook/translation.rb +4 -4
  122. data/lib/hecks/bluebook/value_object.rb +6 -6
  123. data/lib/hecks/bluebook.rb +2 -2
  124. data/lib/hecks/codemod/legacy_dispatch_args.rb +299 -0
  125. data/lib/hecks/codemod/legacy_dispatch_recorder.rb +186 -0
  126. data/lib/hecks/codemod.rb +36 -35
  127. data/lib/hecks/construct.rb +6 -6
  128. data/lib/hecks/corpus.rb +317 -0
  129. data/lib/hecks/deprecation.rb +95 -0
  130. data/lib/hecks/doc/reference.rb +19 -19
  131. data/lib/hecks/embryonaut_bluebook.rb +11 -11
  132. data/lib/hecks/facade/cli_door.rb +69 -10
  133. data/lib/hecks/facade/cli_runner.rb +105 -24
  134. data/lib/hecks/facade/command_request.rb +23 -0
  135. data/lib/hecks/facade/handle.rb +79 -32
  136. data/lib/hecks/facade/json_door.rb +106 -25
  137. data/lib/hecks/facade/surface/aggregate_door.rb +42 -27
  138. data/lib/hecks/facade/surface/chapter.rb +26 -17
  139. data/lib/hecks/facade/surface.rb +16 -3
  140. data/lib/hecks/facade.rb +15 -4
  141. data/lib/hecks/forms/app.rb +46 -30
  142. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  143. data/lib/hecks/forms/field_renderer.rb +142 -6
  144. data/lib/hecks/forms/field_shape.rb +183 -20
  145. data/lib/hecks/forms/html.rb +51 -7
  146. data/lib/hecks/forms/index_renderer.rb +14 -2
  147. data/lib/hecks/forms/params.rb +120 -23
  148. data/lib/hecks/forms/port_argument.rb +2 -2
  149. data/lib/hecks/forms/query_form_renderer.rb +2 -2
  150. data/lib/hecks/forms/record_renderer.rb +2 -2
  151. data/lib/hecks/forms/record_table.rb +1 -1
  152. data/lib/hecks/forms/value_object_shape.rb +3 -3
  153. data/lib/hecks/forms.rb +24 -4
  154. data/lib/hecks/fqn.rb +1 -1
  155. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  156. data/lib/hecks/framework.rb +48 -17
  157. data/lib/hecks/freezer.rb +11 -11
  158. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +90 -90
  159. data/lib/hecks/fuzzing/combination_miner.rb +119 -0
  160. data/lib/hecks/fuzzing/concurrent_dispatch.rb +361 -0
  161. data/lib/hecks/fuzzing/coverage_campaign.rb +118 -0
  162. data/lib/hecks/fuzzing/differential.rb +158 -0
  163. data/lib/hecks/fuzzing/domain_generator.rb +694 -0
  164. data/lib/hecks/fuzzing/era_boundary.rb +124 -0
  165. data/lib/hecks/fuzzing/form_census.rb +199 -0
  166. data/lib/hecks/fuzzing/generated_domain_check.rb +95 -0
  167. data/lib/hecks/fuzzing/invalid_value_generator.rb +6 -6
  168. data/lib/hecks/fuzzing/isolated_boot.rb +226 -38
  169. data/lib/hecks/fuzzing/nondeterministic.rb +67 -0
  170. data/lib/hecks/fuzzing/persistence_parity.rb +161 -0
  171. data/lib/hecks/fuzzing/properties/corrections.rb +100 -0
  172. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +321 -41
  173. data/lib/hecks/fuzzing/properties/guards.rb +129 -26
  174. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +32 -32
  175. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +31 -11
  176. data/lib/hecks/fuzzing/properties/outbox.rb +142 -0
  177. data/lib/hecks/fuzzing/properties/querying.rb +17 -17
  178. data/lib/hecks/fuzzing/properties.rb +84 -39
  179. data/lib/hecks/fuzzing/qa_settings.rb +152 -0
  180. data/lib/hecks/fuzzing/replay.rb +224 -114
  181. data/lib/hecks/fuzzing/rotation_priority.rb +94 -0
  182. data/lib/hecks/fuzzing/rust_gap_manifest.rb +113 -0
  183. data/lib/hecks/fuzzing/self_consistency.rb +676 -0
  184. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +526 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +101 -30
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +55 -8
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +31 -11
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +123 -32
  189. data/lib/hecks/fuzzing/sequence_generator.rb +152 -31
  190. data/lib/hecks/fuzzing/shrinker.rb +197 -0
  191. data/lib/hecks/fuzzing/structural_skips.rb +39 -0
  192. data/lib/hecks/fuzzing/sweep_depth.rb +53 -0
  193. data/lib/hecks/fuzzing/target_capabilities.rb +180 -0
  194. data/lib/hecks/fuzzing/value_generator.rb +66 -14
  195. data/lib/hecks/fuzzing.rb +11 -0
  196. data/lib/hecks/grammar/evolve.rb +10 -10
  197. data/lib/hecks/grammar.rb +7 -7
  198. data/lib/hecks/ir.rb +13 -13
  199. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  200. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  201. data/lib/hecks/language/bluebook/vocabulary.bluebook +365 -15
  202. data/lib/hecks/language/oidc.json +5 -0
  203. data/lib/hecks/literal.rb +9 -9
  204. data/lib/hecks/naming.rb +89 -21
  205. data/lib/hecks/ports/access_control.rb +58 -2
  206. data/lib/hecks/ports/agent/answers.rb +83 -6
  207. data/lib/hecks/ports/agent.rb +119 -35
  208. data/lib/hecks/ports/authentication.rb +44 -4
  209. data/lib/hecks/ports/authorization.rb +53 -11
  210. data/lib/hecks/ports/clock.rb +42 -23
  211. data/lib/hecks/ports/extraction.rb +16 -0
  212. data/lib/hecks/ports/identity_assignment.rb +24 -2
  213. data/lib/hecks/ports/identity_generation.rb +17 -3
  214. data/lib/hecks/ports/identity_resolution.rb +18 -1
  215. data/lib/hecks/ports/loading.rb +4 -0
  216. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  217. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  218. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  219. data/lib/hecks/ports/persistence/execution.rb +4 -0
  220. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  221. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  222. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  223. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  224. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  225. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  226. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  227. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  228. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  229. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  230. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  231. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +160 -58
  232. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  233. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +28 -5
  234. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  235. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  236. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  237. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +44 -6
  238. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  239. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +57 -4
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +338 -83
  242. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  243. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  244. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  245. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +42 -8
  246. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  247. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  248. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  249. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -19
  250. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +5 -5
  251. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +1 -1
  252. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  253. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  254. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  255. data/lib/hecks/ports/persistence/repository_factory.rb +29 -6
  256. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  257. data/lib/hecks/ports/persistence.rb +36 -1
  258. data/lib/hecks/ports/projection.rb +61 -7
  259. data/lib/hecks/ports/query/in_memory.rb +3 -3
  260. data/lib/hecks/ports/query/ordering.rb +6 -6
  261. data/lib/hecks/ports/query.rb +35 -0
  262. data/lib/hecks/projections/bootstrap_table.rb +112 -0
  263. data/lib/hecks/projections/diagrams.rb +75 -75
  264. data/lib/hecks/projections/glossary/html.rb +250 -0
  265. data/lib/hecks/projections/glossary/markdown.rb +105 -0
  266. data/lib/hecks/projections/glossary/mermaid.rb +110 -0
  267. data/lib/hecks/projections/glossary/page.css +271 -0
  268. data/lib/hecks/projections/glossary/page.js +72 -0
  269. data/lib/hecks/projections/glossary/sections.rb +17 -0
  270. data/lib/hecks/projections/glossary/sentences.rb +205 -0
  271. data/lib/hecks/projections/glossary.rb +214 -286
  272. data/lib/hecks/projections/ir.rb +1 -1
  273. data/lib/hecks/projections/model/deviations.rb +18 -17
  274. data/lib/hecks/projections/model.rb +25 -21
  275. data/lib/hecks/projections/oidc.rb +7 -7
  276. data/lib/hecks/projections/parser_table.rb +5 -5
  277. data/lib/hecks/projections/reference.rb +3 -3
  278. data/lib/hecks/projections/rust_vocabulary.rb +443 -0
  279. data/lib/hecks/projections/shape.rb +2 -2
  280. data/lib/hecks/projections/statements.rb +11 -11
  281. data/lib/hecks/projections/vocabulary.rb +9 -9
  282. data/lib/hecks/projections.rb +5 -3
  283. data/lib/hecks/projector/cli_projector.rb +29 -29
  284. data/lib/hecks/projector/docs_projector.rb +13 -13
  285. data/lib/hecks/projector/exporter.rb +42 -21
  286. data/lib/hecks/projector/ir_projector.rb +1 -1
  287. data/lib/hecks/projector/narrate_projector.rb +15 -22
  288. data/lib/hecks/projector/target.rb +13 -13
  289. data/lib/hecks/projector.rb +15 -15
  290. data/lib/hecks/query_ir.rb +47 -47
  291. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  292. data/lib/hecks/query_specification/common/comparison.rb +132 -24
  293. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  294. data/lib/hecks/query_specification/common/null_policy.rb +57 -13
  295. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  296. data/lib/hecks/query_specification/common/options.rb +25 -0
  297. data/lib/hecks/query_specification/field_path.rb +69 -15
  298. data/lib/hecks/query_specification/hop_path.rb +57 -20
  299. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  300. data/lib/hecks/rendering.rb +3 -3
  301. data/lib/hecks/router/namespace_installer.rb +3 -3
  302. data/lib/hecks/router.rb +1 -1
  303. data/lib/hecks/runtime/aggregate_lock.rb +11 -11
  304. data/lib/hecks/runtime/boot_gates.rb +3 -3
  305. data/lib/hecks/runtime/caller.rb +8 -8
  306. data/lib/hecks/runtime/capability_graph.rb +2 -2
  307. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +22 -25
  308. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +64 -67
  309. data/lib/hecks/runtime/command_interpreter.rb +159 -102
  310. data/lib/hecks/runtime/command_rules/admissibility.rb +69 -69
  311. data/lib/hecks/runtime/command_rules/arithmetic.rb +137 -91
  312. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  313. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  314. data/lib/hecks/runtime/command_rules/references.rb +94 -19
  315. data/lib/hecks/runtime/command_rules.rb +3 -0
  316. data/lib/hecks/runtime/dependency_planning.rb +11 -11
  317. data/lib/hecks/runtime/dispatcher.rb +283 -90
  318. data/lib/hecks/runtime/entity_element.rb +282 -46
  319. data/lib/hecks/runtime/entity_interpreter.rb +160 -69
  320. data/lib/hecks/runtime/errors.rb +19 -19
  321. data/lib/hecks/runtime/event.rb +6 -6
  322. data/lib/hecks/runtime/identity.rb +22 -22
  323. data/lib/hecks/runtime/instance.rb +39 -14
  324. data/lib/hecks/runtime/interpreting.rb +12 -12
  325. data/lib/hecks/runtime/invocation.rb +276 -0
  326. data/lib/hecks/runtime/loader.rb +14 -14
  327. data/lib/hecks/runtime/outbox.rb +23 -23
  328. data/lib/hecks/runtime/policy_interpreter.rb +54 -54
  329. data/lib/hecks/runtime/port_operation_interpreter.rb +22 -19
  330. data/lib/hecks/runtime/query_interpreter.rb +111 -56
  331. data/lib/hecks/runtime/reaction_invocation.rb +76 -9
  332. data/lib/hecks/runtime/read_model_interpreter.rb +40 -40
  333. data/lib/hecks/runtime/rebuild_sweep.rb +4 -4
  334. data/lib/hecks/runtime/reference_hop.rb +6 -6
  335. data/lib/hecks/runtime/refusal_wording.rb +92 -112
  336. data/lib/hecks/runtime/registry/saga_persistence.rb +21 -21
  337. data/lib/hecks/runtime/registry/verification.rb +36 -26
  338. data/lib/hecks/runtime/registry.rb +56 -27
  339. data/lib/hecks/runtime/remote_dispatcher.rb +38 -23
  340. data/lib/hecks/runtime/routing.rb +10 -88
  341. data/lib/hecks/runtime/saga_interpreter/correlation.rb +17 -17
  342. data/lib/hecks/runtime/saga_interpreter.rb +93 -56
  343. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  344. data/lib/hecks/runtime/tenant_check.rb +9 -9
  345. data/lib/hecks/runtime/tenant_scope.rb +5 -5
  346. data/lib/hecks/runtime/value/admission.rb +75 -30
  347. data/lib/hecks/runtime/value/coercion.rb +379 -226
  348. data/lib/hecks/runtime/value/entity_list_coercion.rb +248 -0
  349. data/lib/hecks/runtime/value.rb +28 -23
  350. data/lib/hecks/runtime.rb +7 -7
  351. data/lib/hecks/storehouse.rb +64 -64
  352. data/lib/hecks/version.rb +3 -3
  353. data/lib/hecks/vocabulary.rb +207 -5
  354. data/lib/hecks.rb +13 -11
  355. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +11 -11
  356. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +14 -12
  357. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +11 -11
  358. metadata +43 -2
@@ -0,0 +1,361 @@
1
+ require "fileutils"
2
+ require "tmpdir"
3
+ require "tempfile"
4
+ require "json"
5
+ require_relative "isolated_boot"
6
+ require_relative "../naming"
7
+
8
+ module Hecks
9
+ module Fuzzing
10
+ # **A generated sequence, raced for real** — `spec/adapters/driven/
11
+ # postgres_era_concurrent_dispatch_spec.rb` proves ADR 0036's own fix
12
+ # (a real `pg_advisory_xact_lock` serializes a PostgresEra-bound
13
+ # dispatch across separate OS processes) against one hand-authored
14
+ # fixture and one hand-picked conflicting pair (two `$6,000` Debits
15
+ # against a `$10,000` account). This module asks the same question —
16
+ # does the cross-process write lock actually serialize concurrent
17
+ # writers? — of an arbitrary generated sequence against a real target
18
+ # domain, so the practice's own adversarial generator gets to pick the
19
+ # conflict instead of a human picking it once and never again.
20
+ #
21
+ # The mechanism, generalized from that spec rather than REDERIVED: a
22
+ # command step partway through a generated sequence is chosen as the
23
+ # race step; every step before it is setup (replayed once, sequentially,
24
+ # to bring a fresh disposable schema to the state the race step expects
25
+ # to act against); the race step is then dispatched twice — once from
26
+ # each of two real, separate, forked OS processes racing against that
27
+ # same schema, no artificial gating, whichever the scheduler favors.
28
+ #
29
+ # The oracle is the same pair, dispatched sequentially, not a
30
+ # hardcoded expectation — unlike the hand-authored spec (which can
31
+ # assert `%w[refused succeeded]` because it knows its own fixture's
32
+ # business rule), this module has no idea whether an arbitrary
33
+ # generated race step conflicts with itself at all. So it asks the
34
+ # domain: replay the identical setup on a separate fresh schema, then
35
+ # dispatch the same race step twice, one after the other, in one
36
+ # process, with no contention at all — genuinely correct by
37
+ # construction, since nothing else can touch that schema while it
38
+ # runs. Two conflicting writes settle as {"succeeded", "refused"};
39
+ # two independent ones settle as {"succeeded", "succeeded"}; either
40
+ # way, that is the multiset the concurrent pair must also produce if
41
+ # the write lock actually serializes them — order does not matter
42
+ # (which real racer wins a genuine race is never controlled), the set
43
+ # of outcomes does.
44
+ #
45
+ # What a broken lock looks like here: the concurrent pair settling as
46
+ # {"succeeded", "succeeded"} where the sequential oracle says
47
+ # {"succeeded", "refused"} — two processes each hydrated the
48
+ # pre-write state, neither saw the other's write, and the second
49
+ # commit landed as a silent lost update instead of failing its own
50
+ # `given`. That is the exact corruption class ADR 0036 fixed for
51
+ # PostgresEra and `postgres_concurrent_dispatch_spec.rb` still
52
+ # documents, unfixed, for plain Postgres.
53
+ #
54
+ # Not a replacement for the hand-authored spec — that spec proves the
55
+ # mechanism once, precisely, with controlled gating so the assertion
56
+ # is deterministic; this module proves the same mechanism holds for
57
+ # whatever a real domain's own generated sequences throw at it, with
58
+ # no gating (a genuine, ungated race), on every sweep this mode runs.
59
+ module ConcurrentDispatch
60
+ module_function
61
+
62
+ COMMAND_STEP = ->(step) { step["verb"] && !step["query"] && !step["dry_run"] }
63
+
64
+ # One divergence list, the same shape every other mode in this
65
+ # practice produces — `[]` when nothing was found (including the
66
+ # legitimate "this seed's generated sequence has no command step to
67
+ # race at all" case: a sequence of pure queries/dry-runs has nothing
68
+ # to concurrently dispatch, and that is not a finding).
69
+ #
70
+ # `database:` is the shared, never-dropped scratch database
71
+ # (`bin/qa_sweep`'s own `persistence_parity_database`, reused here
72
+ # for the identical reason: a container, not the thing that's
73
+ # unique per run). `race_schema:`/`reference_schema:` are two
74
+ # different disposable schema names this one call owns for its own
75
+ # duration — the caller creates neither ahead of time (both are
76
+ # wiped fresh by the boots below) and drops both afterward, the
77
+ # same lifecycle `persistence_parity_schema` already has.
78
+ def check(domain_path, steps, database:, race_schema:, reference_schema:)
79
+ normalized = steps.map { |step| step.transform_keys(&:to_s) }
80
+ lockable, probe_errors = lockable_verbs(domain_path, normalized, database: database, schema: reference_schema)
81
+ race_index = pick_race_index(normalized, lockable)
82
+ unless race_index
83
+ # `[]` here means "nothing to race", and must only ever mean that.
84
+ # A sequence of pure queries/dry-runs is a legitimate clean
85
+ # result; a probe that raised for every verb is not — that one
86
+ # used to produce the identical `[]` and be logged as a clean
87
+ # concurrency Check (see `lockable_verbs`).
88
+ return [] if probe_errors.empty?
89
+
90
+ return [{ field: "concurrency_unraceable",
91
+ detail: "no command step could be raced because the cross-process-lock probe failed: " \
92
+ "#{probe_errors.uniq.join('; ')}" }]
93
+ end
94
+
95
+ setup_steps = normalized[0...race_index]
96
+ race_step = normalized[race_index]
97
+
98
+ reference = reference_outcomes(domain_path, setup_steps, race_step, database: database, schema: reference_schema)
99
+ concurrent = concurrent_outcomes(domain_path, setup_steps, race_step, database: database, schema: race_schema)
100
+
101
+ divergences_for(race_step, reference, concurrent)
102
+ rescue StandardError => e
103
+ [{ field: "process", detail: "#{e.class}: #{e.message}" }]
104
+ end
105
+
106
+ # The command step CLOSEST to the middle of the sequence — not the
107
+ # first (racing a bare identity-creation with no setup at all is a
108
+ # legitimate, useful case, so index 0 is not excluded) and not
109
+ # chosen for any domain-specific reason: a mid-sequence step has, on
110
+ # average, the deepest state to act against and the best odds of
111
+ # actually conflicting with itself. `nil` when the generated
112
+ # sequence has no command step at all (every step a query or a dry
113
+ # run) — nothing here for this seed to race.
114
+ #
115
+ # `lockable_verbs` — optional, but `check` always passes one: the
116
+ # set of verbs whose own aggregate is actually bound to an adapter
117
+ # that declares `:cross_process_lock` in this domain (see
118
+ # `lockable_verbs` below for why this can't be assumed just because
119
+ # the domain binds some of its own aggregates to PostgresEra).
120
+ # Racing anything outside that set is not a legitimate race at
121
+ # all — nil when nothing eligible is left, same as the "no command
122
+ # step" case, never a finding of its own.
123
+ def pick_race_index(steps, lockable_verbs = nil)
124
+ command_indices = steps.each_index.select { |i| COMMAND_STEP.call(steps[i]) }
125
+ command_indices = command_indices.select { |i| lockable_verbs.include?(steps[i]["verb"]) } if lockable_verbs
126
+ return nil if command_indices.empty?
127
+
128
+ command_indices[command_indices.size / 2]
129
+ end
130
+
131
+ # Which of this sequence's own command verbs are even candidates to
132
+ # race — BUG#142 (SW-quality_control-1789768606's own first real
133
+ # concurrency run, seed 3): a domain that `persisted_by("PostgresEra")`
134
+ # binds its own aggregates is not thereby binding a framework
135
+ # member it merely `uses_framework`s — `hecksagon_builder.rb`'s own
136
+ # `uses_framework` loads only that member's shape, never its
137
+ # persistence (see `examples/banking/bluebook/banking.hecksagon`'s
138
+ # own comment: a framework member's aggregates need a sibling
139
+ # hecksagon, registered under that member's own name, or
140
+ # `Ports::Persistence::BindingPolicy.default_binding` silently
141
+ # gives them "Memory" — no hecksagon registered under that name at
142
+ # all, so `resolve`'s own `missing_binding` refusal (which only
143
+ # fires when a hecksagon exists for that domain and simply omits
144
+ # this aggregate) never gets a chance to say so).
145
+ # `qa/bluebook/quality_control.hecksagon` attaches `Governance` via
146
+ # `uses_framework` with no such sibling — `Governance::
147
+ # RoleAssignment`/`RoleTransition` are Memory-backed, process-
148
+ # local, in the real ledger, `concurrency` mode included. Racing a
149
+ # Memory-backed aggregate across two real OS processes can never
150
+ # agree with the single-process sequential oracle — each racer's
151
+ # own boot gets its own independent, empty store — no matter how
152
+ # correct any write lock is; that is a guaranteed false positive,
153
+ # not evidence of a broken lock.
154
+ #
155
+ # Boots the same PostgresEra-rebound copy the race itself boots
156
+ # (`boot_preserving_schema` — structural inspection only, nothing
157
+ # dispatched, so the schema it's given is left exactly as it found
158
+ # it) and asks each distinct command verb's own resolved repository
159
+ # whether it actually declares `:cross_process_lock` — the same
160
+ # capability `Interpreting#run_dispatch_order_with_isolation`
161
+ # itself keys off of to decide whether a real advisory lock is
162
+ # even in play for that aggregate. A verb this boot can't resolve
163
+ # at all (a malformed adversarial verb, say) is conservatively
164
+ # excluded, not raced on a guess.
165
+ # Answers `[lockable, probe_errors]`. The errors half exists because
166
+ # the probe below rescues to `false`: a verb that cannot be resolved
167
+ # is indistinguishable, from the outside, from one that resolves fine
168
+ # and simply declares no `:cross_process_lock`. If resolution broke
169
+ # for every verb (a renamed capability symbol, a wiring change),
170
+ # `lockable` came back empty, `check` returned `[]`, and the sweep
171
+ # logged a clean concurrency Check for a race that never happened.
172
+ # `check` reports that case now instead of holding it.
173
+ def lockable_verbs(domain_path, steps, database:, schema:)
174
+ verbs = steps.select { |step| COMMAND_STEP.call(step) }.map { |step| step["verb"] }.uniq
175
+ lockable = []
176
+ probe_errors = []
177
+ boot_preserving_schema(domain_path, database: database, schema: schema) do |copy|
178
+ runtime = Hecks.boot(copy)
179
+ verbs.each do |verb|
180
+ lockable << verb if verb_cross_process_lockable?(runtime, verb, probe_errors)
181
+ end
182
+ end
183
+ [lockable, probe_errors]
184
+ end
185
+
186
+ def verb_cross_process_lockable?(runtime, verb, probe_errors = [])
187
+ domain, aggregate_name, = Naming.split_verb(verb)
188
+ return false unless domain && aggregate_name
189
+
190
+ aggregate = runtime.registry.bluebook(domain)&.aggregate(aggregate_name)
191
+ return false unless aggregate
192
+
193
+ repository = runtime.registry.repository(domain, aggregate)
194
+ repository.capabilities.include?(:cross_process_lock)
195
+ rescue StandardError => e
196
+ # Still `false` — a verb this boot cannot resolve is not raced on a
197
+ # guess — but no longer silent: `check` needs to tell "nothing here
198
+ # declares a cross-process lock" from "asking broke".
199
+ probe_errors << "#{verb}: #{e.class}: #{e.message}"
200
+ false
201
+ end
202
+
203
+ def divergences_for(race_step, reference, concurrent)
204
+ crashes = (reference + concurrent).select { |outcome| outcome.start_with?("crashed:") }.uniq
205
+ return crashes.map { |c| { field: "concurrency_crash", verb: race_step["verb"], detail: c } } if crashes.any?
206
+
207
+ return [] if reference.sort == concurrent.sort
208
+
209
+ [{ field: "concurrency_race", verb: race_step["verb"], reference: reference, concurrent: concurrent,
210
+ detail: "two concurrent cross-process dispatches of #{race_step['verb']} settled as #{concurrent.sort} " \
211
+ "where the identical pair, dispatched sequentially with no contention, settled as " \
212
+ "#{reference.sort} — the cross-process write lock did not correctly serialize this write" }]
213
+ end
214
+
215
+ # The oracle — one boot, one process, the setup then the race step
216
+ # twice in immediate succession. Nothing else ever touches this
217
+ # schema while this runs, so whatever the domain itself settles on
218
+ # is correct by construction, not asserted.
219
+ def reference_outcomes(domain_path, setup_steps, race_step, database:, schema:)
220
+ outcomes = []
221
+ IsolatedBoot.call(domain_path, adapter: :postgres_era, database: database, schema: schema) do |copy|
222
+ runtime = Hecks.boot(copy)
223
+ dispatch_all!(runtime, setup_steps)
224
+ outcomes << dispatch_one(runtime, race_step)
225
+ outcomes << dispatch_one(runtime, race_step)
226
+ end
227
+ outcomes
228
+ end
229
+
230
+ # The race itself — setup runs once, sequentially, in this process
231
+ # (the same `IsolatedBoot.call` wipe-then-boot every other mode
232
+ # here already uses), and only then do the two real racers run.
233
+ # Each racer boots its own fresh copy of the domain against the
234
+ # same now-populated schema — `boot_preserving_schema`, below,
235
+ # deliberately skips the wipe `IsolatedBoot.call` always does, or
236
+ # the setup this line just wrote would be gone before either racer
237
+ # ever dispatched anything.
238
+ #
239
+ # Real, separate OS processes, not `Thread.new` — `postgres_era_
240
+ # concurrent_dispatch_spec.rb`'s own header explains why:
241
+ # `Runtime::AggregateLock`'s in-process registry would fully (and
242
+ # misleadingly) serialize two threads sharing one process even with
243
+ # the cross-process lock fix reverted. Only two genuinely separate
244
+ # OS processes exercise the gap this check exists to catch.
245
+ #
246
+ # `Process.spawn`, not `Process.fork` — `bin/qa_sweep`'s own
247
+ # top-of-file comment on `--all` names the identical hazard this
248
+ # sidesteps: by the time a `concurrency` seed runs, this process
249
+ # already holds the QualityControl ledger's own live PostgresEra
250
+ # connection (this module's own caller, `bin/qa_sweep`, booted it
251
+ # long before any seed ran). `Process.fork` duplicates every open
252
+ # file descriptor, SSL session state included — confirmed live
253
+ # while wiring this mode up: forking directly from here corrupted
254
+ # the ledger's own connection the moment either racer child exited,
255
+ # surfacing on the next unrelated ledger write, nowhere near this
256
+ # method's own code. `bin/qa_concurrency_racer` is this method's own
257
+ # worker, one real `ruby` process per racer — read that script's own
258
+ # header for the rest of this reasoning.
259
+ def concurrent_outcomes(domain_path, setup_steps, race_step, database:, schema:)
260
+ IsolatedBoot.call(domain_path, adapter: :postgres_era, database: database, schema: schema) do |copy|
261
+ dispatch_all!(Hecks.boot(copy), setup_steps)
262
+ end
263
+
264
+ root = File.expand_path("../../..", __dir__)
265
+ racer = File.join(root, "bin/qa_concurrency_racer")
266
+ args_json = JSON.generate(race_step["args"] || {})
267
+ logs = Array.new(2) { Tempfile.new(["qa-concurrency-racer-", ".log"]) }
268
+ logs.each(&:unlink)
269
+
270
+ pids = logs.map do |log|
271
+ Process.spawn("bundle", "exec", "ruby", racer, domain_path, database, schema, race_step["verb"], args_json,
272
+ out: log, err: log, chdir: root)
273
+ end
274
+
275
+ pids.each { |pid| Process.wait(pid) }
276
+ logs.map do |log|
277
+ log.rewind
278
+ output = log.read
279
+ log.close
280
+ output.strip.empty? ? "crashed:no output from bin/qa_concurrency_racer" : output.lines.last.chomp
281
+ end
282
+ end
283
+
284
+ # One step, one outcome — never raises: a declared domain refusal is
285
+ # "refused" (the expected, ordinary answer a `given`/invariant can
286
+ # give), anything else escaping is "crashed:<class>: <message>", a
287
+ # genuine finding this module's own caller surfaces rather than lets
288
+ # kill a forked racer silently.
289
+ def dispatch_one(runtime, step)
290
+ args = (step["args"] || {}).transform_keys(&:to_sym)
291
+ runtime.dispatch_flat(step["verb"], args)
292
+ "succeeded"
293
+ rescue *Hecks::Runtime::DOMAIN_REFUSALS, Hecks::Bluebook::Expression::EvaluationError
294
+ "refused"
295
+ rescue StandardError => e
296
+ "crashed:#{e.class}: #{e.message}"
297
+ end
298
+
299
+ # Setup tolerates an ordinary refusal (a generated sequence's own
300
+ # earlier step can legitimately refuse — every other mode in this
301
+ # practice already replays a prefix that way) but never a crash: an
302
+ # unexpected exception during setup means the schema this race is
303
+ # about to run against is in an unknown state, which is itself
304
+ # worth surfacing, not silently racing anyway.
305
+ def dispatch_all!(runtime, steps)
306
+ steps.each do |step|
307
+ outcome = dispatch_one(runtime, step)
308
+ raise "setup step #{step['verb']} #{outcome}" if outcome.start_with?("crashed:")
309
+ end
310
+ end
311
+
312
+ # The same copy-and-rebind `IsolatedBoot.call(..., adapter:
313
+ # :postgres_era, ...)` does, minus the schema wipe — deliberately
314
+ # not reusing `IsolatedBoot.rebind_to_postgres_era!` itself, which
315
+ # bundles `ensure_postgres_era_schema!`'s own `DROP SCHEMA` into the
316
+ # same call with no way to opt out (see that method's own header:
317
+ # the wipe is the "zero-history guarantee every other adapter mode
318
+ # already gives," exactly the guarantee this caller must not have —
319
+ # the whole point of a race is booting against what setup already
320
+ # wrote). `copy_dereferencing`/`rewrite_bindings!` are the same two
321
+ # public steps that method itself calls first; only the `.world`
322
+ # this writes is duplicated from it, not re-derived, because the
323
+ # shape a `PostgresEra`-bound copy's `.world` needs is exactly that
324
+ # method's own, one step short.
325
+ def boot_preserving_schema(domain_path, database:, schema:)
326
+ Dir.mktmpdir("hecks-concurrency") do |tmp|
327
+ copy = File.join(tmp, File.basename(domain_path))
328
+ IsolatedBoot.copy_dereferencing(domain_path, copy)
329
+ FileUtils.rm_rf(File.join(copy, "data"))
330
+ IsolatedBoot.rewrite_bindings!(copy, "PostgresEra")
331
+ write_postgres_era_world!(copy, database: database, schema: schema)
332
+ yield copy
333
+ end
334
+ end
335
+
336
+ def write_postgres_era_world!(copy, database:, schema:)
337
+ Dir.glob(File.join(copy, "**", "*.hecksagon")).each do |hecksagon_path|
338
+ names = File.read(hecksagon_path).scan(/Hecks\.hecksagon\s+"([^"]+)"/).flatten.uniq
339
+ next if names.empty?
340
+
341
+ world_path = File.join(File.dirname(hecksagon_path), "hecks_fuzz_postgres_era.world")
342
+ File.write(world_path, names.map do |name|
343
+ <<~WORLD
344
+ Hecks.world "#{name}" do
345
+ persisted_by("PostgresEra") do
346
+ database "#{database}"
347
+ schema "#{schema}"
348
+ allow_superuser true
349
+ end
350
+ end
351
+ WORLD
352
+ end.join("\n"))
353
+ end
354
+
355
+ Dir.glob(File.join(copy, "**", "*.world")).each do |path|
356
+ File.delete(path) unless File.basename(path) == "hecks_fuzz_postgres_era.world"
357
+ end
358
+ end
359
+ end
360
+ end
361
+ end
@@ -0,0 +1,118 @@
1
+ module Hecks
2
+ module Fuzzing
3
+ # What one sweep has already reached, and where the next seed starts.
4
+ #
5
+ # `SequenceGenerator` steers within one sequence (an unexercised verb is
6
+ # weighted up — picker.rb's `steer`), but every seed of a sweep used to
7
+ # start from nothing and forget everything the seeds before it reached.
8
+ # Roughly half of the QA ledger's bugs are refusal-kind splits (BUG#2–4,
9
+ # 7, 8, 14–16, 19–21, 23, 27, 28, 36–38, 41, 54, 56): a particular verb,
10
+ # in a particular state, with a particular malformed argument, refused
11
+ # one way on Ruby and another on Rust. BUG#11 was found by hand because
12
+ # a 25-step random walk never reached `Annotate` at all. Uniform seeds
13
+ # reach those corners by luck; this makes the sweep remember.
14
+ #
15
+ # The coverage unit is a tuple, not a verb — `verb | step kind |
16
+ # lifecycle state before | adversarial mutation | outcome` (see
17
+ # `SequenceGenerator#coverage_tuple`). "Renew refused LifecycleRefused
18
+ # from free" and "Renew ok from held" are different places a runtime can
19
+ # be wrong, where a verb-level count calls them the same.
20
+ #
21
+ # Two levers, both expressed as plain generator arguments so a seed
22
+ # stays reproducible from one call:
23
+ #
24
+ # 1. Splicing (`prefix:`). A sequence that reached a new tuple joins
25
+ # the corpus, cut at its last new tuple. A later seed may start by
26
+ # re-generating a random-length prefix of a corpus entry — the same
27
+ # seed, favor and nested prefix, so the same steps, dispatched for
28
+ # real, reaching the same state — and then carries on with its own
29
+ # seed's randomness from there. Deep state gets reached once and
30
+ # explored many times, instead of re-rolled per seed.
31
+ # 2. Favor (`favor:`). The verbs this sweep has hit least often —
32
+ # including declared verbs it has never hit at all — are weighted
33
+ # up for the next seed.
34
+ #
35
+ # Nothing here is random at the campaign's own level beyond
36
+ # `Random.new(seed)` per plan, and a plan is printed with every finding
37
+ # (`bin/qa_sweep`'s `reproduce:` line), so no finding depends on
38
+ # re-running the whole campaign to get back to it.
39
+ class CoverageCampaign
40
+ Plan = Struct.new(:prefix, :favor, keyword_init: true) do
41
+ def generator_options = { prefix: prefix, favor: favor }
42
+ def spliced? = !prefix.nil?
43
+ end
44
+
45
+ attr_reader :corpus
46
+
47
+ def initialize(splice_probability:, favor_count:, corpus_limit: 64, max_prefix_depth: 6)
48
+ @splice_probability = splice_probability.to_f
49
+ @favor_count = favor_count.to_i
50
+ @corpus_limit = corpus_limit
51
+ @max_prefix_depth = max_prefix_depth
52
+ @corpus = []
53
+ @seen = Set.new
54
+ @verb_hits = Hash.new(0)
55
+ @declared_verbs = Set.new
56
+ @seeds = 0
57
+ @spliced = 0
58
+ @seeds_with_new = 0
59
+ end
60
+
61
+ def plan(seed)
62
+ random = Random.new(seed)
63
+ prefix = nil
64
+ if !@corpus.empty? && random.rand < @splice_probability
65
+ entry = @corpus[random.rand(@corpus.size)]
66
+ prefix = entry[:spec].merge("steps" => random.rand(1..entry[:attempts]))
67
+ end
68
+ Plan.new(prefix: prefix, favor: rare_verbs)
69
+ end
70
+
71
+ # `trace` is `SequenceGenerator.trace`'s answer: `coverage` is
72
+ # `[[attempt_index, tuple], ...]`, `verbs` every verb the booted
73
+ # catalog offered.
74
+ def record(seed, plan, trace)
75
+ @seeds += 1
76
+ @spliced += 1 if plan.spliced?
77
+ @declared_verbs.merge(trace.verbs)
78
+
79
+ new_at = trace.coverage.filter_map do |index, tuple|
80
+ @verb_hits[tuple.split(" | ").first] += 1
81
+ index if @seen.add?(tuple)
82
+ end
83
+ return if new_at.empty?
84
+
85
+ @seeds_with_new += 1
86
+ admit(seed, plan, new_at.max + 1)
87
+ end
88
+
89
+ def tuples_seen = @seen.size
90
+
91
+ def summary
92
+ "coverage: #{@seen.size} distinct (verb, kind, state, mutation, outcome) tuple(s) over #{@seeds} seed(s); " \
93
+ "#{@seeds_with_new} seed(s) reached something new; #{@spliced} spliced from a corpus of #{@corpus.size}"
94
+ end
95
+
96
+ private
97
+
98
+ def admit(seed, plan, attempts)
99
+ return if depth(plan.prefix) + 1 > @max_prefix_depth
100
+
101
+ spec = { "seed" => seed, "favor" => plan.favor }
102
+ spec["prefix"] = plan.prefix if plan.prefix
103
+ @corpus << { spec: spec, attempts: attempts }
104
+ @corpus.shift while @corpus.size > @corpus_limit
105
+ end
106
+
107
+ def depth(prefix) = prefix.nil? ? 0 : 1 + depth(prefix["prefix"])
108
+
109
+ # Never-hit declared verbs first (hits 0), then the least-hit, ties
110
+ # broken by name so a plan is a pure function of what was recorded.
111
+ def rare_verbs
112
+ return [] unless @favor_count.positive?
113
+
114
+ (@declared_verbs | @verb_hits.keys).min_by(@favor_count) { |verb| [@verb_hits[verb], verb] }
115
+ end
116
+ end
117
+ end
118
+ end
@@ -0,0 +1,158 @@
1
+ require "json"
2
+ require "open3"
3
+ require_relative "replay"
4
+ require_relative "properties"
5
+ require_relative "self_consistency"
6
+ require_relative "rust_gap_manifest"
7
+ require_relative "nondeterministic"
8
+
9
+ module Hecks
10
+ module Fuzzing
11
+ # The Ruby-vs-Rust comparison of one generated sequence — moved here,
12
+ # unchanged, out of `bin/qa_sweep`'s own `diff_ruby_vs_rust` so a second
13
+ # caller (`bin/qa_generated_domains --rust`, which compares domains the
14
+ # generator wrote rather than ones on the rotation) runs the same
15
+ # comparison rather than a re-derived one. `bin/qa_sweep` delegates to
16
+ # it; its own comments on the known-gap filtering, the wire-precision
17
+ # reduction and the deep copy taken before `strip_emitted_flags!` apply
18
+ # here word for word.
19
+ #
20
+ # `differ` is anything answering `RustConformanceHelpers`' comparison
21
+ # helpers (spec/support/rust_conformance_helpers.rb) plus a
22
+ # `structural_skips` set — duck-typed, so lib never requires spec/.
23
+ # Which query verbs may diverge is not the differ's call: it is read
24
+ # off the binary's own manifest.json (`manifest_partition`, below).
25
+ #
26
+ # Returns one divergence list per active mode — `{ differential: [...],
27
+ # self_consistency: [...], properties_in_differential: [...],
28
+ # adapter_parity_sqlite: [...] }`, keys present only for the modes in
29
+ # `modes:` (`differential` always). `adapter_parity_sqlite:` is the
30
+ # caller's own callable, because which two adapters that mode pairs is
31
+ # the caller's dial, not this module's.
32
+ module Differential
33
+ module_function
34
+
35
+ # The one place a Ruby/Rust query divergence may be tolerated — and
36
+ # only for a verb `gaps` (a `RustGapManifest`) declares not generated.
37
+ # Rust refuses such a verb outright while Ruby answers it for real
38
+ # (or refuses it for its own business reason), so both sides' rows for
39
+ # that verb leave the comparison. Everything else stays in: a refusal
40
+ # the manifest doesn't account for — however its message is worded —
41
+ # is a real divergence.
42
+ #
43
+ # `skipped` is every tolerated verb this history actually reached (for
44
+ # the sweep's structural_skip_report). `stale` is a divergence per
45
+ # tolerated verb Rust nonetheless answered with a query row: the
46
+ # manifest says "not generated" but the binary disagrees, so the
47
+ # tolerance itself is wrong and must not silently hold.
48
+ def manifest_partition(gaps, ruby_refusals:, rust_refusals:, ruby_queries:, rust_queries:)
49
+ verb_of = ->(row) { row.key?("verb") ? row["verb"] : row["query"] }
50
+ declared = ->(row) { gaps.not_generated?(verb_of.call(row)) }
51
+ stale = rust_queries.select(&declared).map do |row|
52
+ { field: "manifest", verb: row["query"],
53
+ detail: "#{row['query']} is declared generated: false in manifest.json " \
54
+ "(#{gaps.not_generated(row['query']).values_at('gap_class', 'construct').join('/')}), " \
55
+ "but the Rust binary answered it — regenerate with bin/project_rust" }
56
+ end
57
+ reached = (ruby_refusals + rust_refusals + ruby_queries).select(&declared)
58
+ { ruby_refusals: ruby_refusals.reject(&declared), rust_refusals: rust_refusals.reject(&declared),
59
+ ruby_queries: ruby_queries.reject(&declared), rust_queries: rust_queries.reject(&declared),
60
+ skipped: reached.to_set(&verb_of), stale: stale }
61
+ end
62
+
63
+ def property_divergences(history)
64
+ Properties.check(history).reject { |_, result| result == true }
65
+ .map { |name, message| { field: name.to_s, detail: message } }
66
+ end
67
+
68
+ def self_consistency_divergences(history)
69
+ return [] unless history[:self_consistency]
70
+
71
+ history[:self_consistency].values.flatten(1)
72
+ end
73
+
74
+ # rubocop:disable-next Metrics/AbcSize
75
+ # rubocop:disable-next Metrics/CyclomaticComplexity
76
+ # rubocop:disable-next Metrics/PerceivedComplexity
77
+ # rubocop:disable-next Metrics/MethodLength
78
+ def diff(differ, domain_path, steps, binary, modes:, adapter_parity_sqlite: nil)
79
+ self_consistency = modes.include?(:self_consistency)
80
+ ruby_result = Replay.call(domain_path, steps, self_consistency: self_consistency)
81
+ ruby_instances = JSON.parse(JSON.generate(ruby_result[:instances]))
82
+ ruby_events = JSON.parse(JSON.generate(ruby_result[:events]))
83
+ ruby_refusals = ruby_result[:refusals].map do |r|
84
+ { "verb" => r[:verb].to_s, "kind" => r[:kind].to_s.split("::").last, "error" => r[:error] }
85
+ end
86
+ ruby_queries = JSON.parse(JSON.generate(ruby_result[:queries].map { |row| Nondeterministic.strip(row, :query_row) }))
87
+ ruby_sagas = JSON.parse(JSON.generate(ruby_result[:sagas]))
88
+ ruby_dry_runs = ruby_result[:dry_runs].map { |d| { "verb" => d[:verb].to_s, "ok" => d[:ok] } }
89
+
90
+ outcomes = {}
91
+ outcomes[:properties_in_differential] = property_divergences(ruby_result) if modes.include?(:properties_in_differential)
92
+ if modes.include?(:adapter_parity_sqlite) && adapter_parity_sqlite
93
+ outcomes[:adapter_parity_sqlite] = adapter_parity_sqlite.call
94
+ end
95
+
96
+ stdout, status = Open3.capture2(binary, stdin_data: JSON.generate({ "steps" => steps }))
97
+ unless status.success?
98
+ return outcomes.merge(differential: [{ field: "process",
99
+ detail: "rust binary exited #{status.exitstatus}: #{stdout}" }])
100
+ end
101
+
102
+ rust_output = JSON.parse(stdout)
103
+ rust_live_instances = JSON.parse(JSON.generate(rust_output["instances"])) if self_consistency
104
+ differ.strip_emitted_flags!(rust_output["instances"])
105
+ differ.strip_emitted_flags!(rust_output["queries"])
106
+ differ.strip_occurred_at!(rust_output["events"])
107
+
108
+ divergences = []
109
+ divergences << { field: "instances", ruby: ruby_instances, rust: rust_output["instances"] } \
110
+ unless rust_output["instances"] == ruby_instances
111
+ divergences << { field: "events", ruby: ruby_events, rust: rust_output["events"] } \
112
+ unless rust_output["events"] == ruby_events
113
+
114
+ kept = manifest_partition(RustGapManifest.for_binary(binary),
115
+ ruby_refusals: ruby_refusals, rust_refusals: rust_output["refusals"],
116
+ ruby_queries: ruby_queries, rust_queries: rust_output["queries"])
117
+ differ.structural_skips.merge(kept[:skipped])
118
+ divergences.concat(kept[:stale])
119
+
120
+ by_kind = ->(r) { r.slice("verb", "kind") }
121
+ rust_refusals = kept[:rust_refusals].map(&by_kind)
122
+ kept_ruby_refusals = kept[:ruby_refusals].map(&by_kind)
123
+ divergences << { field: "refusals", ruby: kept_ruby_refusals, rust: rust_refusals } \
124
+ unless rust_refusals == kept_ruby_refusals
125
+
126
+ wordless = ->(row) { differ.reduce_to_wire_precision(row.except("error", "reference_error")) }
127
+ rust_queries = kept[:rust_queries].map(&wordless)
128
+ kept_ruby_queries = kept[:ruby_queries].map(&wordless)
129
+ divergences << { field: "queries", ruby: kept_ruby_queries, rust: rust_queries } \
130
+ unless rust_queries == kept_ruby_queries
131
+
132
+ divergences << { field: "sagas", ruby: ruby_sagas, rust: rust_output["sagas"] } \
133
+ unless rust_output["sagas"] == ruby_sagas
134
+
135
+ cross_domain = differ.cross_domain_policy_names(rust_output)
136
+ kept_ruby_reactions = JSON.parse(JSON.generate(ruby_result[:reactions]))
137
+ .reject { |r| cross_domain.include?(r["policy"]) }
138
+ rust_reactions = rust_output.fetch("reactions")
139
+ divergences << { field: "reactions", ruby: kept_ruby_reactions, rust: rust_reactions } \
140
+ unless rust_reactions == kept_ruby_reactions
141
+
142
+ rust_dry_runs = Array(rust_output["dry_runs"]).map { |d| d.slice("verb", "ok") }
143
+ divergences << { field: "dry_runs", ruby: ruby_dry_runs, rust: rust_dry_runs } \
144
+ unless rust_dry_runs == ruby_dry_runs
145
+ outcomes[:differential] = divergences
146
+
147
+ if self_consistency
148
+ outcomes[:self_consistency] =
149
+ self_consistency_divergences(ruby_result) +
150
+ SelfConsistency.check_rust_rehydration(binary, differ, rust_live_instances) +
151
+ SelfConsistency.check_rust_idempotency(binary, differ, rust_live_instances)
152
+ end
153
+
154
+ outcomes
155
+ end
156
+ end
157
+ end
158
+ end