hecks 2.8.0 → 3.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 (886) hide show
  1. checksums.yaml +4 -4
  2. data/exe/hecks +50 -7
  3. data/lib/hecks/adapters/driven/claude_code.rb +4 -104
  4. data/lib/hecks/adapters/driven/d1.rb +44 -237
  5. data/lib/hecks/adapters/driven/folder.rb +17 -76
  6. data/lib/hecks/adapters/driven/google_authentication.rb +15 -49
  7. data/lib/hecks/adapters/driven/governance_authorization.rb +23 -70
  8. data/lib/hecks/adapters/driven/heki/journal.rb +8 -41
  9. data/lib/hecks/adapters/driven/heki/saga_store.rb +12 -54
  10. data/lib/hecks/adapters/driven/heki/snapshot.rb +4 -12
  11. data/lib/hecks/adapters/driven/heki.rb +13 -42
  12. data/lib/hecks/adapters/driven/identity_registry.rb +3 -14
  13. data/lib/hecks/adapters/driven/in_memory_ordering.rb +9 -26
  14. data/lib/hecks/adapters/driven/in_process_key_vault.rb +2 -14
  15. data/lib/hecks/adapters/driven/lambda/client.rb +10 -51
  16. data/lib/hecks/adapters/driven/lambda.rb +10 -84
  17. data/lib/hecks/adapters/driven/local_storage.rb +5 -40
  18. data/lib/hecks/adapters/driven/memory.rb +32 -100
  19. data/lib/hecks/adapters/driven/mock_stripe_adapter.rb +5 -21
  20. data/lib/hecks/adapters/driven/postgres/codec.rb +6 -28
  21. data/lib/hecks/adapters/driven/postgres/outbox.rb +3 -12
  22. data/lib/hecks/adapters/driven/postgres/reconnect.rb +10 -32
  23. data/lib/hecks/adapters/driven/postgres/schema_builder.rb +34 -108
  24. data/lib/hecks/adapters/driven/postgres/shared_connection.rb +180 -0
  25. data/lib/hecks/adapters/driven/postgres.rb +149 -136
  26. data/lib/hecks/adapters/driven/postgres_era.adapter +4 -15
  27. data/lib/hecks/adapters/driven/prism.rb +48 -38
  28. data/lib/hecks/adapters/driven/sql_query_builder.rb +21 -121
  29. data/lib/hecks/adapters/driven/sqlite/codec.rb +8 -40
  30. data/lib/hecks/adapters/driven/sqlite/projection.rb +26 -82
  31. data/lib/hecks/adapters/driven/sqlite/schema_builder.rb +40 -84
  32. data/lib/hecks/adapters/driven/sqlite.rb +128 -65
  33. data/lib/hecks/adapters/driven/tenant_provisioner.rb +76 -62
  34. data/lib/hecks/adapters/driven.rb +5 -36
  35. data/lib/hecks/adapters/driving/github_webhook.rb +8 -76
  36. data/lib/hecks/behaviors/dsl.rb +7 -25
  37. data/lib/hecks/behaviors/expectations.rb +32 -142
  38. data/lib/hecks/behaviors/ir.rb +7 -20
  39. data/lib/hecks/behaviors/rspec.rb +6 -17
  40. data/lib/hecks/behaviors/runner.rb +10 -34
  41. data/lib/hecks/behaviors.rb +4 -8
  42. data/lib/hecks/bench/cli.rb +62 -0
  43. data/lib/hecks/bench/environment.rb +71 -0
  44. data/lib/hecks/bench/postgres_probe.rb +30 -0
  45. data/lib/hecks/bench/report.rb +76 -0
  46. data/lib/hecks/bench/ruby_runner.rb +93 -0
  47. data/lib/hecks/bench/run.rb +55 -0
  48. data/lib/hecks/bench/rust_runner.rb +101 -0
  49. data/lib/hecks/bench/stats.rb +46 -0
  50. data/lib/hecks/bench/suite.rb +98 -0
  51. data/lib/hecks/bench/workload.rb +89 -0
  52. data/lib/hecks/bench.rb +17 -0
  53. data/lib/hecks/bluebook/aggregate.rb +9 -75
  54. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +9 -38
  55. data/lib/hecks/bluebook/assembly/build.rb +4 -15
  56. data/lib/hecks/bluebook/assembly/contract.rb +15 -136
  57. data/lib/hecks/bluebook/assembly/contracts.rb +56 -174
  58. data/lib/hecks/bluebook/assembly/marks.rb +31 -223
  59. data/lib/hecks/bluebook/assembly/specializer.rb +6 -52
  60. data/lib/hecks/bluebook/assembly.rb +6 -45
  61. data/lib/hecks/bluebook/attribute.rb +12 -61
  62. data/lib/hecks/bluebook/behaviour/aggregate.rb +39 -39
  63. data/lib/hecks/bluebook/behaviour/attribute.rb +3 -14
  64. data/lib/hecks/bluebook/behaviour/chapter.rb +14 -42
  65. data/lib/hecks/bluebook/behaviour/command.rb +14 -66
  66. data/lib/hecks/bluebook/behaviour/domain_port.rb +11 -20
  67. data/lib/hecks/bluebook/behaviour/entity.rb +7 -27
  68. data/lib/hecks/bluebook/behaviour/hexagon.rb +23 -41
  69. data/lib/hecks/bluebook/behaviour/lifecycle.rb +15 -40
  70. data/lib/hecks/bluebook/behaviour/policy.rb +23 -52
  71. data/lib/hecks/bluebook/behaviour/process_manager.rb +30 -57
  72. data/lib/hecks/bluebook/behaviour/read_model.rb +25 -31
  73. data/lib/hecks/bluebook/behaviour/traits.rb +27 -64
  74. data/lib/hecks/bluebook/behaviour/value_object.rb +5 -24
  75. data/lib/hecks/bluebook/capabilities.rb +17 -49
  76. data/lib/hecks/bluebook/chapter.rb +16 -50
  77. data/lib/hecks/bluebook/command.rb +23 -81
  78. data/lib/hecks/bluebook/domain_port.rb +49 -67
  79. data/lib/hecks/bluebook/dsl/aggregate_builder/sealing.rb +50 -229
  80. data/lib/hecks/bluebook/dsl/aggregate_builder.rb +50 -377
  81. data/lib/hecks/bluebook/dsl/attribute_collector.rb +33 -204
  82. data/lib/hecks/bluebook/dsl/binding_proxy.rb +16 -69
  83. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +96 -708
  84. data/lib/hecks/bluebook/dsl/bluebook_builder.rb +63 -169
  85. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +3 -1
  86. data/lib/hecks/bluebook/dsl/command_builder.rb +74 -546
  87. data/lib/hecks/bluebook/dsl/const_shim.rb +8 -41
  88. data/lib/hecks/bluebook/dsl/domain_port_builder.rb +42 -89
  89. data/lib/hecks/bluebook/dsl/entity_builder.rb +44 -330
  90. data/lib/hecks/bluebook/dsl/generic_dispatch.rb +20 -269
  91. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +148 -197
  92. data/lib/hecks/bluebook/dsl/identity_declaration.rb +13 -88
  93. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +6 -20
  94. data/lib/hecks/bluebook/dsl/policy_builder.rb +16 -88
  95. data/lib/hecks/bluebook/dsl/port_builder.rb +2 -6
  96. data/lib/hecks/bluebook/dsl/port_operation_builder.rb +22 -96
  97. data/lib/hecks/bluebook/dsl/process_manager_builder.rb +31 -184
  98. data/lib/hecks/bluebook/dsl/query_builder.rb +38 -43
  99. data/lib/hecks/bluebook/dsl/read_model_builder.rb +75 -201
  100. data/lib/hecks/bluebook/dsl/rule_reference.rb +22 -163
  101. data/lib/hecks/bluebook/dsl/translation_builder.rb +28 -114
  102. data/lib/hecks/bluebook/dsl/value_object_builder.rb +10 -124
  103. data/lib/hecks/bluebook/dsl/word_gate.rb +16 -162
  104. data/lib/hecks/bluebook/dsl/world_builder.rb +46 -111
  105. data/lib/hecks/bluebook/entity.rb +13 -71
  106. data/lib/hecks/bluebook/expression/ast_json.rb +24 -190
  107. data/lib/hecks/bluebook/expression/ast_reader.rb +12 -54
  108. data/lib/hecks/bluebook/expression/canonical_form.rb +11 -88
  109. data/lib/hecks/bluebook/expression/evaluator.rb +47 -295
  110. data/lib/hecks/bluebook/expression/resolver/block_predicates.rb +30 -147
  111. data/lib/hecks/bluebook/expression/resolver.rb +39 -661
  112. data/lib/hecks/bluebook/hexagon.rb +18 -33
  113. data/lib/hecks/bluebook/lifecycle.rb +5 -16
  114. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +1 -7
  115. data/lib/hecks/bluebook/meta_validator/judge.rb +111 -361
  116. data/lib/hecks/bluebook/meta_validator/plan.rb +33 -162
  117. data/lib/hecks/bluebook/meta_validator/port_judge.rb +2 -7
  118. data/lib/hecks/bluebook/meta_validator/readings.rb +56 -342
  119. data/lib/hecks/bluebook/meta_validator/reconstruction.rb +25 -176
  120. data/lib/hecks/bluebook/meta_validator/shapes.rb +53 -267
  121. data/lib/hecks/bluebook/meta_validator/syntax_boot.rb +47 -306
  122. data/lib/hecks/bluebook/meta_validator/translation_judge.rb +7 -43
  123. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +246 -0
  124. data/lib/hecks/bluebook/meta_validator/world_judge.rb +6 -18
  125. data/lib/hecks/bluebook/meta_validator.rb +87 -352
  126. data/lib/hecks/bluebook/model_check/client_profile.rb +6 -21
  127. data/lib/hecks/bluebook/model_check.rb +122 -596
  128. data/lib/hecks/bluebook/pattern_subset.rb +49 -69
  129. data/lib/hecks/bluebook/process_manager.rb +13 -83
  130. data/lib/hecks/bluebook/project_loader.rb +3 -39
  131. data/lib/hecks/bluebook/project_register.rb +12 -60
  132. data/lib/hecks/bluebook/query.rb +27 -51
  133. data/lib/hecks/bluebook/read_model.rb +57 -81
  134. data/lib/hecks/bluebook/reference.rb +7 -41
  135. data/lib/hecks/bluebook/smoke_test.rb +9 -99
  136. data/lib/hecks/bluebook/synthesizer.rb +13 -62
  137. data/lib/hecks/bluebook/translation.rb +26 -62
  138. data/lib/hecks/bluebook/value_object.rb +11 -36
  139. data/lib/hecks/bluebook.rb +5 -22
  140. data/lib/hecks/cache_dir.rb +4 -23
  141. data/lib/hecks/canonical_json.rb +30 -0
  142. data/lib/hecks/chapters.rb +102 -0
  143. data/lib/hecks/cli/behaviors.rb +61 -0
  144. data/lib/hecks/cli/canonicalise.rb +25 -0
  145. data/lib/hecks/cli/check_engine_agreement.rb +32 -0
  146. data/lib/hecks/cli/check_era.rb +88 -0
  147. data/lib/hecks/cli/console.rb +66 -0
  148. data/lib/hecks/cli/deploy_template_diff.rb +40 -0
  149. data/lib/hecks/cli/doc_coverage.rb +32 -0
  150. data/lib/hecks/cli/document.rb +5 -23
  151. data/lib/hecks/cli/expression_projection.rb +41 -0
  152. data/lib/hecks/cli/follow.rb +88 -0
  153. data/lib/hecks/cli/history.rb +44 -0
  154. data/lib/hecks/cli/ir.rb +4 -6
  155. data/lib/hecks/cli/mcp.rb +5 -6
  156. data/lib/hecks/cli/model_check.rb +24 -88
  157. data/lib/hecks/cli/pattern_cases.rb +47 -0
  158. data/lib/hecks/cli/present.rb +39 -0
  159. data/lib/hecks/cli/project_cli.rb +223 -64
  160. data/lib/hecks/cli/project_diagrams.rb +4 -10
  161. data/lib/hecks/cli/project_glossary.rb +44 -0
  162. data/lib/hecks/cli/reference.rb +46 -0
  163. data/lib/hecks/cli/refresh_projections.rb +44 -0
  164. data/lib/hecks/cli/refresh_rspec_runtime_baseline.rb +150 -0
  165. data/lib/hecks/cli/release.rb +70 -0
  166. data/lib/hecks/cli/release_gem.rb +59 -0
  167. data/lib/hecks/cli/rspec_io_parallel_files.rb +137 -0
  168. data/lib/hecks/cli/rspec_shard_files.rb +66 -0
  169. data/lib/hecks/cli/run.rb +15 -61
  170. data/lib/hecks/cli/seed_semantics_corpus.rb +71 -0
  171. data/lib/hecks/cli/serve_query_ir_mcp.rb +30 -0
  172. data/lib/hecks/cli/shape.rb +70 -0
  173. data/lib/hecks/cli/smoke_http.rb +230 -0
  174. data/lib/hecks/cli/smoke_test.rb +7 -19
  175. data/lib/hecks/cli/spec_example.rb +27 -0
  176. data/lib/hecks/cli/statements.rb +32 -0
  177. data/lib/hecks/cli/stores.rb +4 -6
  178. data/lib/hecks/cli/stress_concurrency_specs.rb +177 -0
  179. data/lib/hecks/cli.rb +216 -73
  180. data/lib/hecks/codemod.rb +324 -0
  181. data/lib/hecks/construct.rb +8 -34
  182. data/lib/hecks/corpus.rb +423 -0
  183. data/lib/hecks/deploy/bluebook/deploy.bluebook +617 -158
  184. data/lib/hecks/deploy/bluebook/deploy.hecksagon +41 -11
  185. data/lib/hecks/deploy/oidc.json +82 -2
  186. data/lib/hecks/doc/reference.rb +351 -0
  187. data/lib/hecks/doors/cli_door.rb +196 -0
  188. data/lib/hecks/doors/cli_runner.rb +297 -0
  189. data/lib/hecks/{facade → doors}/command_request.rb +14 -34
  190. data/lib/hecks/doors/handle.rb +206 -0
  191. data/lib/hecks/doors/json_door.rb +132 -0
  192. data/lib/hecks/doors/launcher_options.rb +129 -0
  193. data/lib/hecks/{cli → doors}/mcp_door.rb +25 -120
  194. data/lib/hecks/doors/mcp_door_scope.rb +97 -0
  195. data/lib/hecks/doors/ruby_door/aggregate_door.rb +162 -0
  196. data/lib/hecks/doors/ruby_door/chapter.rb +72 -0
  197. data/lib/hecks/doors/ruby_door.rb +77 -0
  198. data/lib/hecks/doors.rb +61 -0
  199. data/lib/hecks/embryonaut_bluebook/lock.rb +3 -35
  200. data/lib/hecks/embryonaut_bluebook/shape.rb +1 -17
  201. data/lib/hecks/embryonaut_bluebook/vendor.rb +8 -53
  202. data/lib/hecks/embryonaut_bluebook/vendor_cli.rb +4 -19
  203. data/lib/hecks/embryonaut_bluebook.rb +5 -72
  204. data/lib/hecks/engine_agreement.rb +156 -0
  205. data/lib/hecks/facade.rb +23 -51
  206. data/lib/hecks/forms/app.rb +21 -76
  207. data/lib/hecks/forms/banking_presentation.rb +50 -0
  208. data/lib/hecks/forms/command_form_renderer.rb +10 -29
  209. data/lib/hecks/forms/field_renderer.rb +17 -156
  210. data/lib/hecks/forms/field_shape.rb +31 -222
  211. data/lib/hecks/forms/html.rb +12 -47
  212. data/lib/hecks/forms/index_renderer.rb +1 -4
  213. data/lib/hecks/forms/page.rb +3 -9
  214. data/lib/hecks/forms/params.rb +14 -60
  215. data/lib/hecks/forms/port_argument.rb +2 -19
  216. data/lib/hecks/forms/query_form_renderer.rb +4 -79
  217. data/lib/hecks/forms/record_renderer.rb +8 -75
  218. data/lib/hecks/forms/record_table.rb +7 -50
  219. data/lib/hecks/forms/reference_options.rb +3 -30
  220. data/lib/hecks/forms/value_object_shape.rb +8 -35
  221. data/lib/hecks/forms.rb +5 -33
  222. data/lib/hecks/fqn.rb +5 -12
  223. data/lib/hecks/framework/bluebook/compliance.bluebook +16 -65
  224. data/lib/hecks/framework/bluebook/console_settings.bluebook +47 -134
  225. data/lib/hecks/framework/bluebook/framework.hecksagon +5 -14
  226. data/lib/hecks/framework/bluebook/governance.bluebook +13 -34
  227. data/lib/hecks/framework/bluebook/identity.bluebook +11 -26
  228. data/lib/hecks/framework/bluebook/privacy.bluebook +8 -33
  229. data/lib/hecks/framework.rb +11 -80
  230. data/lib/hecks/freezer.rb +5 -39
  231. data/lib/hecks/fuzzing/bounded_exhaustive_expressions.rb +393 -0
  232. data/lib/hecks/fuzzing/combination_miner.rb +86 -0
  233. data/lib/hecks/fuzzing/concurrent_dispatch.rb +214 -0
  234. data/lib/hecks/fuzzing/coverage_campaign.rb +143 -0
  235. data/lib/hecks/fuzzing/differential.rb +156 -0
  236. data/lib/hecks/fuzzing/domain_generator.rb +615 -0
  237. data/lib/hecks/fuzzing/era_boundary.rb +71 -0
  238. data/lib/hecks/fuzzing/form_census.rb +136 -0
  239. data/lib/hecks/fuzzing/generated_domain_check.rb +93 -0
  240. data/lib/hecks/fuzzing/invalid_value_generator.rb +64 -0
  241. data/lib/hecks/fuzzing/isolated_boot.rb +301 -0
  242. data/lib/hecks/fuzzing/nondeterministic.rb +51 -0
  243. data/lib/hecks/fuzzing/outside_world.rb +45 -0
  244. data/lib/hecks/fuzzing/persistence_parity.rb +102 -0
  245. data/lib/hecks/fuzzing/properties/corrections.rb +88 -0
  246. data/lib/hecks/fuzzing/properties/dispatch_and_mutations.rb +314 -0
  247. data/lib/hecks/fuzzing/properties/guards.rb +190 -0
  248. data/lib/hecks/fuzzing/properties/invariants_and_aggregation.rb +300 -0
  249. data/lib/hecks/fuzzing/properties/lifecycle_and_replay.rb +93 -0
  250. data/lib/hecks/fuzzing/properties/outbox.rb +89 -0
  251. data/lib/hecks/fuzzing/properties/querying.rb +175 -0
  252. data/lib/hecks/fuzzing/properties.rb +162 -0
  253. data/lib/hecks/fuzzing/qa_settings.rb +123 -0
  254. data/lib/hecks/fuzzing/replay.rb +505 -0
  255. data/lib/hecks/fuzzing/rotation_priority.rb +48 -0
  256. data/lib/hecks/fuzzing/rust_gap_manifest.rb +108 -0
  257. data/lib/hecks/fuzzing/self_consistency.rb +339 -0
  258. data/lib/hecks/fuzzing/sequence_generator/adversary.rb +385 -0
  259. data/lib/hecks/fuzzing/sequence_generator/catalog.rb +132 -0
  260. data/lib/hecks/fuzzing/sequence_generator/outcome_tracker.rb +95 -0
  261. data/lib/hecks/fuzzing/sequence_generator/picker.rb +77 -0
  262. data/lib/hecks/fuzzing/sequence_generator/query_binding.rb +109 -0
  263. data/lib/hecks/fuzzing/sequence_generator/step_builder.rb +237 -0
  264. data/lib/hecks/fuzzing/sequence_generator.rb +189 -0
  265. data/lib/hecks/fuzzing/shrinker.rb +166 -0
  266. data/lib/hecks/fuzzing/structural_skips.rb +33 -0
  267. data/lib/hecks/fuzzing/sweep_depth.rb +43 -0
  268. data/lib/hecks/fuzzing/target_capabilities.rb +163 -0
  269. data/lib/hecks/fuzzing/value_generator.rb +216 -0
  270. data/lib/hecks/fuzzing.rb +23 -0
  271. data/lib/hecks/grammar/evolve.rb +336 -0
  272. data/lib/hecks/grammar/expression.bluebook +15 -40
  273. data/lib/hecks/grammar/translation.bluebook +3 -6
  274. data/lib/hecks/grammar.rb +16 -41
  275. data/lib/hecks/hecks/adapters/codebase/codemods.rb +45 -0
  276. data/lib/hecks/hecks/adapters/codebase/conformance.rb +71 -0
  277. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +202 -0
  278. data/lib/hecks/hecks/adapters/codebase/gem_registry.rb +80 -0
  279. data/lib/hecks/hecks/adapters/codebase/kernel_tables.rb +54 -0
  280. data/lib/hecks/hecks/adapters/codebase/language.rb +240 -0
  281. data/lib/hecks/hecks/adapters/codebase/npm_registry.rb +102 -0
  282. data/lib/hecks/hecks/adapters/codebase/publishing.rb +142 -0
  283. data/lib/hecks/hecks/adapters/codebase/regeneration.rb +67 -0
  284. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +90 -0
  285. data/lib/hecks/hecks/adapters/codebase/ruby_child.rb +83 -0
  286. data/lib/hecks/hecks/adapters/codebase/secret_vault.rb +36 -0
  287. data/lib/hecks/hecks/adapters/codebase/source_tree.adapter +7 -0
  288. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +189 -0
  289. data/lib/hecks/hecks/adapters/codebase/sqlite_fixture.rb +71 -0
  290. data/lib/hecks/hecks/adapters/codebase/style.rb +171 -0
  291. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +55 -0
  292. data/lib/hecks/hecks/adapters/codebase/test_suite.rb +165 -0
  293. data/lib/hecks/hecks/adapters/codebase/tree.rb +111 -0
  294. data/lib/hecks/hecks/adapters/console_capture.rb +79 -0
  295. data/lib/hecks/hecks/adapters/deploy_toolchain.adapter +7 -0
  296. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +108 -0
  297. data/lib/hecks/hecks/adapters/git.adapter +7 -0
  298. data/lib/hecks/hecks/adapters/git.rb +102 -0
  299. data/lib/hecks/hecks/adapters/host_http.adapter +7 -0
  300. data/lib/hecks/hecks/adapters/host_http.rb +65 -0
  301. data/lib/hecks/hecks/adapters/in_process_boot.adapter +7 -0
  302. data/lib/hecks/hecks/adapters/in_process_boot.rb +197 -0
  303. data/lib/hecks/hecks/adapters/in_process_operations.rb +202 -0
  304. data/lib/hecks/hecks/adapters/journal_store/changes.rb +143 -0
  305. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +145 -0
  306. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +132 -0
  307. data/lib/hecks/hecks/adapters/journal_store/examination.rb +135 -0
  308. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +163 -0
  309. data/lib/hecks/hecks/adapters/journal_store/readings.rb +187 -0
  310. data/lib/hecks/hecks/adapters/journal_store.adapter +7 -0
  311. data/lib/hecks/hecks/adapters/journal_store.rb +64 -0
  312. data/lib/hecks/hecks/adapters/local_files.adapter +7 -0
  313. data/lib/hecks/hecks/adapters/local_files.rb +46 -0
  314. data/lib/hecks/hecks/adapters/pg_admin.adapter +7 -0
  315. data/lib/hecks/hecks/adapters/pg_admin.rb +245 -0
  316. data/lib/hecks/hecks/adapters/process_pool.adapter +7 -0
  317. data/lib/hecks/hecks/adapters/process_pool.rb +119 -0
  318. data/lib/hecks/hecks/adapters/rust_toolchain.adapter +7 -0
  319. data/lib/hecks/hecks/adapters/rust_toolchain.rb +179 -0
  320. data/lib/hecks/hecks/adapters/rust_workspace.rb +150 -0
  321. data/lib/hecks/hecks/adapters/shell.adapter +7 -0
  322. data/lib/hecks/hecks/adapters/shell.rb +49 -0
  323. data/lib/hecks/hecks/adapters/terminal.adapter +7 -0
  324. data/lib/hecks/hecks/adapters/terminal.rb +74 -0
  325. data/lib/hecks/hecks/codebase.behaviors +172 -0
  326. data/lib/hecks/hecks/codebase.bluebook +2453 -0
  327. data/lib/hecks/hecks/context_map.hecksagon +13 -0
  328. data/lib/hecks/hecks/custodian.behaviors +179 -0
  329. data/lib/hecks/hecks/custodian.bluebook +1847 -0
  330. data/lib/hecks/hecks/deploy.behaviors +74 -0
  331. data/lib/hecks/hecks/deploy.world +12 -0
  332. data/lib/hecks/hecks/environments/memory.world +10 -0
  333. data/lib/hecks/hecks/governance.world +7 -0
  334. data/lib/hecks/hecks/hecks.behaviors +62 -0
  335. data/lib/hecks/hecks/hecks.bluebook +124 -0
  336. data/lib/hecks/hecks/hecks.hecksagon +390 -0
  337. data/lib/hecks/hecks/hecks.world +42 -0
  338. data/lib/hecks/ir.rb +14 -83
  339. data/lib/hecks/kernel_capabilities.rb +194 -0
  340. data/lib/hecks/language/adapter.bluebook +2 -7
  341. data/lib/hecks/language/bluebook/aggregate.bluebook +56 -221
  342. data/lib/hecks/language/bluebook/attaches/paging.bluebook +6 -15
  343. data/lib/hecks/language/bluebook/bluebook.bluebook +26 -83
  344. data/lib/hecks/language/bluebook/bluebook.hecksagon +3 -15
  345. data/lib/hecks/language/bluebook/command.bluebook +27 -144
  346. data/lib/hecks/language/bluebook/entity.bluebook +24 -97
  347. data/lib/hecks/language/bluebook/policy.bluebook +16 -67
  348. data/lib/hecks/language/bluebook/process_manager.bluebook +28 -151
  349. data/lib/hecks/language/bluebook/projection.bluebook +126 -60
  350. data/lib/hecks/language/bluebook/query.bluebook +19 -43
  351. data/lib/hecks/language/bluebook/shape.bluebook +23 -73
  352. data/lib/hecks/language/bluebook/syntax.bluebook +51 -262
  353. data/lib/hecks/language/bluebook/vocabulary.bluebook +83 -198
  354. data/lib/hecks/language/hecksagon/adapter_binding.bluebook +3 -10
  355. data/lib/hecks/language/hecksagon/domain_port.bluebook +14 -19
  356. data/lib/hecks/language/hecksagon/hecksagon.bluebook +26 -25
  357. data/lib/hecks/language/hecksagon/port_operation.bluebook +3 -14
  358. data/lib/hecks/language/oidc.json +35 -0
  359. data/lib/hecks/language/port.bluebook +4 -17
  360. data/lib/hecks/language/translation/translation.bluebook +3 -9
  361. data/lib/hecks/language/translation/translation_aggregate.bluebook +5 -24
  362. data/lib/hecks/language/world/wiring.bluebook +1 -3
  363. data/lib/hecks/language/world/world.bluebook +5 -7
  364. data/lib/hecks/literal.rb +8 -50
  365. data/lib/hecks/mcp_stdio_guard.rb +4 -41
  366. data/lib/hecks/naming.rb +29 -137
  367. data/lib/hecks/persistence_legacy_fixture/regenerate.rb +161 -0
  368. data/lib/hecks/persistence_legacy_fixture.rb +113 -0
  369. data/lib/hecks/ports/access_control.rb +7 -23
  370. data/lib/hecks/ports/agent/answers.rb +5 -23
  371. data/lib/hecks/ports/agent.rb +47 -172
  372. data/lib/hecks/ports/authentication.rb +12 -39
  373. data/lib/hecks/ports/authorization.rb +16 -43
  374. data/lib/hecks/ports/clock.rb +5 -45
  375. data/lib/hecks/ports/extraction.rb +7 -12
  376. data/lib/hecks/ports/identity_assignment.rb +8 -29
  377. data/lib/hecks/ports/identity_generation.rb +6 -29
  378. data/lib/hecks/ports/identity_resolution.rb +2 -10
  379. data/lib/hecks/ports/key_vault.rb +9 -45
  380. data/lib/hecks/ports/persistence/append_only.rb +61 -64
  381. data/lib/hecks/ports/persistence/binding_policy.rb +4 -14
  382. data/lib/hecks/ports/persistence/codec_boundary.rb +20 -80
  383. data/lib/hecks/ports/persistence/null_saga_store.rb +4 -20
  384. data/lib/hecks/ports/persistence/plugin.rb +13 -42
  385. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +44 -144
  386. data/lib/hecks/ports/persistence/plugins/era/era_guard/shape_diff.rb +15 -115
  387. data/lib/hecks/ports/persistence/plugins/era/era_guard.rb +20 -85
  388. data/lib/hecks/ports/persistence/plugins/era/era_tamper.rb +6 -25
  389. data/lib/hecks/ports/persistence/plugins/era/expected_era.rb +18 -24
  390. data/lib/hecks/ports/persistence/plugins/era/lineage.rb +55 -181
  391. data/lib/hecks/ports/persistence/plugins/era/postgres_era/events.rb +77 -0
  392. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/era_store.rb +12 -122
  393. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/field_cache.rb +28 -128
  394. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/head_compiler.rb +73 -474
  395. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/mint_transaction.rb +31 -124
  396. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/provisioning.rb +45 -219
  397. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/resumable_backfill.rb +13 -108
  398. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/tail_merge.rb +19 -76
  399. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage/transform_installer.rb +9 -49
  400. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage.rb +6 -123
  401. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/coverage_check.rb +23 -55
  402. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +14 -72
  403. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/merge_coordinator.rb +7 -16
  404. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/minter.rb +43 -127
  405. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager.rb +13 -45
  406. data/lib/hecks/ports/persistence/plugins/era/postgres_era.rb +131 -555
  407. data/lib/hecks/ports/persistence/plugins/era/storage_shape.rb +13 -63
  408. data/lib/hecks/ports/persistence/plugins/era/translation/approval_file.rb +227 -0
  409. data/lib/hecks/ports/persistence/plugins/era/translation/audit/approval_digest.rb +2 -17
  410. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_one.rb +3 -14
  411. data/lib/hecks/ports/persistence/plugins/era/translation/audit/layer_two.rb +13 -58
  412. data/lib/hecks/ports/persistence/plugins/era/translation/audit/unfed_report.rb +3 -17
  413. data/lib/hecks/ports/persistence/plugins/era/translation/audit.rb +9 -26
  414. data/lib/hecks/ports/persistence/plugins/era/translation/reattest.rb +50 -47
  415. data/lib/hecks/ports/persistence/plugins/era/translation/rule_compiler.rb +18 -124
  416. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/differ.rb +34 -123
  417. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/renderer.rb +5 -12
  418. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold/writer.rb +3 -9
  419. data/lib/hecks/ports/persistence/plugins/era/translation/scaffold.rb +2 -14
  420. data/lib/hecks/ports/persistence/plugins/era/translation.rb +1 -0
  421. data/lib/hecks/ports/persistence/plugins/era.rb +4 -23
  422. data/lib/hecks/ports/persistence/postgres_dump/connection.rb +7 -23
  423. data/lib/hecks/ports/persistence/postgres_dump.rb +4 -35
  424. data/lib/hecks/ports/persistence/remote_runtime.rb +2 -24
  425. data/lib/hecks/ports/persistence/repository_factory.rb +8 -27
  426. data/lib/hecks/ports/persistence/state_codec.rb +47 -108
  427. data/lib/hecks/ports/persistence.rb +7 -14
  428. data/lib/hecks/ports/projection.rb +42 -84
  429. data/lib/hecks/ports/query/in_memory.rb +10 -58
  430. data/lib/hecks/ports/query/ordering.rb +11 -32
  431. data/lib/hecks/ports/query.rb +14 -37
  432. data/lib/hecks/ports.rb +2 -8
  433. data/lib/hecks/projection_files.rb +285 -0
  434. data/lib/hecks/projections/bootstrap_table.rb +12 -44
  435. data/lib/hecks/projections/deploy/fargate/assembly.rb +2 -15
  436. data/lib/hecks/projections/deploy/fargate/cdn.rb +1 -48
  437. data/lib/hecks/projections/deploy/fargate/cdn_default.rb +2 -11
  438. data/lib/hecks/projections/deploy/fargate/check.rb +5 -14
  439. data/lib/hecks/projections/deploy/fargate/containers.rb +3 -78
  440. data/lib/hecks/projections/deploy/fargate/extras.rb +3 -44
  441. data/lib/hecks/projections/deploy/fargate/monitoring.rb +0 -44
  442. data/lib/hecks/projections/deploy/fargate/settings.rb +2 -54
  443. data/lib/hecks/projections/deploy/fargate/yaml.rb +13 -58
  444. data/lib/hecks/projections/deploy/fargate.rb +40 -207
  445. data/lib/hecks/projections/deploy/lambda.rb +191 -708
  446. data/lib/hecks/projections/deploy/preview/access_yaml.rb +1 -33
  447. data/lib/hecks/projections/deploy/preview/containers.rb +6 -111
  448. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  449. data/lib/hecks/projections/deploy/preview/routing_yaml.rb +4 -56
  450. data/lib/hecks/projections/deploy/preview/script.rb +2 -14
  451. data/lib/hecks/projections/deploy/preview/settings.rb +3 -33
  452. data/lib/hecks/projections/deploy/preview/task_yaml.rb +2 -38
  453. data/lib/hecks/projections/deploy/preview/template.rb +3 -59
  454. data/lib/hecks/projections/deploy/preview.rb +2 -67
  455. data/lib/hecks/projections/deploy/scripts/deploy-service.sh.tmpl +1 -1
  456. data/lib/hecks/projections/deploy/scripts/expected-era.header +1 -1
  457. data/lib/hecks/projections/deploy/scripts/hosting.mk.tmpl +3 -3
  458. data/lib/hecks/projections/deploy/scripts/settings.rb +3 -25
  459. data/lib/hecks/projections/deploy/scripts/smoke-after-deploy.sh.tmpl +1 -1
  460. data/lib/hecks/projections/deploy/scripts.rb +4 -57
  461. data/lib/hecks/projections/deploy/shared.rb +48 -147
  462. data/lib/hecks/projections/deploy/smoke.rb +4 -61
  463. data/lib/hecks/projections/deploy/template_diff/comparer.rb +3 -25
  464. data/lib/hecks/projections/deploy/template_diff/loader.rb +2 -11
  465. data/lib/hecks/projections/deploy/template_diff.rb +5 -57
  466. data/lib/hecks/projections/diagrams.rb +22 -584
  467. data/lib/hecks/projections/glossary/html.rb +14 -130
  468. data/lib/hecks/projections/glossary/markdown.rb +8 -69
  469. data/lib/hecks/projections/glossary/mermaid.rb +9 -66
  470. data/lib/hecks/projections/glossary/sensitivity.rb +5 -34
  471. data/lib/hecks/projections/glossary/sentences.rb +14 -159
  472. data/lib/hecks/projections/glossary.rb +30 -222
  473. data/lib/hecks/projections/ir.rb +3 -10
  474. data/lib/hecks/projections/model/deviations.rb +16 -43
  475. data/lib/hecks/projections/model.rb +12 -51
  476. data/lib/hecks/projections/oidc.rb +12 -56
  477. data/lib/hecks/projections/parser_table.rb +13 -32
  478. data/lib/hecks/projections/reference.rb +4 -18
  479. data/lib/hecks/projections/rust_vocabulary.rb +13 -251
  480. data/lib/hecks/projections/shape.rb +3 -29
  481. data/lib/hecks/projections/statements.rb +7 -86
  482. data/lib/hecks/projections/vocabulary.rb +6 -22
  483. data/lib/hecks/projections.rb +2 -16
  484. data/lib/hecks/projector/cli_projector.rb +154 -400
  485. data/lib/hecks/projector/docs_projector.rb +10 -201
  486. data/lib/hecks/projector/exporter.rb +42 -134
  487. data/lib/hecks/projector/ir_projector.rb +2 -9
  488. data/lib/hecks/projector/narrate_projector.rb +15 -189
  489. data/lib/hecks/projector/target.rb +13 -76
  490. data/lib/hecks/projector.rb +30 -121
  491. data/lib/hecks/quality_control/adapters/agent.adapter +7 -0
  492. data/lib/hecks/quality_control/adapters/agent.rb +58 -0
  493. data/lib/hecks/quality_control/adapters/angle_tools.adapter +7 -0
  494. data/lib/hecks/quality_control/adapters/angle_tools.rb +15 -0
  495. data/lib/hecks/quality_control/adapters/clearance_recorder.rb +36 -0
  496. data/lib/hecks/quality_control/adapters/clearance_tools.adapter +7 -0
  497. data/lib/hecks/quality_control/adapters/clearance_tools.rb +17 -0
  498. data/lib/hecks/quality_control/adapters/git_pr.adapter +7 -0
  499. data/lib/hecks/quality_control/adapters/git_pr.rb +226 -0
  500. data/lib/hecks/quality_control/adapters/github_checks.adapter +9 -0
  501. data/lib/hecks/quality_control/adapters/github_checks.rb +83 -0
  502. data/lib/hecks/quality_control/adapters/github_ci_webhook.rb +56 -0
  503. data/lib/hecks/quality_control/adapters/github_issues.adapter +7 -0
  504. data/lib/hecks/quality_control/adapters/github_issues.rb +49 -0
  505. data/lib/hecks/quality_control/adapters/qa_tool.rb +59 -0
  506. data/lib/hecks/quality_control/adapters/sweep_tools.adapter +7 -0
  507. data/lib/hecks/quality_control/adapters/sweep_tools.rb +51 -0
  508. data/lib/hecks/quality_control/adapters/target_tools.adapter +7 -0
  509. data/lib/hecks/quality_control/adapters/target_tools.rb +43 -0
  510. data/lib/hecks/quality_control/cli/child.rb +45 -0
  511. data/lib/hecks/quality_control/cli/qa_concurrency_racer.rb +45 -0
  512. data/lib/hecks/quality_control/cli/qa_discover_external_domains.rb +248 -0
  513. data/lib/hecks/quality_control/cli/qa_domain_novelty.rb +163 -0
  514. data/lib/hecks/quality_control/cli/qa_generated_domains.rb +450 -0
  515. data/lib/hecks/quality_control/cli/qa_log_bug.rb +195 -0
  516. data/lib/hecks/quality_control/cli/qa_mine_combinations.rb +220 -0
  517. data/lib/hecks/quality_control/cli/qa_open_pr.rb +305 -0
  518. data/lib/hecks/quality_control/cli/qa_postgres_migrate.rb +154 -0
  519. data/lib/hecks/quality_control/cli/qa_postgres_role.rb +159 -0
  520. data/lib/hecks/quality_control/cli/qa_pr_check.rb +232 -0
  521. data/lib/hecks/quality_control/cli/qa_seed_angles.rb +142 -0
  522. data/lib/hecks/quality_control/cli/qa_seed_targets.rb +69 -0
  523. data/lib/hecks/quality_control/cli/qa_sweep/all_mode.rb +280 -0
  524. data/lib/hecks/quality_control/cli/qa_sweep/checks.rb +329 -0
  525. data/lib/hecks/quality_control/cli/qa_sweep/dials.rb +92 -0
  526. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +117 -0
  527. data/lib/hecks/quality_control/cli/qa_sweep/parsing.rb +175 -0
  528. data/lib/hecks/quality_control/cli/qa_sweep/release_mode.rb +60 -0
  529. data/lib/hecks/quality_control/cli/qa_sweep.rb +611 -0
  530. data/lib/hecks/quality_control/cli/qa_tick.rb +274 -0
  531. data/lib/hecks/quality_control/quality_control.bluebook +2224 -0
  532. data/lib/hecks/quality_control/quality_control.hecksagon +13 -0
  533. data/lib/hecks/quality_control/quality_control.ports.hecksagon +54 -0
  534. data/lib/hecks/query_ir.rb +346 -0
  535. data/lib/hecks/query_ir_mcp.rb +198 -0
  536. data/lib/hecks/query_specification/common/comparators.rb +5 -26
  537. data/lib/hecks/query_specification/common/comparison.rb +35 -143
  538. data/lib/hecks/query_specification/common/dsl.rb +8 -24
  539. data/lib/hecks/query_specification/common/null_policy.rb +20 -70
  540. data/lib/hecks/query_specification/common/options.rb +2 -6
  541. data/lib/hecks/query_specification/common/where_clause.rb +2 -10
  542. data/lib/hecks/query_specification/field_path.rb +37 -77
  543. data/lib/hecks/query_specification/hop_path.rb +12 -76
  544. data/lib/hecks/release/gem_pin.rb +2 -26
  545. data/lib/hecks/release/runner/ci_publisher.rb +5 -8
  546. data/lib/hecks/release/runner/clean_tree.rb +42 -0
  547. data/lib/hecks/release/runner/commands.rb +2 -4
  548. data/lib/hecks/release/runner/console.rb +6 -0
  549. data/lib/hecks/release/runner/gem_publisher.rb +15 -9
  550. data/lib/hecks/release/runner/npm_publisher.rb +9 -53
  551. data/lib/hecks/release/runner/preflight.rb +29 -20
  552. data/lib/hecks/release/runner/published.rb +8 -26
  553. data/lib/hecks/release/runner/tagger.rb +31 -23
  554. data/lib/hecks/release/runner.rb +60 -38
  555. data/lib/hecks/rendering.rb +4 -26
  556. data/lib/hecks/router/namespace_installer.rb +15 -39
  557. data/lib/hecks/router.rb +4 -8
  558. data/lib/hecks/runtime/adapter_lookup.rb +124 -0
  559. data/lib/hecks/runtime/aggregate_lock.rb +8 -33
  560. data/lib/hecks/runtime/boot_gates.rb +3 -18
  561. data/lib/hecks/runtime/caller.rb +19 -59
  562. data/lib/hecks/runtime/capability_graph.rb +5 -21
  563. data/lib/hecks/runtime/command_interpreter/argument_gate.rb +16 -78
  564. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +27 -160
  565. data/lib/hecks/runtime/command_interpreter.rb +124 -306
  566. data/lib/hecks/runtime/command_rules/admissibility.rb +95 -366
  567. data/lib/hecks/runtime/command_rules/arithmetic.rb +66 -196
  568. data/lib/hecks/runtime/command_rules/authorization.rb +13 -45
  569. data/lib/hecks/runtime/command_rules/emission.rb +8 -21
  570. data/lib/hecks/runtime/command_rules/references.rb +23 -202
  571. data/lib/hecks/runtime/dependency_planning.rb +50 -104
  572. data/lib/hecks/runtime/dispatcher.rb +134 -243
  573. data/lib/hecks/runtime/entity_element.rb +40 -462
  574. data/lib/hecks/runtime/entity_interpreter.rb +64 -219
  575. data/lib/hecks/runtime/errors.rb +32 -115
  576. data/lib/hecks/runtime/event.rb +3 -25
  577. data/lib/hecks/runtime/identity.rb +18 -154
  578. data/lib/hecks/runtime/instance.rb +18 -150
  579. data/lib/hecks/runtime/interpreting.rb +14 -70
  580. data/lib/hecks/runtime/invocation.rb +28 -164
  581. data/lib/hecks/runtime/loader.rb +109 -194
  582. data/lib/hecks/runtime/outbox.rb +39 -262
  583. data/lib/hecks/runtime/policy_interpreter.rb +43 -202
  584. data/lib/hecks/runtime/port_operation_interpreter.rb +28 -97
  585. data/lib/hecks/runtime/query_interpreter.rb +122 -187
  586. data/lib/hecks/runtime/reaction_invocation.rb +36 -138
  587. data/lib/hecks/runtime/reaction_outcome.rb +89 -0
  588. data/lib/hecks/runtime/read_model_interpreter.rb +117 -208
  589. data/lib/hecks/runtime/rebuild_sweep.rb +8 -54
  590. data/lib/hecks/runtime/reference_hop.rb +8 -81
  591. data/lib/hecks/runtime/refusal_wording.rb +28 -87
  592. data/lib/hecks/runtime/registry/saga_persistence.rb +10 -102
  593. data/lib/hecks/runtime/registry/verification.rb +213 -227
  594. data/lib/hecks/runtime/registry/world_defaults.rb +4 -53
  595. data/lib/hecks/runtime/registry.rb +156 -247
  596. data/lib/hecks/runtime/remote_dispatcher.rb +68 -154
  597. data/lib/hecks/runtime/routing.rb +4 -15
  598. data/lib/hecks/runtime/saga_interpreter/correlation.rb +14 -61
  599. data/lib/hecks/runtime/saga_interpreter.rb +79 -263
  600. data/lib/hecks/runtime/saga_pending_dispatch.rb +2 -43
  601. data/lib/hecks/runtime/tenant_check.rb +6 -68
  602. data/lib/hecks/runtime/tenant_scope.rb +3 -28
  603. data/lib/hecks/runtime/value/admission.rb +16 -67
  604. data/lib/hecks/runtime/value/coercion.rb +162 -760
  605. data/lib/hecks/runtime/value/entity_list_coercion.rb +26 -219
  606. data/lib/hecks/runtime/value/field_checks.rb +172 -0
  607. data/lib/hecks/runtime/value.rb +15 -84
  608. data/lib/hecks/runtime.rb +35 -69
  609. data/lib/hecks/rust_build/conformance.rb +158 -0
  610. data/lib/hecks/rust_build/conformance_fuzz.rb +71 -0
  611. data/lib/hecks/rust_build/coverage.rb +218 -0
  612. data/lib/hecks/rust_build/native_build.rb +124 -0
  613. data/lib/hecks/rust_build/project_rust/root_mod.rb +120 -0
  614. data/lib/hecks/rust_build/project_rust.rb +332 -0
  615. data/lib/hecks/rust_build/wasm.rb +105 -0
  616. data/lib/hecks/rust_build/wasm_browser.rb +113 -0
  617. data/lib/hecks/rust_build.rb +144 -0
  618. data/lib/hecks/storehouse.rb +155 -582
  619. data/lib/hecks/tenancy/bluebook/tenancy.bluebook +10 -34
  620. data/lib/hecks/tenancy/bluebook/tenancy.hecksagon +6 -23
  621. data/lib/hecks/three_zero/forms.yml +127 -0
  622. data/lib/hecks/three_zero.rb +12 -0
  623. data/lib/hecks/tools/argument_gate_matrix.rb +316 -0
  624. data/lib/hecks/tools/comment_style.rb +1070 -0
  625. data/lib/hecks/tools/corpus_report.rb +87 -0
  626. data/lib/hecks/tools/deploy_recipe.rb +255 -0
  627. data/lib/hecks/tools/deploy_recipe_lint.rb +282 -0
  628. data/lib/hecks/tools/drop_implicit_append_fields.rb +95 -0
  629. data/lib/hecks/tools/era_projection_backfill.rb +94 -0
  630. data/lib/hecks/tools/era_reattest.rb +146 -0
  631. data/lib/hecks/tools/evolve_run.rb +189 -0
  632. data/lib/hecks/tools/fuzz_sweep.rb +373 -0
  633. data/lib/hecks/tools/heki_compaction.rb +100 -0
  634. data/lib/hecks/tools/hoist_local_givens.rb +246 -0
  635. data/lib/hecks/tools/journal_compaction.rb +89 -0
  636. data/lib/hecks/tools/oidc_manifests.rb +70 -0
  637. data/lib/hecks/tools/query_ir_run.rb +81 -0
  638. data/lib/hecks/tools/regeneration_run.rb +166 -0
  639. data/lib/hecks/tools/rust_comment_style.rb +642 -0
  640. data/lib/hecks/tools/sequence_script.rb +62 -0
  641. data/lib/hecks/tools/tail_merge.rb +117 -0
  642. data/lib/hecks/tools/tenant_provisioning.rb +142 -0
  643. data/lib/hecks/tools/translation_audit.rb +208 -0
  644. data/lib/hecks/tools/translation_scaffold.rb +131 -0
  645. data/lib/hecks/tools.rb +86 -0
  646. data/lib/hecks/vendoring/git_environment.rb +2 -8
  647. data/lib/hecks/vendoring/git_source.rb +4 -10
  648. data/lib/hecks/vendoring.rb +7 -46
  649. data/lib/hecks/version.rb +3 -14
  650. data/lib/hecks.rb +52 -90
  651. data/lib/rubocop/cop/hecks/fallback_hash_lookup.rb +8 -46
  652. data/lib/rubocop/cop/hecks/sequential_hash_rename_in_loop.rb +11 -80
  653. data/lib/rubocop/cop/hecks/thread_shared_ivar_mutation.rb +10 -99
  654. data/qa/settings.yml +66 -0
  655. data/rust/.cargo/config.toml +6 -0
  656. data/rust/.gitignore +2 -0
  657. data/rust/Cargo.lock +54 -0
  658. data/rust/Cargo.toml +61 -0
  659. data/rust/build/.cargo/config.toml +3 -0
  660. data/rust/build/.gitignore +1 -0
  661. data/rust/build/Cargo.lock +7 -0
  662. data/rust/build/Cargo.toml +19 -0
  663. data/rust/build/src/build_artifact.rs +47 -0
  664. data/rust/build/src/cargo_sync.rs +248 -0
  665. data/rust/build/src/json.rs +421 -0
  666. data/rust/build/src/lineage_pass.rs +285 -0
  667. data/rust/build/src/main.rs +51 -0
  668. data/rust/build/src/optional_pass.rs +150 -0
  669. data/rust/build/src/pipeline.rs +264 -0
  670. data/rust/build/src/reserved_names.rs +76 -0
  671. data/rust/build/src/resolve.rs +124 -0
  672. data/rust/build/src/root.rs +25 -0
  673. data/rust/build/src/sidecars.rs +73 -0
  674. data/rust/build/src/subprocess.rs +51 -0
  675. data/rust/build/src/tmp.rs +31 -0
  676. data/rust/codegen/.cargo/config.toml +3 -0
  677. data/rust/codegen/.gitignore +1 -0
  678. data/rust/codegen/Cargo.lock +7 -0
  679. data/rust/codegen/Cargo.toml +20 -0
  680. data/rust/codegen/src/attr.rs +32 -0
  681. data/rust/codegen/src/bridging.rs +481 -0
  682. data/rust/codegen/src/commands.rs +1226 -0
  683. data/rust/codegen/src/constraints.rs +109 -0
  684. data/rust/codegen/src/dependency_planning.rs +209 -0
  685. data/rust/codegen/src/domain_generator.rs +1288 -0
  686. data/rust/codegen/src/exemplar.rs +230 -0
  687. data/rust/codegen/src/expr_emitter.rs +87 -0
  688. data/rust/codegen/src/fielded.rs +192 -0
  689. data/rust/codegen/src/hecks_naming.rs +33 -0
  690. data/rust/codegen/src/json.rs +335 -0
  691. data/rust/codegen/src/json_codec.rs +697 -0
  692. data/rust/codegen/src/literal.rs +207 -0
  693. data/rust/codegen/src/main.rs +290 -0
  694. data/rust/codegen/src/manifest.rs +227 -0
  695. data/rust/codegen/src/mutations.rs +784 -0
  696. data/rust/codegen/src/naming.rs +286 -0
  697. data/rust/codegen/src/ports.rs +208 -0
  698. data/rust/codegen/src/prelude.rs +116 -0
  699. data/rust/codegen/src/queries.rs +786 -0
  700. data/rust/codegen/src/reactions.rs +532 -0
  701. data/rust/codegen/src/read_models.rs +697 -0
  702. data/rust/codegen/src/reference_specs.rs +40 -0
  703. data/rust/codegen/src/registry.rs +966 -0
  704. data/rust/codegen/src/reserved_names.rs +76 -0
  705. data/rust/codegen/src/shared.rs +93 -0
  706. data/rust/codegen/src/skip_reason.rs +25 -0
  707. data/rust/codegen/src/types.rs +362 -0
  708. data/rust/host/.cargo/config.toml +3 -0
  709. data/rust/host/.gitignore +1 -0
  710. data/rust/host/Cargo.lock +4297 -0
  711. data/rust/host/Cargo.toml +61 -0
  712. data/rust/host/HECKS_RELEASE +1 -0
  713. data/rust/host/rds-ca-bundle.pem +2736 -0
  714. data/rust/host/src/api.rs +1596 -0
  715. data/rust/host/src/approval.rs +652 -0
  716. data/rust/host/src/auth.rs +1287 -0
  717. data/rust/host/src/bin/expr_harness.rs +46 -0
  718. data/rust/host/src/bin/lineage_harness.rs +119 -0
  719. data/rust/host/src/bin/mint_harness.rs +135 -0
  720. data/rust/host/src/checkout.rs +265 -0
  721. data/rust/host/src/dispatch.rs +1443 -0
  722. data/rust/host/src/expr_json.rs +1117 -0
  723. data/rust/host/src/field_hints.rs +52 -0
  724. data/rust/host/src/ir.rs +733 -0
  725. data/rust/host/src/journal.rs +874 -0
  726. data/rust/host/src/lambda_client.rs +378 -0
  727. data/rust/host/src/log.rs +147 -0
  728. data/rust/host/src/main.rs +474 -0
  729. data/rust/host/src/mint.rs +1865 -0
  730. data/rust/host/src/payments/keystore.rs +330 -0
  731. data/rust/host/src/payments/tests.rs +1400 -0
  732. data/rust/host/src/payments.rs +767 -0
  733. data/rust/host/src/presentation.rs +698 -0
  734. data/rust/host/src/presentation_write.rs +1431 -0
  735. data/rust/host/src/query_step.rs +149 -0
  736. data/rust/host/src/rate_limit.rs +904 -0
  737. data/rust/host/src/reference_transform.rs +241 -0
  738. data/rust/host/src/reference_validate.rs +338 -0
  739. data/rust/host/src/resend.rs +296 -0
  740. data/rust/host/src/secrets.rs +106 -0
  741. data/rust/host/src/server.rs +663 -0
  742. data/rust/host/src/storage_shape.rs +175 -0
  743. data/rust/host/src/test_pg.rs +40 -0
  744. data/rust/host/src/ui_schema.rs +1351 -0
  745. data/rust/host/src/wasm_runner.rs +191 -0
  746. data/rust/host/src/web/newsletter/cooldown.rs +126 -0
  747. data/rust/host/src/web/newsletter/tests.rs +104 -0
  748. data/rust/host/src/web/newsletter.rs +310 -0
  749. data/rust/host/src/web/newsletter_send.rs +357 -0
  750. data/rust/host/src/web/registrations.rs +1505 -0
  751. data/rust/host/src/web.rs +2937 -0
  752. data/rust/lsp/.gitignore +1 -0
  753. data/rust/lsp/Cargo.lock +7 -0
  754. data/rust/lsp/Cargo.toml +16 -0
  755. data/rust/lsp/README.md +133 -0
  756. data/rust/lsp/src/diagnostics.rs +165 -0
  757. data/rust/lsp/src/json.rs +385 -0
  758. data/rust/lsp/src/main.rs +399 -0
  759. data/rust/lsp/src/outline.rs +177 -0
  760. data/rust/lsp/src/rpc.rs +67 -0
  761. data/rust/parser/.cargo/config.toml +5 -0
  762. data/rust/parser/.gitignore +1 -0
  763. data/rust/parser/Cargo.lock +7 -0
  764. data/rust/parser/Cargo.toml +21 -0
  765. data/rust/parser/src/build/closed_sets.rs +27 -0
  766. data/rust/parser/src/build/identity.rs +276 -0
  767. data/rust/parser/src/build/mod.rs +12 -0
  768. data/rust/parser/src/build/naming.rs +141 -0
  769. data/rust/parser/src/build/pattern_subset.rs +161 -0
  770. data/rust/parser/src/build/query_derive.rs +72 -0
  771. data/rust/parser/src/build/query_inference.rs +269 -0
  772. data/rust/parser/src/build/query_options.rs +100 -0
  773. data/rust/parser/src/build/read_model.rs +94 -0
  774. data/rust/parser/src/build/references.rs +32 -0
  775. data/rust/parser/src/canonical.rs +114 -0
  776. data/rust/parser/src/diag.rs +55 -0
  777. data/rust/parser/src/emit.rs +953 -0
  778. data/rust/parser/src/expr/ast_json.rs +147 -0
  779. data/rust/parser/src/expr/evaluator.rs +81 -0
  780. data/rust/parser/src/expr/mod.rs +84 -0
  781. data/rust/parser/src/expr/resolver.rs +497 -0
  782. data/rust/parser/src/ir.rs +387 -0
  783. data/rust/parser/src/keywords.rs +585 -0
  784. data/rust/parser/src/lex.rs +662 -0
  785. data/rust/parser/src/main.rs +252 -0
  786. data/rust/parser/src/parse/aggregate.rs +612 -0
  787. data/rust/parser/src/parse/chapter.rs +517 -0
  788. data/rust/parser/src/parse/command.rs +637 -0
  789. data/rust/parser/src/parse/domain_port.rs +127 -0
  790. data/rust/parser/src/parse/entity.rs +527 -0
  791. data/rust/parser/src/parse/file.rs +67 -0
  792. data/rust/parser/src/parse/hecksagon.rs +231 -0
  793. data/rust/parser/src/parse/lifecycle.rs +162 -0
  794. data/rust/parser/src/parse/mod.rs +1262 -0
  795. data/rust/parser/src/parse/policy.rs +87 -0
  796. data/rust/parser/src/parse/process_manager.rs +305 -0
  797. data/rust/parser/src/parse/query.rs +100 -0
  798. data/rust/parser/src/parse/read_model.rs +214 -0
  799. data/rust/parser/src/parse/value_object.rs +250 -0
  800. data/rust/parser/src/ruby_value.rs +422 -0
  801. data/rust/project/bridging.rb +257 -0
  802. data/rust/project/commands.rb +877 -0
  803. data/rust/project/constraints.rb +86 -0
  804. data/rust/project/dependency_planning.rb +178 -0
  805. data/rust/project/domain_generator.rb +980 -0
  806. data/rust/project/exemplar.rb +148 -0
  807. data/rust/project/expr_emitter.rb +72 -0
  808. data/rust/project/fielded.rb +141 -0
  809. data/rust/project/json_codec.rb +641 -0
  810. data/rust/project/mutations.rb +721 -0
  811. data/rust/project/naming.rb +205 -0
  812. data/rust/project/ports.rb +65 -0
  813. data/rust/project/queries.rb +493 -0
  814. data/rust/project/reactions.rb +317 -0
  815. data/rust/project/read_models.rb +690 -0
  816. data/rust/project/reference_specs.rb +27 -0
  817. data/rust/project/registry.rb +396 -0
  818. data/rust/project/skip_reason.rb +29 -0
  819. data/rust/project/types.rb +225 -0
  820. data/rust/project/write_if_changed.rb +83 -0
  821. data/rust/project.rb +30 -0
  822. data/rust/project_rust_pipeline.rb +383 -0
  823. data/rust/src/exemplar/commands.rs +279 -0
  824. data/rust/src/exemplar/constraints.rs +18 -0
  825. data/rust/src/exemplar/fielded.rs +280 -0
  826. data/rust/src/exemplar/json.rs +277 -0
  827. data/rust/src/exemplar/mod.rs +17 -0
  828. data/rust/src/exemplar/mutations.rs +80 -0
  829. data/rust/src/exemplar/queries.rs +33 -0
  830. data/rust/src/exemplar/reactions.rs +88 -0
  831. data/rust/src/exemplar/read_models.rs +48 -0
  832. data/rust/src/exemplar/registry.rs +166 -0
  833. data/rust/src/exemplar/types.rs +44 -0
  834. data/rust/src/kernel/attribute_shapes/composite.rs +88 -0
  835. data/rust/src/kernel/attribute_shapes/list.rs +18 -0
  836. data/rust/src/kernel/attribute_shapes/mod.rs +44 -0
  837. data/rust/src/kernel/attribute_shapes/optional.rs +9 -0
  838. data/rust/src/kernel/attribute_shapes/scalar.rs +39 -0
  839. data/rust/src/kernel/cli.rs +585 -0
  840. data/rust/src/kernel/dispatch.rs +1090 -0
  841. data/rust/src/kernel/expr.rs +458 -0
  842. data/rust/src/kernel/expression_operators/arithmetic.rs +128 -0
  843. data/rust/src/kernel/expression_operators/comparison.rs +48 -0
  844. data/rust/src/kernel/expression_operators/enumeration.rs +232 -0
  845. data/rust/src/kernel/expression_operators/logical.rs +13 -0
  846. data/rust/src/kernel/expression_operators/membership.rs +18 -0
  847. data/rust/src/kernel/expression_operators/mod.rs +68 -0
  848. data/rust/src/kernel/expression_operators/pattern_match.rs +73 -0
  849. data/rust/src/kernel/expression_operators/positional.rs +126 -0
  850. data/rust/src/kernel/expression_operators/presence.rs +101 -0
  851. data/rust/src/kernel/expression_operators/sign_test.rs +16 -0
  852. data/rust/src/kernel/expression_operators/sized.rs +35 -0
  853. data/rust/src/kernel/expression_operators/text.rs +79 -0
  854. data/rust/src/kernel/expression_operators/to_string.rs +18 -0
  855. data/rust/src/kernel/json.rs +779 -0
  856. data/rust/src/kernel/mod.rs +133 -0
  857. data/rust/src/kernel/named_query.rs +219 -0
  858. data/rust/src/kernel/orchestrate.rs +1810 -0
  859. data/rust/src/kernel/pattern.rs +307 -0
  860. data/rust/src/kernel/query_comparators.rs +206 -0
  861. data/rust/src/kernel/query_ordering.rs +103 -0
  862. data/rust/src/kernel/read_model.rs +914 -0
  863. data/rust/src/kernel/reference_lookup.rs +149 -0
  864. data/rust/src/kernel/repository.rs +468 -0
  865. data/rust/src/kernel/routing.rs +612 -0
  866. data/rust/src/kernel/vocab/aggregate_dispatch_order.rs +118 -0
  867. data/rust/src/kernel/vocab/entity_dispatch_order.rs +114 -0
  868. data/rust/src/kernel/vocab/field_hint.rs +70 -0
  869. data/rust/src/kernel/vocab/mod.rs +20 -0
  870. data/rust/src/kernel/vocab/query_comparator.rs +65 -0
  871. data/rust/src/kernel/vocab/refusal_template.rs +1592 -0
  872. data/rust/src/lib.rs +8 -0
  873. data/rust/src/main.rs +16 -0
  874. data/rust/web/.gitignore +1 -0
  875. data/rust/web/Cargo.lock +119 -0
  876. data/rust/web/Cargo.toml +18 -0
  877. data/rust/web/src/lib.rs +8 -0
  878. metadata +478 -12
  879. data/lib/hecks/facade/cli_door.rb +0 -178
  880. data/lib/hecks/facade/cli_runner.rb +0 -271
  881. data/lib/hecks/facade/handle.rb +0 -293
  882. data/lib/hecks/facade/json_door.rb +0 -247
  883. data/lib/hecks/facade/surface/aggregate_door.rb +0 -222
  884. data/lib/hecks/facade/surface/chapter.rb +0 -116
  885. data/lib/hecks/facade/surface.rb +0 -61
  886. data/lib/hecks/mcp_door_scope.rb +0 -176
@@ -0,0 +1,1351 @@
1
+ // A Rust port of `web/ui_schema.rb`: turns a domain IR plus config into
2
+ // the exact JSON `GET /api/ui-schema` serves (nav, columns, forms).
3
+
4
+ use serde_json::{json, Map, Value};
5
+ use std::collections::HashMap;
6
+ use std::sync::LazyLock;
7
+
8
+ use regex::Regex;
9
+
10
+ /// `Hecks::Bluebook::IR::Attribute::PRIMITIVES`.
11
+ const PRIMITIVES: &[&str] = &["String", "Integer", "Float", "TrueClass", "FalseClass"];
12
+
13
+ /// `UiSchema::DISPLAY_BY_SHAPE` — a detail field's default placement,
14
+ /// taken from its own shape; `display:` config overrides it.
15
+ const DISPLAY_BY_SHAPE: &[(&str, &str)] = &[("text", "prose"), ("lines", "bullet"), ("rows", "rows"), ("compound", "mono")];
16
+
17
+ /// Shapes a table column can't carry, so they're left out of the
18
+ /// default (unconfigured) column list.
19
+ const UNCOLUMNABLE: &[&str] = &["rows", "lines", "compound"];
20
+
21
+ type KeyFor = HashMap<String, String>;
22
+
23
+ /// `UiSchema.build` — the whole document.
24
+ pub fn build(domain_ir: &Value, config: &Value) -> Value {
25
+ let aggregates = aggregates(domain_ir);
26
+ let key_for = key_for(&aggregates, config);
27
+ let by_name: HashMap<&str, &Value> = aggregates.iter().map(|a| (agg_name(a), *a)).collect();
28
+
29
+ let mut collections = Map::new();
30
+ for aggregate in &aggregates {
31
+ let key = key_for.get(agg_name(aggregate)).cloned().unwrap_or_default();
32
+ collections.insert(key, build_collection(aggregate, config, &key_for, &by_name));
33
+ }
34
+
35
+ json!({
36
+ "domain": domain_ir.get("name").cloned().unwrap_or(Value::Null),
37
+ "collections": Value::Object(collections),
38
+ "nav": build_nav(&aggregates, config, &key_for),
39
+ "overview": build_overview(config),
40
+ })
41
+ }
42
+
43
+ /// `GET /api/schema`'s body: every aggregate's states and declared
44
+ /// queries, with arguments shaped the same as a create form's fields.
45
+ pub fn schema(domain_ir: &Value, config: &Value) -> Value {
46
+ let aggregates = aggregates(domain_ir);
47
+ let key_for = key_for(&aggregates, config);
48
+
49
+ let mut schema = Map::new();
50
+ for aggregate in &aggregates {
51
+ let queries: Vec<Value> = array(aggregate, "queries")
52
+ .iter()
53
+ .map(|query| {
54
+ let args: Vec<Value> = array(query, "attributes")
55
+ .iter()
56
+ .map(|attribute| field(attribute, aggregate, &key_for, &Value::Null))
57
+ .collect();
58
+ json!({ "name": query.get("name").cloned().unwrap_or(Value::Null), "args": args })
59
+ })
60
+ .collect();
61
+ schema.insert(
62
+ agg_name(aggregate).to_string(),
63
+ json!({ "states": lifecycle_states(aggregate), "queries": queries }),
64
+ );
65
+ }
66
+ Value::Object(schema)
67
+ }
68
+
69
+ /// `UiSchema.collection_key` — config's own `key:` if it names one,
70
+ /// else the pluralized snake_case of the aggregate's own name.
71
+ pub fn collection_key(aggregate: &Value, config: &Value) -> String {
72
+ match dig(config, &["collections", agg_name(aggregate), "key"]).and_then(|v| v.as_str()) {
73
+ Some(key) => key.to_string(),
74
+ None => plural(&snake(agg_name(aggregate))),
75
+ }
76
+ }
77
+
78
+ /// Aggregate name -> collection key, for every aggregate in the
79
+ /// domain: what a reference field's own `targetKey` resolves through.
80
+ pub fn key_for(aggregates: &[&Value], config: &Value) -> KeyFor {
81
+ aggregates.iter().map(|a| (agg_name(a).to_string(), collection_key(a, config))).collect()
82
+ }
83
+
84
+ pub fn aggregates(domain_ir: &Value) -> Vec<&Value> {
85
+ domain_ir.get("aggregates").and_then(|v| v.as_array()).map(|a| a.iter().collect()).unwrap_or_default()
86
+ }
87
+
88
+ fn build_collection(aggregate: &Value, config: &Value, key_for: &KeyFor, by_name: &HashMap<&str, &Value>) -> Value {
89
+ let name = agg_name(aggregate);
90
+ let agg_cfg = dig(config, &["collections", name]).cloned().unwrap_or_else(|| json!({}));
91
+ let state_cfg = dig(config, &["states", name]).cloned().unwrap_or_else(|| json!({}));
92
+ let formats = agg_cfg.get("field_formats").cloned().unwrap_or(Value::Null);
93
+ let identity = agg_cfg.get("identity").filter(|v| !v.is_null());
94
+ let creating = commands(aggregate).into_iter().find(|c| creates(c));
95
+ let others: Vec<&Value> = commands(aggregate).into_iter().filter(|c| !creates(c)).collect();
96
+ let noun_sing = match agg_cfg.get("noun_sing").and_then(|v| v.as_str()) {
97
+ Some(noun) => noun.to_string(),
98
+ None => snake(name).replace('_', " "),
99
+ };
100
+
101
+ let fields: Vec<Value> = array(aggregate, "attributes").iter().map(|a| field(a, aggregate, key_for, &formats)).collect();
102
+
103
+ let mut collection = Map::new();
104
+ collection.insert("aggregate".to_string(), json!(name));
105
+ collection.insert("label".to_string(), json!(string_or(&agg_cfg, "label", &humanize_words(name))));
106
+ collection.insert("nounSing".to_string(), json!(noun_sing));
107
+ collection.insert("navGroup".to_string(), optional(&agg_cfg, "nav_group"));
108
+ collection.insert("primaryField".to_string(), optional(&agg_cfg, "primary_field"));
109
+ collection.insert("listQuery".to_string(), optional(&agg_cfg, "list_query"));
110
+ collection.insert("stateField".to_string(), match lifecycle(aggregate) {
111
+ Some(l) => l.get("field").cloned().unwrap_or(Value::Null),
112
+ None => Value::Null,
113
+ });
114
+ collection.insert("statusLabels".to_string(), state_facet(&state_cfg, "label"));
115
+ collection.insert("statusTone".to_string(), state_facet(&state_cfg, "tone"));
116
+ collection.insert("statusNotes".to_string(), state_facet(&state_cfg, "note"));
117
+ collection.insert("statusAttention".to_string(), status_attention(&state_cfg));
118
+ collection.insert("fields".to_string(), Value::Array(fields.clone()));
119
+ collection.insert(
120
+ "columns".to_string(),
121
+ build_columns(agg_cfg.get("columns"), &fields, aggregate, by_name, key_for),
122
+ );
123
+ collection.insert(
124
+ "detailFields".to_string(),
125
+ build_detail_fields(agg_cfg.get("detail_fields"), &fields, aggregate, by_name, key_for),
126
+ );
127
+ collection.insert("transitions".to_string(), build_transitions(aggregate, &others, key_for, &formats));
128
+ collection.insert("alwaysAvailable".to_string(), build_always_available(aggregate, &others, key_for, &formats));
129
+ collection.insert(
130
+ "create".to_string(),
131
+ match creating {
132
+ None => Value::Null,
133
+ Some(command) => build_create(command, aggregate, &agg_cfg, identity, key_for, &formats, &noun_sing),
134
+ },
135
+ );
136
+ Value::Object(collection)
137
+ }
138
+
139
+ fn build_create(
140
+ command: &Value,
141
+ aggregate: &Value,
142
+ agg_cfg: &Value,
143
+ identity: Option<&Value>,
144
+ key_for: &KeyFor,
145
+ formats: &Value,
146
+ noun_sing: &str,
147
+ ) -> Value {
148
+ let preconditions = agg_cfg.get("preconditions");
149
+ // An identity-derived field is computed server-side at dispatch
150
+ // (`apply_identity!`); asking someone to type "P-004" by hand would
151
+ // only invite a collision with what the server already computes.
152
+ let identity_field = identity.and_then(|i| i.get("field")).and_then(|v| v.as_str());
153
+ let fields: Vec<Value> = array(command, "attributes")
154
+ .iter()
155
+ .map(|attribute| field_with_precondition(attribute, aggregate, key_for, preconditions, formats))
156
+ .filter(|f| identity_field.is_none_or(|name| f.get("key").and_then(|v| v.as_str()) != Some(name)))
157
+ .collect();
158
+
159
+ json!({
160
+ "command": snake(command.get("name").and_then(|v| v.as_str()).unwrap_or("")),
161
+ "label": string_or(agg_cfg, "create_label", &format!("Add {noun_sing}")),
162
+ "fields": fields,
163
+ })
164
+ }
165
+
166
+ /// `state_cfg.transform_values { |s| s[facet] }.compact` — only the
167
+ /// states that actually declare this facet.
168
+ fn state_facet(state_cfg: &Value, facet: &str) -> Value {
169
+ let mut out = Map::new();
170
+ if let Some(states) = state_cfg.as_object() {
171
+ for (state, entry) in states {
172
+ if let Some(value) = entry.get(facet).filter(|v| !v.is_null()) {
173
+ out.insert(state.clone(), value.clone());
174
+ }
175
+ }
176
+ }
177
+ Value::Object(out)
178
+ }
179
+
180
+ /// Explicit opt-in, not a guess from tone — a state earns a place on
181
+ /// the Overview's attention list only when config says `attention:`.
182
+ fn status_attention(state_cfg: &Value) -> Value {
183
+ let mut out = Vec::new();
184
+ if let Some(states) = state_cfg.as_object() {
185
+ for (state, entry) in states {
186
+ if truthy(entry.get("attention")) {
187
+ out.push(json!(state));
188
+ }
189
+ }
190
+ }
191
+ Value::Array(out)
192
+ }
193
+
194
+ fn build_columns(configured: Option<&Value>, fields: &[Value], aggregate: &Value, by_name: &HashMap<&str, &Value>, key_for: &KeyFor) -> Value {
195
+ let entries = match configured.filter(|v| !v.is_null()) {
196
+ // A configured empty list is a real answer, not "unconfigured"
197
+ // — Ruby's `configured || default` only falls through on nil,
198
+ // and `[]` is truthy there.
199
+ Some(list) => list.as_array().cloned().unwrap_or_default(),
200
+ None => {
201
+ let mut default: Vec<Value> = fields
202
+ .iter()
203
+ .filter(|f| !UNCOLUMNABLE.contains(&f.get("shape").and_then(|v| v.as_str()).unwrap_or("")))
204
+ .map(|f| f.get("key").cloned().unwrap_or(Value::Null))
205
+ .collect();
206
+ if lifecycle(aggregate).is_some() {
207
+ default.push(json!("__state__"));
208
+ }
209
+ default
210
+ }
211
+ };
212
+ Value::Array(entries.iter().map(|entry| column_for(entry, fields, aggregate, by_name, key_for)).collect())
213
+ }
214
+
215
+ /// `UiSchema.column_for` — a bare string names an attribute as-is; a
216
+ /// hash asks for something the shape alone can't say (`as:`/`head:`/`hop:`).
217
+ fn column_for(entry: &Value, fields: &[Value], aggregate: &Value, by_name: &HashMap<&str, &Value>, key_for: &KeyFor) -> Value {
218
+ let sort = sort_config(entry);
219
+
220
+ if entry.as_str() == Some("__state__") {
221
+ // `|| "state"` guards a hand-edited `columns:` naming
222
+ // `__state__` on an aggregate with no lifecycle at all: a
223
+ // config mistake degrades to a generic label, never a 500.
224
+ let field_name = lifecycle(aggregate).and_then(|l| l.get("field")).and_then(|v| v.as_str()).unwrap_or("state");
225
+ return merge(json!({"key": "__state__", "label": humanize_words(field_name), "shape": "pill"}), sort);
226
+ }
227
+
228
+ if let Some(name) = entry.as_str() {
229
+ let base = find_field(fields, name)
230
+ .cloned()
231
+ .unwrap_or_else(|| json!({"key": name, "label": humanize(name), "shape": "text_value"}));
232
+ return merge(base, sort);
233
+ }
234
+
235
+ if truthy(entry.get("hop")) {
236
+ return merge(hop_column(entry, fields, by_name, key_for), sort);
237
+ }
238
+
239
+ let key = entry.get("field").cloned().unwrap_or(Value::Null);
240
+ let key_name = key.as_str().unwrap_or("");
241
+ let base = find_field(fields, key_name).cloned().unwrap_or_else(|| json!({"key": key, "label": humanize(key_name)}));
242
+ let shape = match entry.get("as").filter(|v| !v.is_null()) {
243
+ Some(as_shape) => as_shape.clone(),
244
+ None => base.get("shape").cloned().unwrap_or(Value::Null),
245
+ };
246
+ let label = match entry.get("head").filter(|v| !v.is_null()) {
247
+ Some(head) => head.clone(),
248
+ None => base.get("label").cloned().unwrap_or(Value::Null),
249
+ };
250
+ let overridden = merge(base, json!({"key": key, "shape": shape, "label": label}));
251
+ merge(overridden, sort)
252
+ }
253
+
254
+ /// Unconfigured means sortable ascending — only a hash entry has
255
+ /// anywhere to carry `sortable:`/`sort_default:` at all.
256
+ fn sort_config(entry: &Value) -> Value {
257
+ if !entry.is_object() {
258
+ return json!({"sortable": true, "sortDefault": "asc"});
259
+ }
260
+ let sortable = match entry.get("sortable") {
261
+ Some(value) if !value.is_null() => value.clone(),
262
+ _ => json!(true),
263
+ };
264
+ let sort_default = match entry.get("sort_default").filter(|v| !v.is_null()) {
265
+ Some(value) => value.clone(),
266
+ None => json!("asc"),
267
+ };
268
+ json!({"sortable": sortable, "sortDefault": sort_default})
269
+ }
270
+
271
+ fn build_detail_fields(configured: Option<&Value>, fields: &[Value], aggregate: &Value, by_name: &HashMap<&str, &Value>, key_for: &KeyFor) -> Value {
272
+ let entries = match configured.filter(|v| !v.is_null()) {
273
+ Some(list) => list.as_array().cloned().unwrap_or_default(),
274
+ None => fields.iter().map(|f| f.get("key").cloned().unwrap_or(Value::Null)).collect(),
275
+ };
276
+ Value::Array(entries.iter().map(|entry| detail_field_for(entry, fields, aggregate, by_name, key_for)).collect())
277
+ }
278
+
279
+ fn detail_field_for(entry: &Value, fields: &[Value], aggregate: &Value, by_name: &HashMap<&str, &Value>, key_for: &KeyFor) -> Value {
280
+ let mut base = column_for(entry, fields, aggregate, by_name, key_for);
281
+ if base.get("shape").and_then(|v| v.as_str()) == Some("rows") {
282
+ base = apply_row_columns_filter(base, entry);
283
+ base = apply_row_state_filter(base, entry, aggregate);
284
+ }
285
+ let shape = base.get("shape").and_then(|v| v.as_str()).unwrap_or("");
286
+ let display = match entry.get("display").filter(|v| !v.is_null()) {
287
+ Some(display) => display.clone(),
288
+ None => json!(DISPLAY_BY_SHAPE.iter().find(|(s, _)| *s == shape).map(|(_, d)| *d).unwrap_or("simple")),
289
+ };
290
+ merge(base, json!({"display": display}))
291
+ }
292
+
293
+ /// Which of a rows field's own sub-columns to show, and in what order.
294
+ fn apply_row_columns_filter(base: Value, entry: &Value) -> Value {
295
+ let Some(wanted) = entry.get("columns").and_then(|v| v.as_array()) else { return base };
296
+ let existing = base.get("columns").and_then(|v| v.as_array()).cloned().unwrap_or_default();
297
+ let kept: Vec<Value> = wanted
298
+ .iter()
299
+ .filter_map(|want| {
300
+ let want = want.as_str()?;
301
+ existing.iter().find(|c| c.get("key").and_then(|v| v.as_str()) == Some(want)).cloned()
302
+ })
303
+ .collect();
304
+ merge(base, json!({"columns": kept}))
305
+ }
306
+
307
+ /// Which rows count as "current" — `list_of` never drops a removed
308
+ /// element, so the frontend filters using what this stamps per row.
309
+ fn apply_row_state_filter(base: Value, entry: &Value, aggregate: &Value) -> Value {
310
+ let Some(state_filter) = entry.get("state_filter").filter(|v| !v.is_null()) else { return base };
311
+ let key = match entry.get("field").and_then(|v| v.as_str()) {
312
+ Some(field) => field,
313
+ None => entry.as_str().unwrap_or(""),
314
+ };
315
+ let Some(attribute) = find_attribute(aggregate, key) else { return base };
316
+ let Some(entity) = find_entity(aggregate, type_of(attribute)) else { return base };
317
+ let Some(field_name) = lifecycle(entity).and_then(|l| l.get("field")).filter(|v| !v.is_null()) else { return base };
318
+ merge(base, json!({"stateFilter": {"field": field_name, "value": state_filter}}))
319
+ }
320
+
321
+ /// A second hop, for display only — resolves this reference, then one
322
+ /// of its references, falling back to the plain column if it can't.
323
+ fn hop_column(entry: &Value, fields: &[Value], by_name: &HashMap<&str, &Value>, key_for: &KeyFor) -> Value {
324
+ let key = entry.get("field").cloned().unwrap_or(Value::Null);
325
+ let key_name = key.as_str().unwrap_or("");
326
+ let hop = entry.get("hop").and_then(|v| v.as_str()).unwrap_or("");
327
+ let head = entry.get("head").filter(|v| !v.is_null());
328
+
329
+ let base_field = find_field(fields, key_name).filter(|f| f.get("shape").and_then(|v| v.as_str()) == Some("reference"));
330
+ let Some(base_field) = base_field else {
331
+ let label = head.cloned().unwrap_or_else(|| json!(humanize(key_name)));
332
+ return json!({"key": key, "label": label, "shape": "text_value"});
333
+ };
334
+
335
+ let target = base_field.get("target").and_then(|v| v.as_str()).unwrap_or("");
336
+ let hop_attribute = by_name.get(target).and_then(|agg| find_attribute(agg, hop)).filter(|a| is_reference(a));
337
+ let Some(hop_attribute) = hop_attribute else {
338
+ let label = head.cloned().unwrap_or_else(|| base_field.get("label").cloned().unwrap_or(Value::Null));
339
+ return merge(base_field.clone(), json!({"label": label}));
340
+ };
341
+
342
+ let hop_target = reference_target(type_of(hop_attribute)).unwrap_or_default();
343
+ json!({
344
+ "key": key,
345
+ "shape": "hop",
346
+ "label": head.cloned().unwrap_or_else(|| json!(humanize(hop))),
347
+ "through": hop,
348
+ "targetKey": base_field.get("targetKey").cloned().unwrap_or(Value::Null),
349
+ "hopTargetKey": key_for.get(&hop_target).map(|k| json!(k)).unwrap_or(Value::Null),
350
+ })
351
+ }
352
+
353
+ /// `Lifecycle#states` — the default state, then every state some
354
+ /// transition targets, deduplicated in that order.
355
+ pub(crate) fn lifecycle_states(aggregate: &Value) -> Vec<String> {
356
+ let Some(lifecycle) = lifecycle(aggregate) else { return Vec::new() };
357
+ let mut states = Vec::new();
358
+ if let Some(default) = lifecycle.get("default").and_then(|v| v.as_str()) {
359
+ states.push(default.to_string());
360
+ }
361
+ for transition in array(lifecycle, "transitions") {
362
+ if let Some(target) = transition.get("to_state").and_then(|v| v.as_str()) {
363
+ if !states.iter().any(|s| s == target) {
364
+ states.push(target.to_string());
365
+ }
366
+ }
367
+ }
368
+ states
369
+ }
370
+
371
+ /// `from: nil` means no constraint at all — an unconstrained transition
372
+ /// is offered from every state rather than resolved to just one.
373
+ fn build_transitions(aggregate: &Value, others: &[&Value], key_for: &KeyFor, formats: &Value) -> Value {
374
+ let Some(lifecycle) = lifecycle(aggregate) else { return json!({}) };
375
+ let transitions = array(lifecycle, "transitions");
376
+
377
+ let mut by_state = Map::new();
378
+ for state in lifecycle_states(aggregate) {
379
+ let mut available = Vec::new();
380
+ for command in others {
381
+ let name = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
382
+ let candidates: Vec<&Value> =
383
+ transitions.iter().filter(|t| t.get("command").and_then(|v| v.as_str()) == Some(name)).collect();
384
+ if candidates.is_empty() {
385
+ // "Always available" — handled separately below.
386
+ continue;
387
+ }
388
+ let hit = candidates.iter().find(|t| match t.get("from_state") {
389
+ None | Some(Value::Null) => true,
390
+ Some(from) => from.as_str() == Some(state.as_str()),
391
+ });
392
+ let Some(hit) = hit else { continue };
393
+ available.push(json!({
394
+ "command": snake(name),
395
+ "to": hit.get("to_state").cloned().unwrap_or(Value::Null),
396
+ "label": humanize_words(name),
397
+ "inputs": command_inputs(command, aggregate, key_for, formats),
398
+ }));
399
+ }
400
+ by_state.insert(state, Value::Array(available));
401
+ }
402
+ Value::Object(by_state)
403
+ }
404
+
405
+ /// A command with no lifecycle transition fires regardless of state
406
+ /// (Contract's `Revise`); an aggregate with none has every command here.
407
+ fn build_always_available(aggregate: &Value, others: &[&Value], key_for: &KeyFor, formats: &Value) -> Value {
408
+ let transitions = lifecycle(aggregate).map(|l| array(l, "transitions")).unwrap_or(&[]);
409
+ let available: Vec<Value> = others
410
+ .iter()
411
+ .filter(|command| {
412
+ let name = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
413
+ !transitions.iter().any(|t| t.get("command").and_then(|v| v.as_str()) == Some(name))
414
+ })
415
+ .map(|command| {
416
+ let name = command.get("name").and_then(|v| v.as_str()).unwrap_or("");
417
+ json!({
418
+ "command": snake(name),
419
+ "label": humanize_words(name),
420
+ "inputs": command_inputs(command, aggregate, key_for, formats),
421
+ })
422
+ })
423
+ .collect();
424
+ Value::Array(available)
425
+ }
426
+
427
+ fn command_inputs(command: &Value, aggregate: &Value, key_for: &KeyFor, formats: &Value) -> Value {
428
+ Value::Array(array(command, "attributes").iter().map(|a| field(a, aggregate, key_for, formats)).collect())
429
+ }
430
+
431
+ /// Stamps a create-form reference field with the state config says it
432
+ /// must point at, so the client's picker can filter what the server
433
+ /// would refuse anyway.
434
+ fn field_with_precondition(attribute: &Value, aggregate: &Value, key_for: &KeyFor, preconditions: Option<&Value>, formats: &Value) -> Value {
435
+ let descriptor = field(attribute, aggregate, key_for, formats);
436
+ if descriptor.get("shape").and_then(|v| v.as_str()) != Some("reference") {
437
+ return descriptor;
438
+ }
439
+ let key = descriptor.get("key").and_then(|v| v.as_str()).unwrap_or("");
440
+ let rule = preconditions
441
+ .and_then(|v| v.as_array())
442
+ .and_then(|rules| rules.iter().find(|r| r.get("field").and_then(|v| v.as_str()) == Some(key)));
443
+ let Some(state) = rule.and_then(|r| r.get("state")).filter(|v| !v.is_null()) else { return descriptor };
444
+ merge(descriptor, json!({"filterState": state}))
445
+ }
446
+
447
+ /// Declaration order, unless told otherwise — a nav item takes the
448
+ /// lowest `nav_order` among members sharing a `nav_group`.
449
+ fn build_nav(aggregates: &[&Value], config: &Value, key_for: &KeyFor) -> Value {
450
+ struct Group {
451
+ key: String,
452
+ label: String,
453
+ collections: Vec<String>,
454
+ sort: (f64, usize),
455
+ }
456
+
457
+ let mut order: Vec<String> = Vec::new();
458
+ let mut groups: HashMap<String, Group> = HashMap::new();
459
+
460
+ for (index, aggregate) in aggregates.iter().enumerate() {
461
+ let name = agg_name(aggregate);
462
+ let agg_cfg = dig(config, &["collections", name]).cloned().unwrap_or_else(|| json!({}));
463
+ let nav_group = agg_cfg.get("nav_group").and_then(|v| v.as_str());
464
+ let collection_key = key_for.get(name).cloned().unwrap_or_default();
465
+ let group_key = nav_group.map(String::from).unwrap_or_else(|| collection_key.clone());
466
+ let nav_order = agg_cfg.get("nav_order").and_then(|v| v.as_f64());
467
+
468
+ if !groups.contains_key(&group_key) {
469
+ order.push(group_key.clone());
470
+ let label = match nav_group {
471
+ Some(group) => humanize_words(group),
472
+ None => string_or(&agg_cfg, "label", &humanize_words(name)),
473
+ };
474
+ groups.insert(
475
+ group_key.clone(),
476
+ Group {
477
+ key: group_key.clone(),
478
+ label,
479
+ collections: Vec::new(),
480
+ sort: (nav_order.unwrap_or(f64::INFINITY), index),
481
+ },
482
+ );
483
+ }
484
+
485
+ let group = groups.get_mut(&group_key).expect("just inserted if missing");
486
+ group.collections.push(collection_key);
487
+ if let Some(nav_order) = nav_order {
488
+ if nav_order < group.sort.0 {
489
+ group.sort = (nav_order, index);
490
+ }
491
+ }
492
+ }
493
+
494
+ let mut ordered: Vec<&Group> = order.iter().filter_map(|k| groups.get(k)).collect();
495
+ ordered.sort_by(|a, b| a.sort.partial_cmp(&b.sort).expect("nav_order is never NaN"));
496
+ Value::Array(
497
+ ordered
498
+ .into_iter()
499
+ .map(|g| json!({"key": g.key, "label": g.label, "collections": g.collections}))
500
+ .collect(),
501
+ )
502
+ }
503
+
504
+ /// Overview stats, as data — a client-side fold over already-loaded
505
+ /// records, not a live query against current data.
506
+ fn build_overview(config: &Value) -> Value {
507
+ let stats: Vec<Value> = dig(config, &["overview", "stats"])
508
+ .and_then(|v| v.as_array())
509
+ .map(|stats| stats.iter().map(overview_stat).collect())
510
+ .unwrap_or_default();
511
+ json!({ "stats": stats })
512
+ }
513
+
514
+ fn overview_stat(stat: &Value) -> Value {
515
+ json!({
516
+ "label": stat.get("label").cloned().unwrap_or(Value::Null),
517
+ "collection": stat.get("collection").cloned().unwrap_or(Value::Null),
518
+ "where": stat.get("where").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!({})),
519
+ "as": stat.get("as").filter(|v| !v.is_null()).cloned().unwrap_or_else(|| json!("count")),
520
+ "field": stat.get("field").cloned().unwrap_or(Value::Null),
521
+ })
522
+ }
523
+
524
+ /// `UiSchema.field` — the single place an IR attribute becomes a UI
525
+ /// field descriptor, for aggregate fields, command inputs, and nested values.
526
+ pub fn field(attribute: &Value, aggregate: &Value, key_for: &KeyFor, formats: &Value) -> Value {
527
+ let name = attr_name(attribute);
528
+ let ty = type_of(attribute);
529
+ // `formats` hints on top of the derived shape — nothing else in the
530
+ // IR marks a lone String as date-shaped, or a lone Float as %.
531
+ let format = formats.get(name).filter(|v| !v.is_null()).cloned();
532
+ let base = json!({
533
+ "key": name,
534
+ "label": humanize(name),
535
+ "optional": attribute.get("optional").and_then(|v| v.as_bool()).unwrap_or(false),
536
+ });
537
+
538
+ // `to_h` merges the reduced identity into `id`; an aggregate whose
539
+ // own identity attribute is literally named `id` collides with it,
540
+ // so that key holds a bare scalar here, never `{value: ...}`.
541
+ if name == "id" && identity_heads(aggregate) == ["id"] {
542
+ return merge(base, json!({"shape": "text_value"}));
543
+ }
544
+
545
+ if let Some(target) = reference_target(ty) {
546
+ return merge(
547
+ base,
548
+ json!({
549
+ "shape": "reference",
550
+ "target": target,
551
+ "targetKey": key_for.get(&target).map(|k| json!(k)).unwrap_or(Value::Null),
552
+ }),
553
+ );
554
+ }
555
+
556
+ if PRIMITIVES.contains(&ty) {
557
+ return merge(base, primitive_shape(ty, format));
558
+ }
559
+
560
+ // A value object belongs to its own aggregate's table only — one
561
+ // typed by another aggregate falls through here, exactly as in Ruby.
562
+ if let Some(value_object) = find_value_object(aggregate, ty) {
563
+ if truthy(value_object.get("closed_set")) {
564
+ return merge(base, enum_shape(value_object));
565
+ }
566
+ if is_list(attribute) {
567
+ return merge(base, list_shape(value_object, aggregate, key_for, formats));
568
+ }
569
+ return merge(base, scalar_vo_shape(value_object, aggregate, key_for, formats, format));
570
+ }
571
+
572
+ // An entity, not a value object — a `list_of` of pieces rather than
573
+ // plain values; a piece's own references render normally through
574
+ // this same recursion.
575
+ if is_list(attribute) {
576
+ if let Some(entity) = find_entity(aggregate, ty) {
577
+ return merge(base, json!({"shape": "rows", "columns": row_columns(entity, aggregate, key_for, formats)}));
578
+ }
579
+ }
580
+
581
+ // Defensive only — a resolved bluebook always has one of the above.
582
+ merge(base, json!({"shape": "text_value"}))
583
+ }
584
+
585
+ fn primitive_shape(ty: &str, format: Option<Value>) -> Value {
586
+ let mut shape = Map::new();
587
+ shape.insert("shape".to_string(), json!(if numeric(ty) { "number" } else { "text_value" }));
588
+ if let Some(format) = format {
589
+ shape.insert("format".to_string(), format);
590
+ }
591
+ Value::Object(shape)
592
+ }
593
+
594
+ fn enum_shape(value_object: &Value) -> Value {
595
+ let attributes = array(value_object, "attributes");
596
+ let discriminant = attributes.first().map(|a| attr_name(a)).unwrap_or("value");
597
+ let options: Vec<Value> = array(value_object, "members")
598
+ .iter()
599
+ .filter_map(|member| {
600
+ member.as_array()?.iter().find_map(|pair| {
601
+ let pair = pair.as_array()?;
602
+ (pair.first()?.as_str()? == discriminant).then(|| pair.get(1).cloned())?
603
+ })
604
+ })
605
+ .collect();
606
+ json!({"shape": "enum", "field": discriminant, "options": options})
607
+ }
608
+
609
+ fn scalar_vo_shape(value_object: &Value, aggregate: &Value, key_for: &KeyFor, formats: &Value, format: Option<Value>) -> Value {
610
+ if money_shaped(value_object) {
611
+ return json!({"shape": "money"});
612
+ }
613
+ if let Some(sole) = sole_attribute(value_object) {
614
+ return single_attr_shape(sole, format);
615
+ }
616
+ let fields: Vec<Value> = array(value_object, "attributes").iter().map(|a| field(a, aggregate, key_for, formats)).collect();
617
+ json!({"shape": "compound", "fields": fields})
618
+ }
619
+
620
+ fn list_shape(value_object: &Value, aggregate: &Value, key_for: &KeyFor, formats: &Value) -> Value {
621
+ match sole_attribute(value_object) {
622
+ Some(sole) => json!({"shape": "lines", "field": attr_name(sole)}),
623
+ None => json!({"shape": "rows", "columns": row_columns(value_object, aggregate, key_for, formats)}),
624
+ }
625
+ }
626
+
627
+ /// A row lays a value object's attributes across a table's width; a
628
+ /// cents+currency pair collapses into one "money" column, as at top level.
629
+ fn row_columns(shape: &Value, aggregate: &Value, key_for: &KeyFor, formats: &Value) -> Value {
630
+ let attributes = array(shape, "attributes");
631
+ let names: Vec<&str> = attributes.iter().map(|a| attr_name(a)).collect();
632
+ if !(names.contains(&"cents") && names.contains(&"currency")) {
633
+ return Value::Array(attributes.iter().map(|a| field(a, aggregate, key_for, formats)).collect());
634
+ }
635
+ Value::Array(
636
+ attributes
637
+ .iter()
638
+ .filter(|a| attr_name(a) != "currency")
639
+ .map(|a| {
640
+ if attr_name(a) == "cents" {
641
+ json!({"key": "cents", "label": "Amount ($)", "shape": "money"})
642
+ } else {
643
+ field(a, aggregate, key_for, formats)
644
+ }
645
+ })
646
+ .collect(),
647
+ )
648
+ }
649
+
650
+ /// A scalar value object's wire value is `{<attr name>: ...}`, never a
651
+ /// bare scalar — `field:` names that key so the client knows to unwrap it.
652
+ fn single_attr_shape(attribute: &Value, format: Option<Value>) -> Value {
653
+ let name = attr_name(attribute);
654
+ let shape = if numeric(type_of(attribute)) {
655
+ "number"
656
+ } else {
657
+ match name {
658
+ "text" => "text",
659
+ "address" => "address",
660
+ _ => "text_value",
661
+ }
662
+ };
663
+ let mut out = Map::new();
664
+ out.insert("shape".to_string(), json!(shape));
665
+ out.insert("field".to_string(), json!(name));
666
+ if let Some(format) = format {
667
+ out.insert("format".to_string(), format);
668
+ }
669
+ Value::Object(out)
670
+ }
671
+
672
+ pub fn agg_name(aggregate: &Value) -> &str {
673
+ aggregate.get("name").and_then(|v| v.as_str()).unwrap_or("")
674
+ }
675
+
676
+ pub(crate) fn attr_name(attribute: &Value) -> &str {
677
+ attribute.get("name").and_then(|v| v.as_str()).unwrap_or("")
678
+ }
679
+
680
+ pub(crate) fn type_of(attribute: &Value) -> &str {
681
+ attribute.get("type").and_then(|v| v.as_str()).unwrap_or("")
682
+ }
683
+
684
+ pub(crate) fn array<'a>(value: &'a Value, field: &str) -> &'a [Value] {
685
+ value.get(field).and_then(|v| v.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
686
+ }
687
+
688
+ pub fn commands(aggregate: &Value) -> Vec<&Value> {
689
+ array(aggregate, "commands").iter().collect()
690
+ }
691
+
692
+ /// `IR::Command#creates?` — true exactly when the command declares no
693
+ /// `references`.
694
+ pub fn creates(command: &Value) -> bool {
695
+ command.get("references").map(|r| r.is_null()).unwrap_or(true)
696
+ }
697
+
698
+ fn lifecycle(aggregate: &Value) -> Option<&Value> {
699
+ aggregate.get("lifecycle").filter(|v| !v.is_null())
700
+ }
701
+
702
+ pub fn find_attribute<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
703
+ array(aggregate, "attributes").iter().find(|a| attr_name(a) == name)
704
+ }
705
+
706
+ pub(crate) fn find_value_object<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
707
+ array(aggregate, "value_objects").iter().find(|v| v.get("name").and_then(|n| n.as_str()) == Some(name))
708
+ }
709
+
710
+ pub(crate) fn find_entity<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
711
+ array(aggregate, "entities").iter().find(|e| e.get("name").and_then(|n| n.as_str()) == Some(name))
712
+ }
713
+
714
+ fn find_field<'a>(fields: &'a [Value], key: &str) -> Option<&'a Value> {
715
+ fields.iter().find(|f| f.get("key").and_then(|v| v.as_str()) == Some(key))
716
+ }
717
+
718
+ /// `Reference<X>` -> `X` — the export's pinned spelling, the same
719
+ /// prefix convention `web.rs`'s own `reference_target` reads.
720
+ pub fn reference_target(ty: &str) -> Option<String> {
721
+ ty.strip_prefix("Reference<")?.strip_suffix('>').map(String::from)
722
+ }
723
+
724
+ pub(crate) fn is_reference(attribute: &Value) -> bool {
725
+ reference_target(type_of(attribute)).is_some()
726
+ }
727
+
728
+ pub(crate) fn is_list(attribute: &Value) -> bool {
729
+ attribute.get("list").and_then(|v| v.as_bool()).unwrap_or(false)
730
+ }
731
+
732
+ /// `identity_heads` — the first segment of each declared identity
733
+ /// path ("name.value" -> "name").
734
+ fn identity_heads(aggregate: &Value) -> Vec<&str> {
735
+ array(aggregate, "identified_by")
736
+ .iter()
737
+ .filter_map(|p| p.as_str())
738
+ .map(|p| p.split('.').next().unwrap_or(p))
739
+ .collect()
740
+ }
741
+
742
+ /// `Presentation::ValueObjectShape.money?` — exactly `{cents,
743
+ /// currency}`, sorted, and nothing else.
744
+ fn money_shaped(value_object: &Value) -> bool {
745
+ let mut names: Vec<&str> = array(value_object, "attributes").iter().map(|a| attr_name(a)).collect();
746
+ names.sort_unstable();
747
+ names == ["cents", "currency"]
748
+ }
749
+
750
+ /// `Presentation::ValueObjectShape.sole_attribute` — a value object
751
+ /// with exactly one attribute is a name for a scalar, not a group.
752
+ fn sole_attribute(value_object: &Value) -> Option<&Value> {
753
+ let attributes = array(value_object, "attributes");
754
+ (attributes.len() == 1).then(|| &attributes[0])
755
+ }
756
+
757
+ fn numeric(ty: &str) -> bool {
758
+ ty == "Integer" || ty == "Float"
759
+ }
760
+
761
+ /// `config.dig(...)` with Ruby truthiness — a missing key and an
762
+ /// explicit null are both "not configured"; `[]`/`{}` are real answers.
763
+ fn dig<'a>(config: &'a Value, path: &[&str]) -> Option<&'a Value> {
764
+ let mut node = config;
765
+ for segment in path {
766
+ node = node.get(segment)?;
767
+ }
768
+ (!node.is_null()).then_some(node)
769
+ }
770
+
771
+ fn truthy(value: Option<&Value>) -> bool {
772
+ match value {
773
+ None | Some(Value::Null) => false,
774
+ Some(Value::Bool(b)) => *b,
775
+ Some(_) => true,
776
+ }
777
+ }
778
+
779
+ fn optional(config: &Value, key: &str) -> Value {
780
+ config.get(key).cloned().unwrap_or(Value::Null)
781
+ }
782
+
783
+ fn string_or(config: &Value, key: &str, fallback: &str) -> String {
784
+ config.get(key).and_then(|v| v.as_str()).map(String::from).unwrap_or_else(|| fallback.to_string())
785
+ }
786
+
787
+ /// Ruby's `Hash#merge` semantics: an existing key keeps its position,
788
+ /// serde_json's ordered map preserves it the same way Ruby's hash does.
789
+ fn merge(base: Value, extra: Value) -> Value {
790
+ let mut out = match base {
791
+ Value::Object(map) => map,
792
+ _ => Map::new(),
793
+ };
794
+ if let Value::Object(extra) = extra {
795
+ for (key, value) in extra {
796
+ out.insert(key, value);
797
+ }
798
+ }
799
+ Value::Object(out)
800
+ }
801
+
802
+ static SNAKE_ACRONYM: LazyLock<Regex> =
803
+ LazyLock::new(|| Regex::new(r"([A-Z]+)([A-Z][a-z])").expect("a literal pattern must compile"));
804
+ static SNAKE_BOUNDARY: LazyLock<Regex> =
805
+ LazyLock::new(|| Regex::new(r"([a-z\d])([A-Z])").expect("a literal pattern must compile"));
806
+ static PLURAL_Y: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"[^aeiou]y$").expect("a literal pattern must compile"));
807
+ static PLURAL_ES: LazyLock<Regex> = LazyLock::new(|| Regex::new(r"(s|x|z|ch|sh)$").expect("a literal pattern must compile"));
808
+
809
+ /// `Naming.snake` — the same two word-boundary substitutions, then
810
+ /// downcase.
811
+ pub fn snake(text: &str) -> String {
812
+ let once = SNAKE_ACRONYM.replace_all(text, "${1}_${2}");
813
+ let twice = SNAKE_BOUNDARY.replace_all(&once, "${1}_${2}");
814
+ twice.to_lowercase()
815
+ }
816
+
817
+ /// `Naming.plural` — three suffix rules, exactly as declared; getting
818
+ /// a collection name wrong reads as a typo forever.
819
+ pub fn plural(word: &str) -> String {
820
+ if PLURAL_Y.is_match(word) {
821
+ return format!("{}ies", &word[..word.len() - 1]);
822
+ }
823
+ if PLURAL_ES.is_match(word) {
824
+ return format!("{word}es");
825
+ }
826
+ format!("{word}s")
827
+ }
828
+
829
+ /// `UiSchema.humanize_words` — snake_case, then each word capitalized.
830
+ /// Not `Naming.words`: "ATMCard" reads "Atm Card" here, as in Ruby.
831
+ fn humanize_words(text: &str) -> String {
832
+ // Ruby's `String#split("_")` drops trailing empty segments; Rust's
833
+ // does not. Load-bearing for `"__state__"`, which reaches here
834
+ // literally and must render `" State"`, not `" State "`.
835
+ let snaked = snake(text);
836
+ let mut words: Vec<&str> = snaked.split('_').collect();
837
+ while words.last() == Some(&"") {
838
+ words.pop();
839
+ }
840
+ words
841
+ .into_iter()
842
+ .map(|word| {
843
+ let mut chars = word.chars();
844
+ match chars.next() {
845
+ Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
846
+ None => String::new(),
847
+ }
848
+ })
849
+ .collect::<Vec<String>>()
850
+ .join(" ")
851
+ }
852
+
853
+ /// `UiSchema.humanize` — a trailing `_id` is scaffolding, not a word a
854
+ /// person reads ("client_id" is the Client column).
855
+ fn humanize(key: &str) -> String {
856
+ humanize_words(key.strip_suffix("_id").unwrap_or(key))
857
+ }
858
+
859
+ #[cfg(test)]
860
+ mod tests {
861
+ use super::*;
862
+
863
+ // Embryonaut-shaped, because that's the domain the Ruby engine
864
+ // actually serves. Each test below pins one rule of ui_schema.rb.
865
+
866
+ fn domain() -> Value {
867
+ json!({
868
+ "name": "SampleApp",
869
+ "aggregates": [
870
+ {
871
+ "name": "Client",
872
+ "identified_by": ["reference.value"],
873
+ "attributes": [
874
+ {"name": "reference", "type": "ClientReference", "list": false, "optional": false},
875
+ {"name": "name", "type": "ClientName", "list": false, "optional": false},
876
+ {"name": "contact_email", "type": "EmailAddress", "list": false, "optional": true},
877
+ {"name": "notes", "type": "Note", "list": true, "optional": true}
878
+ ],
879
+ "value_objects": [
880
+ {"name": "ClientReference", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
881
+ {"name": "ClientName", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
882
+ {"name": "EmailAddress", "attributes": [{"name": "address", "type": "String"}], "closed_set": false, "members": []},
883
+ {"name": "Note", "attributes": [{"name": "text", "type": "String"}], "closed_set": false, "members": []}
884
+ ],
885
+ "entities": [],
886
+ "queries": [
887
+ {"name": "Active", "attributes": []},
888
+ {"name": "ByName", "attributes": [{"name": "name", "type": "ClientName", "list": false, "optional": false}]}
889
+ ],
890
+ "commands": [
891
+ {"name": "Register", "references": null, "attributes": [
892
+ {"name": "reference", "type": "ClientReference", "list": false, "optional": false},
893
+ {"name": "name", "type": "ClientName", "list": false, "optional": false}
894
+ ]},
895
+ {"name": "Activate", "references": "Client", "attributes": []},
896
+ {"name": "Note", "references": "Client", "attributes": [
897
+ {"name": "note", "type": "Note", "list": false, "optional": false}
898
+ ]}
899
+ ],
900
+ "lifecycle": {"field": "status", "default": "prospect",
901
+ "transitions": [{"command": "Activate", "to_state": "active", "from_state": "prospect"}]}
902
+ },
903
+ {
904
+ "name": "Proposal",
905
+ "identified_by": ["number.value"],
906
+ "attributes": [
907
+ {"name": "number", "type": "ProposalNumber", "list": false, "optional": false},
908
+ {"name": "client", "type": "Reference<Client>", "list": false, "optional": false},
909
+ {"name": "line_items", "type": "LineItem", "list": true, "optional": false},
910
+ {"name": "kind", "type": "ProposalKind", "list": false, "optional": true},
911
+ {"name": "value", "type": "Money", "list": false, "optional": true}
912
+ ],
913
+ "value_objects": [
914
+ {"name": "ProposalNumber", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []},
915
+ {"name": "Money", "attributes": [{"name": "cents", "type": "Integer"}, {"name": "currency", "type": "String"}],
916
+ "closed_set": false, "members": []},
917
+ {"name": "LineItem", "attributes": [
918
+ {"name": "description", "type": "String"},
919
+ {"name": "cents", "type": "Integer"},
920
+ {"name": "currency", "type": "String"}
921
+ ], "closed_set": false, "members": []},
922
+ {"name": "ProposalKind", "attributes": [{"name": "value", "type": "String"}], "closed_set": true,
923
+ "members": [[["value", "fixed"]], [["value", "retainer"]]]}
924
+ ],
925
+ "entities": [],
926
+ "queries": [],
927
+ "commands": [
928
+ {"name": "Draft", "references": null, "attributes": [
929
+ {"name": "number", "type": "ProposalNumber", "list": false, "optional": false},
930
+ {"name": "client", "type": "Reference<Client>", "list": false, "optional": false}
931
+ ]},
932
+ {"name": "Send", "references": "Proposal", "attributes": []},
933
+ {"name": "Accept", "references": "Proposal", "attributes": []},
934
+ {"name": "Revise", "references": "Proposal", "attributes": [
935
+ {"name": "note", "type": "ProposalNumber", "list": false, "optional": true}
936
+ ]}
937
+ ],
938
+ "lifecycle": {"field": "status", "default": "drafted", "transitions": [
939
+ {"command": "Send", "to_state": "sent", "from_state": "drafted"},
940
+ {"command": "Accept", "to_state": "accepted", "from_state": "sent"}
941
+ ]}
942
+ },
943
+ {
944
+ "name": "Invoice",
945
+ "identified_by": ["number.value"],
946
+ "attributes": [
947
+ {"name": "number", "type": "InvoiceNumber", "list": false, "optional": false},
948
+ {"name": "proposal_id", "type": "Reference<Proposal>", "list": false, "optional": false}
949
+ ],
950
+ "value_objects": [
951
+ {"name": "InvoiceNumber", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []}
952
+ ],
953
+ "entities": [], "queries": [], "commands": [],
954
+ "lifecycle": null
955
+ }
956
+ ]
957
+ })
958
+ }
959
+
960
+ fn client() -> Value {
961
+ domain()["aggregates"][0].clone()
962
+ }
963
+
964
+ fn proposal() -> Value {
965
+ domain()["aggregates"][1].clone()
966
+ }
967
+
968
+ fn keys() -> KeyFor {
969
+ let domain = domain();
970
+ let aggregates = aggregates(&domain);
971
+ key_for(&aggregates, &json!({}))
972
+ }
973
+
974
+ fn field_named(aggregate: &Value, name: &str) -> Value {
975
+ let attribute = find_attribute(aggregate, name).expect("a declared attribute").clone();
976
+ field(&attribute, aggregate, &keys(), &Value::Null)
977
+ }
978
+
979
+ #[test]
980
+ fn a_collection_key_is_the_pluralized_snake_name_unless_config_renames_it() {
981
+ assert_eq!(collection_key(&client(), &json!({})), "clients");
982
+ assert_eq!(collection_key(&proposal(), &json!({})), "proposals");
983
+ assert_eq!(
984
+ collection_key(&proposal(), &json!({"collections": {"Proposal": {"key": "pipeline"}}})),
985
+ "pipeline"
986
+ );
987
+ }
988
+
989
+ #[test]
990
+ fn plural_follows_namings_own_three_rules() {
991
+ assert_eq!(plural("client"), "clients");
992
+ assert_eq!(plural("company"), "companies");
993
+ assert_eq!(plural("address"), "addresses");
994
+ assert_eq!(plural("box"), "boxes");
995
+ // A vowel before the y takes a plain "s" — "days", not "daies".
996
+ assert_eq!(plural("day"), "days");
997
+ }
998
+
999
+ #[test]
1000
+ fn humanize_drops_a_trailing_id_the_way_the_console_does() {
1001
+ assert_eq!(humanize("client_id"), "Client");
1002
+ assert_eq!(humanize("contact_email"), "Contact Email");
1003
+ assert_eq!(humanize_words("RecurringPayment"), "Recurring Payment");
1004
+ }
1005
+
1006
+ // Pins why `humanize_words` drops trailing empty segments: Ruby's
1007
+ // `String#split("_")` drops them, Rust's does not, and
1008
+ // `"__state__"` reaches here literally often enough to matter.
1009
+ #[test]
1010
+ fn humanize_words_splits_the_way_ruby_splits_leading_gaps_kept_trailing_dropped() {
1011
+ assert_eq!(humanize_words("__state__"), " State");
1012
+ assert_eq!(humanize_words("a__b"), "A B");
1013
+ assert_eq!(humanize_words(""), "");
1014
+ }
1015
+
1016
+ // A faithful port of a real Ruby quirk: a configured `__state__`
1017
+ // column (always the hash form) misses `column_for`'s bare-string
1018
+ // branch and comes out humanized literally with a null shape —
1019
+ // pinned here, not "fixed", since index.html renders it correctly.
1020
+ #[test]
1021
+ fn a_state_column_configured_as_a_hash_keeps_the_ruby_engines_own_literal_label() {
1022
+ let config = json!({"collections": {"Client": {"columns": [{"field": "name"}, {"field": "__state__"}]}}});
1023
+ let schema = build(&domain(), &config);
1024
+
1025
+ assert_eq!(
1026
+ schema["collections"]["clients"]["columns"][1],
1027
+ json!({"key": "__state__", "label": " State", "shape": null, "sortable": true, "sortDefault": "asc"})
1028
+ );
1029
+ }
1030
+
1031
+ #[test]
1032
+ fn a_single_attribute_value_object_keeps_the_field_its_wire_value_is_wrapped_in() {
1033
+ assert_eq!(
1034
+ field_named(&client(), "name"),
1035
+ json!({"key": "name", "label": "Name", "optional": false, "shape": "text_value", "field": "value"})
1036
+ );
1037
+ }
1038
+
1039
+ #[test]
1040
+ fn an_address_named_scalar_earns_the_address_shape_and_a_text_named_one_earns_prose() {
1041
+ assert_eq!(field_named(&client(), "contact_email")["shape"], "address");
1042
+ assert_eq!(field_named(&client(), "contact_email")["field"], "address");
1043
+ // `notes` is a list of a sole-attribute VO — lines, not rows.
1044
+ assert_eq!(field_named(&client(), "notes"), json!({"key": "notes", "label": "Notes", "optional": true, "shape": "lines", "field": "text"}));
1045
+ }
1046
+
1047
+ #[test]
1048
+ fn a_reference_carries_both_the_target_and_the_collection_key_it_lives_under() {
1049
+ assert_eq!(
1050
+ field_named(&proposal(), "client"),
1051
+ json!({"key": "client", "label": "Client", "optional": false, "shape": "reference",
1052
+ "target": "Client", "targetKey": "clients"})
1053
+ );
1054
+ }
1055
+
1056
+ #[test]
1057
+ fn a_cents_currency_value_object_is_money_and_a_list_of_them_is_rows_with_one_money_column() {
1058
+ assert_eq!(field_named(&proposal(), "value")["shape"], "money");
1059
+ assert_eq!(
1060
+ field_named(&proposal(), "line_items"),
1061
+ json!({"key": "line_items", "label": "Line Items", "optional": false, "shape": "rows", "columns": [
1062
+ {"key": "description", "label": "Description", "optional": false, "shape": "text_value"},
1063
+ {"key": "cents", "label": "Amount ($)", "shape": "money"}
1064
+ ]})
1065
+ );
1066
+ }
1067
+
1068
+ #[test]
1069
+ fn a_closed_set_becomes_an_enum_with_its_declared_members_as_options() {
1070
+ assert_eq!(
1071
+ field_named(&proposal(), "kind"),
1072
+ json!({"key": "kind", "label": "Kind", "optional": true, "shape": "enum", "field": "value",
1073
+ "options": ["fixed", "retainer"]})
1074
+ );
1075
+ }
1076
+
1077
+ #[test]
1078
+ fn a_field_format_rides_on_top_of_the_derived_shape_rather_than_replacing_it() {
1079
+ let aggregate = client();
1080
+ let attribute = find_attribute(&aggregate, "name").expect("declared").clone();
1081
+ let shaped = field(&attribute, &aggregate, &keys(), &json!({"name": "date"}));
1082
+
1083
+ assert_eq!(shaped["shape"], "text_value");
1084
+ assert_eq!(shaped["format"], "date");
1085
+ }
1086
+
1087
+ // An aggregate whose own identity-bearing attribute is literally
1088
+ // named `id` collides with the `id` key `to_h` merges in — the
1089
+ // descriptor has to say "bare scalar", not "{value: ...}".
1090
+ #[test]
1091
+ fn an_aggregate_identified_by_its_own_id_attribute_reads_as_a_bare_scalar() {
1092
+ let aggregate = json!({
1093
+ "name": "Item", "identified_by": ["id.value"],
1094
+ "attributes": [{"name": "id", "type": "ItemId", "list": false, "optional": false}],
1095
+ "value_objects": [{"name": "ItemId", "attributes": [{"name": "value", "type": "String"}], "closed_set": false, "members": []}],
1096
+ "entities": [], "commands": [], "queries": [], "lifecycle": null
1097
+ });
1098
+
1099
+ assert_eq!(field_named(&aggregate, "id"), json!({"key": "id", "label": "Id", "optional": false, "shape": "text_value"}));
1100
+ // …while an aggregate identified by any other attribute keeps
1101
+ // its real wrapped shape, `field: "value"` and all.
1102
+ assert_eq!(field_named(&client(), "reference")["field"], "value");
1103
+ }
1104
+
1105
+ #[test]
1106
+ fn an_unconfigured_collection_derives_every_column_plus_the_state_pill() {
1107
+ let schema = build(&domain(), &json!({}));
1108
+ let columns = &schema["collections"]["clients"]["columns"];
1109
+
1110
+ // `notes` is lines-shaped, so it is left out of the default
1111
+ // column list; `__state__` is appended because Client has a
1112
+ // lifecycle.
1113
+ assert_eq!(
1114
+ columns.as_array().expect("columns").iter().map(|c| c["key"].clone()).collect::<Vec<Value>>(),
1115
+ vec![json!("reference"), json!("name"), json!("contact_email"), json!("__state__")]
1116
+ );
1117
+ assert_eq!(columns[3], json!({"key": "__state__", "label": "Status", "shape": "pill", "sortable": true, "sortDefault": "asc"}));
1118
+ assert_eq!(schema["collections"]["clients"]["stateField"], "status");
1119
+ }
1120
+
1121
+ #[test]
1122
+ fn an_aggregate_with_no_lifecycle_gets_no_state_column_and_a_null_state_field() {
1123
+ let schema = build(&domain(), &json!({}));
1124
+ let invoices = &schema["collections"]["invoices"];
1125
+
1126
+ assert_eq!(invoices["stateField"], Value::Null);
1127
+ assert_eq!(invoices["transitions"], json!({}));
1128
+ assert!(
1129
+ !invoices["columns"].as_array().expect("columns").iter().any(|c| c["key"] == "__state__"),
1130
+ "{invoices}"
1131
+ );
1132
+ }
1133
+
1134
+ #[test]
1135
+ fn a_configured_column_can_rename_its_head_and_opt_out_of_sorting() {
1136
+ let config = json!({"collections": {"Client": {"columns": [
1137
+ {"field": "name", "head": "Who", "sortable": false, "sort_default": "desc"}
1138
+ ]}}});
1139
+ let schema = build(&domain(), &config);
1140
+
1141
+ assert_eq!(
1142
+ schema["collections"]["clients"]["columns"],
1143
+ json!([{"key": "name", "label": "Who", "optional": false, "shape": "text_value", "field": "value",
1144
+ "sortable": false, "sortDefault": "desc"}])
1145
+ );
1146
+ }
1147
+
1148
+ #[test]
1149
+ fn a_hop_column_reaches_through_one_reference_to_the_next() {
1150
+ let config = json!({"collections": {"Invoice": {"columns": [{"field": "proposal_id", "hop": "client"}]}}});
1151
+ let schema = build(&domain(), &config);
1152
+
1153
+ assert_eq!(
1154
+ schema["collections"]["invoices"]["columns"][0],
1155
+ json!({"key": "proposal_id", "shape": "hop", "label": "Client", "through": "client",
1156
+ "targetKey": "proposals", "hopTargetKey": "clients", "sortable": true, "sortDefault": "asc"})
1157
+ );
1158
+ }
1159
+
1160
+ #[test]
1161
+ fn a_hop_that_does_not_resolve_degrades_to_the_plain_reference_column() {
1162
+ let config = json!({"collections": {"Invoice": {"columns": [{"field": "proposal_id", "hop": "not_a_field"}]}}});
1163
+ let schema = build(&domain(), &config);
1164
+ let column = &schema["collections"]["invoices"]["columns"][0];
1165
+
1166
+ assert_eq!(column["shape"], "reference");
1167
+ assert_eq!(column["targetKey"], "proposals");
1168
+ }
1169
+
1170
+ #[test]
1171
+ fn detail_fields_take_their_display_from_their_own_shape_unless_config_says_otherwise() {
1172
+ let schema = build(&domain(), &json!({}));
1173
+ let detail: HashMap<String, Value> = schema["collections"]["proposals"]["detailFields"]
1174
+ .as_array()
1175
+ .expect("detail fields")
1176
+ .iter()
1177
+ .map(|f| (f["key"].as_str().unwrap_or("").to_string(), f["display"].clone()))
1178
+ .collect();
1179
+
1180
+ assert_eq!(detail["line_items"], "rows");
1181
+ assert_eq!(detail["number"], "simple");
1182
+
1183
+ let config = json!({"collections": {"Proposal": {"detail_fields": [{"field": "number", "display": "mono"}]}}});
1184
+ let configured = build(&domain(), &config);
1185
+ assert_eq!(configured["collections"]["proposals"]["detailFields"][0]["display"], "mono");
1186
+ }
1187
+
1188
+ #[test]
1189
+ fn transitions_are_grouped_by_the_state_they_can_fire_from() {
1190
+ let schema = build(&domain(), &json!({}));
1191
+ let transitions = &schema["collections"]["proposals"]["transitions"];
1192
+
1193
+ assert_eq!(transitions["drafted"], json!([{"command": "send", "to": "sent", "label": "Send", "inputs": []}]));
1194
+ assert_eq!(transitions["sent"], json!([{"command": "accept", "to": "accepted", "label": "Accept", "inputs": []}]));
1195
+ assert_eq!(transitions["accepted"], json!([]));
1196
+ }
1197
+
1198
+ #[test]
1199
+ fn a_command_with_no_transition_at_all_is_always_available_rather_than_dropped() {
1200
+ let schema = build(&domain(), &json!({}));
1201
+
1202
+ assert_eq!(
1203
+ schema["collections"]["proposals"]["alwaysAvailable"],
1204
+ json!([{"command": "revise", "label": "Revise", "inputs": [
1205
+ {"key": "note", "label": "Note", "optional": true, "shape": "text_value", "field": "value"}
1206
+ ]}])
1207
+ );
1208
+ }
1209
+
1210
+ #[test]
1211
+ fn every_non_creating_command_is_always_available_when_there_is_no_lifecycle() {
1212
+ let aggregate = json!({
1213
+ "name": "Ledger", "identified_by": ["id"], "attributes": [], "value_objects": [], "entities": [],
1214
+ "queries": [], "lifecycle": null,
1215
+ "commands": [{"name": "Open", "references": null, "attributes": []},
1216
+ {"name": "Post", "references": "Ledger", "attributes": []}]
1217
+ });
1218
+ let domain = json!({"name": "Books", "aggregates": [aggregate]});
1219
+ let schema = build(&domain, &json!({}));
1220
+
1221
+ assert_eq!(schema["collections"]["ledgers"]["transitions"], json!({}));
1222
+ assert_eq!(schema["collections"]["ledgers"]["alwaysAvailable"][0]["command"], "post");
1223
+ }
1224
+
1225
+ #[test]
1226
+ fn a_create_form_leaves_out_the_field_the_server_mints_for_itself() {
1227
+ let config = json!({"collections": {"Client": {
1228
+ "identity": {"field": "reference", "strategy": "slug", "source": "name"},
1229
+ "create_label": "Add a client"
1230
+ }}});
1231
+ let schema = build(&domain(), &config);
1232
+ let create = &schema["collections"]["clients"]["create"];
1233
+
1234
+ assert_eq!(create["command"], "register");
1235
+ assert_eq!(create["label"], "Add a client");
1236
+ assert_eq!(
1237
+ create["fields"].as_array().expect("fields").iter().map(|f| f["key"].clone()).collect::<Vec<Value>>(),
1238
+ vec![json!("name")]
1239
+ );
1240
+ }
1241
+
1242
+ #[test]
1243
+ fn a_create_form_with_no_identity_rule_keeps_every_declared_field_and_a_derived_label() {
1244
+ let schema = build(&domain(), &json!({}));
1245
+ let create = &schema["collections"]["clients"]["create"];
1246
+
1247
+ assert_eq!(create["label"], "Add client");
1248
+ assert_eq!(create["fields"].as_array().expect("fields").len(), 2);
1249
+ }
1250
+
1251
+ #[test]
1252
+ fn a_precondition_tells_a_reference_picker_which_records_it_may_offer() {
1253
+ let config = json!({"collections": {"Proposal": {"preconditions": [{"field": "client", "state": "active"}]}}});
1254
+ let schema = build(&domain(), &config);
1255
+ let fields = schema["collections"]["proposals"]["create"]["fields"].clone();
1256
+
1257
+ assert_eq!(fields[1]["key"], "client");
1258
+ assert_eq!(fields[1]["filterState"], "active");
1259
+ }
1260
+
1261
+ #[test]
1262
+ fn nav_keeps_declaration_order_until_nav_order_says_otherwise() {
1263
+ let schema = build(&domain(), &json!({}));
1264
+ assert_eq!(
1265
+ schema["nav"].as_array().expect("nav").iter().map(|g| g["key"].clone()).collect::<Vec<Value>>(),
1266
+ vec![json!("clients"), json!("proposals"), json!("invoices")]
1267
+ );
1268
+
1269
+ let config = json!({"collections": {"Invoice": {"nav_order": 1}, "Client": {"nav_order": 2}}});
1270
+ let ordered = build(&domain(), &config);
1271
+ assert_eq!(
1272
+ ordered["nav"].as_array().expect("nav").iter().map(|g| g["key"].clone()).collect::<Vec<Value>>(),
1273
+ vec![json!("invoices"), json!("clients"), json!("proposals")]
1274
+ );
1275
+ }
1276
+
1277
+ #[test]
1278
+ fn two_aggregates_sharing_a_nav_group_become_one_item_at_the_lowest_order_among_them() {
1279
+ let config = json!({"collections": {
1280
+ "Proposal": {"nav_group": "paperwork", "nav_order": 3},
1281
+ "Invoice": {"nav_group": "paperwork", "nav_order": 1}
1282
+ }});
1283
+ let schema = build(&domain(), &config);
1284
+ let nav = schema["nav"].as_array().expect("nav");
1285
+
1286
+ assert_eq!(nav.len(), 2);
1287
+ assert_eq!(nav[0], json!({"key": "paperwork", "label": "Paperwork", "collections": ["proposals", "invoices"]}));
1288
+ assert_eq!(nav[1]["key"], "clients");
1289
+ }
1290
+
1291
+ #[test]
1292
+ fn overview_stats_carry_their_fold_and_their_where_clause_through_unchanged() {
1293
+ let config = json!({"overview": {"stats": [
1294
+ {"label": "Active clients", "collection": "clients", "where": {"state": "active"}},
1295
+ {"label": "Outstanding", "collection": "invoices", "as": "money_sum", "field": "line_items"}
1296
+ ]}});
1297
+ let schema = build(&domain(), &config);
1298
+
1299
+ assert_eq!(
1300
+ schema["overview"],
1301
+ json!({"stats": [
1302
+ {"label": "Active clients", "collection": "clients", "where": {"state": "active"}, "as": "count", "field": null},
1303
+ {"label": "Outstanding", "collection": "invoices", "where": {}, "as": "money_sum", "field": "line_items"}
1304
+ ]})
1305
+ );
1306
+ }
1307
+
1308
+ #[test]
1309
+ fn a_domain_with_no_config_at_all_still_builds_a_whole_schema() {
1310
+ let schema = build(&domain(), &json!({}));
1311
+
1312
+ assert_eq!(schema["domain"], "SampleApp");
1313
+ assert_eq!(schema["overview"], json!({"stats": []}));
1314
+ assert_eq!(schema["collections"]["clients"]["label"], "Client");
1315
+ assert_eq!(schema["collections"]["clients"]["nounSing"], "client");
1316
+ assert_eq!(schema["collections"]["clients"]["statusLabels"], json!({}));
1317
+ }
1318
+
1319
+ #[test]
1320
+ fn state_styling_rides_through_per_facet_and_attention_is_opt_in() {
1321
+ let config = json!({"states": {"Client": {
1322
+ "prospect": {"tone": "warn", "label": "Prospect"},
1323
+ "active": {"tone": "good", "attention": true, "note": "paying"}
1324
+ }}});
1325
+ let schema = build(&domain(), &config);
1326
+ let clients = &schema["collections"]["clients"];
1327
+
1328
+ assert_eq!(clients["statusLabels"], json!({"prospect": "Prospect"}));
1329
+ assert_eq!(clients["statusTone"], json!({"prospect": "warn", "active": "good"}));
1330
+ assert_eq!(clients["statusNotes"], json!({"active": "paying"}));
1331
+ assert_eq!(clients["statusAttention"], json!(["active"]));
1332
+ }
1333
+
1334
+ #[test]
1335
+ fn the_structural_schema_lists_every_state_and_every_query_with_shaped_arguments() {
1336
+ let schema = schema(&domain(), &json!({}));
1337
+
1338
+ assert_eq!(schema["Client"]["states"], json!(["prospect", "active"]));
1339
+ assert_eq!(schema["Invoice"]["states"], json!([]));
1340
+ assert_eq!(
1341
+ schema["Client"]["queries"],
1342
+ json!([
1343
+ {"name": "Active", "args": []},
1344
+ {"name": "ByName", "args": [
1345
+ {"key": "name", "label": "Name", "optional": false, "shape": "text_value", "field": "value"}
1346
+ ]}
1347
+ ])
1348
+ );
1349
+ }
1350
+ }
1351
+