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
@@ -11,11 +11,24 @@ module Hecks
11
11
  # for the same input either way; only the surrounding `<form>`'s
12
12
  # method differs.
13
13
  module FieldRenderer
14
- # `values` is the nested hash a sticky re-render (a rejected command,
15
- # a submitted query) carries back — read with the SAME dotted path a
14
+ # Renders one field, and recursively a group's children, as form markup with any held
15
+ # value filled back in.
16
+ #
17
+ # `values` is the hash a sticky re-render (a rejected command,
18
+ # a submitted query) carries back — read with the same dotted path a
16
19
  # Field already carries, via `dig`. `reference_options` maps a
17
20
  # `:reference` field's own path to `[[id, label], ...]`, built by the
18
21
  # caller (it needs a repository; this module stays pure markup).
22
+ #
23
+ # @param field [Forms::Field] the field to render
24
+ # @param values [Hash{String, Symbol => Object}] held values, either flat and keyed by
25
+ # dotted path or nested by path segment; see `dig`
26
+ # @param errors [Hash{String => String}, nil] message per dotted field path; nil for none
27
+ # @param reference_options [Hash{String => Array<Array>, nil}] `[id, label]` pairs per
28
+ # `:reference` field path, as `ReferenceOptions.collect` builds them; a nil or empty
29
+ # entry renders a plain text input
30
+ # @return [String] HTML for the field: a `<fieldset>` for `:group` and `:money`, a
31
+ # `<div class="field">` otherwise
19
32
  def self.render(field, values: {}, errors: nil, reference_options: {})
20
33
  case field.kind
21
34
  when :group then group(field, values, errors, reference_options, tag: "fieldset")
@@ -25,6 +38,17 @@ module Hecks
25
38
  end
26
39
  end
27
40
 
41
+ # Renders a field's children inside one enclosing element, with the field's label as
42
+ # its `<legend>`.
43
+ #
44
+ # @param field [Forms::Field] a `:group` or `:money` field carrying `children`
45
+ # @param values [Hash{String, Symbol => Object}] held values; see `dig`
46
+ # @param errors [Hash{String => String}, nil] message per dotted field path
47
+ # @param reference_options [Hash{String => Array<Array>, nil}] `[id, label]` pairs per
48
+ # `:reference` field path
49
+ # @param tag [String] name of the enclosing element, such as `"fieldset"`
50
+ # @param css [String, nil] class attribute for the enclosing element; nil for none
51
+ # @return [String] HTML for the enclosing element and every child
28
52
  def self.group(field, values, errors, reference_options, tag:, css: nil)
29
53
  inner = field.children.map { |child| render(child, values: values, errors: errors, reference_options: reference_options) }
30
54
  <<~HTML
@@ -35,6 +59,14 @@ module Hecks
35
59
  HTML
36
60
  end
37
61
 
62
+ # Renders a `:list` field as one textarea taking an element per line, which is the shape
63
+ # `Params.extract_list` reads back.
64
+ #
65
+ # @param field [Forms::Field] a `:list` field whose first child describes one element
66
+ # @param values [Hash{String, Symbol => Object}] held values; the list's entry may be an
67
+ # Array, a newline-joined String, or absent
68
+ # @param _errors [Hash{String => String}, nil] ignored; a list shows no per-field message
69
+ # @return [String] HTML for the labelled textarea and its help line
38
70
  def self.list(field, values, _errors)
39
71
  item = field.children.first
40
72
  current = Array(dig(values, field.path)).join("\n")
@@ -48,6 +80,15 @@ module Hecks
48
80
  HTML
49
81
  end
50
82
 
83
+ # Renders a single-value field with the control its `kind` calls for, wrapped with its
84
+ # label, help text and error.
85
+ #
86
+ # @param field [Forms::Field] a leaf field
87
+ # @param values [Hash{String, Symbol => Object}] held values; see `dig`
88
+ # @param errors [Hash{String => String}, nil] message per dotted field path
89
+ # @param reference_options [Hash{String => Array<Array>, nil}] `[id, label]` pairs per
90
+ # `:reference` field path
91
+ # @return [String] HTML for the `<div class="field">` block
51
92
  def self.leaf(field, values, errors, reference_options)
52
93
  value = dig(values, field.path)
53
94
  error = errors && errors[field.path]
@@ -62,6 +103,14 @@ module Hecks
62
103
  wrap(field, body, error)
63
104
  end
64
105
 
106
+ # Wraps a rendered control with its label, help text and error message; a `:boolean`
107
+ # gets no label here because `checkbox` renders its own beside the box.
108
+ #
109
+ # @param field [Forms::Field] the field the control belongs to
110
+ # @param body [String] HTML of the control itself
111
+ # @param error [String, nil] message shown as an alert; nil for none
112
+ # @return [String] HTML for the `<div class="field">` block, classed `has-error` when
113
+ # `error` is given
65
114
  def self.wrap(field, body, error)
66
115
  <<~HTML
67
116
  <div class="field#{' has-error' if error}">
@@ -73,6 +122,13 @@ module Hecks
73
122
  HTML
74
123
  end
75
124
 
125
+ # Renders an `<input>` of the field's `html_type`, falling back to the field's default
126
+ # for a missing value; a whole-cents number also gets a live preview span.
127
+ #
128
+ # @param field [Forms::Field] a leaf field
129
+ # @param value [Object, nil] the held value, rendered with `to_s`; nil shows the default
130
+ # @return [String] HTML for the input, followed by the preview `<span>` when the field
131
+ # is a `:number` whose help text mentions cents
76
132
  def self.input(field, value)
77
133
  money = field.kind == :number && field.help.to_s.include?("cents")
78
134
  tag = Tag.void("input", id: dom_id(field.path), name: field.path, type: field.html_type,
@@ -84,10 +140,23 @@ module Hecks
84
140
  %(#{tag} <span id="#{dom_id(field.path)}-preview" class="mono help" aria-live="polite"></span>)
85
141
  end
86
142
 
143
+ # Renders a `<textarea>` holding the value as escaped text; unlike `input`, the field's
144
+ # default is not filled in.
145
+ #
146
+ # @param field [Forms::Field] a `:textarea` field
147
+ # @param value [Object, nil] the held value, rendered with `to_s`; nil renders empty
148
+ # @return [String] HTML for the textarea
87
149
  def self.textarea(field, value)
88
150
  %(<textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{Escape.html(value)}</textarea>)
89
151
  end
90
152
 
153
+ # Renders a checkbox preceded by a hidden `"0"` input of the same name, so an unticked
154
+ # box still submits a value, with the field's label beside it.
155
+ #
156
+ # @param field [Forms::Field] a `:boolean` field
157
+ # @param value [Boolean, String, nil] the held value; `true`, `"true"`, `"on"` and `"1"`
158
+ # tick the box, and nil falls back to a default of exactly `true`
159
+ # @return [String] HTML for the checkbox row
91
160
  def self.checkbox(field, value)
92
161
  checked = value.nil? ? field.default == true : [true, "true", "on", "1"].include?(value)
93
162
  <<~HTML
@@ -99,6 +168,13 @@ module Hecks
99
168
  HTML
100
169
  end
101
170
 
171
+ # Renders one radio button per option, pre-checking the one equal to the held value or,
172
+ # failing that, to the field's default.
173
+ #
174
+ # @param field [Forms::Field] a `:radio` field whose `options` are `[value, label]` pairs
175
+ # @param value [Object, nil] the held value, compared with `to_s`; nil falls back to the
176
+ # default
177
+ # @return [String] HTML for the `<div role="radiogroup">` block
102
178
  def self.radio_group(field, value)
103
179
  selected = value || field.default
104
180
  options = field.options.map do |option_value, option_label|
@@ -111,6 +187,13 @@ module Hecks
111
187
  %(<div class="radio-group" role="radiogroup">#{options.join}</div>)
112
188
  end
113
189
 
190
+ # Renders a `<select>` over the field's fixed options, with a blank "—" entry first when
191
+ # the field is optional.
192
+ #
193
+ # @param field [Forms::Field] a `:select` field whose `options` are `[value, label]` pairs
194
+ # @param value [Object, nil] the held value, compared with `to_s`; nil falls back to the
195
+ # default
196
+ # @return [String] HTML for the select
114
197
  def self.select(field, value)
115
198
  selected = value || field.default
116
199
  options = field.options.map do |option_value, option_label|
@@ -121,6 +204,16 @@ module Hecks
121
204
  %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{options.join}</select>)
122
205
  end
123
206
 
207
+ # Renders a `<select>` of existing records for a `:reference` field, degrading to a plain
208
+ # text id input when no records are on offer.
209
+ #
210
+ # @param field [Forms::Field] a `:reference` field; its `html_type` is set to `"text"`
211
+ # in place when the text fallback is taken
212
+ # @param value [Object, nil] the held id, compared with `to_s`; nil preselects the
213
+ # disabled "choose one…" entry of a required field
214
+ # @param options [Array<Array>, nil] `[id, label]` pairs to offer; nil or empty takes the
215
+ # text fallback
216
+ # @return [String] HTML for the select, or for the fallback `<input>`
124
217
  def self.reference_select(field, value, options)
125
218
  return input(field.tap { |f| f.html_type = "text" }, value) unless options && !options.empty?
126
219
 
@@ -135,30 +228,73 @@ module Hecks
135
228
  %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{rendered.join}</select>)
136
229
  end
137
230
 
231
+ # Renders the asterisk that marks a required field's label or legend.
232
+ #
233
+ # @param field [Forms::Field] the field being labelled
234
+ # @return [String] HTML for the mark, or `""` when the field is optional
138
235
  def self.required_mark(field) = field.required? ? %(<span class="required-mark" title="required">*</span>) : ""
236
+
237
+ # Derives the element id of a field's control from its dotted path, such as
238
+ # `"f-amount-cents"` for `"amount.cents"`.
239
+ #
240
+ # @param path [String, Symbol] the field's dotted path
241
+ # @return [String] the id, `"f-"` followed by the path with dots turned to hyphens
139
242
  def self.dom_id(path) = "f-#{path.to_s.tr('.', '-')}"
243
+
244
+ # Picks the `pattern` attribute an input carries, withholding it from a `:reference`
245
+ # field, whose input takes a record id rather than the attribute's own value.
246
+ #
247
+ # @param field [Forms::Field] a leaf field
248
+ # @return [String, nil] the regex source the attribute declares as `pattern:`; nil for a
249
+ # `:reference` field or when none is declared
140
250
  def self.leaf_pattern(field) = field.kind == :reference ? nil : field.pattern
251
+
252
+ # Names the preview span a whole-cents input's `data-money-cents` attribute points at.
253
+ #
254
+ # @param field [Forms::Field] the cents field
255
+ # @return [String] a CSS id selector, such as `"#f-amount-cents-preview"`
141
256
  def self.money_preview_target(field) = "##{dom_id(field.path)}-preview"
142
257
 
258
+ # Renders the `required` and `aria-describedby` attributes of a control as an
259
+ # attribute string, for the controls assembled by hand rather than through `Tag.void`.
260
+ #
261
+ # @param field [Forms::Field] the field the control belongs to
262
+ # @return [String] the rendered attributes, or `""` when the field is optional and has
263
+ # no help text
143
264
  def self.aria(field)
144
265
  Tag.attrs(required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil)
145
266
  end
146
267
 
268
+ # Builds the `required` and `aria-describedby` attributes of a control as a Hash, to
269
+ # splat into `Tag.void`.
270
+ #
271
+ # @param field [Forms::Field] the field the control belongs to
272
+ # @return [Hash{Symbol => Boolean, String, nil}] `:required` and `:aria_describedby`;
273
+ # the latter is the help span's element id, or nil when the field has no help text
147
274
  def self.aria_attrs(field)
148
275
  { required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil }
149
276
  end
150
277
 
151
- # `values` is a nested hash; `path` a dotted string using the SAME
152
- # segment spelling the hash keys are built from (`leaf_key` in
278
+ # Reads the value held for a dotted field path out of either spelling a form's values
279
+ # arrive in, flat or nested, without losing a stored `false`.
280
+ #
281
+ # `values` is a nested hash; `path` a dotted string using the same
282
+ # segment spelling the hash keys are built from (`Params.nest` in
153
283
  # params.rb) — symbols one level down from a group, strings at the
154
284
  # flat top when a sticky POST re-render hands raw params back untouched.
285
+ #
286
+ # @param values [Hash{String, Symbol => Object}, Object] held values; anything that is
287
+ # not a Hash, nil included, reads as nil
288
+ # @param path [String, Symbol] the field's dotted path, such as `"amount.cents"`
289
+ # @return [Object, nil] the held value exactly as stored (a raw form String, or a typed
290
+ # value from a record's state); nil when no key on the path answers
155
291
  def self.dig(values, path)
156
292
  return nil unless values.is_a?(Hash)
157
293
 
158
- # A sticky re-render after a refused submission hands back the RAW
294
+ # A sticky re-render after a refused submission hands back the raw
159
295
  # flat params (`{"amount.cents"=>"1050"}` — the same shape the form
160
296
  # posted, string values and all, dotted key intact) ; a prefill from
161
- # an existing record's own state hands back a NESTED hash instead
297
+ # an existing record's own state hands back a nested hash instead
162
298
  # (`{amount: {cents: 1050}}`). Flat wins when both would answer,
163
299
  # since only the raw form is ever what the caller actually typed.
164
300
  # `key?` decides which spelling answers, at every step below —
@@ -1,37 +1,57 @@
1
+ require_relative "../vocabulary"
1
2
  require_relative "../bluebook/attribute"
2
3
  require_relative "../naming"
3
4
  require_relative "value_object_shape"
4
5
 
5
6
  module Hecks
6
7
  module Forms
7
- # One resolved field, ready for a renderer to turn into markup. A LEAF
8
+ # One resolved field, ready for a renderer to turn into markup. A leaf
8
9
  # scalar carries `kind`/`options`/etc; a `:group` or `:list` carries
9
10
  # `children` instead and nothing else on itself.
10
11
  #
11
- # `path` is the DOTTED field path this attribute is reached at —
12
+ # `path` is the dotted field path this attribute is reached at —
12
13
  # `"email.address"`, `"amount.cents"` — the same convention the language
13
14
  # already uses for a query's own cross-object `where` clauses
14
15
  # (`where(:"customer.status" => ...)`), not a fresh one invented here. A
15
16
  # command receives its arguments as nested hashes
16
- # (`email: {address: "..."}`), so `Params.unflatten` (params.rb) walks
17
+ # (`email: {address: "..."}`), so `Params.extract` (params.rb) walks
17
18
  # this same dotted spelling back apart on submit — one convention, two
18
19
  # directions.
19
20
  Field = Struct.new(
20
21
  :path, :label, :kind, :html_type, :options, :children, :default, :optional,
21
22
  :pattern, :step, :help, :target_aggregate, keyword_init: true
22
23
  ) do
24
+ # Tells whether this field holds a value itself rather than carrying `children`.
25
+ #
26
+ # @return [Boolean] false for a `:group` or `:list`, true for every other kind,
27
+ # `:money` included
23
28
  def leaf? = kind != :group && kind != :list
29
+
30
+ # Tells whether a submission may leave this field blank.
31
+ #
32
+ # @return [Boolean, nil] the `optional` member as given; nil when the field was built
33
+ # without one, which reads as required
24
34
  def optional? = optional
35
+
36
+ # Tells whether a submission must fill this field in.
37
+ #
38
+ # @return [Boolean] true unless `optional` was set truthy
25
39
  def required? = !optional
26
40
  end
27
41
 
28
42
  # Turns a wire-spelled field or path segment into the plain-English text
29
43
  # `FieldShape` embeds as a `Field`'s label or a group's legend.
30
44
  module Humanize
45
+ # Humanizes the last segment of a field name or dotted path into sentence-case words.
46
+ #
31
47
  # "given" -> "Given", "daily_limit" -> "Daily limit", "end_to_end" ->
32
48
  # "End to end" — the label a plain reader wants, not the wire spelling.
49
+ #
50
+ # @param text [String, Symbol, nil] a field name or dotted path, such as
51
+ # `"amount.cents"`; only the part after the last `"."` is used
52
+ # @return [String] the humanized words, or `""` when `text` is nil or empty
33
53
  def self.label(text)
34
- # Split on "." FIRST, alone, to take only the last path segment —
54
+ # Split on "." first, alone, to take only the last path segment —
35
55
  # "daily_limit" is one segment (the underscore is a word break
36
56
  # inside it, not a path hop) and must keep both its words; only a
37
57
  # genuinely dotted path ("amount.cents") drops everything before
@@ -39,14 +59,19 @@ module Hecks
39
59
  segment = text.to_s.split(".").last.to_s
40
60
  return segment if segment.empty?
41
61
 
42
- # The word split itself is `Naming.words`' now — the one
43
- # humanizer, shared with the glossary projection.
62
+ # The word split itself is `Naming.words` — the one humanizer,
63
+ # shared with the glossary projection.
44
64
  Naming.words(segment)
45
65
  end
46
66
 
67
+ # Humanizes every segment of a dotted path and joins them with " → ".
68
+ #
47
69
  # The full dotted path, each segment humanized and joined with
48
70
  # " → " for a fieldset legend spanning more than one hop
49
71
  # ("Amount → Cents") — a group's own legend, not a leaf's label.
72
+ #
73
+ # @param path [String, Symbol, nil] a dotted field path, such as `"amount.cents"`
74
+ # @return [String] the humanized trail, or `""` when `path` is nil or empty
50
75
  def self.breadcrumb(path)
51
76
  path.to_s.split(".").map { |part| label(part) }.join(" → ")
52
77
  end
@@ -61,12 +86,23 @@ module Hecks
61
86
  module FieldShape
62
87
  PRIMITIVES = Bluebook::Attribute::PRIMITIVES
63
88
 
64
- # `aggregate:` is the Aggregate that OWNS this attribute (a
89
+ # Resolves one declared attribute into the field a form renders for it, choosing the
90
+ # input kind from the attribute's type, pattern, `admits:` set and value-object shape.
91
+ #
92
+ # `aggregate:` is the Aggregate that owns this attribute (a
65
93
  # command's, a query's, or — recursively — a value object's) —
66
94
  # needed to resolve `reference_to`, `admits:`, and a same-chapter
67
95
  # value object by name. `path:` defaults to the attribute's own name;
68
96
  # a caller resolving a nested value object's attribute passes the
69
97
  # dotted path so far.
98
+ #
99
+ # @param attribute [Bluebook::Attribute] the attribute to build a field for
100
+ # @param aggregate [Bluebook::Aggregate] the aggregate that owns the attribute
101
+ # @param path [String] dotted field path the attribute is reached at
102
+ # @return [Forms::Field] the resolved field; a single-attribute value object resolves
103
+ # to its inner leaf, so the returned path may be longer than `path`
104
+ # @raise [Bluebook::DSL::Malformed] if a `reference_to` attribute cannot say which
105
+ # aggregate declares it
70
106
  def self.resolve(attribute, aggregate:, path: attribute.name.to_s)
71
107
  return list_field(attribute, aggregate, path) if attribute.list?
72
108
 
@@ -80,6 +116,15 @@ module Hecks
80
116
  primitive_field(attribute, common)
81
117
  end
82
118
 
119
+ # Builds the `:list` field for a `list_of` attribute, carrying as its one child the field
120
+ # a single element would resolve to.
121
+ #
122
+ # @param attribute [Bluebook::Attribute] the list attribute
123
+ # @param aggregate [Bluebook::Aggregate] the aggregate that owns the attribute
124
+ # @param path [String] dotted field path the list is reached at
125
+ # @return [Forms::Field] a `:list` field whose `children` holds exactly one element field
126
+ # @raise [Bluebook::DSL::Malformed] if the element is a `reference_to` that cannot say
127
+ # which aggregate declares it
83
128
  def self.list_field(attribute, aggregate, path)
84
129
  # The scalar shape one element of this list would take, so a
85
130
  # renderer can say what belongs on each line without a second
@@ -92,6 +137,17 @@ module Hecks
92
137
  children: [resolve(scalar, aggregate: aggregate, path: path)])
93
138
  end
94
139
 
140
+ # Builds the `:reference` field for a `reference_to` attribute, remembering the target
141
+ # aggregate so a renderer can offer its records.
142
+ #
143
+ # @param attribute [Bluebook::Attribute] an attribute whose type is a
144
+ # `Bluebook::Reference`
145
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
146
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
147
+ # @return [Forms::Field] a `:reference` field; its `target_aggregate` is nil when the
148
+ # target belongs to a domain that is not loaded
149
+ # @raise [Bluebook::DSL::Malformed] if the reference cannot say which aggregate
150
+ # declares it
95
151
  def self.reference_field(attribute, common)
96
152
  target = attribute.type.resolve
97
153
  Field.new(**common, kind: :reference, html_type: "text", target_aggregate: target,
@@ -102,27 +158,40 @@ module Hecks
102
158
  end)
103
159
  end
104
160
 
105
- # `admits:` names a closed set declared ELSEWHERE (`"Account::
161
+ # Builds the radio or select field for an attribute whose `admits:` names a closed set,
162
+ # offering exactly that set's members.
163
+ #
164
+ # `admits:` names a closed set declared elsewhere (`"Account::
106
165
  # LedgerDirection"`) that the value must belong to — see
107
166
  # Runtime::Value::Admission#admitted_members, which this mirrors
108
167
  # exactly (same split, same chapter walk, same discriminant rule) so
109
168
  # a rendered `<select>` never offers a member the runtime would then
110
169
  # refuse.
170
+ #
171
+ # @param attribute [Bluebook::Attribute] an attribute declaring `admits:`
172
+ # @param aggregate [Bluebook::Aggregate] the aggregate that owns the attribute; its
173
+ # chapter is searched for the named set
174
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
175
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
176
+ # @return [Forms::Field] a `:radio` or `:select` field, its path extended by one hop
177
+ # when the attribute's own type is a single-attribute value object; a plain
178
+ # primitive field when the named set is not declared
111
179
  def self.admitted_field(attribute, aggregate, common)
112
180
  set_aggregate_name, set_name = attribute.admits.to_s.split("::", 2)
113
181
  chapter = aggregate.hecks_owner
114
182
  set = set_name && chapter&.aggregate(set_aggregate_name)&.value_object(set_name)
115
- return primitive_field(attribute, common) unless set # undeclared — refuse-at-dispatch stays the backstop
183
+ # undeclared — refuse-at-dispatch stays the backstop
184
+ return primitive_field(attribute, common) unless set
116
185
 
117
186
  options = select_or_radio(common, closed_set_options(set))
118
- # The attribute's OWN type still has to be built the shape coercion
187
+ # The attribute's own type still has to be built the shape coercion
119
188
  # expects (`Value::Coercion#fields_for` refuses anything that
120
189
  # isn't a Hash or a Value for a value-object-typed attribute) — a
121
190
  # plain String attribute stays a bare scalar, but a value object
122
191
  # like `MovementDirection { value }` still needs the ".value" hop
123
- # even though the SET it's checked against (`admits:`) is declared
192
+ # even though the set it's checked against (`admits:`) is declared
124
193
  # somewhere else entirely. Same unwrap `value_object_field` does,
125
- # kept separate because an admitted set changes the OPTIONS, not
194
+ # kept separate because an admitted set changes the options, not
126
195
  # which field the hop lands on.
127
196
  own_shape = own_value_object(attribute, aggregate)
128
197
  inner = own_shape && ValueObjectShape.sole_attribute(own_shape)
@@ -132,10 +201,28 @@ module Hecks
132
201
  options
133
202
  end
134
203
 
204
+ # Finds the value object an attribute's type names, looking on the owning aggregate
205
+ # first and then on every other aggregate of the same chapter.
206
+ #
207
+ # @param attribute [Bluebook::Attribute] the attribute whose type names a value object
208
+ # @param aggregate [Bluebook::Aggregate] the aggregate that owns the attribute
209
+ # @return [Bluebook::ValueObject, nil] the declared shape, or nil when no aggregate in
210
+ # the chapter declares a value object of that name
135
211
  def self.own_value_object(attribute, aggregate)
136
212
  aggregate.value_object(attribute.type) || cross_aggregate_value_object(aggregate, attribute.type)
137
213
  end
138
214
 
215
+ # Builds the field for an attribute typed as a value object: a closed-set choice, a
216
+ # money pair, the unwrapped inner leaf of a single-attribute shape, or a group.
217
+ #
218
+ # @param attribute [Bluebook::Attribute] an attribute whose type is not a primitive
219
+ # @param aggregate [Bluebook::Aggregate] the aggregate that owns the attribute
220
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
221
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
222
+ # @return [Forms::Field] the resolved field; a plain primitive field when the named
223
+ # value object is not declared anywhere in the chapter
224
+ # @raise [Bluebook::DSL::Malformed] if a nested `reference_to` attribute cannot say
225
+ # which aggregate declares it
139
226
  def self.value_object_field(attribute, aggregate, common)
140
227
  shape = own_value_object(attribute, aggregate)
141
228
  return primitive_field(attribute, common) unless shape
@@ -144,9 +231,9 @@ module Hecks
144
231
  return money_field(shape, common) if ValueObjectShape.money?(shape)
145
232
 
146
233
  # A single-attribute value object (EmailAddress{address}, CustomerNumber{value})
147
- # is a NAME for a scalar, not a genuine group — unwrap it so the form
234
+ # is a name for a scalar, not a genuine group — unwrap it so the form
148
235
  # asks for one thing ("Email address") instead of a one-item fieldset,
149
- # and so the inner attribute's OWN pattern (the real email regex,
236
+ # and so the inner attribute's own pattern (the real email regex,
150
237
  # declared on `address`, not on the outer `email` attribute) drives
151
238
  # the input type. [[feedback_name_the_scalar_field]] says the same
152
239
  # thing about Ruby call sites; a form asks the identical question.
@@ -158,6 +245,14 @@ module Hecks
158
245
  group_field(shape, aggregate, common)
159
246
  end
160
247
 
248
+ # Searches every aggregate of the owning chapter for a value object by name, so a shape
249
+ # declared on a sibling aggregate still resolves.
250
+ #
251
+ # @param aggregate [Bluebook::Aggregate] any aggregate of the chapter to search
252
+ # @param type_name [String] name of the value object, as `Bluebook::Attribute#type`
253
+ # spells it
254
+ # @return [Bluebook::ValueObject, nil] the first match in aggregate declaration order, or
255
+ # nil when none declares it or the aggregate has no owning chapter
161
256
  def self.cross_aggregate_value_object(aggregate, type_name)
162
257
  aggregate.hecks_owner&.aggregates&.each do |sibling|
163
258
  found = sibling.value_object(type_name)
@@ -166,11 +261,31 @@ module Hecks
166
261
  nil
167
262
  end
168
263
 
264
+ # Builds the `:group` field for a multi-attribute value object, resolving each of its
265
+ # attributes one path hop below the group.
266
+ #
267
+ # @param shape [Bluebook::ValueObject] the value object whose attributes become children
268
+ # @param aggregate [Bluebook::Aggregate] the aggregate the nested attributes resolve
269
+ # against
270
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares; only
271
+ # `:path`, `:label` and `:optional` are read
272
+ # @return [Forms::Field] a `:group` field with one child per attribute of `shape`
273
+ # @raise [Bluebook::DSL::Malformed] if a nested `reference_to` attribute cannot say
274
+ # which aggregate declares it
169
275
  def self.group_field(shape, aggregate, common)
170
276
  children = shape.attributes.map { |inner| resolve(inner, aggregate: aggregate, path: "#{common[:path]}.#{inner.name}") }
171
277
  Field.new(path: common[:path], label: common[:label], kind: :group, optional: common[:optional], children: children)
172
278
  end
173
279
 
280
+ # Builds the `:money` field for a `cents`/`currency` value object: a required-or-not
281
+ # whole-cents number input beside an always-optional currency code.
282
+ #
283
+ # @param shape [Bluebook::ValueObject] the money-shaped value object, read for the
284
+ # declared defaults of `cents` and `currency`
285
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares; only
286
+ # `:path`, `:label` and `:optional` are read
287
+ # @return [Forms::Field] a `:money` field whose children are the `cents` and `currency`
288
+ # leaves, the currency defaulting to `"USD"` when the shape declares none
174
289
  def self.money_field(shape, common)
175
290
  cents = Field.new(path: "#{common[:path]}.cents", label: "Amount (cents)", kind: :number,
176
291
  html_type: "number", step: "1", optional: common[:optional],
@@ -182,27 +297,57 @@ module Hecks
182
297
  children: [cents, currency])
183
298
  end
184
299
 
300
+ # Lists a closed set's members by their discriminant — the value object's first
301
+ # attribute — as the strings a form offers.
302
+ #
303
+ # @param value_object [Bluebook::ValueObject] a closed-set (`one_of`) value object
304
+ # @return [Array<String>] one discriminant value per declared member, in declaration
305
+ # order; `[]` when the shape declares no members
185
306
  def self.closed_set_options(value_object)
186
307
  discriminant = value_object.attributes.first.name
187
308
  value_object.members.map { |member| member[discriminant].to_s }
188
309
  end
189
310
 
311
+ # Builds the radio or select field for an attribute typed as a closed set, its path
312
+ # extended by the set's discriminant attribute.
313
+ #
190
314
  # A `one_of` shape (`AccountKind{name}`, `LedgerDirection{value}`) is
191
- # ALWAYS single-attribute in this language — the discriminant IS the
315
+ # always single-attribute in this language — the discriminant is the
192
316
  # whole value object — so the field's own path always gains that one
193
317
  # hop; nothing to branch on the way `admitted_field` has to (a set
194
318
  # named by `admits:` may sit on a multi-field value object it doesn't
195
319
  # itself define the members of).
320
+ #
321
+ # @param shape [Bluebook::ValueObject] the closed-set value object
322
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
323
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
324
+ # @return [Forms::Field] a `:radio` or `:select` field offering the set's members
196
325
  def self.closed_set_field(shape, common)
197
326
  discriminant = shape.attributes.first.name
198
327
  select_or_radio(common.merge(path: "#{common[:path]}.#{discriminant}"), closed_set_options(shape))
199
328
  end
200
329
 
330
+ # Builds a choice field over fixed options, as radio buttons for four or fewer and a
331
+ # `<select>` beyond that.
332
+ #
333
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
334
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
335
+ # @param options [Array<String>] the values offered; each is its own label
336
+ # @return [Forms::Field] a `:radio` or `:select` field whose `options` are
337
+ # `[value, label]` pairs
201
338
  def self.select_or_radio(common, options)
202
339
  kind = options.size <= 4 ? :radio : :select
203
340
  Field.new(**common, kind: kind, html_type: "text", options: options.map { |value| [value, value] })
204
341
  end
205
342
 
343
+ # Builds the leaf field for a primitive-typed attribute: a number input for `Integer`
344
+ # and `Float`, a checkbox for a boolean, and a text-family input for anything else.
345
+ #
346
+ # @param attribute [Bluebook::Attribute] the attribute whose type picks the input
347
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
348
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
349
+ # @return [Forms::Field] a `:number`, `:boolean`, `:text` or `:textarea` leaf; an
350
+ # `Integer` carries `step` `"1"`, a `Float` `"any"`
206
351
  def self.primitive_field(attribute, common)
207
352
  case attribute.type.to_s
208
353
  when "Integer" then Field.new(**common, kind: :number, html_type: "number", step: "1")
@@ -214,11 +359,27 @@ module Hecks
214
359
  end
215
360
  end
216
361
 
217
- EMAIL_HINT = /email/i
218
- URL_HINT = /\b(url|uri|website|link)\b/i
219
- TEL_HINT = /phone|\btel(ephone)?\b/i
220
- TEXTAREA_HINT = /\b(text|body|note|notes|description|message|comment)\b/i
362
+ # Vocabulary::FieldHint (language/bluebook/vocabulary.bluebook), read
363
+ # off the generated table: `pattern` is the regex source, matched
364
+ # case-insensitively. bin/project_field_hints writes the Rust host's
365
+ # copy from the same rows.
366
+ HINTS = Hecks::Vocabulary.rows("FieldHint")
367
+ .to_h { |row| [row["name"], Regexp.new(row["pattern"], Regexp::IGNORECASE)] }
368
+ .freeze
369
+ EMAIL_HINT = HINTS.fetch("email")
370
+ URL_HINT = HINTS.fetch("url")
371
+ TEL_HINT = HINTS.fetch("tel")
372
+ TEXTAREA_HINT = HINTS.fetch("textarea")
221
373
 
374
+ # Builds a text-family leaf, picking `email`, `url`, `tel` or plain `text` from the
375
+ # attribute's pattern and the `FieldHint` name patterns, and a textarea for a
376
+ # long-text name.
377
+ #
378
+ # @param attribute [Bluebook::Attribute] the attribute whose name and pattern are read
379
+ # @param common [Hash{Symbol => Object}] the `Field` members every kind shares, keyed
380
+ # `:path`, `:label`, `:default`, `:optional`, `:pattern`
381
+ # @return [Forms::Field] a `:text` leaf, or `:textarea` when the input type is plain
382
+ # `"text"` and the name matches `TEXTAREA_HINT`
222
383
  def self.text_field(attribute, common)
223
384
  name = attribute.name.to_s
224
385
  pattern = attribute.pattern.to_s