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,341 @@
1
+ require "json"
2
+ require "rack"
3
+ require_relative "page"
4
+ require_relative "field_shape"
5
+ require_relative "index_renderer"
6
+ require_relative "record_renderer"
7
+ require_relative "command_form_renderer"
8
+ require_relative "query_form_renderer"
9
+ require_relative "params"
10
+ require_relative "../facade/command_request"
11
+ require_relative "../facade/json_door"
12
+
13
+ module Hecks
14
+ module Forms
15
+ # The content-negotiated router: `GET /Banking/Account/Overdrawn.html`
16
+ # renders the query view built in this directory; the identical path
17
+ # with no extension (or `.json`) dispatches the SAME ask through
18
+ # `Runtime::Dispatcher#query` and answers with its raw result — one
19
+ # route, two representations, exactly the "change the file format on
20
+ # the route" mechanism this was built around
21
+ # (docs/command-form-and-query-form-bluebook.md).
22
+ #
23
+ # A plain Rack app (`#call(env)`) — no Sinatra, no Rails. `rack` itself
24
+ # is a LAZY dependency the same way `pg`/`oauth2`/`aws-sdk-lambda` are
25
+ # for their own adapters (see the Gemfile's own comment) : a project
26
+ # that never boots this file never needs it installed.
27
+ class App
28
+ def self.for(registry:, app_name:)
29
+ config = Forms.config(app_name) ||
30
+ raise(ArgumentError, "no app #{app_name.inspect} configured — " \
31
+ "call Hecks::Forms.configure(#{app_name.inspect}) { expose \"...\" } first")
32
+ new(registry: registry, exposed: config.exposes)
33
+ end
34
+
35
+ def initialize(registry:, exposed:, dispatcher: nil)
36
+ @registry = registry
37
+ @exposed = exposed
38
+ @dispatcher = dispatcher || Runtime::Dispatcher.new(registry)
39
+ end
40
+
41
+ def call(env)
42
+ request = Rack::Request.new(env)
43
+ route(request)
44
+ rescue RouteNotFound => e
45
+ respond(404, "text/plain", e.message)
46
+ end
47
+
48
+ class RouteNotFound < StandardError; end
49
+
50
+ private
51
+
52
+ def route(request)
53
+ segments = request.path_info.split("/").reject(&:empty?)
54
+ return home(request) if segments.empty?
55
+
56
+ domain = segments[0]
57
+ refuse_unexposed(domain)
58
+ chapter = @registry.bluebook(domain) || raise(RouteNotFound, "no domain #{domain.inspect} loaded")
59
+
60
+ return aggregate_route(request, chapter, *split_format(segments[1])) if segments.size == 2
61
+
62
+ return verb_or_record_route(request, chapter, segments[1], *split_format(segments[2])) if segments.size == 3
63
+
64
+ if segments.size == 4
65
+ aggregate = chapter.aggregate(segments[1])
66
+ entity = aggregate&.entities&.find { |candidate| candidate.hecks_name == segments[2] }
67
+ entity_command = entity&.command(split_format(segments[3]).first)
68
+ if entity_command
69
+ raise RouteNotFound,
70
+ "entity command routes are not supported by Forms; use a command door with " \
71
+ "to.aggregate and to.entity"
72
+ end
73
+ end
74
+
75
+ respond(404, "text/plain", "no route for #{request.path_info}")
76
+ end
77
+
78
+ def refuse_unexposed(domain)
79
+ return if @exposed.include?(domain)
80
+
81
+ raise RouteNotFound, "#{domain.inspect} is not exposed by this app — declared chapters: #{@exposed.join(', ')}"
82
+ end
83
+
84
+ # H12 (docs/audits/2026-08-10-main-bug-audit.md) — splitting on the
85
+ # FIRST "." truncated any identity value containing a dot (an email
86
+ # `identified_by { email.address }`, a decimal-ish reference — an
87
+ # aggregate's identity is free-form unless its value object declares
88
+ # a `pattern:`, see S3 in the same audit) at its own first dot, so
89
+ # `reference.value=c.1` 404'd everywhere: detail page, JSON view, and
90
+ # its own index-table link. Only a LITERAL trailing ".html"/".json"
91
+ # now counts as a format — every other dot in the segment is just
92
+ # part of the identity. An identity that itself happens to end in
93
+ # exactly ".html" or ".json" is still ambiguous with a real format
94
+ # suffix (the same tension any extension-based content-negotiation
95
+ # scheme has), but that was already true before this fix and is not
96
+ # this bug.
97
+ def split_format(segment)
98
+ segment = segment.to_s
99
+ return [Regexp.last_match(1), Regexp.last_match(2)] if segment =~ /\A(.*)\.(html|json)\z/
100
+
101
+ [segment, "json"]
102
+ end
103
+
104
+ # ---- home -------------------------------------------------------
105
+
106
+ def home(request)
107
+ format = request.params["format"] || "html"
108
+ chapters = @exposed.to_h { |name| [name, @registry.bluebook(name)] }.compact
109
+ return json(200, chapters.transform_values { |c| { aggregates: c.aggregates.map(&:hecks_name) } }) if format != "html"
110
+
111
+ html("Exposed domains", IndexRenderer.render(chapters))
112
+ end
113
+
114
+ # ---- /:domain/:aggregate.:format ---------------------------------
115
+
116
+ def aggregate_route(request, chapter, aggregate_name, format)
117
+ aggregate = find_aggregate(chapter, aggregate_name)
118
+ return respond(405, "text/plain", "GET only") unless request.get?
119
+
120
+ if format == "html"
121
+ html("#{chapter.name}::#{aggregate.hecks_name}", RecordRenderer.index(registry: @registry, domain: chapter.name, aggregate: aggregate),
122
+ breadcrumbs: [[chapter.name, "/"], [aggregate.hecks_name, nil]])
123
+ else
124
+ instances = @registry.repository(chapter.name, aggregate).all
125
+ # id LAST — see Instance#to_h's own comment: an aggregate free
126
+ # to declare its own attribute literally named `id` has that
127
+ # attribute's own wrapped value sitting in `i.state[:id]`
128
+ # already, which used to silently clobber the correct bare
129
+ # identity when merged first.
130
+ json(200, instances.map { |i| i.state.merge(id: i.id) })
131
+ end
132
+ end
133
+
134
+ # ---- /:domain/:aggregate/:verb_or_id.:format ----------------------
135
+
136
+ def verb_or_record_route(request, chapter, aggregate_name, verb_or_id, format)
137
+ aggregate = find_aggregate(chapter, aggregate_name)
138
+ domain = chapter.name
139
+
140
+ # L11 (docs/audits/2026-08-10-main-bug-audit.md) — a record's own
141
+ # id is free-form (S3) and can collide with one of its own
142
+ # aggregate's command/query names ("Close", "Overdrawn", ...).
143
+ # A GET for such an id must still be able to reach that RECORD's
144
+ # own detail page when a record with that literal id actually
145
+ # exists — checking the verb first (the previous order) meant a
146
+ # record unlucky enough to be named after a real verb could never
147
+ # be viewed again. POST never means "view a record" at all
148
+ # (`record_route` only ever answers GET), so command submission
149
+ # there is unambiguous and is left to match the verb first, same
150
+ # as before.
151
+ if request.get? && (instance = @registry.repository(domain, aggregate).find(verb_or_id))
152
+ return record_route(request, domain, aggregate, verb_or_id, format, instance: instance)
153
+ end
154
+
155
+ if (command = aggregate.command(verb_or_id))
156
+ return command_route(request, domain, aggregate, command, format)
157
+ end
158
+
159
+ if (query = aggregate.query(verb_or_id))
160
+ return query_route(request, domain, aggregate, query, format)
161
+ end
162
+
163
+ record_route(request, domain, aggregate, verb_or_id, format)
164
+ end
165
+
166
+ def command_route(request, domain, aggregate, command, format)
167
+ action = "/#{domain}/#{aggregate.hecks_name}/#{command.hecks_name}.html"
168
+ return command_json(request, domain, aggregate, command) if format != "html"
169
+ if request.get?
170
+ return command_form(domain, aggregate, command, action,
171
+ prefill: receiver_compatibility(request.GET, command))
172
+ end
173
+ return respond(405, "text/plain", "GET or POST only") unless request.post?
174
+
175
+ submit_command(request, domain, aggregate, command, action)
176
+ end
177
+
178
+ def command_form(domain, aggregate, command, action, status: 200, values: nil, error: nil, prefill: {})
179
+ html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}",
180
+ CommandFormRenderer.render(registry: @registry, domain: domain, aggregate: aggregate, command: command,
181
+ action: action, values: values, error: error, prefill: prefill),
182
+ breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"],
183
+ [command.hecks_name, nil]],
184
+ status: status)
185
+ end
186
+
187
+ def submit_command(request, domain, aggregate, command, action)
188
+ raw, envelope = submitted_command(request, aggregate, command)
189
+ result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
190
+ # L12 — the id is free-form (S3), so it must be percent-encoded as
191
+ # a path segment here, not just interpolated raw.
192
+ redirect("/#{domain}/#{aggregate.hecks_name}/#{Escape.path(result.id)}.html")
193
+ rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
194
+ status = e.is_a?(Runtime::NotFound) ? 404 : 422
195
+ command_form(domain, aggregate, command, action, status: status, values: raw, error: e)
196
+ end
197
+
198
+ def command_json(request, domain, aggregate, command)
199
+ return json(200, command.to_h) if request.get? && request.GET.reject { |k, _| k == "format" }.empty?
200
+ return respond(405, "text/plain", "GET or POST only") unless request.post?
201
+
202
+ _, envelope = submitted_command(request, aggregate, command)
203
+ result = @dispatcher.dispatch("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}", **envelope)
204
+ # id LAST — same reasoning as the other JSON-serializing call
205
+ # sites in this file (see aggregate_route's own comment).
206
+ json(201, result.state.merge(id: result.id))
207
+ rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
208
+ status = e.is_a?(Runtime::NotFound) ? 404 : 422
209
+ json(status, { error: e.class.name.split("::").last, message: e.message })
210
+ end
211
+
212
+ def query_route(request, domain, aggregate, query, format)
213
+ return respond(405, "text/plain", "GET only") unless request.get?
214
+
215
+ params = request.GET
216
+ asked = params.reject { |k, _| k == "format" }
217
+ return query_json(domain, aggregate, query, asked) if format != "html"
218
+
219
+ query_html(domain, aggregate, query, asked)
220
+ end
221
+
222
+ def query_html(domain, aggregate, query, asked)
223
+ action = "/#{domain}/#{aggregate.hecks_name}/#{query.hecks_name}.html"
224
+ fields = query.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
225
+ results, error = asked.empty? ? [nil, nil] : run_query(domain, aggregate, query, fields, asked)
226
+ html("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}",
227
+ QueryFormRenderer.render(registry: @registry, domain: domain, aggregate: aggregate, query: query,
228
+ action: action, params: asked, results: results, error: error),
229
+ breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"],
230
+ [query.hecks_name, nil]],
231
+ status: error ? 422 : 200)
232
+ end
233
+
234
+ def query_json(domain, aggregate, query, asked)
235
+ return json(200, query.to_h) if asked.empty?
236
+
237
+ fields = query.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
238
+ results, error = run_query(domain, aggregate, query, fields, asked)
239
+ return json(422, { error: error.class.name.split("::").last, message: error.message }) if error
240
+
241
+ # id LAST — same reasoning as the other JSON-serializing call
242
+ # sites in this file (see aggregate_route's own comment).
243
+ json(200, results.map { |i| i.state.merge(id: i.id) })
244
+ end
245
+
246
+ # `Dispatcher#query` answers an array of plain hashes
247
+ # (`{id:}.merge(state)`, flattened by `QueryInterpreter#call` on
248
+ # every path it can take — native pushdown or the in-memory
249
+ # fallback alike), not `Runtime::Instance`. Wrapped into `Record`
250
+ # here, once, so every renderer downstream reads one shape whether
251
+ # its records came from a query or straight off a repository.
252
+ def run_query(domain, aggregate, query, fields, asked)
253
+ args = Params.extract(fields, asked)
254
+ rows = @dispatcher.query("#{domain}::#{aggregate.hecks_name}.#{query.hecks_name}", **args)
255
+ [rows.map { |row| Record.new(row[:id], row.reject { |k, _| k == :id }) }, nil]
256
+ # L10 (docs/audits/2026-08-10-main-bug-audit.md) — `Params.extract`
257
+ # (params.rb's `extract_list`) reads a list-of-value-object line as
258
+ # JSON (the honest fallback for a multi-attribute list element this
259
+ # prototype's textarea doesn't build a second widget for). A caller
260
+ # who types a non-JSON line into that field raises `JSON::ParserError`
261
+ # BEFORE dispatch ever sees it — both command submission paths
262
+ # already rescue it (`submit_command`, `command_json`); this one
263
+ # didn't, so a malformed list-of-VO query 500'd instead of showing
264
+ # the same 422 every other bad-input path shows.
265
+ rescue *Runtime::DOMAIN_REFUSALS, ArgumentError, TypeError, JSON::ParserError => e
266
+ [nil, e]
267
+ end
268
+
269
+ def record_route(request, domain, aggregate, id, format, instance: nil)
270
+ return respond(405, "text/plain", "GET only") unless request.get?
271
+
272
+ instance ||= @registry.repository(domain, aggregate).find(id)
273
+ return not_found(aggregate, id, format) unless instance
274
+ # id LAST — same reasoning as the other JSON-serializing call
275
+ # sites in this file (see aggregate_route's own comment).
276
+ return json(200, instance.state.merge(id: instance.id)) if format != "html"
277
+
278
+ html("#{domain}::#{aggregate.hecks_name} #{id}", RecordRenderer.show(registry: @registry, domain: domain, aggregate: aggregate, id: id),
279
+ breadcrumbs: [[domain, "/"], [aggregate.hecks_name, "/#{domain}/#{aggregate.hecks_name}.html"], [id, nil]])
280
+ end
281
+
282
+ def not_found(aggregate, id, format)
283
+ return json(404, { error: "NotFound", message: "no #{aggregate.hecks_name} #{id}" }) if format != "html"
284
+
285
+ respond(404, "text/html; charset=utf-8",
286
+ Page.render(title: "not found", body: "<h1>Not found</h1><p>No #{Escape.html(aggregate.hecks_name)} #{Escape.html(id)}.</p>"))
287
+ end
288
+
289
+ def find_aggregate(chapter, name)
290
+ chapter.aggregate(name) || raise(RouteNotFound, "#{chapter.name} has no aggregate #{name.inspect}")
291
+ end
292
+
293
+ def command_envelope(command, args)
294
+ Facade::CommandRequest.normalize(args, receiver: command_receiver(command), legacy_receiver: :id)
295
+ end
296
+
297
+ def command_receiver(command) = command.creates? ? nil : :aggregate
298
+
299
+ def submitted_command(request, aggregate, command)
300
+ if request.media_type == "application/json"
301
+ raw = Facade::JsonDoor.parse(request.body.read)
302
+ envelope = Facade::JsonDoor.command_request(raw, receiver: command_receiver(command),
303
+ legacy_receiver: :id)
304
+ return [raw, envelope]
305
+ end
306
+
307
+ raw = receiver_compatibility(request.POST, command)
308
+ fields = CommandFormRenderer.fields_for(aggregate, command)
309
+ args = Params.extract(fields, raw)
310
+ [raw, command_envelope(command, args)]
311
+ end
312
+
313
+ # Existing integrations may still submit id. It remains an accepted
314
+ # edge spelling, but new forms expose only the receiver name to.
315
+ def receiver_compatibility(raw, command)
316
+ return raw if command.creates?
317
+ return raw unless raw.key?("id") && !raw.key?("to")
318
+
319
+ raw.merge("to" => raw["id"])
320
+ end
321
+
322
+ # ---- rendering ----------------------------------------------------
323
+
324
+ def html(title, body, breadcrumbs: [], status: 200)
325
+ respond(status, "text/html; charset=utf-8", Page.render(title: title, body: body, breadcrumbs: breadcrumbs))
326
+ end
327
+
328
+ def json(status, payload)
329
+ respond(status, "application/json", JSON.pretty_generate(payload))
330
+ end
331
+
332
+ def redirect(location)
333
+ [302, { "location" => location }, []]
334
+ end
335
+
336
+ def respond(status, content_type, body)
337
+ [status, { "content-type" => content_type }, [body]]
338
+ end
339
+ end
340
+ end
341
+ end
@@ -0,0 +1,113 @@
1
+ require "json"
2
+ require_relative "html"
3
+ require_relative "field_shape"
4
+ require_relative "field_renderer"
5
+ require_relative "reference_options"
6
+ require_relative "params"
7
+
8
+ module Hecks
9
+ module Forms
10
+ # Command -> the page body for its HTML form — the working half of
11
+ # what `command_form.bluebook` names (see docs/command-form-and-query-
12
+ # form-bluebook.md). Every command in every loaded, exposed bluebook
13
+ # renders through here — nothing here is per-command code; see that
14
+ # doc for the 1:1 rule this deliberately holds to for now.
15
+ module CommandFormRenderer
16
+ # `registry` is only used to populate a `:reference` field's
17
+ # `<select>` with real records (including the identity picker itself,
18
+ # for a non-creating command — see `identity_field` below). `values`/
19
+ # `error` carry a sticky re-render after a refused submission; leave
20
+ # both nil/`{}` for a fresh form. `prefill` carries values a caller
21
+ # arrived WITH (typically `?to=...` off a record's own detail page) —
22
+ # kept separate from `values` because a prefill is not an error retry
23
+ # and should not be treated as one by a future reader of this code.
24
+ # The SAME field list a POST handler needs to cast raw params against
25
+ # (params.rb's `Params.extract`) — one derivation, so a page never
26
+ # renders an input the submit handler doesn't also expect.
27
+ def self.fields_for(aggregate, command)
28
+ addressing = command.creates? ? [] : [identity_field(aggregate)]
29
+ addressing + command.attributes.map { |a| FieldShape.resolve(a, aggregate: aggregate) }
30
+ end
31
+
32
+ def self.render(registry:, domain:, aggregate:, command:, action:, values: nil, error: nil, prefill: {})
33
+ all_fields = fields_for(aggregate, command)
34
+ reference_options = ReferenceOptions.collect(registry, domain, all_fields)
35
+ shown_values = values || prefill
36
+
37
+ <<~HTML
38
+ #{header(domain, aggregate, command)}
39
+ #{error_banner(error)}
40
+ <form method="post" action="#{Escape.attr(action)}" novalidate>
41
+ #{all_fields.map { |f| FieldRenderer.render(f, values: shown_values, errors: field_errors(error), reference_options: reference_options) }.join}
42
+ <div class="actions">
43
+ <button type="submit">#{Escape.html(command.hecks_name)}</button>
44
+ <a class="button secondary" href="#{Escape.attr("/#{domain}/#{aggregate.hecks_name}")}">Cancel</a>
45
+ </div>
46
+ </form>
47
+ #{inspect_panel(domain, aggregate, command, action, all_fields)}
48
+ HTML
49
+ end
50
+
51
+ def self.identity_field(aggregate)
52
+ Field.new(path: "to", label: "#{aggregate.hecks_name} (#{aggregate.identity_paths.join(', ')})",
53
+ kind: :reference, html_type: "text", target_aggregate: aggregate,
54
+ help: "The record this command acts on.")
55
+ end
56
+
57
+ def self.header(domain, aggregate, command)
58
+ <<~HTML
59
+ <h1>#{Escape.html("#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}")}</h1>
60
+ #{command.role ? %(<span class="badge role" title="Declared on the command; this prototype dispatches with no caller bound, so the check does not run.">role: #{Escape.html(command.role)} (not enforced here)</span>) : ''}
61
+ #{command.creates? ? %(<span class="badge">creates a new #{Escape.html(aggregate.hecks_name)}</span>) : ''}
62
+ #{command.goal ? %(<p class="goal">#{Escape.html(command.goal)}</p>) : ''}
63
+ #{givens_callout(command)}
64
+ HTML
65
+ end
66
+
67
+ def self.givens_callout(command)
68
+ return "" if command.givens.empty?
69
+
70
+ items = command.givens.map { |given| "<li>#{Escape.html(given.description)}</li>" }
71
+ %(<div class="callout"><strong>Preconditions</strong> — refused if any fail:<ul>#{items.join}</ul></div>)
72
+ end
73
+
74
+ def self.error_banner(error)
75
+ return "" unless error
76
+
77
+ <<~HTML
78
+ <div class="error-banner" role="alert">
79
+ <p><strong>#{Escape.html(error.class.name.split('::').last)}</strong> — #{Escape.html(error.message)}</p>
80
+ </div>
81
+ HTML
82
+ end
83
+
84
+ # No structured field attribution exists on a domain refusal today
85
+ # (it is a typed exception with a rendered message — see
86
+ # docs/command-form-and-query-form-bluebook.md's note on
87
+ # `RefusalWording`), so this returns empty rather than guessing which
88
+ # field a message meant; the banner above carries the real text
89
+ # instead of a misattributed hint.
90
+ def self.field_errors(_error) = {}
91
+
92
+ def self.inspect_panel(domain, aggregate, command, action, fields)
93
+ verb = "#{domain}::#{aggregate.hecks_name}.#{command.hecks_name}"
94
+ paths = Params.paths(fields)
95
+ curl = <<~SH.strip
96
+ curl -X POST '#{action}' \\
97
+ #{paths.map { |path| "-d '#{path}=...'" }.join(" \\\n ")}
98
+ SH
99
+ <<~HTML
100
+ <details class="inspect">
101
+ <summary>Inspect — #{Escape.html(verb)}</summary>
102
+ <p>Emits: #{command.emits.empty? ? '<em>nothing declared</em>' : command.emits.map { |e| "<code>#{Escape.html(e)}</code>" }.join(', ')}</p>
103
+ <p>Equivalent request (as <code>curl</code>) — every field is <code>#{Escape.html('name.path')}</code>-encoded, form or JSON alike:</p>
104
+ <div class="link-row"><code id="curl-snippet">#{Escape.html(curl)}</code><button type="button" class="copy" data-copy="#curl-snippet">copy</button></div>
105
+ <p>Fields this command takes: #{paths.map { |p| "<code>#{Escape.html(p)}</code>" }.join(', ')}</p>
106
+ <p>The command's own declaration, as the runtime holds it:</p>
107
+ <pre>#{Escape.html(JSON.pretty_generate(command.to_h))}</pre>
108
+ </details>
109
+ HTML
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,3 @@
1
+ Hecks::Forms.configure "BankingConsole" do
2
+ expose "Banking"
3
+ end
@@ -0,0 +1,177 @@
1
+ require_relative "html"
2
+ require_relative "field_shape"
3
+ require_relative "../naming"
4
+
5
+ module Hecks
6
+ module Forms
7
+ # A Field (field_shape.rb) -> the `<div class="field">...</div>` or
8
+ # `<fieldset>...</fieldset>` markup for it. One renderer, called
9
+ # recursively for a group's children, shared by CommandFormRenderer
10
+ # (POST) and QueryFormRenderer (GET) — the same attribute shape asks
11
+ # for the same input either way; only the surrounding `<form>`'s
12
+ # method differs.
13
+ module FieldRenderer
14
+ # `values` is the nested hash a sticky re-render (a rejected command,
15
+ # a submitted query) carries back — read with the SAME dotted path a
16
+ # Field already carries, via `dig`. `reference_options` maps a
17
+ # `:reference` field's own path to `[[id, label], ...]`, built by the
18
+ # caller (it needs a repository; this module stays pure markup).
19
+ def self.render(field, values: {}, errors: nil, reference_options: {})
20
+ case field.kind
21
+ when :group then group(field, values, errors, reference_options, tag: "fieldset")
22
+ when :money then group(field, values, errors, reference_options, tag: "fieldset", css: "money")
23
+ when :list then list(field, values, errors)
24
+ else leaf(field, values, errors, reference_options)
25
+ end
26
+ end
27
+
28
+ def self.group(field, values, errors, reference_options, tag:, css: nil)
29
+ inner = field.children.map { |child| render(child, values: values, errors: errors, reference_options: reference_options) }
30
+ <<~HTML
31
+ <#{tag}#{css ? %( class="#{css}") : ""}>
32
+ <legend>#{Escape.html(field.label)}#{required_mark(field)}</legend>
33
+ #{inner.join("\n")}
34
+ </#{tag}>
35
+ HTML
36
+ end
37
+
38
+ def self.list(field, values, _errors)
39
+ item = field.children.first
40
+ current = Array(dig(values, field.path)).join("\n")
41
+ <<~HTML
42
+ <div class="field">
43
+ <label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>
44
+ <textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}"
45
+ #{aria(field)} placeholder="one #{Escape.attr(item.label.downcase)} per line">#{Escape.html(current)}</textarea>
46
+ <span class="help" id="#{dom_id(field.path)}-help">One #{Escape.html(item.label.downcase)} per line#{item.leaf? ? '' : ' — each line is one JSON object'}.</span>
47
+ </div>
48
+ HTML
49
+ end
50
+
51
+ def self.leaf(field, values, errors, reference_options)
52
+ value = dig(values, field.path)
53
+ error = errors && errors[field.path]
54
+ body = case field.kind
55
+ when :boolean then checkbox(field, value)
56
+ when :radio then radio_group(field, value)
57
+ when :select then select(field, value)
58
+ when :textarea then textarea(field, value)
59
+ when :reference then reference_select(field, value, reference_options[field.path])
60
+ else input(field, value)
61
+ end
62
+ wrap(field, body, error)
63
+ end
64
+
65
+ def self.wrap(field, body, error)
66
+ <<~HTML
67
+ <div class="field#{error ? ' has-error' : ''}">
68
+ #{field.kind == :boolean ? '' : %(<label for="#{dom_id(field.path)}">#{Escape.html(field.label)}#{required_mark(field)}</label>)}
69
+ #{body}
70
+ #{field.help ? %(<span class="help" id="#{dom_id(field.path)}-help">#{Escape.html(field.help)}</span>) : ''}
71
+ #{error ? %(<span class="help" role="alert">#{Escape.html(error)}</span>) : ''}
72
+ </div>
73
+ HTML
74
+ end
75
+
76
+ def self.input(field, value)
77
+ money = field.kind == :number && field.help.to_s.include?("cents")
78
+ tag = Tag.void("input", id: dom_id(field.path), name: field.path, type: field.html_type,
79
+ value: value || field.default, step: field.step, pattern: leaf_pattern(field),
80
+ placeholder: field.default, **aria_attrs(field),
81
+ **(money ? { data_money_cents: money_preview_target(field) } : {}))
82
+ return tag unless money
83
+
84
+ %(#{tag} <span id="#{dom_id(field.path)}-preview" class="mono help" aria-live="polite"></span>)
85
+ end
86
+
87
+ def self.textarea(field, value)
88
+ %(<textarea id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{Escape.html(value)}</textarea>)
89
+ end
90
+
91
+ def self.checkbox(field, value)
92
+ checked = value.nil? ? field.default == true : [true, "true", "on", "1"].include?(value)
93
+ <<~HTML
94
+ <div class="checkbox-row">
95
+ <input type="hidden" name="#{Escape.attr(field.path)}" value="0">
96
+ #{Tag.void('input', id: dom_id(field.path), name: field.path, type: 'checkbox', value: '1', checked: checked)}
97
+ <label for="#{dom_id(field.path)}">#{Escape.html(field.label)}</label>
98
+ </div>
99
+ HTML
100
+ end
101
+
102
+ def self.radio_group(field, value)
103
+ selected = value || field.default
104
+ options = field.options.map do |option_value, option_label|
105
+ checked = option_value.to_s == selected.to_s
106
+ id = "#{dom_id(field.path)}-#{Naming.snake(option_value)}"
107
+ <<~HTML
108
+ <label>#{Tag.void('input', id: id, type: 'radio', name: field.path, value: option_value, checked: checked)} #{Escape.html(option_label)}</label>
109
+ HTML
110
+ end
111
+ %(<div class="radio-group" role="radiogroup">#{options.join}</div>)
112
+ end
113
+
114
+ def self.select(field, value)
115
+ selected = value || field.default
116
+ options = field.options.map do |option_value, option_label|
117
+ %(<option value="#{Escape.attr(option_value)}"#{option_value.to_s == selected.to_s ? ' selected' : ''}>#{Escape.html(option_label)}</option>)
118
+ end
119
+ blank = field.optional? ? %(<option value="">—</option>) : ""
120
+ %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{options.join}</select>)
121
+ end
122
+
123
+ def self.reference_select(field, value, options)
124
+ return input(field.tap { |f| f.html_type = "text" }, value) unless options && !options.empty?
125
+
126
+ rendered = options.map do |id, label|
127
+ %(<option value="#{Escape.attr(id)}"#{id.to_s == value.to_s ? ' selected' : ''}>#{Escape.html(label)}</option>)
128
+ end
129
+ blank = field.optional? ? %(<option value="">—</option>) : %(<option value="" disabled#{value ? '' : ' selected'}>choose one…</option>)
130
+ %(<select id="#{dom_id(field.path)}" name="#{Escape.attr(field.path)}" #{aria(field)}>#{blank}#{rendered.join}</select>)
131
+ end
132
+
133
+ def self.required_mark(field) = field.required? ? %(<span class="required-mark" title="required">*</span>) : ""
134
+ def self.dom_id(path) = "f-#{path.to_s.tr('.', '-')}"
135
+ def self.leaf_pattern(field) = field.kind == :reference ? nil : field.pattern
136
+ def self.money_preview_target(field) = "##{dom_id(field.path)}-preview"
137
+
138
+ def self.aria(field)
139
+ Tag.attrs(required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil)
140
+ end
141
+
142
+ def self.aria_attrs(field)
143
+ { required: field.required?, aria_describedby: field.help ? "#{dom_id(field.path)}-help" : nil }
144
+ end
145
+
146
+ # `values` is a nested hash; `path` a dotted string using the SAME
147
+ # segment spelling the hash keys are built from (`leaf_key` in
148
+ # params.rb) — symbols one level down from a group, strings at the
149
+ # flat top when a sticky POST re-render hands raw params back untouched.
150
+ def self.dig(values, path)
151
+ return nil unless values.is_a?(Hash)
152
+
153
+ # A sticky re-render after a refused submission hands back the RAW
154
+ # flat params (`{"amount.cents"=>"1050"}` — the same shape the form
155
+ # posted, string values and all, dotted key intact) ; a prefill from
156
+ # an existing record's own state hands back a NESTED hash instead
157
+ # (`{amount: {cents: 1050}}`). Flat wins when both would answer,
158
+ # since only the raw form is ever what the caller actually typed.
159
+ # `key?` decides which spelling answers, at every step below —
160
+ # never `||`, which would treat a genuinely-held `false` the
161
+ # same as an absent key and fall through to `nil`.
162
+ str = path.to_s
163
+ return values[str] if values.key?(str)
164
+
165
+ sym = path.to_sym
166
+ return values[sym] if values.key?(sym)
167
+
168
+ str.split(".").reduce(values) do |acc, segment|
169
+ break nil unless acc.is_a?(Hash)
170
+
171
+ seg_sym = segment.to_sym
172
+ acc.key?(seg_sym) ? acc[seg_sym] : acc[segment]
173
+ end
174
+ end
175
+ end
176
+ end
177
+ end