hecks 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (373) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/adapters/driven/claude_code.rb +72 -7
  3. data/lib/hecks/adapters/driven/d1.rb +187 -23
  4. data/lib/hecks/adapters/driven/folder.rb +83 -10
  5. data/lib/hecks/adapters/driven/google_authentication.rb +33 -12
  6. data/lib/hecks/adapters/driven/governance_authorization.rb +87 -18
  7. data/lib/hecks/adapters/driven/heki/journal.rb +13 -4
  8. data/lib/hecks/adapters/driven/heki/saga_store.rb +56 -10
  9. data/lib/hecks/adapters/driven/heki/snapshot.rb +1 -1
  10. data/lib/hecks/adapters/driven/heki.rb +106 -9
  11. data/lib/hecks/adapters/driven/identity_registry.rb +12 -2
  12. data/lib/hecks/adapters/driven/in_memory_ordering.rb +25 -3
  13. data/lib/hecks/adapters/driven/in_process_key_vault.adapter +3 -0
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +53 -0
  15. data/lib/hecks/adapters/driven/lambda/client.rb +67 -14
  16. data/lib/hecks/adapters/driven/lambda.rb +82 -35
  17. data/lib/hecks/adapters/driven/local_storage.rb +83 -10
  18. data/lib/hecks/adapters/driven/memory.rb +205 -9
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +21 -1
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +27 -11
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +40 -2
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +23 -7
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +14 -14
  24. data/lib/hecks/adapters/driven/postgres.rb +175 -28
  25. data/lib/hecks/adapters/driven/prism.rb +50 -11
  26. data/lib/hecks/adapters/driven/secure_random_identity.rb +3 -0
  27. data/lib/hecks/adapters/driven/sql_query_builder.rb +34 -22
  28. data/lib/hecks/adapters/driven/sqlite/codec.rb +38 -10
  29. data/lib/hecks/adapters/driven/sqlite/projection.rb +60 -32
  30. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +12 -12
  31. data/lib/hecks/adapters/driven/sqlite.rb +181 -21
  32. data/lib/hecks/adapters/driven/system_clock.rb +3 -0
  33. data/lib/hecks/adapters/driven/tenant_provisioner.adapter +3 -0
  34. data/lib/hecks/adapters/driven/tenant_provisioner.rb +66 -0
  35. data/lib/hecks/adapters/driven.rb +6 -4
  36. data/lib/hecks/adapters/driving/github_webhook.rb +31 -18
  37. data/lib/hecks/behaviors/dsl.rb +60 -2
  38. data/lib/hecks/behaviors/expectations.rb +190 -29
  39. data/lib/hecks/behaviors/ir.rb +12 -1
  40. data/lib/hecks/behaviors/rspec.rb +9 -1
  41. data/lib/hecks/behaviors/runner.rb +21 -2
  42. data/lib/hecks/behaviors.rb +9 -1
  43. data/lib/hecks/bluebook/aggregate.rb +43 -13
  44. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +17 -10
  45. data/lib/hecks/bluebook/assembly/build.rb +26 -5
  46. data/lib/hecks/bluebook/assembly/contract.rb +98 -23
  47. data/lib/hecks/bluebook/assembly/contracts.rb +59 -52
  48. data/lib/hecks/bluebook/assembly/marks.rb +159 -30
  49. data/lib/hecks/bluebook/assembly/specializer.rb +38 -21
  50. data/lib/hecks/bluebook/assembly.rb +32 -14
  51. data/lib/hecks/bluebook/attribute.rb +26 -12
  52. data/lib/hecks/bluebook/behaviour/aggregate.rb +43 -11
  53. data/lib/hecks/bluebook/behaviour/attribute.rb +18 -5
  54. data/lib/hecks/bluebook/behaviour/chapter.rb +76 -5
  55. data/lib/hecks/bluebook/behaviour/command.rb +55 -25
  56. data/lib/hecks/bluebook/behaviour/domain_port.rb +27 -7
  57. data/lib/hecks/bluebook/behaviour/entity.rb +20 -8
  58. data/lib/hecks/bluebook/behaviour/hexagon.rb +30 -4
  59. data/lib/hecks/bluebook/behaviour/lifecycle.rb +27 -6
  60. data/lib/hecks/bluebook/behaviour/policy.rb +42 -17
  61. data/lib/hecks/bluebook/behaviour/process_manager.rb +39 -8
  62. data/lib/hecks/bluebook/behaviour/query.rb +6 -1
  63. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -8
  64. data/lib/hecks/bluebook/behaviour/traits.rb +48 -12
  65. data/lib/hecks/bluebook/behaviour/value_object.rb +21 -9
  66. data/lib/hecks/bluebook/capabilities.rb +27 -0
  67. data/lib/hecks/bluebook/chapter.rb +51 -9
  68. data/lib/hecks/bluebook/command.rb +62 -17
  69. data/lib/hecks/bluebook/domain_port.rb +34 -9
  70. data/lib/hecks/bluebook/dsl/adapter_builder.rb +24 -0
  71. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +49 -49
  72. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +282 -123
  73. data/lib/hecks/bluebook/dsl/attribute_collector.rb +112 -75
  74. data/lib/hecks/bluebook/dsl/binding_proxy.rb +81 -2
  75. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +486 -117
  76. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +179 -47
  77. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +116 -0
  78. data/lib/hecks/bluebook/dsl/command_builder.rb +284 -122
  79. data/lib/hecks/bluebook/dsl/const_shim.rb +46 -15
  80. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +90 -25
  81. data/lib/hecks/bluebook/dsl/entity_builder.rb +191 -61
  82. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +148 -132
  83. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +130 -30
  84. data/lib/hecks/bluebook/dsl/identity_declaration.rb +38 -21
  85. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +27 -4
  86. data/lib/hecks/bluebook/dsl/policy_builder.rb +86 -36
  87. data/lib/hecks/bluebook/dsl/port_builder.rb +38 -7
  88. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +56 -22
  89. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +111 -47
  90. data/lib/hecks/bluebook/dsl/query_builder.rb +37 -8
  91. data/lib/hecks/bluebook/dsl/read_model_builder.rb +127 -52
  92. data/lib/hecks/bluebook/dsl/rule_reference.rb +97 -43
  93. data/lib/hecks/bluebook/dsl/translation_builder.rb +150 -44
  94. data/lib/hecks/bluebook/dsl/value_object_builder.rb +68 -20
  95. data/lib/hecks/bluebook/dsl/word_gate.rb +59 -53
  96. data/lib/hecks/bluebook/dsl/world_builder.rb +51 -8
  97. data/lib/hecks/bluebook/entity.rb +40 -11
  98. data/lib/hecks/bluebook/expression/ast_json.rb +128 -36
  99. data/lib/hecks/bluebook/expression/ast_reader.rb +32 -3
  100. data/lib/hecks/bluebook/expression/canonical_form.rb +55 -16
  101. data/lib/hecks/bluebook/expression/evaluator.rb +221 -43
  102. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +54 -18
  103. data/lib/hecks/bluebook/expression/resolver.rb +369 -128
  104. data/lib/hecks/bluebook/hexagon.rb +35 -1
  105. data/lib/hecks/bluebook/lifecycle.rb +12 -1
  106. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +2 -1
  107. data/lib/hecks/bluebook/meta_validator/judge.rb +126 -108
  108. data/lib/hecks/bluebook/meta_validator/plan.rb +81 -46
  109. data/lib/hecks/bluebook/meta_validator/port_judge.rb +3 -2
  110. data/lib/hecks/bluebook/meta_validator/readings.rb +200 -50
  111. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +68 -41
  112. data/lib/hecks/bluebook/meta_validator/shapes.rb +166 -21
  113. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +286 -46
  114. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +11 -10
  115. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -5
  116. data/lib/hecks/bluebook/meta_validator.rb +235 -139
  117. data/lib/hecks/bluebook/model_check.rb +434 -104
  118. data/lib/hecks/bluebook/pattern_subset.rb +32 -10
  119. data/lib/hecks/bluebook/policy.rb +15 -13
  120. data/lib/hecks/bluebook/process_manager.rb +27 -14
  121. data/lib/hecks/bluebook/project_discovery.rb +5 -0
  122. data/lib/hecks/bluebook/project_loader.rb +40 -0
  123. data/lib/hecks/bluebook/project_register.rb +50 -6
  124. data/lib/hecks/bluebook/query.rb +31 -4
  125. data/lib/hecks/bluebook/read_model.rb +35 -15
  126. data/lib/hecks/bluebook/reference.rb +26 -13
  127. data/lib/hecks/bluebook/smoke_test.rb +46 -23
  128. data/lib/hecks/bluebook/synthesizer.rb +46 -12
  129. data/lib/hecks/bluebook/translation.rb +34 -5
  130. data/lib/hecks/bluebook/value_object.rb +29 -11
  131. data/lib/hecks/bluebook.rb +5 -6
  132. data/lib/hecks/codemod.rb +138 -50
  133. data/lib/hecks/construct.rb +21 -7
  134. data/lib/hecks/corpus.rb +438 -0
  135. data/lib/hecks/deploy/bluebook/deploy.hecksagon +19 -0
  136. data/lib/hecks/doc/reference.rb +200 -31
  137. data/lib/hecks/embryonaut_bluebook.rb +38 -15
  138. data/lib/hecks/facade/cli_door.rb +69 -10
  139. data/lib/hecks/facade/cli_runner.rb +105 -24
  140. data/lib/hecks/facade/command_request.rb +23 -0
  141. data/lib/hecks/facade/handle.rb +155 -35
  142. data/lib/hecks/facade/json_door.rb +106 -25
  143. data/lib/hecks/facade/surface/aggregate_door.rb +50 -27
  144. data/lib/hecks/facade/surface/chapter.rb +26 -17
  145. data/lib/hecks/facade/surface.rb +16 -3
  146. data/lib/hecks/facade.rb +15 -4
  147. data/lib/hecks/forms/app.rb +46 -30
  148. data/lib/hecks/forms/command_form_renderer.rb +70 -9
  149. data/lib/hecks/forms/field_renderer.rb +142 -6
  150. data/lib/hecks/forms/field_shape.rb +183 -19
  151. data/lib/hecks/forms/html.rb +51 -7
  152. data/lib/hecks/forms/index_renderer.rb +14 -2
  153. data/lib/hecks/forms/page.rb +14 -0
  154. data/lib/hecks/forms/params.rb +120 -23
  155. data/lib/hecks/forms/port_argument.rb +14 -2
  156. data/lib/hecks/forms/query_form_renderer.rb +65 -2
  157. data/lib/hecks/forms/record_renderer.rb +60 -2
  158. data/lib/hecks/forms/record_table.rb +28 -1
  159. data/lib/hecks/forms/reference_options.rb +24 -0
  160. data/lib/hecks/forms/value_object_shape.rb +13 -3
  161. data/lib/hecks/forms.rb +24 -4
  162. data/lib/hecks/fqn.rb +59 -1
  163. data/lib/hecks/framework/bluebook/governance.bluebook +9 -0
  164. data/lib/hecks/framework/bluebook/privacy.bluebook +155 -0
  165. data/lib/hecks/framework/oidc.json +15 -0
  166. data/lib/hecks/framework.rb +79 -25
  167. data/lib/hecks/freezer.rb +27 -11
  168. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +241 -106
  169. data/lib/hecks/fuzzing/combination_miner.rb +178 -0
  170. data/lib/hecks/fuzzing/concurrent_dispatch.rb +241 -45
  171. data/lib/hecks/fuzzing/coverage_campaign.rb +161 -0
  172. data/lib/hecks/fuzzing/differential.rb +192 -0
  173. data/lib/hecks/fuzzing/domain_generator.rb +871 -0
  174. data/lib/hecks/fuzzing/era_boundary.rb +68 -19
  175. data/lib/hecks/fuzzing/form_census.rb +121 -20
  176. data/lib/hecks/fuzzing/generated_domain_check.rb +171 -0
  177. data/lib/hecks/fuzzing/invalid_value_generator.rb +45 -6
  178. data/lib/hecks/fuzzing/isolated_boot.rb +137 -80
  179. data/lib/hecks/fuzzing/nondeterministic.rb +79 -0
  180. data/lib/hecks/fuzzing/persistence_parity.rb +111 -21
  181. data/lib/hecks/fuzzing/properties/corrections.rb +34 -9
  182. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +300 -51
  183. data/lib/hecks/fuzzing/properties/guards.rb +86 -42
  184. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +80 -32
  185. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +40 -35
  186. data/lib/hecks/fuzzing/properties/outbox.rb +70 -32
  187. data/lib/hecks/fuzzing/properties/querying.rb +82 -28
  188. data/lib/hecks/fuzzing/properties.rb +84 -51
  189. data/lib/hecks/fuzzing/qa_settings.rb +164 -0
  190. data/lib/hecks/fuzzing/replay.rb +241 -133
  191. data/lib/hecks/fuzzing/rotation_priority.rb +48 -28
  192. data/lib/hecks/fuzzing/rust_gap_manifest.rb +139 -0
  193. data/lib/hecks/fuzzing/self_consistency.rb +315 -137
  194. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +46 -40
  195. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +18 -11
  196. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +13 -12
  197. data/lib/hecks/fuzzing/sequence_generator/picker.rb +21 -12
  198. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +59 -32
  199. data/lib/hecks/fuzzing/sequence_generator.rb +158 -23
  200. data/lib/hecks/fuzzing/shrinker.rb +309 -0
  201. data/lib/hecks/fuzzing/structural_skips.rb +37 -130
  202. data/lib/hecks/fuzzing/sweep_depth.rb +12 -4
  203. data/lib/hecks/fuzzing/target_capabilities.rb +107 -29
  204. data/lib/hecks/fuzzing/value_generator.rb +110 -22
  205. data/lib/hecks/fuzzing.rb +5 -0
  206. data/lib/hecks/grammar/evolve.rb +188 -12
  207. data/lib/hecks/grammar.rb +53 -7
  208. data/lib/hecks/ir.rb +51 -20
  209. data/lib/hecks/language/bluebook/bluebook.bluebook +41 -0
  210. data/lib/hecks/language/bluebook/policy.bluebook +11 -1
  211. data/lib/hecks/language/bluebook/vocabulary.bluebook +348 -13
  212. data/lib/hecks/language/hecksagon/hecksagon.bluebook +11 -0
  213. data/lib/hecks/language/oidc.json +5 -0
  214. data/lib/hecks/literal.rb +41 -9
  215. data/lib/hecks/naming.rb +112 -31
  216. data/lib/hecks/ports/access_control.rb +53 -2
  217. data/lib/hecks/ports/agent/answers.rb +83 -6
  218. data/lib/hecks/ports/agent.rb +119 -35
  219. data/lib/hecks/ports/authentication.rb +44 -4
  220. data/lib/hecks/ports/authorization.rb +50 -11
  221. data/lib/hecks/ports/clock.rb +42 -23
  222. data/lib/hecks/ports/extraction.rb +16 -0
  223. data/lib/hecks/ports/identity_assignment.rb +23 -2
  224. data/lib/hecks/ports/identity_generation.rb +17 -3
  225. data/lib/hecks/ports/identity_resolution.rb +17 -1
  226. data/lib/hecks/ports/key_vault.port +6 -0
  227. data/lib/hecks/ports/key_vault.rb +58 -0
  228. data/lib/hecks/ports/loading.rb +4 -0
  229. data/lib/hecks/ports/persistence/append_only.rb +172 -8
  230. data/lib/hecks/ports/persistence/binding_policy.rb +34 -0
  231. data/lib/hecks/ports/persistence/codec_boundary.rb +178 -0
  232. data/lib/hecks/ports/persistence/execution.rb +4 -0
  233. data/lib/hecks/ports/persistence/null_saga_store.rb +12 -1
  234. data/lib/hecks/ports/persistence/plugin.rb +42 -4
  235. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +218 -25
  236. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +77 -9
  237. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +81 -24
  238. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +29 -18
  239. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +144 -60
  240. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +103 -8
  241. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +98 -23
  242. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +282 -109
  243. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +63 -25
  244. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +118 -66
  245. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +51 -28
  246. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +34 -5
  247. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +25 -12
  248. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +129 -34
  249. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +51 -6
  250. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +33 -9
  251. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +16 -0
  252. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +95 -6
  253. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +25 -2
  254. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +314 -90
  255. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +68 -10
  256. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +9 -3
  257. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +9 -2
  258. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +48 -8
  259. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +16 -1
  260. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +36 -5
  261. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +23 -3
  262. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +58 -19
  263. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +96 -5
  264. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +15 -0
  265. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +9 -1
  266. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -2
  267. data/lib/hecks/ports/persistence/plugins/era.rb +12 -2
  268. data/lib/hecks/ports/persistence/remote_runtime.rb +9 -2
  269. data/lib/hecks/ports/persistence/repository_factory.rb +23 -3
  270. data/lib/hecks/ports/persistence/state_codec.rb +319 -0
  271. data/lib/hecks/ports/persistence.rb +36 -1
  272. data/lib/hecks/ports/projection.rb +61 -7
  273. data/lib/hecks/ports/query/in_memory.rb +41 -8
  274. data/lib/hecks/ports/query/ordering.rb +21 -6
  275. data/lib/hecks/ports/query.rb +35 -0
  276. data/lib/hecks/ports.rb +1 -0
  277. data/lib/hecks/projections/bootstrap_table.rb +147 -0
  278. data/lib/hecks/projections/diagrams.rb +315 -79
  279. data/lib/hecks/projections/glossary/html.rb +91 -3
  280. data/lib/hecks/projections/glossary/markdown.rb +50 -3
  281. data/lib/hecks/projections/glossary/mermaid.rb +49 -1
  282. data/lib/hecks/projections/glossary/sections.rb +1 -1
  283. data/lib/hecks/projections/glossary/sentences.rb +110 -5
  284. data/lib/hecks/projections/glossary.rb +173 -31
  285. data/lib/hecks/projections/ir.rb +1 -1
  286. data/lib/hecks/projections/model/deviations.rb +62 -17
  287. data/lib/hecks/projections/model.rb +75 -21
  288. data/lib/hecks/projections/oidc.rb +45 -8
  289. data/lib/hecks/projections/parser_table.rb +53 -12
  290. data/lib/hecks/projections/reference.rb +15 -3
  291. data/lib/hecks/projections/rust_vocabulary.rb +646 -0
  292. data/lib/hecks/projections/shape.rb +10 -2
  293. data/lib/hecks/projections/statements.rb +69 -22
  294. data/lib/hecks/projections/vocabulary.rb +26 -9
  295. data/lib/hecks/projections.rb +5 -3
  296. data/lib/hecks/projector/cli_projector.rb +245 -37
  297. data/lib/hecks/projector/docs_projector.rb +154 -28
  298. data/lib/hecks/projector/exporter.rb +104 -29
  299. data/lib/hecks/projector/ir_projector.rb +7 -1
  300. data/lib/hecks/projector/narrate_projector.rb +144 -23
  301. data/lib/hecks/projector/target.rb +42 -18
  302. data/lib/hecks/projector.rb +86 -18
  303. data/lib/hecks/query_ir.rb +94 -47
  304. data/lib/hecks/query_specification/common/comparators.rb +19 -3
  305. data/lib/hecks/query_specification/common/comparison.rb +109 -27
  306. data/lib/hecks/query_specification/common/dsl.rb +65 -9
  307. data/lib/hecks/query_specification/common/null_policy.rb +61 -15
  308. data/lib/hecks/query_specification/common/null_semantics.rb +4 -0
  309. data/lib/hecks/query_specification/common/options.rb +25 -0
  310. data/lib/hecks/query_specification/field_path.rb +69 -15
  311. data/lib/hecks/query_specification/hop_path.rb +57 -20
  312. data/lib/hecks/query_specification/read_model/specification.rb +4 -0
  313. data/lib/hecks/rendering.rb +9 -3
  314. data/lib/hecks/router/namespace_installer.rb +16 -3
  315. data/lib/hecks/router.rb +56 -1
  316. data/lib/hecks/runtime/aggregate_lock.rb +20 -11
  317. data/lib/hecks/runtime/boot_gates.rb +21 -3
  318. data/lib/hecks/runtime/caller.rb +40 -8
  319. data/lib/hecks/runtime/capability_graph.rb +13 -2
  320. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +40 -41
  321. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +45 -67
  322. data/lib/hecks/runtime/command_interpreter.rb +195 -113
  323. data/lib/hecks/runtime/command_rules/admissibility.rb +231 -80
  324. data/lib/hecks/runtime/command_rules/arithmetic.rb +154 -96
  325. data/lib/hecks/runtime/command_rules/authorization.rb +38 -17
  326. data/lib/hecks/runtime/command_rules/emission.rb +18 -1
  327. data/lib/hecks/runtime/command_rules/references.rb +145 -55
  328. data/lib/hecks/runtime/command_rules.rb +3 -0
  329. data/lib/hecks/runtime/dependency_planning.rb +56 -11
  330. data/lib/hecks/runtime/dispatcher.rb +263 -92
  331. data/lib/hecks/runtime/entity_element.rb +304 -67
  332. data/lib/hecks/runtime/entity_interpreter.rb +149 -88
  333. data/lib/hecks/runtime/errors.rb +37 -23
  334. data/lib/hecks/runtime/event.rb +15 -10
  335. data/lib/hecks/runtime/identity.rb +91 -23
  336. data/lib/hecks/runtime/instance.rb +103 -18
  337. data/lib/hecks/runtime/interpreting.rb +24 -16
  338. data/lib/hecks/runtime/invocation.rb +358 -0
  339. data/lib/hecks/runtime/loader.rb +105 -19
  340. data/lib/hecks/runtime/outbox.rb +164 -26
  341. data/lib/hecks/runtime/policy_interpreter.rb +73 -60
  342. data/lib/hecks/runtime/port_operation_interpreter.rb +42 -19
  343. data/lib/hecks/runtime/query_interpreter.rb +93 -74
  344. data/lib/hecks/runtime/reaction_invocation.rb +73 -28
  345. data/lib/hecks/runtime/read_model_interpreter.rb +60 -44
  346. data/lib/hecks/runtime/rebuild_sweep.rb +32 -4
  347. data/lib/hecks/runtime/reference_hop.rb +48 -6
  348. data/lib/hecks/runtime/refusal_wording.rb +142 -115
  349. data/lib/hecks/runtime/registry/saga_persistence.rb +32 -21
  350. data/lib/hecks/runtime/registry/verification.rb +153 -28
  351. data/lib/hecks/runtime/registry.rb +202 -32
  352. data/lib/hecks/runtime/remote_dispatcher.rb +125 -24
  353. data/lib/hecks/runtime/routing.rb +36 -154
  354. data/lib/hecks/runtime/saga_interpreter/correlation.rb +25 -27
  355. data/lib/hecks/runtime/saga_interpreter.rb +90 -76
  356. data/lib/hecks/runtime/saga_pending_dispatch.rb +12 -12
  357. data/lib/hecks/runtime/tenant_check.rb +33 -13
  358. data/lib/hecks/runtime/tenant_scope.rb +23 -5
  359. data/lib/hecks/runtime/value/admission.rb +75 -30
  360. data/lib/hecks/runtime/value/coercion.rb +555 -142
  361. data/lib/hecks/runtime/value/entity_list_coercion.rb +132 -60
  362. data/lib/hecks/runtime/value.rb +71 -21
  363. data/lib/hecks/runtime.rb +39 -7
  364. data/lib/hecks/storehouse.rb +368 -72
  365. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +130 -0
  366. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +32 -0
  367. data/lib/hecks/version.rb +3 -3
  368. data/lib/hecks/vocabulary.rb +205 -4
  369. data/lib/hecks.rb +91 -11
  370. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +19 -11
  371. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +24 -12
  372. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +38 -14
  373. metadata +28 -2
@@ -11,8 +11,14 @@ module Hecks
11
11
  # account number a support rep typed into a form and got wrong — flows
12
12
  # through here on every render, so escaping is not optional decoration.
13
13
  module Escape
14
+ # Escapes a value for HTML text content, replacing `&`, `<`, `>`, `"` and `'` with
15
+ # entities so nothing in it becomes a tag.
16
+ #
14
17
  # Order matters — `&` first, or every escape this method itself just
15
18
  # wrote (`&amp;`, `&lt;`, ...) gets re-escaped a second time.
19
+ #
20
+ # @param value [Object, nil] the value to show, rendered with `to_s`; nil renders as `""`
21
+ # @return [String] the escaped text
16
22
  def self.html(value)
17
23
  value.to_s
18
24
  .gsub("&", "&amp;")
@@ -22,14 +28,22 @@ module Hecks
22
28
  .gsub("'", "&#39;")
23
29
  end
24
30
 
31
+ # Escapes a value for use inside a quoted HTML attribute.
32
+ #
25
33
  # Safe inside a double-quoted HTML attribute specifically — `html`
26
34
  # already covers this (it escapes `"`), kept as a named alias so a
27
35
  # call site reads "this value fills an attribute" rather than repeating
28
36
  # the same escaping and leaving the reader to check they match.
37
+ #
38
+ # @param value [Object, nil] the attribute value, rendered with `to_s`; nil renders
39
+ # as `""`
40
+ # @return [String] the escaped text, without surrounding quotes
29
41
  def self.attr(value) = html(value)
30
42
 
43
+ # Percent-encodes a value for use as a query-string value.
44
+ #
31
45
  # L12 (docs/audits/2026-08-10-main-bug-audit.md) — safe as a
32
- # query-string VALUE. `html`/`attr` guard against the value becoming
46
+ # query-string value. `html`/`attr` guard against the value becoming
33
47
  # markup, but say nothing about it staying inside the URL syntax
34
48
  # position it was placed in: an aggregate's identity is free-form
35
49
  # unless its value object declares a `pattern:` (see S3 in the same
@@ -38,23 +52,32 @@ module Hecks
38
52
  # interpolation (a stray `&` smuggles a second query parameter, `#`
39
53
  # truncates the path at a fragment, `/` splits the path into an
40
54
  # extra segment, ...). Percent-encodes via
41
- # `application/x-www-form-urlencoded` (`+` for space) — correct ONLY
55
+ # `application/x-www-form-urlencoded` (`+` for space) — correct only
42
56
  # for a query-string value (query_form_renderer.rb's `quick_links`,
43
- # record_renderer.rb's `?to=`). For a URL PATH segment use `path`
57
+ # record_renderer.rb's `?to=`). For a URL path segment use `path`
44
58
  # below instead — `+` is a literal plus there, not an escaped space,
45
59
  # so this method would corrupt any id containing a space. Callers
46
- # still wrap the ASSEMBLED href/Location in `attr` (or `html`) as
60
+ # still wrap the assembled href/Location in `attr` (or `html`) as
47
61
  # usual — this only covers the id's own component, not the
48
62
  # surrounding markup.
63
+ #
64
+ # @param value [Object, nil] the value to encode, rendered with `to_s`
65
+ # @return [String] the `application/x-www-form-urlencoded` form, a space rendered as `+`
49
66
  def self.url(value) = URI.encode_www_form_component(value.to_s)
50
67
 
51
- # Same guard as `url`, for a URL PATH segment instead of a
68
+ # Percent-encodes a value for use as one URL path segment.
69
+ #
70
+ # Same guard as `url`, for a URL path segment instead of a
52
71
  # query-string value. `encode_www_form_component` renders space as
53
72
  # `+`, which is only meaningful inside a query string — in a path
54
73
  # segment `+` is a literal plus, so an id like "John Smith" would
55
74
  # round-trip to "John+Smith" and 404 against the real id "John
56
75
  # Smith". Reuse the same percent-encoding and just correct that one
57
76
  # character back to `%20`.
77
+ #
78
+ # @param value [Object, nil] the segment to encode, rendered with `to_s`
79
+ # @return [String] the percent-encoded segment, a space rendered as `%20` and `/` as
80
+ # `%2F`
58
81
  def self.path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
59
82
  end
60
83
 
@@ -64,6 +87,14 @@ module Hecks
64
87
  # `true` renders as a bare boolean attribute (`required`, not
65
88
  # `required="true"`); `nil`/`false` are dropped entirely.
66
89
  module Tag
90
+ # Renders a Hash of attributes as the text that sits inside an opening tag, escaping
91
+ # every value and spelling an underscored name with hyphens.
92
+ #
93
+ # @param pairs [Hash{Symbol => Object}] attribute values by name; `true`
94
+ # renders the bare name, nil and `false` drop the attribute, anything else is
95
+ # rendered with `to_s`
96
+ # @return [String] space-separated attributes, such as `required aria-describedby="x"`;
97
+ # `""` when every pair is dropped
67
98
  def self.attrs(pairs)
68
99
  pairs.filter_map do |name, value|
69
100
  next if value.nil? || value == false
@@ -73,15 +104,28 @@ module Hecks
73
104
  end.join(" ")
74
105
  end
75
106
 
107
+ # Renders an opening tag with its attributes. The tag name is interpolated unescaped,
108
+ # so it must come from code, never from domain data.
109
+ #
110
+ # @param name [String] the element name, such as `"input"`
111
+ # @param pairs [Hash{Symbol => Object}] attributes, rendered as `attrs` renders them
112
+ # @return [String] the opening tag, such as `<input type="text" required>`
76
113
  def self.open(name, **pairs)
77
114
  rendered = attrs(pairs)
78
115
  rendered.empty? ? "<#{name}>" : "<#{name} #{rendered}>"
79
116
  end
80
117
 
81
- # `self.open(...)`, not bare `open(...)` — this Html.open (an HTML tag
118
+ # Renders a void element such as `<input>`. A self-closing tag reads the same as an
119
+ # opening one; HTML5 needs no slash.
120
+ #
121
+ # `self.open(...)`, not bare `open(...)` — this `Tag.open` (an HTML tag
82
122
  # renderer, right above) shadows Kernel#open safely either way, but the
83
123
  # explicit receiver also settles Security/Open's static ambiguity.
84
- def self.void(name, **pairs) = self.open(name, **pairs) # self-closing tags read the same; HTML5 needs no slash
124
+ #
125
+ # @param name [String] the element name, such as `"input"`
126
+ # @param pairs [Hash{Symbol => Object}] attributes, rendered as `attrs` renders them
127
+ # @return [String] the tag, identical to what `open` renders
128
+ def self.void(name, **pairs) = self.open(name, **pairs)
85
129
  end
86
130
  end
87
131
  end
@@ -4,9 +4,15 @@ module Hecks
4
4
  module Forms
5
5
  # The home page: every exposed chapter, every aggregate on it — the
6
6
  # entry point into what would otherwise be a URL you'd have to already
7
- # know. `chapters` is `{domain_name => Bluebook}`, in `expose` order
8
- # (see config.rb).
7
+ # know. `chapters` is `{domain_name => Bluebook::Chapter}`, in `expose`
8
+ # order (see `Forms::Config` in forms.rb).
9
9
  module IndexRenderer
10
+ # Renders the home page body: one section per exposed chapter, each linking to its
11
+ # aggregates.
12
+ #
13
+ # @param chapters [Hash{String => Bluebook::Chapter}] the loaded chapters by domain
14
+ # name, in `expose` order
15
+ # @return [String] the HTML page body; only the heading when `chapters` is empty
10
16
  def self.render(chapters)
11
17
  sections = chapters.map { |name, chapter| chapter_section(name, chapter) }
12
18
  <<~HTML
@@ -15,6 +21,12 @@ module Hecks
15
21
  HTML
16
22
  end
17
23
 
24
+ # Renders one chapter's section: its name, its vision as a badge when it declares one,
25
+ # and a link per aggregate showing how many commands and queries it has.
26
+ #
27
+ # @param name [String] the domain name, used as the heading and the first path segment
28
+ # @param chapter [Bluebook::Chapter] the loaded chapter
29
+ # @return [String] HTML for the section's heading and aggregate list
18
30
  def self.chapter_section(name, chapter)
19
31
  items = chapter.aggregates.map do |aggregate|
20
32
  counts = "#{aggregate.commands.size} command#{'s' unless aggregate.commands.size == 1}, " \
@@ -9,6 +9,15 @@ module Hecks
9
9
  # `prefers-color-scheme` alone; nothing here reads a cookie or a query
10
10
  # param for it, so it is never wrong for the browser rendering it.
11
11
  module Page
12
+ # Wraps one page's own body HTML in the shared shell: doctype, head, nav, footer,
13
+ # inline styles and script.
14
+ #
15
+ # @param title [String] the page title, escaped into `<title>` and the browser tab
16
+ # @param body [String] the page's own body markup, inserted unescaped inside `<main>`
17
+ # @param breadcrumbs [Array<Array(String, String), Array(String, nil)>] each
18
+ # `[label, href]` pair, in order; the last pair's `href` should be `nil` for the
19
+ # current page
20
+ # @return [String] the complete HTML document
12
21
  def self.render(title:, body:, breadcrumbs: [])
13
22
  <<~HTML
14
23
  <!doctype html>
@@ -36,6 +45,11 @@ module Hecks
36
45
  HTML
37
46
  end
38
47
 
48
+ # Renders the breadcrumb trail as a `<nav>`, each crumb a link except the last.
49
+ #
50
+ # @param crumbs [Array<Array(String, String), Array(String, nil)>] each
51
+ # `[label, href]` pair; a `nil` href renders as the current, unlinked page
52
+ # @return [String] the `<nav>` markup, HTML-escaped; `""` when `crumbs` is empty
39
53
  def self.breadcrumbs_html(crumbs)
40
54
  return "" if crumbs.empty?
41
55
 
@@ -4,35 +4,59 @@ require_relative "field_renderer"
4
4
 
5
5
  module Hecks
6
6
  module Forms
7
- # The two directions between a flat, dotted, ALL-STRINGS web payload
7
+ # The two directions between a flat, dotted, all-strings web payload
8
8
  # (`{"amount.cents"=>"1050", "amount.currency"=>"USD"}`, whether it came
9
9
  # off a POST form body or a GET query string — Rack hands back the same
10
10
  # flat shape for both as long as nothing uses `[]` bracket names) and the
11
11
  # nested, typed hash `Dispatcher#dispatch`/`#query` actually take
12
12
  # (`{amount: {cents: 1050, currency: "USD"}}`).
13
13
  #
14
- # `extract` NEEDS the Field tree, not just the raw params — a numeric
14
+ # `extract` needs the Field tree, not just the raw params — a numeric
15
15
  # leaf's own runtime check (`Value::Coercion#check_numeric_fields`)
16
16
  # requires an actual `Integer`/`Float`, not a String that merely looks
17
17
  # like one (`given.is_a?(expected)`, no coercion attempted there); a web
18
18
  # form can only ever hand back strings, so the cast has to happen here,
19
- # once, using the SAME shape `FieldShape` already resolved for
19
+ # once, using the same shape `FieldShape` already resolved for
20
20
  # rendering the input in the first place — one reading of the IR, not
21
21
  # two that could disagree.
22
22
  module Params
23
- # Every LEAF field carries its own full dotted path regardless of how
23
+ # Casts a flat, all-strings web payload into the nested, typed arguments a command or
24
+ # query takes, guided by the field tree the form was rendered from.
25
+ #
26
+ # Every leaf field carries its own full dotted path regardless of how
24
27
  # deep `FieldShape` nested it to get there — a single-attribute value
25
- # object unwraps to a leaf sitting at the TOP of the fields array with
28
+ # object unwraps to a leaf sitting at the top of the fields array with
26
29
  # a two-segment path (`"reference.value"`), the exact same shape a
27
30
  # `:group`'s own child carries. So this collects every leaf as
28
- # (full path -> value) FIRST, and nests by the path's OWN segments
31
+ # (full path -> value) first, and nests by the path's own segments
29
32
  # last — one nesting rule, blind to how a field arrived at its path.
33
+ #
34
+ # @param fields [Array<Forms::Field>] the resolved field tree the payload was posted
35
+ # against
36
+ # @param raw [Hash{String => String}] the flat web payload, keyed by dotted field path
37
+ # @return [Hash{Symbol => Object}] nested, typed arguments keyed by path segment, ready
38
+ # to splat into a dispatch; a blank optional field is left out
39
+ # @raise [ArgumentError] if a number field's text is not numeric, or two fields collide
40
+ # at one path (see `nest`)
41
+ # @raise [TypeError] if a required number field is missing from `raw`
42
+ # @raise [JSON::ParserError] if a line of a list-of-value-objects field is not JSON
30
43
  def self.extract(fields, raw)
31
44
  pairs = {}
32
45
  fields.each { |field| collect(field, raw, pairs) }
33
46
  nest(pairs)
34
47
  end
35
48
 
49
+ # Gathers one field's typed value, and recursively its children's, into `pairs` under
50
+ # each leaf's full dotted path; a blank optional leaf adds nothing.
51
+ #
52
+ # @param field [Forms::Field] the field to read
53
+ # @param raw [Hash{String => String}] the flat web payload, keyed by dotted field path
54
+ # @param pairs [Hash{String => Object}] accumulator of typed values by dotted path,
55
+ # added to in place
56
+ # @return [void]
57
+ # @raise [ArgumentError] if a number field's text is not numeric
58
+ # @raise [TypeError] if a required number field is missing from `raw`
59
+ # @raise [JSON::ParserError] if a line of a list-of-value-objects field is not JSON
36
60
  def self.collect(field, raw, pairs)
37
61
  case field.kind
38
62
  when :group, :money then field.children.each { |child| collect(child, raw, pairs) }
@@ -41,23 +65,32 @@ module Hecks
41
65
  end
42
66
  end
43
67
 
68
+ # Nests values keyed by dotted path into a Hash keyed by path segment, refusing two
69
+ # paths that cannot share one result.
70
+ #
44
71
  # A path-prefix collision: one field named (say) "price" alongside
45
- # another named "price.cents" implies "price" is BOTH a scalar leaf
72
+ # another named "price.cents" implies "price" is both a scalar leaf
46
73
  # and the parent of a nested group — the two can never coexist in
47
74
  # the same result hash. Depending on which pair `each_with_object`
48
- # reaches first, the naive walk below used to fail in one of two
49
- # ways: a scalar planted first left `acc[segment] ||= {}` seeing a
50
- # truthy non-Hash and reusing IT as `node`, so the next `node[leaf] =
51
- # value` blew up with a raw `TypeError` from calling `String#[]=`
52
- # with a Symbol key; a scalar planted AFTER the nested group instead
53
- # sailed through `node[leaf] = value` and silently clobbered the
54
- # entire nested hash with the scalar, losing every sibling under it
55
- # with no error at all. Both directions are checked explicitly here
56
- # so either order raises the SAME clear `ArgumentError` instead of a
57
- # confusing crash or silent data loss — this is the family of error
58
- # every command/query submission path in app.rb already rescues into
59
- # a 422 (`ArgumentError` sits right alongside the domain refusals in
60
- # every one of those rescue clauses).
75
+ # reaches first, an unguarded walk fails in one of two ways: a scalar
76
+ # planted first leaves `acc[segment] ||= {}` seeing a truthy non-Hash
77
+ # and reusing it as `node`, so the next `node[leaf] = value` blows up
78
+ # with a raw `TypeError` from calling `String#[]=` with a Symbol key;
79
+ # a scalar planted after the nested group instead sails through
80
+ # `node[leaf] = value` and silently clobbers the entire nested hash
81
+ # with the scalar, losing every sibling under it with no error at
82
+ # all. Both directions are checked explicitly here so either order
83
+ # raises the same clear `ArgumentError` instead of a confusing crash
84
+ # or silent data loss — this is the family of error every
85
+ # command/query submission path in app.rb already rescues into a 422
86
+ # (`ArgumentError` sits right alongside the domain refusals in every
87
+ # one of those rescue clauses).
88
+ #
89
+ # @param pairs [Hash{String => Object}] values keyed by dotted path, such as
90
+ # `{"amount.cents" => 1050}`
91
+ # @return [Hash{Symbol => Object}] the nested result, such as `{amount: {cents: 1050}}`
92
+ # @raise [ArgumentError] if one path names a plain value where another names a nested
93
+ # group, in either order
61
94
  def self.nest(pairs)
62
95
  pairs.each_with_object({}) do |(path, value), result|
63
96
  segments = path.to_s.split(".").map(&:to_sym)
@@ -74,6 +107,10 @@ module Hecks
74
107
  end
75
108
  end
76
109
 
110
+ # Builds, without raising, the error `nest` raises for a path-prefix collision.
111
+ #
112
+ # @param path [String] the dotted path at which the collision was found
113
+ # @return [ArgumentError] an error whose message names the conflicting path
77
114
  def self.nesting_collision(path)
78
115
  ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
79
116
  "one names it as a plain value and another as a nested group")
@@ -82,11 +119,21 @@ module Hecks
82
119
  SKIP = Object.new.freeze
83
120
  private_constant :SKIP
84
121
 
122
+ # Reads a `:list` field's textarea into an Array, one element per non-blank line.
123
+ #
85
124
  # One line of the textarea per element. A line that itself needs
86
125
  # several fields (a multi-attribute value object as a list element)
87
126
  # is read as JSON on that one line — the honest fallback documented in
88
127
  # docs/command-form-and-query-form-bluebook.md rather than a second
89
128
  # widget this prototype doesn't build yet.
129
+ #
130
+ # @param field [Forms::Field] a `:list` field whose first child describes one element
131
+ # @param raw [Hash{String => String}] the flat web payload, keyed by dotted field path
132
+ # @return [Array<Object>, Object] the elements — cast scalars for a leaf element,
133
+ # symbol-keyed parsed JSON otherwise — or the private `SKIP` sentinel when the field
134
+ # is absent or blank
135
+ # @raise [ArgumentError] if a number element's line is not numeric
136
+ # @raise [JSON::ParserError] if a non-leaf element's line is not JSON
90
137
  def self.extract_list(field, raw)
91
138
  text = raw[field.path]
92
139
  return SKIP if text.nil? || text.strip.empty?
@@ -97,6 +144,16 @@ module Hecks
97
144
  end
98
145
  end
99
146
 
147
+ # Reads one leaf field's typed value out of the payload; a boolean always answers, since
148
+ # an unticked checkbox is itself a value.
149
+ #
150
+ # @param field [Forms::Field] a leaf field
151
+ # @param raw [Hash{String => String}] the flat web payload, keyed by dotted field path
152
+ # @return [Boolean, Integer, Float, String, nil, Object] the cast value; nil when a
153
+ # required non-number field is missing; the private `SKIP` sentinel when an optional
154
+ # field is absent or empty
155
+ # @raise [ArgumentError] if a number field's text is not numeric
156
+ # @raise [TypeError] if a required number field is missing from `raw`
100
157
  def self.extract_leaf(field, raw)
101
158
  return checkbox(raw[field.path]) if field.kind == :boolean
102
159
 
@@ -106,10 +163,24 @@ module Hecks
106
163
  cast_scalar(field, text)
107
164
  end
108
165
 
166
+ # Reads a submitted checkbox value as a Boolean.
167
+ #
168
+ # @param raw_value [String, nil] the submitted text; nil when the field was not posted
169
+ # @return [Boolean] true for `"on"`, `"1"` or `"true"` in any letter case, false for
170
+ # anything else
109
171
  def self.checkbox(raw_value)
110
172
  %w[on 1 true].include?(raw_value.to_s.downcase)
111
173
  end
112
174
 
175
+ # Casts a leaf's submitted text to the Ruby type the runtime's numeric check requires,
176
+ # which accepts an actual `Integer` or `Float` and never a numeric-looking String.
177
+ #
178
+ # @param field [Forms::Field] the leaf the text was submitted for
179
+ # @param text [String, nil] the submitted text
180
+ # @return [Integer, Float, String, nil] an Integer for a `:number` field with `step`
181
+ # `"1"`, a Float for any other `:number` field, and `text` itself for every other kind
182
+ # @raise [ArgumentError] if a number field's text is not numeric
183
+ # @raise [TypeError] if a number field's text is nil
113
184
  def self.cast_scalar(field, text)
114
185
  case field.kind
115
186
  when :number then field.step == "1" ? Integer(text) : Float(text)
@@ -117,13 +188,23 @@ module Hecks
117
188
  end
118
189
  end
119
190
 
191
+ # Flattens held values back into the dotted-path, all-strings pairs a query string
192
+ # carries — the reverse of `extract`.
193
+ #
120
194
  # The other direction — a nested value hash back to the flat dotted
121
195
  # pairs a GET link's query string carries, so a query view's
122
- # "shareable link" and its filter FORM stay two renderings of the
123
- # SAME data rather than two formats that can drift. Reads with
196
+ # "shareable link" and its filter form stay two renderings of the
197
+ # same data rather than two formats that can drift. Reads with
124
198
  # `FieldRenderer.dig` — the identical full-path lookup a re-rendered
125
199
  # input's own value comes from — for the same reason `extract` above
126
200
  # nests by full path rather than by tree shape.
201
+ #
202
+ # @param fields [Array<Forms::Field>] the field tree naming which paths to read
203
+ # @param values [Hash{String, Symbol => Object}] held values, flat or nested; see
204
+ # `FieldRenderer.dig`
205
+ # @param into [Hash{String => String}] accumulator the pairs are added to in place
206
+ # @return [Hash{String => String}] `into`, holding one pair per leaf with a non-nil value
207
+ # and one per list (its elements newline-joined, `""` when it holds nothing)
127
208
  def self.flatten(fields, values, into: {})
128
209
  fields.each do |field|
129
210
  case field.kind
@@ -137,11 +218,19 @@ module Hecks
137
218
  into
138
219
  end
139
220
 
221
+ # Lists every dotted path a field tree submits under, whether or not anything was
222
+ # entered for it.
223
+ #
140
224
  # Every leaf/list path a field tree carries, independent of any
141
225
  # values — what `command_form_renderer.rb`'s inspect panel wants
142
226
  # ("which fields does this command take"), where `flatten` above wants
143
- # "what does THIS submission look like" and returns nothing for a
227
+ # "what does this one submission look like" and returns nothing for a
144
228
  # field nothing was entered for.
229
+ #
230
+ # @param fields [Array<Forms::Field>] the field tree to walk
231
+ # @param into [Array<String>] accumulator the paths are appended to in place
232
+ # @return [Array<String>] `into`, holding each leaf and list path in render order;
233
+ # `:group` and `:money` fields contribute their children's paths, not their own
145
234
  def self.paths(fields, into: [])
146
235
  fields.each do |field|
147
236
  case field.kind
@@ -152,6 +241,14 @@ module Hecks
152
241
  into
153
242
  end
154
243
 
244
+ # Renders held values as a URL query string, percent-encoding each flattened path and
245
+ # value.
246
+ #
247
+ # @param fields [Array<Forms::Field>] the field tree naming which paths to read
248
+ # @param values [Hash{String, Symbol => Object}] held values, flat or nested; see
249
+ # `FieldRenderer.dig`
250
+ # @return [String] `key=value` pairs joined with `&`, without a leading `?`; `""` when
251
+ # `flatten` yields no pairs
155
252
  def self.to_query_string(fields, values)
156
253
  pairs = flatten(fields, values)
157
254
  pairs.map { |key, value| "#{URI.encode_www_form_component(key)}=#{URI.encode_www_form_component(value)}" }.join("&")
@@ -5,13 +5,13 @@ module Hecks
5
5
  # boot. Two spellings the previous inline version got wrong:
6
6
  #
7
7
  # --port=8080 the equals form — the old `ARGV.each_cons(2)` scan
8
- # only ever recognized "--port", "8080" as TWO
8
+ # only ever recognized "--port", "8080" as two
9
9
  # separate argv entries, so this spelling matched
10
10
  # nothing and silently fell through to the default.
11
11
  # -p abc a non-numeric value — the old code did `.to_i` on
12
12
  # whatever followed unconditionally, so a typo
13
13
  # quietly became port 0 (Rackup/WEBrick's actual
14
- # behavior for `Port: 0` is to bind an EPHEMERAL
14
+ # behavior for `Port: 0` is to bind an ephemeral
15
15
  # port — arguably useful on purpose elsewhere, but
16
16
  # never what a mistyped `-p abc` meant to ask for).
17
17
  #
@@ -22,6 +22,13 @@ module Hecks
22
22
  module PortArgument
23
23
  module_function
24
24
 
25
+ # Reads the `-p`/`--port` argument out of an argv array.
26
+ #
27
+ # @param argv [Array<String>] the command-line arguments, ARGV-shaped
28
+ # @param default [Integer] the port to use when neither spelling appears at all
29
+ # @return [Array(Integer, nil), Array(nil, String)] `[port, nil]` on a clean
30
+ # parse, or `[nil, message]` when an explicit port was given but isn't a
31
+ # real port number
25
32
  def parse(argv, default: 4567)
26
33
  equals = argv.find { |arg| arg.start_with?("--port=") }
27
34
  return resolve(equals.split("=", 2).last) if equals
@@ -32,6 +39,11 @@ module Hecks
32
39
  resolve(argv[index + 1])
33
40
  end
34
41
 
42
+ # Validates and converts a raw `-p`/`--port` argument value.
43
+ #
44
+ # @param value [String, nil] the text following `-p`/`--port`, or nil if none
45
+ # @return [Array(Integer, nil), Array(nil, String)] `[port, nil]` if `value`
46
+ # is a whole number between 1 and 65535, or `[nil, message]` otherwise
35
47
  def resolve(value)
36
48
  return [nil, "-p/--port requires a value"] if value.nil? || value.empty?
37
49
  return [nil, "-p/--port must be a whole number, got #{value.inspect}"] unless value.match?(/\A\d+\z/)
@@ -16,10 +16,25 @@ module Hecks
16
16
  # hiding that behind a form); a row of ready-made links for any
17
17
  # closed-set parameter, since a caller filtering by an enum should not
18
18
  # have to fill in a form to get a link they could just click; a filter
19
- # FORM for everything else, which — being a GET form — produces
19
+ # form for everything else, which — being a GET form — produces
20
20
  # exactly the same kind of link on submit; and the results table once
21
21
  # a request actually supplies parameters.
22
22
  module QueryFormRenderer
23
+ # Renders one query's whole GET view: header, canonical link, quick links, filter
24
+ # form, any error, results, and the inspect panel.
25
+ #
26
+ # @param registry [Runtime::Registry] the booted registry, for resolving reference
27
+ # fields' target aggregates
28
+ # @param domain [String] the owning chapter's name
29
+ # @param aggregate [Bluebook::Aggregate] the aggregate the query belongs to
30
+ # @param query [Bluebook::Query] the query being rendered
31
+ # @param action [String] the form's `action` URL and the canonical link's base
32
+ # @param params [Hash] the request's own query params, pre-filling the filter form
33
+ # @param results [Array<Hash>, nil] the query's own answer rows; `nil` renders no
34
+ # results section
35
+ # @param error [StandardError, nil] a raised error to show in the error banner;
36
+ # `nil` renders none
37
+ # @return [String] the page body markup
23
38
  def self.render(registry:, domain:, aggregate:, query:, action:, params: {}, results: nil, error: nil)
24
39
  fields = query.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
25
40
  reference_options = ReferenceOptions.collect(registry, domain, fields)
@@ -35,6 +50,12 @@ module Hecks
35
50
  HTML
36
51
  end
37
52
 
53
+ # Renders the query's title, description and badges.
54
+ #
55
+ # @param domain [String] the owning chapter's name
56
+ # @param aggregate [Bluebook::Aggregate] the aggregate the query belongs to
57
+ # @param query [Bluebook::Query] the query being rendered
58
+ # @return [String] the header markup
38
59
  def self.header(domain, aggregate, query)
39
60
  <<~HTML
40
61
  <h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}")}</h1>
@@ -43,12 +64,22 @@ module Hecks
43
64
  HTML
44
65
  end
45
66
 
67
+ # Renders the query's declared badges — today, just its `limit` when it declares one.
68
+ #
69
+ # @param query [Bluebook::Query] the query being rendered
70
+ # @return [String] the badge markup; `""` when the query declares no `limit`
46
71
  def self.badges(query)
47
72
  parts = []
48
73
  parts << %(<span class="badge">limit #{Escape.html(query.limit.to_h[:value])}</span>) if query.limit
49
74
  parts.join
50
75
  end
51
76
 
77
+ # Renders the query's plain-GET canonical link template, one `path={path}` per
78
+ # parameter.
79
+ #
80
+ # @param action [String] the query's base URL
81
+ # @param fields [Array<Forms::Field>] the resolved field tree naming the parameters
82
+ # @return [String] the help text and the templated link markup
52
83
  def self.canonical_link(action, fields)
53
84
  paths = Params.paths(fields)
54
85
  template = paths.empty? ? action : "#{action}?#{paths.map { |path| "#{path}={#{path}}" }.join('&')}"
@@ -60,10 +91,16 @@ module Hecks
60
91
 
61
92
  # The one enum-shaped parameter (if there is one) rendered as literal,
62
93
  # clickable links — no form to fill in for "show me the suspended
63
- # ones". Capped at the FIRST closed-set field on purpose: a second one
94
+ # ones". Capped at the first closed-set field on purpose: a second one
64
95
  # would mean a cross product of links, which reads as noise rather
65
96
  # than help. A query with more than one is still fully reachable
66
97
  # through the filter form below.
98
+ #
99
+ # @param action [String] the query's base URL
100
+ # @param fields [Array<Forms::Field>] the resolved field tree to search for a
101
+ # `:select` or `:radio` field
102
+ # @return [String] one clickable link per option of the first closed-set field
103
+ # found; `""` when no field is `:select` or `:radio`
67
104
  def self.quick_links(action, fields)
68
105
  field = fields.find { |f| %i[select radio].include?(f.kind) }
69
106
  return "" unless field
@@ -75,6 +112,14 @@ module Hecks
75
112
  %(<div class="example-links">#{links.join}</div>)
76
113
  end
77
114
 
115
+ # Renders the GET filter form covering every declared parameter.
116
+ #
117
+ # @param action [String] the form's `action` URL
118
+ # @param fields [Array<Forms::Field>] the resolved field tree to render
119
+ # @param params [Hash] the request's own query params, pre-filling each field
120
+ # @param reference_options [Hash{String => Array<Array(String, String)>, nil}] each
121
+ # `:reference` field's own dropdown options, as `ReferenceOptions.collect` returns
122
+ # @return [String] the form markup; `""` when the query declares no parameters
78
123
  def self.filter_form(action, fields, params, reference_options)
79
124
  return "" if fields.empty?
80
125
 
@@ -86,6 +131,10 @@ module Hecks
86
131
  HTML
87
132
  end
88
133
 
134
+ # Renders a raised error as a dismissable-looking alert banner.
135
+ #
136
+ # @param error [StandardError, nil] the error to show
137
+ # @return [String] the banner markup; `""` when `error` is `nil`
89
138
  def self.error_banner(error)
90
139
  return "" unless error
91
140
 
@@ -93,12 +142,26 @@ module Hecks
93
142
  "#{Escape.html(error.message)}</p></div>"
94
143
  end
95
144
 
145
+ # Renders the results heading and table, once a request actually supplied
146
+ # parameters.
147
+ #
148
+ # @param aggregate [Bluebook::Aggregate] the aggregate whose fields shape the table
149
+ # @param results [Array<Hash>, nil] the query's own answer rows
150
+ # @param domain [String] the owning chapter's name, for building record links
151
+ # @return [String] the results section markup; `""` when `results` is `nil`
96
152
  def self.results_section(aggregate, results, domain)
97
153
  return "" unless results
98
154
 
99
155
  "<h2>Results (#{results.size})</h2>#{RecordTable.render(aggregate, results, domain: domain)}"
100
156
  end
101
157
 
158
+ # Renders a collapsed panel with the query's own parameter list and raw IR JSON.
159
+ #
160
+ # @param domain [String] the owning chapter's name
161
+ # @param aggregate [Bluebook::Aggregate] the aggregate the query belongs to
162
+ # @param query [Bluebook::Query] the query being rendered
163
+ # @param fields [Array<Forms::Field>] the resolved field tree, for the parameter list
164
+ # @return [String] the `<details>` panel markup
102
165
  def self.inspect_panel(domain, aggregate, query, fields)
103
166
  verb = "#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}"
104
167
  paths = Params.paths(fields)