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
@@ -0,0 +1,299 @@
1
+ require "json"
2
+ require "prism"
3
+
4
+ module Hecks
5
+ module Codemod
6
+ # The rewriting half of `bin/codemod_legacy_dispatch_args` — reads what
7
+ # `LegacyDispatchRecorder` observed and rewrites each agreed call site
8
+ # from loose keyword facts to `to:` / `with:`:
9
+ #
10
+ # runtime.dispatch("Banking::Account.FreezeAccount", number: { value: "a" })
11
+ # # becomes
12
+ # runtime.dispatch("Banking::Account.FreezeAccount", to: "a")
13
+ #
14
+ # A key that carried receiver identity and is a declared fact of the
15
+ # command appears in both `to:` and `with:`, and only when its expression
16
+ # is safe to evaluate twice.
17
+ #
18
+ # A site is rewritten only when every recorded dispatch through it split
19
+ # the same way, strict `with:` accepted it (or refused it with the same
20
+ # class the loose call was refused with), no key it would move into `to:`
21
+ # also rode the event payload (`to:` would empty that field for whatever
22
+ # reacts downstream), and the call's own text still
23
+ # carries exactly the recorded keys, identity keys as literal pairs.
24
+ # Anything else is reported with its reason and left alone. Ruby files
25
+ # and the runnable blocks of Markdown guides (the ones `spec/support/
26
+ # doctest.rb` executes) are both rewritten.
27
+ module LegacyDispatchArgs
28
+ ROUTING = %i[to with saga_correlation].freeze
29
+ RUNNABLE_OPENERS = ["```ruby bluebook", "```ruby boot", "```ruby", "<!-- doctest:boot"].freeze
30
+
31
+ Plan = Struct.new(:to_keys, :with_keys, :slots, keyword_init: true)
32
+ Outcome = Struct.new(:path, :line, :status, :reason, keyword_init: true)
33
+ Skip = Struct.new(:reason)
34
+
35
+ module_function
36
+
37
+ # { [path, line] => Plan or Skip }
38
+ def plans(recording)
39
+ File.foreach(recording).map { |line| JSON.parse(line) }
40
+ .select { |entry| entry["site"] }
41
+ .group_by { |entry| entry["site"].rpartition(":").then { |path, _, line| [path, line.to_i] } }
42
+ .transform_values { |entries| plan_for(entries) }
43
+ end
44
+
45
+ def plan_for(entries)
46
+ refused = entries.find { |entry| entry["unrewritable"] }
47
+ return Skip.new(refused["unrewritable"]) if refused
48
+
49
+ mismatch = entries.find { |entry| entry["strict"] && entry["strict"] != entry["outcome"] }
50
+ if mismatch
51
+ return Skip.new("with: would refuse (#{mismatch['strict']}) where the loose call ended #{mismatch['outcome']}")
52
+ end
53
+
54
+ leaked = entries.flat_map { |entry| (entry["to_keys"].to_a - entry["with_keys"].to_a) & entry["payload_keys"].to_a }.uniq
55
+ unless leaked.empty?
56
+ return Skip.new("#{leaked.join(', ')} carries receiver identity AND rides the event payload, " \
57
+ "which `to:` would empty")
58
+ end
59
+
60
+ shapes = entries.map { |entry| [entry["keys"].sort, entry["to_keys"], entry["with_keys"].sort, entry["slots"]] }.uniq
61
+ return Skip.new("the site dispatched #{shapes.size} differently-shaped fact sets") unless shapes.one?
62
+
63
+ first = entries.first
64
+ Plan.new(to_keys: first["to_keys"], with_keys: first["with_keys"], slots: first["slots"])
65
+ end
66
+
67
+ # [new_text, outcomes] for one file; `site_plans` is { line => Plan or Skip }.
68
+ def rewrite(path, site_plans)
69
+ text = File.binread(path).force_encoding(Encoding::UTF_8)
70
+ outcomes = []
71
+ edits = []
72
+
73
+ site_plans.sort.each do |line, plan|
74
+ result = plan.is_a?(Skip) ? plan : edit_for(text, path, line, plan)
75
+ if result.is_a?(Skip)
76
+ outcomes << Outcome.new(path: path, line: line, status: :skipped, reason: result.reason)
77
+ else
78
+ edits << result
79
+ outcomes << Outcome.new(path: path, line: line, status: :rewritten)
80
+ end
81
+ end
82
+
83
+ edits.uniq.sort_by { |start, _, _| -start }.each do |start, finish, replacement|
84
+ text = text.byteslice(0, start) + replacement + text.byteslice(finish..)
85
+ end
86
+ [text, outcomes]
87
+ end
88
+
89
+ # [start byte, end byte, replacement] in file coordinates, or a Skip.
90
+ def edit_for(text, path, line, plan)
91
+ offset, code, first_line = region(text, path, line)
92
+ return Skip.new("line #{line} is not inside a runnable Ruby block") unless code
93
+
94
+ tree = Prism.parse(code, line: first_line)
95
+ return Skip.new("the enclosing Ruby does not parse") unless tree.errors.empty?
96
+
97
+ calls = []
98
+ collect_calls(tree.value, line, calls)
99
+ skips = []
100
+ calls.sort_by { |call| call.location.length }.each do |call|
101
+ keywords = call.arguments.arguments.last
102
+ result = replacement_for(keywords, plan)
103
+ if result.is_a?(Skip)
104
+ skips << result
105
+ next
106
+ end
107
+
108
+ return [offset + keywords.location.start_offset, offset + keywords.location.end_offset, result]
109
+ end
110
+ skips.first || Skip.new("no call with keyword arguments at line #{line}")
111
+ end
112
+
113
+ # The whole file for Ruby; for Markdown, the runnable block containing
114
+ # `line`. [byte offset of the block, its code, its first line number].
115
+ def region(text, path, line)
116
+ return [0, text, 1] unless path.end_with?(".md")
117
+
118
+ offset = 0
119
+ open = nil
120
+ text.each_line.with_index(1) do |source_line, number|
121
+ if open
122
+ if source_line.strip == open[:closer]
123
+ if open[:runnable] && (open[:line]...number).cover?(line)
124
+ return [open[:offset], text.byteslice(open[:offset], offset - open[:offset]),
125
+ open[:line]]
126
+ end
127
+
128
+ open = nil
129
+ end
130
+ elsif source_line.start_with?("```", "<!-- doctest:boot")
131
+ opener = source_line.rstrip
132
+ open = { closer: opener.start_with?("<!--") ? "-->" : "```", runnable: RUNNABLE_OPENERS.include?(opener),
133
+ offset: offset + source_line.bytesize, line: number + 1 }
134
+ end
135
+ offset += source_line.bytesize
136
+ end
137
+ nil
138
+ end
139
+
140
+ def collect_calls(node, line, found)
141
+ return unless node
142
+
143
+ if node.is_a?(Prism::CallNode) && node.arguments&.arguments&.last.is_a?(Prism::KeywordHashNode) &&
144
+ node.location.start_line <= line && line <= node.location.end_line
145
+ found << node
146
+ end
147
+ node.compact_child_nodes.each { |child| collect_calls(child, line, found) }
148
+ end
149
+
150
+ def key_of(element)
151
+ element.is_a?(Prism::AssocNode) && element.key.is_a?(Prism::SymbolNode) ? element.key.unescaped : nil
152
+ end
153
+
154
+ # The rewritten keyword arguments, or why this call does not match the plan.
155
+ # rubocop:disable-next Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
156
+ def replacement_for(keywords, plan)
157
+ elements = keywords.elements
158
+ return Skip.new("a keyword with a non-literal key") if elements.any? { |e| e.is_a?(Prism::AssocNode) && key_of(e).nil? }
159
+
160
+ routing = elements.select { |element| ROUTING.map(&:to_s).include?(key_of(element)) }
161
+ loose = elements - routing
162
+ return Skip.new("no loose keywords in this call") if loose.empty?
163
+ return Skip.new("to: is spelled in the call but the recording routed by identity") if routing.any? do |e|
164
+ key_of(e) == "to"
165
+ end && !plan.slots.empty?
166
+
167
+ literal = loose.filter_map { |element| key_of(element) }
168
+ splats = loose.reject { |element| key_of(element) }
169
+ recorded = (plan.to_keys + plan.with_keys).uniq
170
+ if splats.empty? && literal.sort != recorded.sort
171
+ return Skip.new("the call's keys (#{literal.sort.join(', ')}) are not the recorded keys (#{recorded.sort.join(', ')})")
172
+ end
173
+ if splats.any? && !((literal - recorded).empty? && (plan.to_keys - literal).empty?)
174
+ return Skip.new("a splat carries keys this call does not spell out")
175
+ end
176
+
177
+ route = route_text(loose, plan)
178
+ return route if route.is_a?(Skip)
179
+
180
+ to_only = loose.select { |element| (plan.to_keys - plan.with_keys).include?(key_of(element)) }
181
+ kept = elements - to_only
182
+ facts = loose - to_only
183
+ positions = facts.map { |element| kept.index(element) }
184
+ return Skip.new("routing keywords sit between the facts") unless positions.each_cons(2).all? { |a, b| b == a + 1 }
185
+
186
+ assemble(keywords, kept, facts, route)
187
+ end
188
+
189
+ def route_text(loose, plan)
190
+ parts = []
191
+ plan.slots.each do |slot|
192
+ element = loose.find { |candidate| key_of(candidate) == slot["key"] }
193
+ expression = element && identity_expression(element.value, slot)
194
+ return Skip.new("#{slot['key']}: its identity is not spelled the recorded way") unless expression
195
+ if plan.with_keys.include?(slot["key"]) && !pure?(expression)
196
+ return Skip.new("#{slot['key']}: carries identity and a fact, and its expression is not safe to evaluate twice")
197
+ end
198
+
199
+ parts[slot["part"]] = expression.slice
200
+ end
201
+
202
+ case parts.size
203
+ when 0 then nil
204
+ when 1 then "to: #{parts.first}"
205
+ when 2 then "to: { aggregate: #{parts[0]}, entity: #{parts[1]} }"
206
+ else "to: { aggregate: #{parts[0]}, entities: [#{parts[1..].join(', ')}] }"
207
+ end
208
+ end
209
+
210
+ # Every fact keeps its own line, re-aligned. A fact that used to
211
+ # start a line was aligned under whatever the call opened with; once
212
+ # the facts sit inside `with: { `, that column means nothing, so each
213
+ # following fact is re-indented under the first fact's new column
214
+ # (ordinary Ruby hash alignment) and any line inside a single fact's
215
+ # own value is shifted by the same amount, keeping its shape.
216
+ def assemble(keywords, kept, facts, route)
217
+ elements = keywords.elements
218
+ source = keywords.location.slice
219
+ base = keywords.location.start_offset
220
+ gap_after = lambda do |element|
221
+ following = elements[elements.index(element) + 1]
222
+ start = element.location.end_offset - base
223
+ following ? source.byteslice(start, following.location.start_offset - element.location.end_offset) : ", "
224
+ end
225
+
226
+ pieces = route ? [route] : []
227
+ column = advance(keywords.location.start_column, pieces.empty? ? "" : "#{route}, ")
228
+ index = 0
229
+ while index < kept.size
230
+ element = kept[index]
231
+ if facts.first.equal?(element)
232
+ pieces << with_clause(facts, column, gap_after)
233
+ index += facts.size
234
+ else
235
+ pieces << element.slice
236
+ index += 1
237
+ end
238
+ column = advance(column, "#{pieces.last}, ")
239
+ end
240
+ pieces.join(", ")
241
+ end
242
+
243
+ def with_clause(facts, column, gap_after)
244
+ opener = "with: { "
245
+ fact_column = column + opener.length
246
+ delta = fact_column - facts.first.location.start_column
247
+ body = facts.each_with_index.map do |fact, position|
248
+ gap = position < facts.size - 1 ? gap_after.call(fact).sub(/\n[^\S\n]*\z/, "\n#{' ' * fact_column}") : ""
249
+ reindent(fact.slice, delta) + gap
250
+ end.join
251
+ "#{opener}#{body} }"
252
+ end
253
+
254
+ # The column `text` ends at, having started at `column`.
255
+ def advance(column, text)
256
+ before, newline, after = text.rpartition("\n")
257
+ newline.empty? ? column + before.length + after.length : after.length
258
+ end
259
+
260
+ # Every line but the first moved right (or left) by `delta`.
261
+ def reindent(text, delta)
262
+ return text if delta.zero? || !text.include?("\n")
263
+
264
+ first, _, rest = text.partition("\n")
265
+ "#{first}\n" + rest.gsub(/^[^\S\n]*/) do |indent|
266
+ delta.positive? ? indent + (" " * delta) : indent[0, [indent.length + delta, 0].max]
267
+ end
268
+ end
269
+
270
+ def identity_expression(value, slot)
271
+ return value if slot["form"] == "scalar"
272
+ return nil unless value.is_a?(Prism::HashNode) && value.elements.one?
273
+
274
+ pair = value.elements.first
275
+ pair.value if key_of(pair) == slot["inner"]
276
+ end
277
+
278
+ # Safe to evaluate twice: literals, variable/constant reads, and
279
+ # argument-free method chains over them (`order.id`, `created.instance.id`).
280
+ def pure?(node)
281
+ case node
282
+ when Prism::StringNode, Prism::SymbolNode, Prism::IntegerNode, Prism::FloatNode, Prism::NilNode,
283
+ Prism::TrueNode, Prism::FalseNode, Prism::LocalVariableReadNode, Prism::InstanceVariableReadNode,
284
+ Prism::ConstantReadNode, Prism::ConstantPathNode
285
+ true
286
+ when Prism::InterpolatedStringNode
287
+ node.parts.all? do |part|
288
+ part.is_a?(Prism::StringNode) ||
289
+ (part.is_a?(Prism::EmbeddedStatementsNode) && Array(part.statements&.body).all? { |inner| pure?(inner) })
290
+ end
291
+ when Prism::CallNode
292
+ node.arguments.nil? && node.block.nil? && (node.receiver.nil? || pure?(node.receiver))
293
+ else
294
+ false
295
+ end
296
+ end
297
+ end
298
+ end
299
+ end
@@ -0,0 +1,186 @@
1
+ # Nothing here requires a gem at load time — this file arrives through
2
+ # RUBYOPT, ahead of `bundler/setup`, and activating a default gem (json)
3
+ # that early makes Bundler refuse the Gemfile's own version. `json` is
4
+ # required on first write instead, by which point Bundler has resolved.
5
+ require_relative "../deprecation"
6
+
7
+ module Hecks
8
+ module Codemod
9
+ # The observing half of `bin/codemod_legacy_dispatch_args`. Loaded into a
10
+ # test run through RUBYOPT (`bin/codemod_legacy_dispatch_args record --
11
+ # <command>` arms it), it wraps `Runtime::Dispatcher#dispatch` and, for
12
+ # every call that passed loose keyword facts, appends one JSON line
13
+ # saying how that call's facts split between `to:` and `with:`.
14
+ #
15
+ # **Why record instead of reading source** — loose keyword facts carry the
16
+ # receiver's identity (`number: { value: "a1" }`, `id: order.id`) mixed
17
+ # in with the command's facts, and which key is which is a property of
18
+ # the target command's IR (its `identified_by`, its declared attributes,
19
+ # the reference aliases `ReactionInvocation` accepts), not of the call's
20
+ # text. The recorder asks the same `ReactionInvocation.build(explicit:
21
+ # true)` split a policy's `with:` projection already uses, against the
22
+ # real registry and the real values, then checks the strict `with:`
23
+ # door would accept the result. The rewriter only touches a site whose
24
+ # every observation agrees.
25
+ #
26
+ # Each line: site ("path:line", the caller's own line), verb, the loose
27
+ # keys, `to_keys` (keys that carried receiver identity), `with_keys`
28
+ # (declared facts — a key can be both), `slots` (which key fills which
29
+ # part of the route, and whether its identity sat inside a one-field
30
+ # hash), `strict` (the refusal class strict `with:` raises, or nil),
31
+ # `outcome` ("ok" or the class the real dispatch raised),
32
+ # `payload_keys` (every key the dispatch's own events carried), and
33
+ # `unrewritable` when no split exists.
34
+ module LegacyDispatchRecorder
35
+ module_function
36
+
37
+ def arm!(path)
38
+ return install!(path) if defined?(Hecks::Runtime::Dispatcher)
39
+
40
+ trace = TracePoint.new(:end) do |point|
41
+ next unless point.self.is_a?(Module) && point.self.name == "Hecks::Runtime::Dispatcher"
42
+
43
+ trace.disable
44
+ install!(path)
45
+ end
46
+ trace.enable
47
+ end
48
+
49
+ def install!(path)
50
+ Hecks::Runtime::Dispatcher.prepend(Wrap)
51
+ Wrap.path = path
52
+ end
53
+
54
+ # Prepended onto Runtime::Dispatcher.
55
+ module Wrap
56
+ class << self
57
+ attr_accessor :path
58
+ end
59
+
60
+ def dispatch(verb, to: nil, with: nil, saga_correlation: nil, **legacy_args)
61
+ return super if legacy_args.empty?
62
+
63
+ entry = LegacyDispatchRecorder.observe(self, verb, to, with, legacy_args)
64
+ begin
65
+ result = super
66
+ entry[:outcome] = "ok"
67
+ entry[:payload_keys] = LegacyDispatchRecorder.payload_keys(result)
68
+ result
69
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised unchanged
70
+ entry[:outcome] = e.class.name
71
+ raise
72
+ ensure
73
+ LegacyDispatchRecorder.write(Wrap.path, entry)
74
+ end
75
+ end
76
+ end
77
+
78
+ # Every key the dispatch's own events carry. A loose fact reaches the
79
+ # event payload whether the command declares it or not, and a policy
80
+ # with no `with:` projection forwards that payload verbatim — so
81
+ # moving a key out of the facts and into `to:` can silently empty a
82
+ # field whatever reacts downstream needs. Recorded here so `plan_for`
83
+ # can refuse exactly those sites; a real one (Banking's own
84
+ # FreezeAccount -> ReviewOnFreeze -> AccountFreezeReview.Open, in
85
+ # docs/implemented/reference/policy.md, whose own `# => true` on
86
+ # `reaction_log.last[:delivered]` went false) is why this exists.
87
+ def payload_keys(result)
88
+ return [] unless result.respond_to?(:events)
89
+
90
+ result.events.flat_map { |event| Hash(event.payload).keys.map(&:to_s) }.uniq
91
+ rescue StandardError
92
+ []
93
+ end
94
+
95
+ def observe(dispatcher, verb, to, with, legacy)
96
+ frame = caller_locations(2).find { |location| Hecks::Deprecation.external?(location) }
97
+ entry = { site: frame && "#{frame.absolute_path || frame.path}:#{frame.lineno}", verb: verb.to_s,
98
+ to_given: !to.nil?, keys: legacy.keys.map(&:to_s) }
99
+ entry.merge(split(dispatcher.registry, verb, to, with, legacy))
100
+ rescue StandardError => e
101
+ entry.merge(unrewritable: "#{e.class}: #{e.message}")
102
+ end
103
+
104
+ def write(path, entry)
105
+ require "json"
106
+ File.open(path, "a") do |file|
107
+ file.flock(File::LOCK_EX)
108
+ file.puts(JSON.generate(entry))
109
+ end
110
+ end
111
+
112
+ def split(registry, verb, to, with, legacy)
113
+ return { unrewritable: "with: and loose keywords in the same call" } if with
114
+ return { unrewritable: "a loose keyword that is not a Symbol" } unless legacy.keys.all?(Symbol)
115
+
116
+ invocation = Hecks::Runtime::ReactionInvocation
117
+ target = invocation.send(:resolve_target, registry, verb)
118
+
119
+ if to
120
+ return { to_keys: [], with_keys: legacy.keys.map(&:to_s), slots: [],
121
+ strict: strict(target, to, legacy) }
122
+ end
123
+
124
+ built = invocation.build(registry: registry, verb: verb, projected: legacy, explicit: true)
125
+ route = built[:to]
126
+ with_keys = legacy.keys.select { |key| built[:with].key?(key) }
127
+ to_keys = route.nil? ? [] : legacy.keys.select { |key| consumed?(registry, verb, legacy, key, route) }
128
+ slots = slots_for(route, to_keys, legacy)
129
+ return slots if slots.is_a?(Hash)
130
+
131
+ { to_keys: to_keys.map(&:to_s), with_keys: with_keys.map(&:to_s), slots: slots,
132
+ strict: strict(target, route, legacy.slice(*with_keys)) }
133
+ end
134
+
135
+ # A key carried receiver identity when the route changes (or can no
136
+ # longer be built) without it.
137
+ def consumed?(registry, verb, legacy, key, route)
138
+ Hecks::Runtime::ReactionInvocation.build(registry: registry, verb: verb,
139
+ projected: legacy.except(key), explicit: true)[:to] != route
140
+ rescue StandardError
141
+ true
142
+ end
143
+
144
+ # Which key fills each part of the route. Refuses anything a source
145
+ # rewrite cannot spell as `to: <that key's expression>`: a composite
146
+ # identity, two keys naming the same part, or a non-String identity
147
+ # (`to:` takes only a String).
148
+ def slots_for(route, to_keys, legacy)
149
+ return [] if route.nil?
150
+
151
+ parts = route.is_a?(Hash) ? [route[:aggregate], *route[:entities]] : [route]
152
+ if to_keys.size != parts.size
153
+ return { unrewritable: "identity spread over #{to_keys.size} keys for #{parts.size} route parts" }
154
+ end
155
+
156
+ to_keys.map do |key|
157
+ form, inner, text = candidate(legacy[key])
158
+ return { unrewritable: "#{key}: identity is not a String or a one-field hash of one" } unless text
159
+
160
+ matches = parts.each_index.select { |index| parts[index].to_s == text }
161
+ return { unrewritable: "#{key}: identity matches #{matches.size} route parts" } unless matches.one?
162
+
163
+ { key: key.to_s, part: matches.first, form: form, inner: inner }
164
+ end
165
+ end
166
+
167
+ def candidate(raw)
168
+ return ["scalar", nil, raw] if raw.is_a?(String)
169
+ return nil unless raw.is_a?(Hash) && raw.size == 1
170
+
171
+ inner, value = raw.first
172
+ ["hash", inner.to_s, value] if value.is_a?(String)
173
+ end
174
+
175
+ def strict(target, route, facts)
176
+ Hecks::Runtime::Invocation.route(route, entity_depth: target.entities.size)
177
+ Hecks::Runtime::Invocation.facts_for(target.command, with: facts, legacy: {})
178
+ nil
179
+ rescue StandardError => e
180
+ e.class.name
181
+ end
182
+ end
183
+ end
184
+ end
185
+
186
+ Hecks::Codemod::LegacyDispatchRecorder.arm!(ENV["HECKS_LEGACY_DISPATCH_RECORD"]) if ENV["HECKS_LEGACY_DISPATCH_RECORD"]