hecks 0.2.0 → 1.0.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 (419) hide show
  1. checksums.yaml +5 -5
  2. data/lib/hecks/adapters/driven/claude_code.adapter +3 -0
  3. data/lib/hecks/adapters/driven/claude_code.rb +127 -0
  4. data/lib/hecks/adapters/driven/d1.adapter +12 -0
  5. data/lib/hecks/adapters/driven/d1.rb +427 -0
  6. data/lib/hecks/adapters/driven/folder.adapter +3 -0
  7. data/lib/hecks/adapters/driven/folder.rb +199 -0
  8. data/lib/hecks/adapters/driven/google_authentication.adapter +3 -0
  9. data/lib/hecks/adapters/driven/google_authentication.rb +100 -0
  10. data/lib/hecks/adapters/driven/governance_authorization.adapter +3 -0
  11. data/lib/hecks/adapters/driven/governance_authorization.rb +101 -0
  12. data/lib/hecks/adapters/driven/heki/journal.rb +56 -0
  13. data/lib/hecks/adapters/driven/heki/saga_store.rb +99 -0
  14. data/lib/hecks/adapters/driven/heki/snapshot.rb +65 -0
  15. data/lib/hecks/adapters/driven/heki.adapter +4 -0
  16. data/lib/hecks/adapters/driven/heki.rb +179 -0
  17. data/lib/hecks/adapters/driven/identity_registry.adapter +3 -0
  18. data/lib/hecks/adapters/driven/identity_registry.rb +26 -0
  19. data/lib/hecks/adapters/driven/in_memory_ordering.rb +51 -0
  20. data/lib/hecks/adapters/driven/lambda/client.rb +63 -0
  21. data/lib/hecks/adapters/driven/lambda.adapter +4 -0
  22. data/lib/hecks/adapters/driven/lambda.rb +145 -0
  23. data/lib/hecks/adapters/driven/memory.adapter +3 -0
  24. data/lib/hecks/adapters/driven/memory.rb +103 -0
  25. data/lib/hecks/adapters/driven/mock_stripe_adapter.adapter +3 -0
  26. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +28 -0
  27. data/lib/hecks/adapters/driven/postgres/codec.rb +88 -0
  28. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +207 -0
  29. data/lib/hecks/adapters/driven/postgres.adapter +5 -0
  30. data/lib/hecks/adapters/driven/postgres.rb +438 -0
  31. data/lib/hecks/adapters/driven/postgres_era.adapter +17 -0
  32. data/lib/hecks/adapters/driven/prism.adapter +3 -0
  33. data/lib/hecks/adapters/driven/prism.rb +80 -0
  34. data/lib/hecks/adapters/driven/secure_random_identity.adapter +3 -0
  35. data/lib/hecks/adapters/driven/secure_random_identity.rb +14 -0
  36. data/lib/hecks/adapters/driven/sql_query_builder.rb +221 -0
  37. data/lib/hecks/adapters/driven/sqlite/codec.rb +80 -0
  38. data/lib/hecks/adapters/driven/sqlite/projection.rb +172 -0
  39. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +192 -0
  40. data/lib/hecks/adapters/driven/sqlite.adapter +9 -0
  41. data/lib/hecks/adapters/driven/sqlite.rb +306 -0
  42. data/lib/hecks/adapters/driven/system_clock.adapter +3 -0
  43. data/lib/hecks/adapters/driven/system_clock.rb +14 -0
  44. data/lib/hecks/adapters/driven.rb +54 -0
  45. data/lib/hecks/adapters.rb +6 -0
  46. data/lib/hecks/behaviors/dsl.rb +125 -0
  47. data/lib/hecks/behaviors/expectations.rb +340 -0
  48. data/lib/hecks/behaviors/ir.rb +31 -0
  49. data/lib/hecks/behaviors/rspec.rb +42 -0
  50. data/lib/hecks/behaviors/runner.rb +96 -0
  51. data/lib/hecks/behaviors.rb +25 -0
  52. data/lib/hecks/bluebook/aggregate.rb +108 -0
  53. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +134 -0
  54. data/lib/hecks/bluebook/assembly/build.rb +48 -0
  55. data/lib/hecks/bluebook/assembly/contract.rb +112 -0
  56. data/lib/hecks/bluebook/assembly/contracts.rb +438 -0
  57. data/lib/hecks/bluebook/assembly/marks.rb +228 -0
  58. data/lib/hecks/bluebook/assembly/specializer.rb +70 -0
  59. data/lib/hecks/bluebook/assembly.rb +91 -0
  60. data/lib/hecks/bluebook/attribute.rb +96 -0
  61. data/lib/hecks/bluebook/behaviour/aggregate.rb +83 -0
  62. data/lib/hecks/bluebook/behaviour/attribute.rb +27 -0
  63. data/lib/hecks/bluebook/behaviour/chapter.rb +72 -0
  64. data/lib/hecks/bluebook/behaviour/command.rb +116 -0
  65. data/lib/hecks/bluebook/behaviour/domain_port.rb +25 -0
  66. data/lib/hecks/bluebook/behaviour/entity.rb +59 -0
  67. data/lib/hecks/bluebook/behaviour/hexagon.rb +50 -0
  68. data/lib/hecks/bluebook/behaviour/lifecycle.rb +68 -0
  69. data/lib/hecks/bluebook/behaviour/policy.rb +52 -0
  70. data/lib/hecks/bluebook/behaviour/process_manager.rb +51 -0
  71. data/lib/hecks/bluebook/behaviour/query.rb +10 -0
  72. data/lib/hecks/bluebook/behaviour/read_model.rb +29 -0
  73. data/lib/hecks/bluebook/behaviour/traits.rb +81 -0
  74. data/lib/hecks/bluebook/behaviour/value_object.rb +33 -0
  75. data/lib/hecks/bluebook/chapter.rb +78 -0
  76. data/lib/hecks/bluebook/command.rb +124 -0
  77. data/lib/hecks/bluebook/domain_port.rb +102 -0
  78. data/lib/hecks/bluebook/dsl/adapter_builder.rb +34 -0
  79. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +1018 -0
  80. data/lib/hecks/bluebook/dsl/attribute_collector.rb +348 -0
  81. data/lib/hecks/bluebook/dsl/binding_proxy.rb +71 -0
  82. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +1087 -0
  83. data/lib/hecks/bluebook/dsl/command_builder.rb +714 -0
  84. data/lib/hecks/bluebook/dsl/const_shim.rb +81 -0
  85. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +79 -0
  86. data/lib/hecks/bluebook/dsl/entity_builder.rb +430 -0
  87. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +366 -0
  88. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +163 -0
  89. data/lib/hecks/bluebook/dsl/identity_declaration.rb +191 -0
  90. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +44 -0
  91. data/lib/hecks/bluebook/dsl/malformed.rb +7 -0
  92. data/lib/hecks/bluebook/dsl/policy_builder.rb +135 -0
  93. data/lib/hecks/bluebook/dsl/port_builder.rb +39 -0
  94. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +142 -0
  95. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +306 -0
  96. data/lib/hecks/bluebook/dsl/query_builder.rb +113 -0
  97. data/lib/hecks/bluebook/dsl/read_model_builder.rb +233 -0
  98. data/lib/hecks/bluebook/dsl/rule_reference.rb +173 -0
  99. data/lib/hecks/bluebook/dsl/translation_builder.rb +243 -0
  100. data/lib/hecks/bluebook/dsl/value_object_builder.rb +178 -0
  101. data/lib/hecks/bluebook/dsl/word_gate.rb +228 -0
  102. data/lib/hecks/bluebook/dsl/world_builder.rb +117 -0
  103. data/lib/hecks/bluebook/dsl.rb +45 -0
  104. data/lib/hecks/bluebook/entity.rb +103 -0
  105. data/lib/hecks/bluebook/expression/canonical_form.rb +123 -0
  106. data/lib/hecks/bluebook/expression/evaluator.rb +304 -0
  107. data/lib/hecks/bluebook/expression/projection.json +218 -0
  108. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +233 -0
  109. data/lib/hecks/bluebook/expression/resolver.rb +781 -0
  110. data/lib/hecks/bluebook/expression.rb +13 -0
  111. data/lib/hecks/bluebook/hexagon.rb +60 -0
  112. data/lib/hecks/bluebook/lifecycle.rb +42 -0
  113. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +54 -0
  114. data/lib/hecks/bluebook/meta_validator/judge.rb +621 -0
  115. data/lib/hecks/bluebook/meta_validator/plan.rb +332 -0
  116. data/lib/hecks/bluebook/meta_validator/port_judge.rb +51 -0
  117. data/lib/hecks/bluebook/meta_validator/readings.rb +360 -0
  118. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +351 -0
  119. data/lib/hecks/bluebook/meta_validator/shapes.rb +266 -0
  120. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +255 -0
  121. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +138 -0
  122. data/lib/hecks/bluebook/meta_validator/world_judge.rb +78 -0
  123. data/lib/hecks/bluebook/meta_validator.rb +542 -0
  124. data/lib/hecks/bluebook/model_check.rb +445 -0
  125. data/lib/hecks/bluebook/pattern_subset.rb +184 -0
  126. data/lib/hecks/bluebook/policy.rb +41 -0
  127. data/lib/hecks/bluebook/process_manager.rb +128 -0
  128. data/lib/hecks/bluebook/project_discovery.rb +30 -0
  129. data/lib/hecks/bluebook/project_loader.rb +40 -0
  130. data/lib/hecks/bluebook/project_register.rb +82 -0
  131. data/lib/hecks/bluebook/query.rb +61 -0
  132. data/lib/hecks/bluebook/read_model.rb +109 -0
  133. data/lib/hecks/bluebook/reference.rb +74 -0
  134. data/lib/hecks/bluebook/smoke_test.rb +166 -0
  135. data/lib/hecks/bluebook/synthesizer.rb +95 -0
  136. data/lib/hecks/bluebook/translation.rb +92 -0
  137. data/lib/hecks/bluebook/value_object.rb +66 -0
  138. data/lib/hecks/bluebook.rb +74 -0
  139. data/lib/hecks/codemod.rb +342 -0
  140. data/lib/hecks/construct.rb +71 -0
  141. data/lib/hecks/deploy/bluebook/deploy.bluebook +219 -0
  142. data/lib/hecks/deploy/bluebook/deploy.hecksagon +4 -0
  143. data/lib/hecks/deploy/oidc.json +18 -0
  144. data/lib/hecks/doc/reference.rb +410 -0
  145. data/lib/hecks/embryonaut_bluebook.rb +75 -0
  146. data/lib/hecks/facade/cli_door.rb +119 -0
  147. data/lib/hecks/facade/cli_runner.rb +190 -0
  148. data/lib/hecks/facade/command_request.rb +105 -0
  149. data/lib/hecks/facade/handle.rb +173 -0
  150. data/lib/hecks/facade/json_door.rb +166 -0
  151. data/lib/hecks/facade/surface/aggregate_door.rb +185 -0
  152. data/lib/hecks/facade/surface/chapter.rb +107 -0
  153. data/lib/hecks/facade/surface.rb +48 -0
  154. data/lib/hecks/facade.rb +44 -0
  155. data/lib/hecks/forms/app.rb +341 -0
  156. data/lib/hecks/forms/command_form_renderer.rb +113 -0
  157. data/lib/hecks/forms/examples/banking_console.bluebook +3 -0
  158. data/lib/hecks/forms/field_renderer.rb +177 -0
  159. data/lib/hecks/forms/field_shape.rb +232 -0
  160. data/lib/hecks/forms/html.rb +84 -0
  161. data/lib/hecks/forms/index_renderer.rb +35 -0
  162. data/lib/hecks/forms/page.rb +157 -0
  163. data/lib/hecks/forms/params.rb +161 -0
  164. data/lib/hecks/forms/port_argument.rb +46 -0
  165. data/lib/hecks/forms/query_form_renderer.rb +114 -0
  166. data/lib/hecks/forms/record_renderer.rb +119 -0
  167. data/lib/hecks/forms/record_table.rb +68 -0
  168. data/lib/hecks/forms/reference_options.rb +30 -0
  169. data/lib/hecks/forms/value_object_shape.rb +46 -0
  170. data/lib/hecks/forms.rb +54 -0
  171. data/lib/hecks/fqn.rb +94 -0
  172. data/lib/hecks/framework/bluebook/compliance.bluebook +1 -0
  173. data/lib/hecks/framework/bluebook/console_settings.bluebook +489 -0
  174. data/lib/hecks/framework/bluebook/framework.hecksagon +32 -0
  175. data/lib/hecks/framework/bluebook/governance.bluebook +145 -0
  176. data/lib/hecks/framework/bluebook/identity.bluebook +90 -0
  177. data/lib/hecks/framework/oidc.json +39 -0
  178. data/lib/hecks/framework.rb +90 -0
  179. data/lib/hecks/freezer.rb +67 -0
  180. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +527 -0
  181. data/lib/hecks/fuzzing/invalid_value_generator.rb +86 -0
  182. data/lib/hecks/fuzzing/isolated_boot.rb +286 -0
  183. data/lib/hecks/fuzzing/properties.rb +1192 -0
  184. data/lib/hecks/fuzzing/replay.rb +668 -0
  185. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +93 -0
  186. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +80 -0
  187. data/lib/hecks/fuzzing/sequence_generator/picker.rb +96 -0
  188. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +250 -0
  189. data/lib/hecks/fuzzing/sequence_generator.rb +131 -0
  190. data/lib/hecks/fuzzing/value_generator.rb +200 -0
  191. data/lib/hecks/fuzzing.rb +12 -0
  192. data/lib/hecks/grammar/evolve.rb +324 -0
  193. data/lib/hecks/grammar/expression.bluebook +420 -0
  194. data/lib/hecks/grammar/expression_operators.json +1648 -0
  195. data/lib/hecks/grammar/grammar.hecksagon +20 -0
  196. data/lib/hecks/grammar/oidc.json +69 -0
  197. data/lib/hecks/grammar/translation.bluebook +203 -0
  198. data/lib/hecks/grammar.rb +178 -0
  199. data/lib/hecks/ir.rb +126 -0
  200. data/lib/hecks/language/adapter.bluebook +116 -0
  201. data/lib/hecks/language/bluebook/aggregate.bluebook +581 -0
  202. data/lib/hecks/language/bluebook/attaches/paging.bluebook +76 -0
  203. data/lib/hecks/language/bluebook/bluebook.bluebook +256 -0
  204. data/lib/hecks/language/bluebook/bluebook.hecksagon +24 -0
  205. data/lib/hecks/language/bluebook/command.bluebook +471 -0
  206. data/lib/hecks/language/bluebook/entity.bluebook +392 -0
  207. data/lib/hecks/language/bluebook/policy.bluebook +189 -0
  208. data/lib/hecks/language/bluebook/process_manager.bluebook +381 -0
  209. data/lib/hecks/language/bluebook/projection.bluebook +267 -0
  210. data/lib/hecks/language/bluebook/query.bluebook +245 -0
  211. data/lib/hecks/language/bluebook/shape.bluebook +292 -0
  212. data/lib/hecks/language/bluebook/syntax.bluebook +448 -0
  213. data/lib/hecks/language/bluebook/vocabulary.bluebook +379 -0
  214. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +51 -0
  215. data/lib/hecks/language/hecksagon/domain_port.bluebook +76 -0
  216. data/lib/hecks/language/hecksagon/hecksagon.bluebook +131 -0
  217. data/lib/hecks/language/hecksagon/port_operation.bluebook +102 -0
  218. data/lib/hecks/language/oidc.json +333 -0
  219. data/lib/hecks/language/port.bluebook +120 -0
  220. data/lib/hecks/language/translation/translation.bluebook +110 -0
  221. data/lib/hecks/language/translation/translation_aggregate.bluebook +267 -0
  222. data/lib/hecks/language/world/wiring.bluebook +62 -0
  223. data/lib/hecks/language/world/world.bluebook +84 -0
  224. data/lib/hecks/literal.rb +125 -0
  225. data/lib/hecks/naming.rb +174 -0
  226. data/lib/hecks/ports/access_control.port +9 -0
  227. data/lib/hecks/ports/access_control.rb +62 -0
  228. data/lib/hecks/ports/agent/answers.rb +104 -0
  229. data/lib/hecks/ports/agent.port +8 -0
  230. data/lib/hecks/ports/agent.rb +167 -0
  231. data/lib/hecks/ports/authentication.port +6 -0
  232. data/lib/hecks/ports/authentication.rb +50 -0
  233. data/lib/hecks/ports/authorization.port +7 -0
  234. data/lib/hecks/ports/authorization.rb +62 -0
  235. data/lib/hecks/ports/clock.port +5 -0
  236. data/lib/hecks/ports/clock.rb +62 -0
  237. data/lib/hecks/ports/extraction.port +5 -0
  238. data/lib/hecks/ports/extraction.rb +37 -0
  239. data/lib/hecks/ports/identity_assignment.port +5 -0
  240. data/lib/hecks/ports/identity_assignment.rb +45 -0
  241. data/lib/hecks/ports/identity_generation.port +5 -0
  242. data/lib/hecks/ports/identity_generation.rb +49 -0
  243. data/lib/hecks/ports/identity_resolution.port +5 -0
  244. data/lib/hecks/ports/identity_resolution.rb +40 -0
  245. data/lib/hecks/ports/loading.port +4 -0
  246. data/lib/hecks/ports/loading.rb +13 -0
  247. data/lib/hecks/ports/persistence/append_only.rb +138 -0
  248. data/lib/hecks/ports/persistence/binding_policy.rb +56 -0
  249. data/lib/hecks/ports/persistence/execution.rb +23 -0
  250. data/lib/hecks/ports/persistence/null_saga_store.rb +25 -0
  251. data/lib/hecks/ports/persistence/plugin.rb +54 -0
  252. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +169 -0
  253. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +128 -0
  254. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +161 -0
  255. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +61 -0
  256. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +304 -0
  257. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +171 -0
  258. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +190 -0
  259. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +478 -0
  260. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +166 -0
  261. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +314 -0
  262. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +168 -0
  263. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +170 -0
  264. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +134 -0
  265. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +137 -0
  266. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +89 -0
  267. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +85 -0
  268. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +43 -0
  269. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +132 -0
  270. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +74 -0
  271. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +758 -0
  272. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +120 -0
  273. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +31 -0
  274. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +46 -0
  275. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +102 -0
  276. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +46 -0
  277. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +70 -0
  278. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +72 -0
  279. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +120 -0
  280. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +183 -0
  281. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +41 -0
  282. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +28 -0
  283. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +30 -0
  284. data/lib/hecks/ports/persistence/plugins/era/translation.rb +11 -0
  285. data/lib/hecks/ports/persistence/plugins/era.rb +48 -0
  286. data/lib/hecks/ports/persistence/remote_runtime.rb +42 -0
  287. data/lib/hecks/ports/persistence/repository_factory.rb +30 -0
  288. data/lib/hecks/ports/persistence.port +4 -0
  289. data/lib/hecks/ports/persistence.rb +39 -0
  290. data/lib/hecks/ports/projection.port +4 -0
  291. data/lib/hecks/ports/projection.rb +96 -0
  292. data/lib/hecks/ports/query/in_memory.rb +60 -0
  293. data/lib/hecks/ports/query/ordering.rb +41 -0
  294. data/lib/hecks/ports/query.rb +36 -0
  295. data/lib/hecks/ports.rb +27 -0
  296. data/lib/hecks/projections/diagrams.rb +642 -0
  297. data/lib/hecks/projections/ir.rb +18 -0
  298. data/lib/hecks/projections/model/deviations.rb +98 -0
  299. data/lib/hecks/projections/model.rb +145 -0
  300. data/lib/hecks/projections/oidc.rb +110 -0
  301. data/lib/hecks/projections/parser_table.rb +159 -0
  302. data/lib/hecks/projections/reference.rb +38 -0
  303. data/lib/hecks/projections/shape.rb +44 -0
  304. data/lib/hecks/projections/statements.rb +110 -0
  305. data/lib/hecks/projections/vocabulary.rb +100 -0
  306. data/lib/hecks/projections.rb +32 -0
  307. data/lib/hecks/projector/cli_projector.rb +446 -0
  308. data/lib/hecks/projector/docs_projector.rb +321 -0
  309. data/lib/hecks/projector/exporter.rb +158 -0
  310. data/lib/hecks/projector/ir_projector.rb +18 -0
  311. data/lib/hecks/projector/narrate_projector.rb +243 -0
  312. data/lib/hecks/projector/target.rb +97 -0
  313. data/lib/hecks/projector.rb +186 -0
  314. data/lib/hecks/query_ir.rb +411 -0
  315. data/lib/hecks/query_specification/common/authorization_spec.rb +9 -0
  316. data/lib/hecks/query_specification/common/comparators.rb +25 -0
  317. data/lib/hecks/query_specification/common/comparison.rb +174 -0
  318. data/lib/hecks/query_specification/common/cursor_spec.rb +9 -0
  319. data/lib/hecks/query_specification/common/dsl.rb +58 -0
  320. data/lib/hecks/query_specification/common/inspection_spec.rb +9 -0
  321. data/lib/hecks/query_specification/common/limit_spec.rb +9 -0
  322. data/lib/hecks/query_specification/common/null_policy.rb +92 -0
  323. data/lib/hecks/query_specification/common/null_semantics.rb +11 -0
  324. data/lib/hecks/query_specification/common/offset_spec.rb +9 -0
  325. data/lib/hecks/query_specification/common/options.rb +32 -0
  326. data/lib/hecks/query_specification/common/order_by.rb +9 -0
  327. data/lib/hecks/query_specification/common/specification.rb +10 -0
  328. data/lib/hecks/query_specification/common/where_clause.rb +9 -0
  329. data/lib/hecks/query_specification/field_path.rb +107 -0
  330. data/lib/hecks/query_specification/hop_path.rb +132 -0
  331. data/lib/hecks/query_specification/read_model/specification.rb +18 -0
  332. data/lib/hecks/query_specification.rb +14 -0
  333. data/lib/hecks/rendering.rb +48 -0
  334. data/lib/hecks/router/namespace_installer.rb +157 -0
  335. data/lib/hecks/router.rb +70 -0
  336. data/lib/hecks/runtime/aggregate_lock.rb +45 -0
  337. data/lib/hecks/runtime/boot_gates.rb +41 -0
  338. data/lib/hecks/runtime/caller.rb +66 -0
  339. data/lib/hecks/runtime/capability_graph.rb +44 -0
  340. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +135 -0
  341. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +260 -0
  342. data/lib/hecks/runtime/command_interpreter.rb +497 -0
  343. data/lib/hecks/runtime/command_rules/admissibility.rb +357 -0
  344. data/lib/hecks/runtime/command_rules/arithmetic.rb +260 -0
  345. data/lib/hecks/runtime/command_rules/authorization.rb +65 -0
  346. data/lib/hecks/runtime/command_rules/emission.rb +34 -0
  347. data/lib/hecks/runtime/command_rules/references.rb +189 -0
  348. data/lib/hecks/runtime/command_rules.rb +28 -0
  349. data/lib/hecks/runtime/dependency_planning.rb +245 -0
  350. data/lib/hecks/runtime/dispatcher.rb +301 -0
  351. data/lib/hecks/runtime/entity_element.rb +253 -0
  352. data/lib/hecks/runtime/entity_interpreter.rb +299 -0
  353. data/lib/hecks/runtime/errors.rb +122 -0
  354. data/lib/hecks/runtime/event.rb +51 -0
  355. data/lib/hecks/runtime/identity.rb +156 -0
  356. data/lib/hecks/runtime/instance.rb +175 -0
  357. data/lib/hecks/runtime/interpreting.rb +90 -0
  358. data/lib/hecks/runtime/loader.rb +184 -0
  359. data/lib/hecks/runtime/policy_interpreter.rb +366 -0
  360. data/lib/hecks/runtime/port_operation_interpreter.rb +210 -0
  361. data/lib/hecks/runtime/query_interpreter.rb +286 -0
  362. data/lib/hecks/runtime/reaction_invocation.rb +253 -0
  363. data/lib/hecks/runtime/read_model_interpreter.rb +341 -0
  364. data/lib/hecks/runtime/rebuild_sweep.rb +74 -0
  365. data/lib/hecks/runtime/reference_hop.rb +99 -0
  366. data/lib/hecks/runtime/refusal_wording.rb +126 -0
  367. data/lib/hecks/runtime/registry/saga_persistence.rb +142 -0
  368. data/lib/hecks/runtime/registry/verification.rb +246 -0
  369. data/lib/hecks/runtime/registry.rb +279 -0
  370. data/lib/hecks/runtime/remote_dispatcher.rb +143 -0
  371. data/lib/hecks/runtime/routing.rb +96 -0
  372. data/lib/hecks/runtime/saga_interpreter/correlation.rb +97 -0
  373. data/lib/hecks/runtime/saga_interpreter.rb +503 -0
  374. data/lib/hecks/runtime/saga_pending_dispatch.rb +45 -0
  375. data/lib/hecks/runtime/tenant_check.rb +84 -0
  376. data/lib/hecks/runtime/tenant_scope.rb +55 -0
  377. data/lib/hecks/runtime/value/admission.rb +130 -0
  378. data/lib/hecks/runtime/value/coercion.rb +565 -0
  379. data/lib/hecks/runtime/value/invariant_violation.rb +5 -0
  380. data/lib/hecks/runtime/value.rb +125 -0
  381. data/lib/hecks/runtime.rb +107 -0
  382. data/lib/hecks/storehouse.rb +632 -0
  383. data/lib/hecks/version.rb +16 -0
  384. data/lib/hecks/vocabulary.rb +218 -0
  385. data/lib/hecks.rb +123 -6
  386. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +90 -0
  387. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +128 -0
  388. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +160 -0
  389. metadata +412 -222
  390. data/bin/hecks +0 -7
  391. data/bin/hecks-package +0 -65
  392. data/bin/hecks_console +0 -12
  393. data/bin/hecks_serverless +0 -6
  394. data/lib/cli/build.rb +0 -14
  395. data/lib/cli/command_runner.rb +0 -28
  396. data/lib/cli/console.rb +0 -10
  397. data/lib/cli/generate.rb +0 -37
  398. data/lib/cli/hecks-cli.rb +0 -27
  399. data/lib/cli/test.rb +0 -57
  400. data/lib/console/commands.rb +0 -8
  401. data/lib/console/hecks-console.rb +0 -1
  402. data/lib/packager/README.md +0 -0
  403. data/lib/packager/app_runner.rb +0 -21
  404. data/lib/packager/args.rb +0 -26
  405. data/lib/packager/compatibility/fixnum.rb +0 -6
  406. data/lib/packager/hecks.rb +0 -39
  407. data/lib/packager/query_runner.rb +0 -21
  408. data/lib/packager/resources/Dockerfile +0 -11
  409. data/lib/packager/resources/app_binary +0 -7
  410. data/lib/packager/resources/bundle_config +0 -3
  411. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-linux-x86_64.tar.gz +0 -0
  412. data/lib/packager/resources/traveling-ruby-20150715-2.2.2-osx.tar.gz +0 -0
  413. data/lib/serverless/Domain +0 -32
  414. data/lib/serverless/cli.rb +0 -75
  415. data/lib/serverless/resources/command_name.js +0 -5
  416. data/lib/serverless/resources/environment.js +0 -7
  417. data/lib/serverless/resources/handler.js.tt +0 -28
  418. data/lib/serverless/resources/run_binary.js +0 -22
  419. data/lib/serverless/resources/serverless.yml +0 -20
@@ -0,0 +1,232 @@
1
+ require_relative "../bluebook/attribute"
2
+ require_relative "value_object_shape"
3
+
4
+ module Hecks
5
+ module Forms
6
+ # One resolved field, ready for a renderer to turn into markup. A LEAF
7
+ # scalar carries `kind`/`options`/etc; a `:group` or `:list` carries
8
+ # `children` instead and nothing else on itself.
9
+ #
10
+ # `path` is the DOTTED field path this attribute is reached at —
11
+ # `"email.address"`, `"amount.cents"` — the same convention the language
12
+ # already uses for a query's own cross-object `where` clauses
13
+ # (`where(:"customer.status" => ...)`), not a fresh one invented here. A
14
+ # command receives its arguments as nested hashes
15
+ # (`email: {address: "..."}`), so `Params.unflatten` (params.rb) walks
16
+ # this same dotted spelling back apart on submit — one convention, two
17
+ # directions.
18
+ Field = Struct.new(
19
+ :path, :label, :kind, :html_type, :options, :children, :default, :optional,
20
+ :pattern, :step, :help, :target_aggregate, keyword_init: true
21
+ ) do
22
+ def leaf? = kind != :group && kind != :list
23
+ def optional? = optional
24
+ def required? = !optional
25
+ end
26
+
27
+ module Humanize
28
+ # "given" -> "Given", "daily_limit" -> "Daily limit", "end_to_end" ->
29
+ # "End to end" — the label a plain reader wants, not the wire spelling.
30
+ def self.label(text)
31
+ # Split on "." FIRST, alone, to take only the last path segment —
32
+ # "daily_limit" is one segment (the underscore is a word break
33
+ # inside it, not a path hop) and must keep both its words; only a
34
+ # genuinely dotted path ("amount.cents") drops everything before
35
+ # the last ".".
36
+ segment = text.to_s.split(".").last.to_s
37
+ words = segment.split("_")
38
+ return segment if words.empty?
39
+
40
+ ([words.first.capitalize] + words.drop(1)).join(" ")
41
+ end
42
+
43
+ # The full dotted path, each segment humanized and joined with
44
+ # " → " for a fieldset legend spanning more than one hop
45
+ # ("Amount → Cents") — a group's own legend, not a leaf's label.
46
+ def self.breadcrumb(path)
47
+ path.to_s.split(".").map { |part| label(part) }.join(" → ")
48
+ end
49
+ end
50
+
51
+ # Attribute -> Field. The one mapping every renderer in this
52
+ # directory reads instead of re-deriving its own — see
53
+ # docs/command-form-and-query-form-bluebook.md's survey of the gap this closes (the
54
+ # prior generic-console attempt fell back to `type="text"` for anything
55
+ # that wasn't a number or an enum; a `pattern` naming an email shape, or a
56
+ # closed set another aggregate declares, went unread).
57
+ module FieldShape
58
+ PRIMITIVES = Bluebook::Attribute::PRIMITIVES
59
+
60
+ # `aggregate:` is the Aggregate that OWNS this attribute (a
61
+ # command's, a query's, or — recursively — a value object's) —
62
+ # needed to resolve `reference_to`, `admits:`, and a same-chapter
63
+ # value object by name. `path:` defaults to the attribute's own name;
64
+ # a caller resolving a nested value object's attribute passes the
65
+ # dotted path so far.
66
+ def self.resolve(attribute, aggregate:, path: attribute.name.to_s)
67
+ return list_field(attribute, aggregate, path) if attribute.list?
68
+
69
+ common = { path: path, label: Humanize.label(path), default: attribute.default,
70
+ optional: attribute.optional?, pattern: attribute.pattern }
71
+
72
+ return reference_field(attribute, common) if attribute.reference?
73
+ return admitted_field(attribute, aggregate, common) if attribute.admits
74
+ return value_object_field(attribute, aggregate, common) unless PRIMITIVES.include?(attribute.type)
75
+
76
+ primitive_field(attribute, common)
77
+ end
78
+
79
+ def self.list_field(attribute, aggregate, path)
80
+ # The scalar shape one element of this list would take, so a
81
+ # renderer can say what belongs on each line without a second
82
+ # mapping table. `list:` is the only thing that differs.
83
+ scalar = Bluebook::Attribute.new(
84
+ name: attribute.name, type: attribute.type, list: false,
85
+ default: nil, optional: true, pattern: attribute.pattern, admits: attribute.admits
86
+ )
87
+ Field.new(path: path, label: Humanize.label(path), kind: :list, optional: attribute.optional?,
88
+ children: [resolve(scalar, aggregate: aggregate, path: path)])
89
+ end
90
+
91
+ def self.reference_field(attribute, common)
92
+ target = attribute.type.resolve
93
+ Field.new(**common, kind: :reference, html_type: "text", target_aggregate: target,
94
+ help: target ? "References an existing #{target.hecks_name} by id." :
95
+ "References an aggregate in another domain — enter its id.")
96
+ end
97
+
98
+ # `admits:` names a closed set declared ELSEWHERE (`"Account::
99
+ # LedgerDirection"`) that the value must belong to — see
100
+ # Runtime::Value::Admission#admitted_members, which this mirrors
101
+ # exactly (same split, same chapter walk, same discriminant rule) so
102
+ # a rendered `<select>` never offers a member the runtime would then
103
+ # refuse.
104
+ def self.admitted_field(attribute, aggregate, common)
105
+ set_aggregate_name, set_name = attribute.admits.to_s.split("::", 2)
106
+ chapter = aggregate.hecks_owner
107
+ set = set_name && chapter&.aggregate(set_aggregate_name)&.value_object(set_name)
108
+ return primitive_field(attribute, common) unless set # undeclared — refuse-at-dispatch stays the backstop
109
+
110
+ options = select_or_radio(common, closed_set_options(set))
111
+ # The attribute's OWN type still has to be built the shape coercion
112
+ # expects (`Value::Coercion#fields_for` refuses anything that
113
+ # isn't a Hash or a Value for a value-object-typed attribute) — a
114
+ # plain String attribute stays a bare scalar, but a value object
115
+ # like `MovementDirection { value }` still needs the ".value" hop
116
+ # even though the SET it's checked against (`admits:`) is declared
117
+ # somewhere else entirely. Same unwrap `value_object_field` does,
118
+ # kept separate because an admitted set changes the OPTIONS, not
119
+ # which field the hop lands on.
120
+ own_shape = own_value_object(attribute, aggregate)
121
+ inner = own_shape && ValueObjectShape.sole_attribute(own_shape)
122
+ return options unless inner
123
+
124
+ options.path = "#{common[:path]}.#{inner.name}"
125
+ options
126
+ end
127
+
128
+ def self.own_value_object(attribute, aggregate)
129
+ aggregate.value_object(attribute.type) || cross_aggregate_value_object(aggregate, attribute.type)
130
+ end
131
+
132
+ def self.value_object_field(attribute, aggregate, common)
133
+ shape = own_value_object(attribute, aggregate)
134
+ return primitive_field(attribute, common) unless shape
135
+
136
+ return closed_set_field(shape, common) if shape.closed_set?
137
+ return money_field(shape, common) if ValueObjectShape.money?(shape)
138
+
139
+ # A single-attribute value object (EmailAddress{address}, CustomerNumber{value})
140
+ # is a NAME for a scalar, not a genuine group — unwrap it so the form
141
+ # asks for one thing ("Email address") instead of a one-item fieldset,
142
+ # and so the inner attribute's OWN pattern (the real email regex,
143
+ # declared on `address`, not on the outer `email` attribute) drives
144
+ # the input type. [[feedback_name_the_scalar_field]] says the same
145
+ # thing about Ruby call sites; a form asks the identical question.
146
+ if (inner = ValueObjectShape.sole_attribute(shape))
147
+ return resolve(inner, aggregate: aggregate, path: "#{common[:path]}.#{inner.name}")
148
+ .tap { |field| field.optional = common[:optional] || field.optional }
149
+ end
150
+
151
+ group_field(shape, aggregate, common)
152
+ end
153
+
154
+ def self.cross_aggregate_value_object(aggregate, type_name)
155
+ aggregate.hecks_owner&.aggregates&.each do |sibling|
156
+ found = sibling.value_object(type_name)
157
+ return found if found
158
+ end
159
+ nil
160
+ end
161
+
162
+ def self.group_field(shape, aggregate, common)
163
+ children = shape.attributes.map { |inner| resolve(inner, aggregate: aggregate, path: "#{common[:path]}.#{inner.name}") }
164
+ Field.new(path: common[:path], label: common[:label], kind: :group, optional: common[:optional], children: children)
165
+ end
166
+
167
+ def self.money_field(shape, common)
168
+ cents = Field.new(path: "#{common[:path]}.cents", label: "Amount (cents)", kind: :number,
169
+ html_type: "number", step: "1", optional: common[:optional],
170
+ default: shape.attribute(:cents)&.default, help: "Whole cents — 1050 is $10.50.")
171
+ currency = Field.new(path: "#{common[:path]}.currency", label: "Currency", kind: :text, html_type: "text",
172
+ optional: true, default: shape.attribute(:currency)&.default || "USD",
173
+ help: "Three-letter code.")
174
+ Field.new(path: common[:path], label: common[:label], kind: :money, optional: common[:optional],
175
+ children: [cents, currency])
176
+ end
177
+
178
+ def self.closed_set_options(value_object)
179
+ discriminant = value_object.attributes.first.name
180
+ value_object.members.map { |member| member[discriminant].to_s }
181
+ end
182
+
183
+ # A `one_of` shape (`AccountKind{name}`, `LedgerDirection{value}`) is
184
+ # ALWAYS single-attribute in this language — the discriminant IS the
185
+ # whole value object — so the field's own path always gains that one
186
+ # hop; nothing to branch on the way `admitted_field` has to (a set
187
+ # named by `admits:` may sit on a multi-field value object it doesn't
188
+ # itself define the members of).
189
+ def self.closed_set_field(shape, common)
190
+ discriminant = shape.attributes.first.name
191
+ select_or_radio(common.merge(path: "#{common[:path]}.#{discriminant}"), closed_set_options(shape))
192
+ end
193
+
194
+ def self.select_or_radio(common, options)
195
+ kind = options.size <= 4 ? :radio : :select
196
+ Field.new(**common, kind: kind, html_type: "text", options: options.map { |value| [value, value] })
197
+ end
198
+
199
+ def self.primitive_field(attribute, common)
200
+ case attribute.type.to_s
201
+ when "Integer" then Field.new(**common, kind: :number, html_type: "number", step: "1")
202
+ when "Float" then Field.new(**common, kind: :number, html_type: "number", step: "any")
203
+ when "TrueClass", "FalseClass"
204
+ Field.new(**common, kind: :boolean, html_type: "checkbox")
205
+ else
206
+ text_field(attribute, common)
207
+ end
208
+ end
209
+
210
+ EMAIL_HINT = /email/i
211
+ URL_HINT = /\b(url|uri|website|link)\b/i
212
+ TEL_HINT = /phone|\btel(ephone)?\b/i
213
+ TEXTAREA_HINT = /\b(text|body|note|notes|description|message|comment)\b/i
214
+
215
+ def self.text_field(attribute, common)
216
+ name = attribute.name.to_s
217
+ pattern = attribute.pattern.to_s
218
+ html_type = if pattern.include?("@") || name.match?(EMAIL_HINT)
219
+ "email"
220
+ elsif pattern.match?(/https?/i) || name.match?(URL_HINT)
221
+ "url"
222
+ elsif name.match?(TEL_HINT)
223
+ "tel"
224
+ else
225
+ "text"
226
+ end
227
+ kind = html_type == "text" && name.match?(TEXTAREA_HINT) ? :textarea : :text
228
+ Field.new(**common, kind: kind, html_type: html_type)
229
+ end
230
+ end
231
+ end
232
+ end
@@ -0,0 +1,84 @@
1
+ require "uri"
2
+
3
+ module Hecks
4
+ module Forms
5
+ # Hand-rolled, on purpose — the repo has no ERB anywhere and no template
6
+ # engine dependency (see docs/command-form-and-query-form-bluebook.md's survey). Every
7
+ # other generator in this codebase (bin/reference's markdown, the IR's own
8
+ # `to_h`) builds output as plain Ruby strings; this does the same for HTML,
9
+ # with exactly one job: nothing that reaches `Escape.html` ever becomes a
10
+ # tag. A feature developer's own domain data — a customer's name, an
11
+ # account number a support rep typed into a form and got wrong — flows
12
+ # through here on every render, so escaping is not optional decoration.
13
+ module Escape
14
+ # Order matters — `&` first, or every escape this method itself just
15
+ # wrote (`&amp;`, `&lt;`, ...) gets re-escaped a second time.
16
+ def self.html(value)
17
+ value.to_s
18
+ .gsub("&", "&amp;")
19
+ .gsub("<", "&lt;")
20
+ .gsub(">", "&gt;")
21
+ .gsub('"', "&quot;")
22
+ .gsub("'", "&#39;")
23
+ end
24
+
25
+ # Safe inside a double-quoted HTML attribute specifically — `html`
26
+ # already covers this (it escapes `"`), kept as a named alias so a
27
+ # call site reads "this value fills an attribute" rather than repeating
28
+ # the same escaping and leaving the reader to check they match.
29
+ def self.attr(value) = html(value)
30
+
31
+ # L12 (docs/audits/2026-08-10-main-bug-audit.md) — safe as a
32
+ # query-string VALUE. `html`/`attr` guard against the value becoming
33
+ # markup, but say nothing about it staying inside the URL syntax
34
+ # position it was placed in: an aggregate's identity is free-form
35
+ # unless its value object declares a `pattern:` (see S3 in the same
36
+ # audit), so `&`, `+`, `?`, `#`, and `/` are all otherwise legal id
37
+ # characters, and each would corrupt an href/Location built by naive
38
+ # interpolation (a stray `&` smuggles a second query parameter, `#`
39
+ # truncates the path at a fragment, `/` splits the path into an
40
+ # extra segment, ...). Percent-encodes via
41
+ # `application/x-www-form-urlencoded` (`+` for space) — correct ONLY
42
+ # 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`
44
+ # below instead — `+` is a literal plus there, not an escaped space,
45
+ # so this method would corrupt any id containing a space. Callers
46
+ # still wrap the ASSEMBLED href/Location in `attr` (or `html`) as
47
+ # usual — this only covers the id's own component, not the
48
+ # surrounding markup.
49
+ def self.url(value) = URI.encode_www_form_component(value.to_s)
50
+
51
+ # Same guard as `url`, for a URL PATH segment instead of a
52
+ # query-string value. `encode_www_form_component` renders space as
53
+ # `+`, which is only meaningful inside a query string — in a path
54
+ # segment `+` is a literal plus, so an id like "John Smith" would
55
+ # round-trip to "John+Smith" and 404 against the real id "John
56
+ # Smith". Reuse the same percent-encoding and just correct that one
57
+ # character back to `%20`.
58
+ def self.path(value) = URI.encode_www_form_component(value.to_s).gsub("+", "%20")
59
+ end
60
+
61
+ # A tiny attribute-hash -> string helper, shared by every renderer in
62
+ # this directory so `<input ...>` doesn't get hand-assembled five
63
+ # different ways with five different escaping bugs waiting in each.
64
+ # `true` renders as a bare boolean attribute (`required`, not
65
+ # `required="true"`); `nil`/`false` are dropped entirely.
66
+ module Tag
67
+ def self.attrs(pairs)
68
+ pairs.filter_map do |name, value|
69
+ next if value.nil? || value == false
70
+ next name.to_s.tr("_", "-") if value == true
71
+
72
+ %(#{name.to_s.tr("_", "-")}="#{Escape.attr(value)}")
73
+ end.join(" ")
74
+ end
75
+
76
+ def self.open(name, **pairs)
77
+ rendered = attrs(pairs)
78
+ rendered.empty? ? "<#{name}>" : "<#{name} #{rendered}>"
79
+ end
80
+
81
+ def self.void(name, **pairs) = open(name, **pairs) # self-closing tags read the same; HTML5 needs no slash
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,35 @@
1
+ require_relative "html"
2
+
3
+ module Hecks
4
+ module Forms
5
+ # The home page: every exposed chapter, every aggregate on it — the
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).
9
+ module IndexRenderer
10
+ def self.render(chapters)
11
+ sections = chapters.map { |name, chapter| chapter_section(name, chapter) }
12
+ <<~HTML
13
+ <h1>Exposed domains</h1>
14
+ #{sections.join}
15
+ HTML
16
+ end
17
+
18
+ def self.chapter_section(name, chapter)
19
+ items = chapter.aggregates.map do |aggregate|
20
+ counts = "#{aggregate.commands.size} command#{'s' unless aggregate.commands.size == 1}, " \
21
+ "#{aggregate.queries.size} quer#{aggregate.queries.size == 1 ? 'y' : 'ies'}"
22
+ <<~HTML
23
+ <li><a href="/#{Escape.attr(name)}/#{Escape.attr(aggregate.hecks_name)}.html">
24
+ <span>#{Escape.html(aggregate.hecks_name)}</span><span class="kind">#{Escape.html(counts)}</span>
25
+ </a></li>
26
+ HTML
27
+ end
28
+ <<~HTML
29
+ <h2>#{Escape.html(name)}#{chapter.vision ? %( <span class="badge">#{Escape.html(chapter.vision)}</span>) : ''}</h2>
30
+ <ul class="verb-list">#{items.join}</ul>
31
+ HTML
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,157 @@
1
+ require_relative "html"
2
+
3
+ module Hecks
4
+ module Forms
5
+ # The one HTML shell every page in this app renders inside — nav,
6
+ # typography, form/table/badge styles, all inline (no CDN, no build
7
+ # step: this ships inside the `hecks` gem, and a project that
8
+ # embeds it gets one dependency-free response per request). Themed via
9
+ # `prefers-color-scheme` alone; nothing here reads a cookie or a query
10
+ # param for it, so it is never wrong for the browser rendering it.
11
+ module Page
12
+ def self.render(title:, body:, breadcrumbs: [])
13
+ <<~HTML
14
+ <!doctype html>
15
+ <html lang="en">
16
+ <head>
17
+ <meta charset="utf-8">
18
+ <meta name="viewport" content="width=device-width, initial-scale=1">
19
+ <title>#{Escape.html(title)} · hecks forms</title>
20
+ <style>#{STYLE}</style>
21
+ </head>
22
+ <body>
23
+ <header class="site-header">
24
+ <a class="brand" href="/">hecks forms</a>
25
+ #{breadcrumbs_html(breadcrumbs)}
26
+ </header>
27
+ <main>
28
+ #{body}
29
+ </main>
30
+ <footer class="site-footer">
31
+ <p>Generated straight from the loaded bluebook IR — nothing here is hand-authored per page. See <code>docs/command-form-and-query-form-bluebook.md</code>.</p>
32
+ </footer>
33
+ <script>#{SCRIPT}</script>
34
+ </body>
35
+ </html>
36
+ HTML
37
+ end
38
+
39
+ def self.breadcrumbs_html(crumbs)
40
+ return "" if crumbs.empty?
41
+
42
+ items = crumbs.map do |label, href|
43
+ href ? %(<a href="#{Escape.attr(href)}">#{Escape.html(label)}</a>) : %(<span aria-current="page">#{Escape.html(label)}</span>)
44
+ end
45
+ %(<nav class="breadcrumbs" aria-label="Breadcrumb">#{items.join(' <span class="sep">/</span> ')}</nav>)
46
+ end
47
+
48
+ STYLE = <<~CSS
49
+ :root {
50
+ --bg: #fbfaf8; --surface: #ffffff; --border: #e2ddd3; --text: #24211c;
51
+ --muted: #6b6355; --accent: #8a5a2b; --accent-contrast: #fff8ef;
52
+ --danger-bg: #fdeceb; --danger-border: #e2a49c; --danger-text: #7a2419;
53
+ --focus: #2b6cb0; --code-bg: #f1ede4; --radius: 8px;
54
+ --mono: "SF Mono", "JetBrains Mono", ui-monospace, Menlo, Consolas, monospace;
55
+ --sans: -apple-system, "Segoe UI", Helvetica, Arial, sans-serif;
56
+ }
57
+ @media (prefers-color-scheme: dark) {
58
+ :root {
59
+ --bg: #17140f; --surface: #201c15; --border: #3a3327; --text: #ece6d9;
60
+ --muted: #a89d87; --accent: #d99a52; --accent-contrast: #241a0c;
61
+ --danger-bg: #341613; --danger-border: #7a352b; --danger-text: #f3a99c;
62
+ --focus: #6ab0ff; --code-bg: #241f17;
63
+ }
64
+ }
65
+ * { box-sizing: border-box; }
66
+ body { background: var(--bg); color: var(--text); font-family: var(--sans); margin: 0; line-height: 1.5; }
67
+ a { color: var(--accent); }
68
+ a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, textarea:focus-visible {
69
+ outline: 2px solid var(--focus); outline-offset: 2px;
70
+ }
71
+ .site-header { display: flex; align-items: baseline; gap: 1rem; padding: .9rem 1.5rem;
72
+ border-bottom: 1px solid var(--border); background: var(--surface); flex-wrap: wrap; }
73
+ .brand { font-family: var(--mono); font-weight: 600; text-decoration: none; color: var(--text); }
74
+ .breadcrumbs { font-size: .9rem; color: var(--muted); }
75
+ .breadcrumbs .sep { padding: 0 .3rem; }
76
+ main { max-width: 46rem; margin: 0 auto; padding: 2rem 1.5rem 4rem; }
77
+ .site-footer { max-width: 46rem; margin: 0 auto; padding: 1rem 1.5rem 3rem; color: var(--muted); font-size: .82rem; }
78
+ h1 { font-size: 1.5rem; margin: 0 0 .25rem; }
79
+ h2 { font-size: 1.1rem; margin: 2rem 0 .6rem; }
80
+ .goal { color: var(--muted); margin: 0 0 1rem; }
81
+ .badge { display: inline-block; font-family: var(--mono); font-size: .72rem; padding: .15rem .5rem;
82
+ border-radius: 999px; border: 1px solid var(--border); color: var(--muted); margin: 0 .3rem .3rem 0; }
83
+ .badge.role { color: var(--accent); border-color: var(--accent); }
84
+ .callout { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius);
85
+ padding: .8rem 1rem; margin: 0 0 1.2rem; font-size: .92rem; }
86
+ .callout ul { margin: .3rem 0 0; padding-left: 1.2rem; }
87
+ .error-banner { background: var(--danger-bg); border: 1px solid var(--danger-border); color: var(--danger-text);
88
+ border-radius: var(--radius); padding: .8rem 1rem; margin: 0 0 1.2rem; }
89
+ .error-banner p { margin: 0; }
90
+ fieldset { border: 1px solid var(--border); border-radius: var(--radius); margin: 0 0 1.1rem; padding: .8rem 1rem 1rem; }
91
+ legend { font-size: .85rem; color: var(--muted); padding: 0 .4rem; }
92
+ .field { margin: 0 0 1.1rem; }
93
+ .field > label { display: block; font-weight: 600; font-size: .9rem; margin-bottom: .3rem; }
94
+ .field .required-mark { color: var(--accent); }
95
+ .field .help { display: block; color: var(--muted); font-size: .8rem; margin-top: .3rem; }
96
+ .field input[type=text], .field input[type=email], .field input[type=url], .field input[type=tel],
97
+ .field input[type=number], .field select, .field textarea {
98
+ width: 100%; padding: .5rem .6rem; border: 1px solid var(--border); border-radius: var(--radius);
99
+ background: var(--surface); color: var(--text); font: inherit;
100
+ }
101
+ .field textarea { min-height: 4.5rem; font-family: var(--mono); font-size: .85rem; }
102
+ .radio-group { display: flex; flex-wrap: wrap; gap: .3rem 1rem; }
103
+ .radio-group label { font-weight: 400; display: flex; align-items: center; gap: .35rem; }
104
+ .checkbox-row { display: flex; align-items: center; gap: .5rem; }
105
+ button, .button { font: inherit; border-radius: var(--radius); border: 1px solid var(--accent);
106
+ background: var(--accent); color: var(--accent-contrast); padding: .55rem 1.1rem; cursor: pointer; }
107
+ button.secondary, .button.secondary { background: transparent; color: var(--text); border-color: var(--border); }
108
+ button.copy { font-size: .75rem; padding: .2rem .5rem; }
109
+ table { width: 100%; border-collapse: collapse; font-size: .88rem; }
110
+ .table-scroll { overflow-x: auto; margin: 0 0 1.2rem; }
111
+ th, td { text-align: left; padding: .45rem .6rem; border-bottom: 1px solid var(--border); white-space: nowrap; }
112
+ thead th { color: var(--muted); font-weight: 600; position: sticky; top: 0; background: var(--bg); }
113
+ tbody tr:hover { background: var(--surface); }
114
+ code, pre, .mono { font-family: var(--mono); font-size: .82rem; }
115
+ pre { background: var(--code-bg); border-radius: var(--radius); padding: .8rem 1rem; overflow-x: auto; margin: 0; }
116
+ .link-row { display: flex; gap: .5rem; align-items: center; margin: 0 0 .8rem; }
117
+ .link-row code { flex: 1; overflow-x: auto; padding: .4rem .6rem; background: var(--code-bg); border-radius: var(--radius); }
118
+ .example-links { display: flex; flex-wrap: wrap; gap: .4rem; margin: 0 0 1rem; }
119
+ .example-links a { border: 1px solid var(--border); border-radius: 999px; padding: .25rem .7rem;
120
+ text-decoration: none; font-family: var(--mono); font-size: .82rem; }
121
+ details.inspect { margin: 2rem 0 0; border-top: 1px dashed var(--border); padding-top: 1rem; }
122
+ details.inspect summary { cursor: pointer; color: var(--muted); font-size: .85rem; }
123
+ .verb-list { list-style: none; padding: 0; margin: 0; }
124
+ .verb-list li { border-bottom: 1px solid var(--border); }
125
+ .verb-list a { display: flex; justify-content: space-between; gap: 1rem; padding: .7rem .2rem;
126
+ text-decoration: none; color: var(--text); }
127
+ .verb-list .kind { color: var(--muted); font-family: var(--mono); font-size: .78rem; }
128
+ .actions { display: flex; flex-wrap: wrap; gap: .5rem; margin: .6rem 0 1.4rem; }
129
+ CSS
130
+
131
+ # Decorative only — every form here already works with JS disabled
132
+ # (plain `<form method=post>`/`method=get`), matching this repo's own
133
+ # "no ERB, nothing scripted" bar for the domain layer itself.
134
+ SCRIPT = <<~JS
135
+ document.addEventListener("click", (event) => {
136
+ const button = event.target.closest("[data-copy]");
137
+ if (!button) return;
138
+ const text = document.querySelector(button.getAttribute("data-copy"))?.textContent ?? "";
139
+ navigator.clipboard?.writeText(text.trim());
140
+ const original = button.textContent;
141
+ button.textContent = "copied";
142
+ setTimeout(() => { button.textContent = original; }, 1200);
143
+ });
144
+ document.querySelectorAll("[data-money-cents]").forEach((input) => {
145
+ const preview = document.querySelector(input.getAttribute("data-money-cents"));
146
+ if (!preview) return;
147
+ const update = () => {
148
+ const cents = parseInt(input.value, 10);
149
+ preview.textContent = Number.isFinite(cents) ? "= " + (cents / 100).toFixed(2) : "";
150
+ };
151
+ input.addEventListener("input", update);
152
+ update();
153
+ });
154
+ JS
155
+ end
156
+ end
157
+ end
@@ -0,0 +1,161 @@
1
+ require "json"
2
+ require "uri"
3
+ require_relative "field_renderer"
4
+
5
+ module Hecks
6
+ module Forms
7
+ # The two directions between a flat, dotted, ALL-STRINGS web payload
8
+ # (`{"amount.cents"=>"1050", "amount.currency"=>"USD"}`, whether it came
9
+ # off a POST form body or a GET query string — Rack hands back the same
10
+ # flat shape for both as long as nothing uses `[]` bracket names) and the
11
+ # nested, typed hash `Dispatcher#dispatch`/`#query` actually take
12
+ # (`{amount: {cents: 1050, currency: "USD"}}`).
13
+ #
14
+ # `extract` NEEDS the Field tree, not just the raw params — a numeric
15
+ # leaf's own runtime check (`Value::Coercion#check_numeric_fields`)
16
+ # requires an actual `Integer`/`Float`, not a String that merely looks
17
+ # like one (`given.is_a?(expected)`, no coercion attempted there); a web
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
20
+ # rendering the input in the first place — one reading of the IR, not
21
+ # two that could disagree.
22
+ module Params
23
+ # Every LEAF field carries its own full dotted path regardless of how
24
+ # 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
26
+ # a two-segment path (`"reference.value"`), the exact same shape a
27
+ # `:group`'s own child carries. So this collects every leaf as
28
+ # (full path -> value) FIRST, and nests by the path's OWN segments
29
+ # last — one nesting rule, blind to how a field arrived at its path.
30
+ def self.extract(fields, raw)
31
+ pairs = {}
32
+ fields.each { |field| collect(field, raw, pairs) }
33
+ nest(pairs)
34
+ end
35
+
36
+ def self.collect(field, raw, pairs)
37
+ case field.kind
38
+ when :group, :money then field.children.each { |child| collect(child, raw, pairs) }
39
+ when :list then (value = extract_list(field, raw)) == SKIP || pairs[field.path] = value
40
+ else (value = extract_leaf(field, raw)) == SKIP || pairs[field.path] = value
41
+ end
42
+ end
43
+
44
+ # A path-prefix collision: one field named (say) "price" alongside
45
+ # another named "price.cents" implies "price" is BOTH a scalar leaf
46
+ # and the parent of a nested group — the two can never coexist in
47
+ # 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).
61
+ def self.nest(pairs)
62
+ pairs.each_with_object({}) do |(path, value), result|
63
+ segments = path.to_s.split(".").map(&:to_sym)
64
+ leaf = segments.pop
65
+ node = segments.reduce(result) do |acc, segment|
66
+ existing = acc[segment]
67
+ raise nesting_collision(path) if existing && !existing.is_a?(Hash)
68
+
69
+ acc[segment] ||= {}
70
+ end
71
+ raise nesting_collision(path) if node[leaf].is_a?(Hash)
72
+
73
+ node[leaf] = value
74
+ end
75
+ end
76
+
77
+ def self.nesting_collision(path)
78
+ ArgumentError.new("#{path.inspect} conflicts with another field at the same path — " \
79
+ "one names it as a plain value and another as a nested group")
80
+ end
81
+
82
+ SKIP = Object.new.freeze
83
+ private_constant :SKIP
84
+
85
+ # One line of the textarea per element. A line that itself needs
86
+ # several fields (a multi-attribute value object as a list element)
87
+ # is read as JSON on that one line — the honest fallback documented in
88
+ # docs/command-form-and-query-form-bluebook.md rather than a second
89
+ # widget this prototype doesn't build yet.
90
+ def self.extract_list(field, raw)
91
+ text = raw[field.path]
92
+ return SKIP if text.nil? || text.strip.empty?
93
+
94
+ item = field.children.first
95
+ text.each_line.map(&:strip).reject(&:empty?).map do |line|
96
+ item.leaf? ? cast_scalar(item, line) : JSON.parse(line, symbolize_names: true)
97
+ end
98
+ end
99
+
100
+ def self.extract_leaf(field, raw)
101
+ return checkbox(raw[field.path]) if field.kind == :boolean
102
+
103
+ text = raw[field.path]
104
+ return SKIP if (text.nil? || text.empty?) && field.optional?
105
+
106
+ cast_scalar(field, text)
107
+ end
108
+
109
+ def self.checkbox(raw_value)
110
+ %w[on 1 true].include?(raw_value.to_s.downcase)
111
+ end
112
+
113
+ def self.cast_scalar(field, text)
114
+ case field.kind
115
+ when :number then field.step == "1" ? Integer(text) : Float(text)
116
+ else text
117
+ end
118
+ end
119
+
120
+ # The other direction — a nested value hash back to the flat dotted
121
+ # 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
124
+ # `FieldRenderer.dig` — the identical full-path lookup a re-rendered
125
+ # input's own value comes from — for the same reason `extract` above
126
+ # nests by full path rather than by tree shape.
127
+ def self.flatten(fields, values, into: {})
128
+ fields.each do |field|
129
+ case field.kind
130
+ when :group, :money then flatten(field.children, values, into: into)
131
+ when :list then into[field.path] = Array(FieldRenderer.dig(values, field.path)).join("\n")
132
+ else
133
+ value = FieldRenderer.dig(values, field.path)
134
+ into[field.path] = value.to_s unless value.nil?
135
+ end
136
+ end
137
+ into
138
+ end
139
+
140
+ # Every leaf/list path a field tree carries, independent of any
141
+ # values — what `command_form_renderer.rb`'s inspect panel wants
142
+ # ("which fields does this command take"), where `flatten` above wants
143
+ # "what does THIS submission look like" and returns nothing for a
144
+ # field nothing was entered for.
145
+ def self.paths(fields, into: [])
146
+ fields.each do |field|
147
+ case field.kind
148
+ when :group, :money then paths(field.children, into: into)
149
+ else into << field.path
150
+ end
151
+ end
152
+ into
153
+ end
154
+
155
+ def self.to_query_string(fields, values)
156
+ pairs = flatten(fields, values)
157
+ pairs.map { |key, value| "#{URI.encode_www_form_component(key)}=#{URI.encode_www_form_component(value)}" }.join("&")
158
+ end
159
+ end
160
+ end
161
+ end