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,1431 @@
1
+ // The Rust-native counterpart to `web/presentation_config.rb`'s `.save!`, serving
2
+ // `PUT /api/presentation`: validates against the live domain IR, then dispatches real commands.
3
+
4
+ use crate::dispatch;
5
+ use crate::lambda_client::LambdaInvoker;
6
+ use crate::journal::LineageConfig;
7
+ use crate::presentation::{self, KernelRows};
8
+ use crate::ui_schema;
9
+ use serde_json::{json, Map, Value};
10
+ use std::collections::BTreeSet;
11
+ use std::path::Path;
12
+ use tokio::sync::Mutex;
13
+ use tokio_postgres::Client;
14
+
15
+ // Duplicates the chapter's own `one_of` so this refuses before any dispatch starts.
16
+ const VALID_TONES: &[&str] = &["good", "warn", "danger", "muted", "accent"];
17
+ const VALID_DISPLAYS: &[&str] = &["simple", "prose", "bullet", "mono", "rows"];
18
+ const VALID_FORMATS: &[&str] = &["date", "percent"];
19
+ const VALID_FOLDS: &[&str] = &["count", "money_sum"];
20
+ const VALID_SORTS: &[&str] = &["asc", "desc"];
21
+
22
+ // What a save can answer with other than "saved". `api.rs` maps each variant to a status code,
23
+ // so this module never mentions one.
24
+ pub(crate) enum SaveRefusal {
25
+ // This host's kernel carries no ConsoleSettings chapter.
26
+ NoKernel,
27
+ // `PresentationConfig::Malformed` — `app.rb`'s own 422.
28
+ Malformed(String),
29
+ // A real domain refusal from the kernel, carried whole for `api::domain_refusal`.
30
+ Domain(Value),
31
+ Internal(String),
32
+ }
33
+
34
+ // Returns the reloaded config, never the submitted one, so a caller sees what was stored.
35
+ pub(crate) async fn save(
36
+ domain_ir: &Value,
37
+ submitted: &Value,
38
+ client: &Mutex<Client>,
39
+ wasm_path: &Path,
40
+ lineage: &LineageConfig,
41
+ invoker: &dyn LambdaInvoker,
42
+ ) -> Result<Value, SaveRefusal> {
43
+ // Asked before validated: a host with no such chapter cannot honour this request at all,
44
+ // regardless of which rule the submitted config would otherwise break.
45
+ let existing = match presentation::kernel_rows(client, wasm_path).await {
46
+ Ok(Some(rows)) => rows,
47
+ Ok(None) => return Err(SaveRefusal::NoKernel),
48
+ Err(e) => return Err(SaveRefusal::Internal(format!("{e:#}"))),
49
+ };
50
+
51
+ validate(submitted, domain_ir).map_err(SaveRefusal::Malformed)?;
52
+
53
+ let mut writer = Writer { client, wasm_path, lineage, invoker };
54
+ writer.write_states(section(submitted, "states"), &existing).await?;
55
+ writer.write_collections(section(submitted, "collections"), &existing).await?;
56
+ writer.write_overview(section(submitted, "overview"), &existing).await?;
57
+
58
+ presentation::load(client, wasm_path, lineage).await.map_err(|e| SaveRefusal::Internal(format!("{e:#}")))
59
+ }
60
+
61
+ // A missing, null, or non-object section is the empty one rather than an error: `validate` has
62
+ // already run every real check against the same value.
63
+ fn section<'a>(config: &'a Value, key: &str) -> &'a Value {
64
+ const EMPTY: &Value = &Value::Null;
65
+ config.get(key).filter(|v| v.is_object()).unwrap_or(EMPTY)
66
+ }
67
+
68
+ fn entries(value: &Value) -> Vec<(&String, &Value)> {
69
+ value.as_object().map(|map| map.iter().collect()).unwrap_or_default()
70
+ }
71
+
72
+ // A null, a missing key, or anything that isn't a list is the empty list.
73
+ fn list(value: Option<&Value>) -> &[Value] {
74
+ value.and_then(|v| v.as_array()).map(|a| a.as_slice()).unwrap_or(&[])
75
+ }
76
+
77
+ // Every present field is re-dispatched on every save, even to an unchanged value, so the two
78
+ // engines produce the same history for the same request. A field once set and later omitted is
79
+ // never cleared: the chapter declares no `Clear*` command.
80
+ struct Writer<'a> {
81
+ client: &'a Mutex<Client>,
82
+ wasm_path: &'a Path,
83
+ lineage: &'a LineageConfig,
84
+ invoker: &'a dyn LambdaInvoker,
85
+ }
86
+
87
+ impl Writer<'_> {
88
+ // A creating command — facts only, no identity to route to.
89
+ async fn declare(&mut self, verb: &str, facts: Value) -> Result<(), SaveRefusal> {
90
+ let outcome = dispatch::handle_facts(self.client, self.wasm_path, verb, facts, None, self.lineage, self.invoker)
91
+ .await
92
+ .map_err(|e| SaveRefusal::Internal(format!("{e:#}")))?;
93
+ self.accepted(outcome)
94
+ }
95
+
96
+ // A command acting on an existing row, routed by that row's own identity: "Agg:state" for
97
+ // StateStyle, the aggregate name for Collection, "overview" for the singleton.
98
+ async fn route(&mut self, verb: &str, id: &str, facts: Value) -> Result<(), SaveRefusal> {
99
+ let outcome =
100
+ dispatch::handle_routed(self.client, self.wasm_path, verb, json!(id), facts, None, self.lineage, self.invoker)
101
+ .await
102
+ .map_err(|e| SaveRefusal::Internal(format!("{e:#}")))?;
103
+ self.accepted(outcome)
104
+ }
105
+
106
+ // A refusal stops the whole save where it happened: several independent dispatches, not one
107
+ // atomic write. `validate` above is what makes this rare rather than routine.
108
+ fn accepted(&self, outcome: dispatch::Outcome) -> Result<(), SaveRefusal> {
109
+ if outcome.accepted {
110
+ Ok(())
111
+ } else {
112
+ Err(SaveRefusal::Domain(outcome.result))
113
+ }
114
+ }
115
+
116
+ async fn write_states(&mut self, states: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
117
+ let known: BTreeSet<&str> = existing.state_ids();
118
+ for (agg, per_state) in entries(states) {
119
+ for (state, entry) in entries(per_state) {
120
+ let id = format!("{agg}:{state}");
121
+ if !known.contains(id.as_str()) {
122
+ self.declare(
123
+ "ConsoleSettings::StateStyle.Declare",
124
+ json!({"agg": {"value": agg}, "state": {"value": state}}),
125
+ )
126
+ .await?;
127
+ }
128
+
129
+ if let Some(tone) = present(entry.get("tone")) {
130
+ self.route("ConsoleSettings::StateStyle.SetTone", &id, json!({"tone": {"value": tone}})).await?;
131
+ }
132
+ // Key presence, not truthiness: an explicit `false` is a real answer to store.
133
+ if let Some(attention) = entry.get("attention") {
134
+ let flag = if attention.as_bool() == Some(true) { "true" } else { "false" };
135
+ self.route("ConsoleSettings::StateStyle.SetAttention", &id, json!({"attention": {"value": flag}}))
136
+ .await?;
137
+ }
138
+
139
+ let extra = extra_fields(entry, KNOWN_STATE_KEYS);
140
+ if !extra.is_empty() {
141
+ self.route(
142
+ "ConsoleSettings::StateStyle.SetExtra",
143
+ &id,
144
+ json!({"extra_json": {"value": Value::Object(extra).to_string()}}),
145
+ )
146
+ .await?;
147
+ }
148
+ }
149
+ }
150
+ Ok(())
151
+ }
152
+
153
+ async fn write_collections(&mut self, collections: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
154
+ let known: BTreeSet<&str> = existing.collection_ids();
155
+ for (agg, entry) in entries(collections) {
156
+ if !known.contains(agg.as_str()) {
157
+ self.declare("ConsoleSettings::Collection.Declare", json!({"agg": {"value": agg}})).await?;
158
+ }
159
+
160
+ for (field, command) in [
161
+ ("label", "SetLabel"),
162
+ ("key", "SetKey"),
163
+ ("nav_order", "SetNavOrder"),
164
+ ("primary_field", "SetPrimaryField"),
165
+ ("list_query", "SetListQuery"),
166
+ ] {
167
+ if let Some(value) = present(entry.get(field)) {
168
+ let verb = format!("ConsoleSettings::Collection.{command}");
169
+ self.route(&verb, agg, json!({ field: {"value": value} })).await?;
170
+ }
171
+ }
172
+
173
+ if let Some(identity) = entry.get("identity").filter(|v| v.is_object()) {
174
+ self.write_identity(agg, identity).await?;
175
+ }
176
+
177
+ // Always dispatched, even for an absent list: an empty `Replace*` is the meaningful
178
+ // "none configured", and is how a column removed from the config goes away.
179
+ self.route(
180
+ "ConsoleSettings::Collection.ReplaceColumns",
181
+ agg,
182
+ json!({ "columns": columns_for_dispatch(entry.get("columns")) }),
183
+ )
184
+ .await?;
185
+ self.route(
186
+ "ConsoleSettings::Collection.ReplaceDetailFields",
187
+ agg,
188
+ json!({ "detail_fields": detail_fields_for_dispatch(entry.get("detail_fields")) }),
189
+ )
190
+ .await?;
191
+ self.route(
192
+ "ConsoleSettings::Collection.ReplaceDetailFieldColumns",
193
+ agg,
194
+ json!({ "detail_field_columns": detail_field_columns_for_dispatch(entry.get("detail_fields")) }),
195
+ )
196
+ .await?;
197
+ self.route(
198
+ "ConsoleSettings::Collection.ReplacePreconditions",
199
+ agg,
200
+ json!({ "preconditions": preconditions_for_dispatch(entry.get("preconditions")) }),
201
+ )
202
+ .await?;
203
+ self.route(
204
+ "ConsoleSettings::Collection.ReplaceFieldFormats",
205
+ agg,
206
+ json!({ "field_formats": field_formats_for_dispatch(entry.get("field_formats")) }),
207
+ )
208
+ .await?;
209
+
210
+ let extra = extra_fields(entry, KNOWN_COLLECTION_KEYS);
211
+ if !extra.is_empty() {
212
+ self.route(
213
+ "ConsoleSettings::Collection.SetExtra",
214
+ agg,
215
+ json!({"extra_json": {"value": Value::Object(extra).to_string()}}),
216
+ )
217
+ .await?;
218
+ }
219
+ }
220
+ Ok(())
221
+ }
222
+
223
+ async fn write_identity(&mut self, agg: &str, identity: &Value) -> Result<(), SaveRefusal> {
224
+ let mut facts = Map::new();
225
+ facts.insert("identity_field".to_string(), json!({"value": identity.get("field")}));
226
+ facts.insert("identity_strategy".to_string(), json!({"value": identity.get("strategy")}));
227
+ if let Some(source) = present(identity.get("source")) {
228
+ facts.insert("identity_source".to_string(), json!({ "value": source }));
229
+ }
230
+ if let Some(prefix) = present(identity.get("prefix")) {
231
+ facts.insert("identity_prefix".to_string(), json!({ "value": prefix }));
232
+ }
233
+ let extra = extra_fields(identity, KNOWN_IDENTITY_KEYS);
234
+ if !extra.is_empty() {
235
+ facts.insert("identity_extra_json".to_string(), json!({"value": Value::Object(extra).to_string()}));
236
+ }
237
+ self.route("ConsoleSettings::Collection.SetIdentity", agg, Value::Object(facts)).await
238
+ }
239
+
240
+ // The one row: `Declare`d once, then `ReplaceStats` whole on every save, even an empty list.
241
+ async fn write_overview(&mut self, overview: &Value, existing: &KernelRows) -> Result<(), SaveRefusal> {
242
+ if existing.overview.is_empty() {
243
+ self.declare("ConsoleSettings::Overview.Declare", json!({"key": {"value": "overview"}})).await?;
244
+ }
245
+ self.route(
246
+ "ConsoleSettings::Overview.ReplaceStats",
247
+ "overview",
248
+ json!({ "stats": stats_for_dispatch(overview.get("stats")) }),
249
+ )
250
+ .await
251
+ }
252
+ }
253
+
254
+ // Whatever a real entry carries beyond its KNOWN_*_KEYS is round-tripped through `extra_json`,
255
+ // read back by `presentation.rs`'s `merge_extra`. Order matches Ruby's own constants exactly.
256
+ const KNOWN_STATE_KEYS: &[&str] = &["tone", "attention"];
257
+ const KNOWN_COLLECTION_KEYS: &[&str] = &[
258
+ "label",
259
+ "key",
260
+ "nav_order",
261
+ "primary_field",
262
+ "list_query",
263
+ "identity",
264
+ "columns",
265
+ "detail_fields",
266
+ "preconditions",
267
+ "field_formats",
268
+ "after_create",
269
+ ];
270
+ const KNOWN_IDENTITY_KEYS: &[&str] = &["field", "strategy", "source", "prefix"];
271
+ const KNOWN_COLUMN_KEYS: &[&str] = &["field", "sortable", "sort_default"];
272
+ const KNOWN_DETAIL_FIELD_KEYS: &[&str] = &["field", "display", "state_filter", "columns"];
273
+ const KNOWN_PRECONDITION_KEYS: &[&str] = &["field", "state"];
274
+
275
+ fn extra_fields(entry: &Value, known: &[&str]) -> Map<String, Value> {
276
+ let mut extra = Map::new();
277
+ if let Some(object) = entry.as_object() {
278
+ for (key, value) in object {
279
+ if !known.contains(&key.as_str()) {
280
+ extra.insert(key.clone(), value.clone());
281
+ }
282
+ }
283
+ }
284
+ extra
285
+ }
286
+
287
+ // A missing key and an explicit null are both "not given". `false` is never filtered here;
288
+ // `attention`, the one field that can legitimately be `false`, is read by key presence instead.
289
+ fn present(value: Option<&Value>) -> Option<&Value> {
290
+ value.filter(|v| !v.is_null())
291
+ }
292
+
293
+ fn columns_for_dispatch(columns: Option<&Value>) -> Vec<Value> {
294
+ list(columns)
295
+ .iter()
296
+ .map(|column| {
297
+ // A column is either a bare field name or a descriptor hash — both shapes
298
+ // `validate_collection!` reads.
299
+ let Some(object) = column.as_object() else { return json!({ "field": column }) };
300
+ let mut row = Map::new();
301
+ row.insert("field".to_string(), object.get("field").cloned().unwrap_or(Value::Null));
302
+ // "true"/"false" as strings: `Column#sortable` is a String attribute, not a boolean.
303
+ if let Some(sortable) = object.get("sortable") {
304
+ row.insert("sortable".to_string(), json!(ruby_to_s(sortable)));
305
+ }
306
+ if let Some(sort_default) = present(object.get("sort_default")) {
307
+ row.insert("sort_default".to_string(), sort_default.clone());
308
+ }
309
+ let extra = extra_fields(column, KNOWN_COLUMN_KEYS);
310
+ if !extra.is_empty() {
311
+ row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
312
+ }
313
+ Value::Object(row)
314
+ })
315
+ .collect()
316
+ }
317
+
318
+ fn detail_fields_for_dispatch(detail_fields: Option<&Value>) -> Vec<Value> {
319
+ list(detail_fields)
320
+ .iter()
321
+ .map(|field| {
322
+ let Some(object) = field.as_object() else { return json!({ "field": field }) };
323
+ let mut row = Map::new();
324
+ row.insert("field".to_string(), object.get("field").cloned().unwrap_or(Value::Null));
325
+ if let Some(display) = present(object.get("display")) {
326
+ row.insert("display".to_string(), display.clone());
327
+ }
328
+ if let Some(state_filter) = present(object.get("state_filter")) {
329
+ row.insert("state_filter".to_string(), state_filter.clone());
330
+ }
331
+ let extra = extra_fields(field, KNOWN_DETAIL_FIELD_KEYS);
332
+ if !extra.is_empty() {
333
+ row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
334
+ }
335
+ Value::Object(row)
336
+ })
337
+ .collect()
338
+ }
339
+
340
+ // Flattened to one row per (field, column) pair; `presentation.rs` groups it back by field.
341
+ fn detail_field_columns_for_dispatch(detail_fields: Option<&Value>) -> Vec<Value> {
342
+ let mut rows = Vec::new();
343
+ for field in list(detail_fields) {
344
+ let Some(object) = field.as_object() else { continue };
345
+ let Some(columns) = object.get("columns") else { continue };
346
+ let name = object.get("field").cloned().unwrap_or(Value::Null);
347
+ for column in list(Some(columns)) {
348
+ rows.push(json!({ "field": name, "column": column }));
349
+ }
350
+ }
351
+ rows
352
+ }
353
+
354
+ fn preconditions_for_dispatch(preconditions: Option<&Value>) -> Vec<Value> {
355
+ list(preconditions)
356
+ .iter()
357
+ .map(|rule| {
358
+ let mut row = Map::new();
359
+ row.insert("field".to_string(), rule.get("field").cloned().unwrap_or(Value::Null));
360
+ row.insert("state".to_string(), rule.get("state").cloned().unwrap_or(Value::Null));
361
+ let extra = extra_fields(rule, KNOWN_PRECONDITION_KEYS);
362
+ if !extra.is_empty() {
363
+ row.insert("extra_json".to_string(), json!(Value::Object(extra).to_string()));
364
+ }
365
+ Value::Object(row)
366
+ })
367
+ .collect()
368
+ }
369
+
370
+ // A config map (`{field => format}`) becomes a list of `{field, format}` rows; `presentation.rs`
371
+ // undoes the reshape on the way out.
372
+ fn field_formats_for_dispatch(field_formats: Option<&Value>) -> Vec<Value> {
373
+ field_formats
374
+ .and_then(|v| v.as_object())
375
+ .map(|map| map.iter().map(|(field, format)| json!({"field": field, "format": format})).collect())
376
+ .unwrap_or_default()
377
+ }
378
+
379
+ fn stats_for_dispatch(stats: Option<&Value>) -> Vec<Value> {
380
+ list(stats)
381
+ .iter()
382
+ .map(|stat| {
383
+ let mut row = Map::new();
384
+ row.insert("label".to_string(), stat.get("label").cloned().unwrap_or(Value::Null));
385
+ row.insert("collection".to_string(), stat.get("collection").cloned().unwrap_or(Value::Null));
386
+ if let Some(fold) = present(stat.get("as")) {
387
+ row.insert("as".to_string(), fold.clone());
388
+ }
389
+ if let Some(field) = present(stat.get("field")) {
390
+ row.insert("field".to_string(), field.clone());
391
+ }
392
+ // Verified-then-opaque storage: hecks has no attribute type for an arbitrary nested
393
+ // map, and `validate_where` below has already walked the real structure.
394
+ if let Some(clause) = present(stat.get("where")) {
395
+ row.insert("where_json".to_string(), json!(clause.to_string()));
396
+ }
397
+ Value::Object(row)
398
+ })
399
+ .collect()
400
+ }
401
+
402
+ // Ruby's `#to_s`: a JSON string keeps its own characters (never `"\"true\""`).
403
+ fn ruby_to_s(value: &Value) -> String {
404
+ match value {
405
+ Value::String(text) => text.clone(),
406
+ Value::Null => String::new(),
407
+ other => other.to_string(),
408
+ }
409
+ }
410
+
411
+ // Every message below matches `PresentationConfig`'s own wording exactly, pinned by the tests
412
+ // at the bottom of this file, so a console that switched runtimes explains a mistake the same way.
413
+ type Refusal = Result<(), String>;
414
+
415
+ // Checks in the order Ruby does: states, the both-directions completeness check, collections,
416
+ // the overview, then the one deliberate scope boundary.
417
+ pub(crate) fn validate(config: &Value, domain_ir: &Value) -> Refusal {
418
+ let aggregates = ui_schema::aggregates(domain_ir);
419
+ let states = section(config, "states");
420
+ let collections = section(config, "collections");
421
+
422
+ for (agg_name, per_state) in entries(states) {
423
+ validate_states(agg_name, per_state, &aggregates)?;
424
+ }
425
+ missing_state_entries(states, &aggregates)?;
426
+ for (agg_name, entry) in entries(collections) {
427
+ validate_collection(agg_name, entry, &aggregates, domain_ir)?;
428
+ }
429
+ validate_overview(section(config, "overview"), &aggregates, config)?;
430
+ refuse_unsupported_yet(collections)
431
+ }
432
+
433
+ // `after_create` is validated in full above but has nowhere to persist yet; refusing here means
434
+ // a caller who tries loses nothing silently. A deliberate scope boundary, not a bug.
435
+ fn refuse_unsupported_yet(collections: &Value) -> Refusal {
436
+ for (agg_name, entry) in entries(collections) {
437
+ if present(entry.get("after_create")).is_some() {
438
+ return Err(format!(
439
+ "{agg_name}'s after_create isn't stored by ConsoleSettings yet \
440
+ (see console_settings.bluebook's own header) — remove it before saving"
441
+ ));
442
+ }
443
+ }
444
+ Ok(())
445
+ }
446
+
447
+ fn find_aggregate<'a>(aggregates: &[&'a Value], name: &str) -> Option<&'a Value> {
448
+ aggregates.iter().copied().find(|a| ui_schema::agg_name(a) == name)
449
+ }
450
+
451
+ fn require_aggregate<'a>(aggregates: &[&'a Value], name: &str) -> Result<&'a Value, String> {
452
+ find_aggregate(aggregates, name).ok_or_else(|| format!("{name} is not a real aggregate this domain declares"))
453
+ }
454
+
455
+ fn validate_states(agg_name: &str, per_state: &Value, aggregates: &[&Value]) -> Refusal {
456
+ let aggregate = require_aggregate(aggregates, agg_name)?;
457
+ let known = ui_schema::lifecycle_states(aggregate);
458
+ for (state_name, entry) in entries(per_state) {
459
+ validate_state_entry(agg_name, state_name, entry, &known)?;
460
+ }
461
+ Ok(())
462
+ }
463
+
464
+ // Both directions: checking only that `states` never names an unreal state would still let a
465
+ // real state go quietly unstyled forever.
466
+ fn missing_state_entries(states: &Value, aggregates: &[&Value]) -> Refusal {
467
+ let mut missing = Vec::new();
468
+ for aggregate in aggregates {
469
+ let name = ui_schema::agg_name(aggregate);
470
+ let configured = states.get(name);
471
+ for state in ui_schema::lifecycle_states(aggregate) {
472
+ let styled = configured.and_then(|c| c.get(&state)).is_some();
473
+ if !styled {
474
+ missing.push(format!("{name}.{state}"));
475
+ }
476
+ }
477
+ }
478
+ if missing.is_empty() {
479
+ return Ok(());
480
+ }
481
+ Err(format!("no presentation entry for {} — every real state needs one, even a bare one", missing.join(", ")))
482
+ }
483
+
484
+ fn validate_state_entry(agg_name: &str, state_name: &str, entry: &Value, known: &[String]) -> Refusal {
485
+ if !known.iter().any(|s| s == state_name) {
486
+ return Err(format!(
487
+ "{agg_name} has no state {} — it declares {}",
488
+ inspect(&json!(state_name)),
489
+ known.join(", ")
490
+ ));
491
+ }
492
+
493
+ if let Some(tone) = present(entry.get("tone")) {
494
+ if !VALID_TONES.contains(&ruby_to_s(tone).as_str()) {
495
+ return Err(format!(
496
+ "{agg_name}.{state_name}'s tone {} isn't one of {}",
497
+ inspect(tone),
498
+ VALID_TONES.join(", ")
499
+ ));
500
+ }
501
+ }
502
+
503
+ match entry.get("attention") {
504
+ None | Some(Value::Null) | Some(Value::Bool(_)) => Ok(()),
505
+ Some(other) => {
506
+ Err(format!("{agg_name}.{state_name}'s attention must be true or false, not {}", inspect(other)))
507
+ }
508
+ }
509
+ }
510
+
511
+ fn validate_collection(agg_name: &str, entry: &Value, aggregates: &[&Value], domain_ir: &Value) -> Refusal {
512
+ let aggregate = require_aggregate(aggregates, agg_name)?;
513
+
514
+ validate_columns(agg_name, aggregate, entry.get("columns"))?;
515
+
516
+ // `nav_order` is the one place a non-string scalar is meaningful,
517
+ // so it is the one place the check is about the JSON type rather
518
+ // than a vocabulary.
519
+ if let Some(nav_order) = entry.get("nav_order") {
520
+ if !nav_order.is_number() {
521
+ return Err(format!("{agg_name}'s nav_order must be a number, not {}", inspect(nav_order)));
522
+ }
523
+ }
524
+
525
+ for rule in list(entry.get("preconditions")) {
526
+ validate_precondition(agg_name, aggregate, rule, aggregates)?;
527
+ }
528
+ validate_field_formats(agg_name, aggregate, entry.get("field_formats"))?;
529
+ if let Some(identity) = present(entry.get("identity")) {
530
+ validate_identity(agg_name, aggregate, identity)?;
531
+ }
532
+ validate_detail_fields(agg_name, aggregate, entry.get("detail_fields"))?;
533
+ if let Some(list_query) = present(entry.get("list_query")) {
534
+ validate_list_query(agg_name, aggregate, list_query)?;
535
+ }
536
+ if let Some(after_create) = present(entry.get("after_create")) {
537
+ validate_after_create(agg_name, after_create, aggregates, domain_ir)?;
538
+ }
539
+ Ok(())
540
+ }
541
+
542
+ // Looser than state validation: a column names an attribute (or `"__state__"`), so the check is
543
+ // "does this aggregate declare it", not a closed vocabulary.
544
+ fn validate_columns(agg_name: &str, aggregate: &Value, columns: Option<&Value>) -> Refusal {
545
+ for column in list(columns) {
546
+ let key = column.get("field").filter(|_| column.is_object()).unwrap_or(column);
547
+ let name = ruby_to_s(key);
548
+ if name != "__state__" && ui_schema::find_attribute(aggregate, &name).is_none() {
549
+ return Err(format!(
550
+ "{agg_name} has no attribute {} to put in its columns — it declares {}",
551
+ inspect(key),
552
+ attribute_names(aggregate)
553
+ ));
554
+ }
555
+
556
+ let Some(object) = column.as_object() else { continue };
557
+ if let Some(sortable) = object.get("sortable") {
558
+ if !sortable.is_boolean() {
559
+ return Err(format!(
560
+ "{agg_name}'s column {} has sortable {}, not true or false",
561
+ inspect(key),
562
+ inspect(sortable)
563
+ ));
564
+ }
565
+ }
566
+ if let Some(sort_default) = present(object.get("sort_default")) {
567
+ if !VALID_SORTS.contains(&ruby_to_s(sort_default).as_str()) {
568
+ return Err(format!(
569
+ "{agg_name}'s column {} has sort_default {}, not asc or desc",
570
+ inspect(key),
571
+ inspect(sort_default)
572
+ ));
573
+ }
574
+ }
575
+ }
576
+ Ok(())
577
+ }
578
+
579
+ fn attribute_names(aggregate: &Value) -> String {
580
+ ui_schema::array(aggregate, "attributes").iter().map(ui_schema::attr_name).collect::<Vec<_>>().join(", ")
581
+ }
582
+
583
+ // A non-reference field is caught here first, since otherwise the state check below would
584
+ // simply never fire.
585
+ fn validate_precondition(agg_name: &str, aggregate: &Value, rule: &Value, aggregates: &[&Value]) -> Refusal {
586
+ let Some(field) = present(rule.get("field")) else {
587
+ return Err(format!("{agg_name} has a precondition with no field named"));
588
+ };
589
+ let name = ruby_to_s(field);
590
+ let attribute = ui_schema::find_attribute(aggregate, &name);
591
+ let is_reference = attribute.map(ui_schema::is_reference).unwrap_or(false);
592
+ if !is_reference {
593
+ return Err(format!("{agg_name}.{name} isn't a reference — a precondition only makes sense against one"));
594
+ }
595
+
596
+ let Some(state) = present(rule.get("state")) else { return Ok(()) };
597
+ // A cross-domain or otherwise unresolvable target — nothing more to
598
+ // check here, exactly as Ruby's own `return unless target` says.
599
+ let Some(target_name) = ui_schema::reference_target(ui_schema::type_of(attribute.expect("checked above"))) else {
600
+ return Ok(());
601
+ };
602
+ let Some(target) = find_aggregate(aggregates, &target_name) else { return Ok(()) };
603
+
604
+ let known = ui_schema::lifecycle_states(target);
605
+ if known.iter().any(|s| *s == ruby_to_s(state)) {
606
+ return Ok(());
607
+ }
608
+ Err(format!(
609
+ "{agg_name}.{name} points at {target_name}, which has no state {} — it declares {}",
610
+ inspect(state),
611
+ known.join(", ")
612
+ ))
613
+ }
614
+
615
+ // Keyed by attribute name, not path: a nested compound field is found the same way, since
616
+ // `ui_schema` threads the same map down through every level of recursion.
617
+ fn validate_field_formats(agg_name: &str, aggregate: &Value, formats: Option<&Value>) -> Refusal {
618
+ let Some(map) = formats.and_then(|v| v.as_object()) else { return Ok(()) };
619
+ for (field_name, format) in map {
620
+ if ui_schema::find_attribute(aggregate, field_name).is_none() && !nested_attribute(aggregate, field_name) {
621
+ return Err(format!("{agg_name} has no attribute {} to format", inspect(&json!(field_name))));
622
+ }
623
+ if !VALID_FORMATS.contains(&ruby_to_s(format).as_str()) {
624
+ return Err(format!(
625
+ "{agg_name}.{field_name}'s format {} isn't one of {}",
626
+ inspect(format),
627
+ VALID_FORMATS.join(", ")
628
+ ));
629
+ }
630
+ }
631
+ Ok(())
632
+ }
633
+
634
+ fn nested_attribute(aggregate: &Value, field_name: &str) -> bool {
635
+ ui_schema::array(aggregate, "attributes").iter().any(|attribute| {
636
+ if ui_schema::is_reference(attribute) {
637
+ return false;
638
+ }
639
+ let Some(value_object) = ui_schema::find_value_object(aggregate, ui_schema::type_of(attribute)) else {
640
+ return false;
641
+ };
642
+ ui_schema::array(value_object, "attributes").iter().any(|sub| ui_schema::attr_name(sub) == field_name)
643
+ })
644
+ }
645
+
646
+ // Never a reference: there is nothing to derive from another aggregate's identity.
647
+ fn validate_identity(agg_name: &str, aggregate: &Value, rule: &Value) -> Refusal {
648
+ let Some(field) = present(rule.get("field")) else {
649
+ return Err(format!("{agg_name} has an identity rule with no field named"));
650
+ };
651
+ let name = ruby_to_s(field);
652
+ let Some(attribute) = ui_schema::find_attribute(aggregate, &name) else {
653
+ return Err(format!("{agg_name} has no attribute {} to auto-derive", inspect(field)));
654
+ };
655
+ if ui_schema::is_reference(attribute) {
656
+ return Err(format!("{agg_name}.{name} is a reference — identity can only derive a plain field"));
657
+ }
658
+
659
+ match rule.get("strategy").map(ruby_to_s).unwrap_or_default().as_str() {
660
+ "slug" => {
661
+ let Some(source) = present(rule.get("source")) else {
662
+ return Err(format!("{agg_name}'s identity slug strategy needs a source field"));
663
+ };
664
+ if ui_schema::find_attribute(aggregate, &ruby_to_s(source)).is_none() {
665
+ return Err(format!("{agg_name} has no attribute {} to slug from", inspect(source)));
666
+ }
667
+ Ok(())
668
+ }
669
+ "sequence" => {
670
+ if present(rule.get("prefix")).is_none() {
671
+ return Err(format!("{agg_name}'s identity sequence strategy needs a prefix"));
672
+ }
673
+ Ok(())
674
+ }
675
+ // No further config to check — the domain's own
676
+ // identity_assignment adapter decides everything, and a domain
677
+ // with none wired refuses loudly at dispatch time, not here.
678
+ "port" => Ok(()),
679
+ _ => Err(format!(
680
+ "{agg_name}'s identity strategy {} isn't 'slug', 'sequence', or 'port'",
681
+ inspect(rule.get("strategy").unwrap_or(&Value::Null))
682
+ )),
683
+ }
684
+ }
685
+
686
+ fn validate_detail_fields(agg_name: &str, aggregate: &Value, entries_value: Option<&Value>) -> Refusal {
687
+ for field in list(entries_value) {
688
+ let key = field.get("field").filter(|_| field.is_object()).unwrap_or(field);
689
+ let name = ruby_to_s(key);
690
+ let Some(attribute) = ui_schema::find_attribute(aggregate, &name) else {
691
+ return Err(format!(
692
+ "{agg_name} has no attribute {} to put in its detail fields — it declares {}",
693
+ inspect(key),
694
+ attribute_names(aggregate)
695
+ ));
696
+ };
697
+
698
+ let Some(object) = field.as_object() else { continue };
699
+
700
+ if let Some(display) = present(object.get("display")) {
701
+ if !VALID_DISPLAYS.contains(&ruby_to_s(display).as_str()) {
702
+ return Err(format!(
703
+ "{agg_name}'s detail field {} has display {}, not one of {}",
704
+ inspect(key),
705
+ inspect(display),
706
+ VALID_DISPLAYS.join(", ")
707
+ ));
708
+ }
709
+ }
710
+ if let Some(columns) = present(object.get("columns")) {
711
+ validate_row_columns(agg_name, aggregate, &name, attribute, columns)?;
712
+ }
713
+ if let Some(state_filter) = present(object.get("state_filter")) {
714
+ validate_row_state_filter(agg_name, aggregate, &name, attribute, state_filter)?;
715
+ }
716
+ }
717
+ Ok(())
718
+ }
719
+
720
+ // Only meaningful for an entity-backed rows field: a value object has no lifecycle at all.
721
+ fn validate_row_state_filter(
722
+ agg_name: &str,
723
+ aggregate: &Value,
724
+ key: &str,
725
+ attribute: &Value,
726
+ state_filter: &Value,
727
+ ) -> Refusal {
728
+ let type_name = ui_schema::type_of(attribute).to_string();
729
+ let Some(entity) = ui_schema::find_entity(aggregate, &type_name) else {
730
+ return Err(format!(
731
+ "{agg_name}'s detail field {} has state_filter: but isn't an entity-backed rows field",
732
+ inspect(&json!(key))
733
+ ));
734
+ };
735
+ let known = ui_schema::lifecycle_states(entity);
736
+ if known.iter().any(|s| *s == ruby_to_s(state_filter)) {
737
+ return Ok(());
738
+ }
739
+ Err(format!(
740
+ "{agg_name}'s detail field {} has state_filter {}, but {type_name} has no such state — it declares {}",
741
+ inspect(&json!(key)),
742
+ inspect(state_filter),
743
+ known.join(", ")
744
+ ))
745
+ }
746
+
747
+ // A `columns:` on a field that is neither a value object nor an entity is a config mistake
748
+ // this catches by refusing rather than silently doing nothing.
749
+ fn validate_row_columns(agg_name: &str, aggregate: &Value, key: &str, attribute: &Value, columns: &Value) -> Refusal {
750
+ let type_name = ui_schema::type_of(attribute).to_string();
751
+ let rows_source = ui_schema::find_value_object(aggregate, &type_name)
752
+ .or_else(|| ui_schema::find_entity(aggregate, &type_name))
753
+ .filter(|_| ui_schema::is_list(attribute));
754
+ let Some(rows_source) = rows_source else {
755
+ return Err(format!(
756
+ "{agg_name}'s detail field {} has columns: but isn't a rows-shaped field",
757
+ inspect(&json!(key))
758
+ ));
759
+ };
760
+
761
+ let known: Vec<&str> = ui_schema::array(rows_source, "attributes").iter().map(ui_schema::attr_name).collect();
762
+ for column in list(Some(columns)) {
763
+ if !known.contains(&ruby_to_s(column).as_str()) {
764
+ return Err(format!(
765
+ "{agg_name}'s detail field {} has no row column {} — it has {}",
766
+ inspect(&json!(key)),
767
+ inspect(column),
768
+ known.join(", ")
769
+ ));
770
+ }
771
+ }
772
+ Ok(())
773
+ }
774
+
775
+ // No-arg only: a query needing its own arguments has nowhere in a generic table view to get
776
+ // one from.
777
+ fn validate_list_query(agg_name: &str, aggregate: &Value, query_name: &Value) -> Refusal {
778
+ let name = ruby_to_s(query_name);
779
+ let Some(query) = find_query(aggregate, &name) else {
780
+ return Err(format!("{agg_name} has no query {} to use as its list_query", inspect(query_name)));
781
+ };
782
+ let arguments = ui_schema::array(query, "attributes");
783
+ if arguments.is_empty() {
784
+ return Ok(());
785
+ }
786
+ Err(format!(
787
+ "{agg_name}'s list_query {} takes its own arguments ({}) — a generic table view has nowhere to get them from",
788
+ inspect(query_name),
789
+ arguments.iter().map(ui_schema::attr_name).collect::<Vec<_>>().join(", ")
790
+ ))
791
+ }
792
+
793
+ fn find_query<'a>(owner: &'a Value, name: &str) -> Option<&'a Value> {
794
+ ui_schema::array(owner, "queries").iter().find(|q| q.get("name").and_then(|v| v.as_str()) == Some(name))
795
+ }
796
+
797
+ // Cannot check whether a `$field` exists on the source aggregate, since every one of its own
798
+ // attributes is fair game — a real gap, and the same one Ruby has.
799
+ fn validate_after_create(agg_name: &str, after_create: &Value, aggregates: &[&Value], domain_ir: &Value) -> Refusal {
800
+ let Some(dispatch_to) = present(after_create.get("dispatch")) else {
801
+ return Err(format!("{agg_name}'s after_create names no dispatch"));
802
+ };
803
+ let dotted = ruby_to_s(dispatch_to);
804
+ let (target_agg_name, target_command_name) = dotted.split_once('.').unwrap_or((dotted.as_str(), ""));
805
+
806
+ let Some(target_agg) = find_aggregate(aggregates, target_agg_name) else {
807
+ return Err(format!(
808
+ "{agg_name}'s after_create dispatches {}, but {} is not a real aggregate this domain declares",
809
+ inspect(dispatch_to),
810
+ inspect(&json!(target_agg_name))
811
+ ));
812
+ };
813
+ let Some(target_command) = find_command(target_agg, target_command_name) else {
814
+ return Err(format!(
815
+ "{agg_name}'s after_create dispatches {}, but {target_agg_name} declares no command {}",
816
+ inspect(dispatch_to),
817
+ inspect(&json!(target_command_name))
818
+ ));
819
+ };
820
+
821
+ let Some(args) = after_create.get("args").and_then(|v| v.as_object()) else { return Ok(()) };
822
+ for (key, spec) in args {
823
+ if ui_schema::find_attribute(target_command, key).is_none() {
824
+ return Err(format!(
825
+ "{agg_name}'s after_create names arg {}, but {dotted} declares no such attribute (it declares {})",
826
+ inspect(&json!(key)),
827
+ attribute_names(target_command)
828
+ ));
829
+ }
830
+ validate_after_create_arg_spec(agg_name, key, spec, aggregates, domain_ir)?;
831
+ }
832
+ Ok(())
833
+ }
834
+
835
+ fn find_command<'a>(aggregate: &'a Value, name: &str) -> Option<&'a Value> {
836
+ ui_schema::commands(aggregate).into_iter().find(|c| c.get("name").and_then(|v| v.as_str()) == Some(name))
837
+ }
838
+
839
+ fn validate_after_create_arg_spec(
840
+ agg_name: &str,
841
+ key: &str,
842
+ spec: &Value,
843
+ aggregates: &[&Value],
844
+ _domain_ir: &Value,
845
+ ) -> Refusal {
846
+ let Some(count_query) = spec.get("count_query").filter(|_| spec.is_object()).and_then(|v| present(Some(v))) else {
847
+ return Ok(());
848
+ };
849
+ let dotted = ruby_to_s(count_query);
850
+ let (source_agg_name, rest) = dotted.split_once('.').unwrap_or((dotted.as_str(), ""));
851
+ let Some(source_agg) = find_aggregate(aggregates, source_agg_name) else {
852
+ return Err(format!(
853
+ "{agg_name}'s after_create arg {} counts {}, but {} is not a real aggregate",
854
+ inspect(&json!(key)),
855
+ inspect(count_query),
856
+ inspect(&json!(source_agg_name))
857
+ ));
858
+ };
859
+
860
+ if let Some((entity_name, query_name)) = rest.split_once('.') {
861
+ let Some(entity) = ui_schema::find_entity(source_agg, entity_name) else {
862
+ return Err(format!(
863
+ "{agg_name}'s after_create arg {} counts {}, but {source_agg_name} has no piece {}",
864
+ inspect(&json!(key)),
865
+ inspect(count_query),
866
+ inspect(&json!(entity_name))
867
+ ));
868
+ };
869
+ if find_query(entity, query_name).is_none() {
870
+ return Err(format!(
871
+ "{agg_name}'s after_create arg {} counts {}, but {entity_name} declares no query {}",
872
+ inspect(&json!(key)),
873
+ inspect(count_query),
874
+ inspect(&json!(query_name))
875
+ ));
876
+ }
877
+ } else if find_query(source_agg, rest).is_none() {
878
+ return Err(format!(
879
+ "{agg_name}'s after_create arg {} counts {}, but {source_agg_name} declares no query {}",
880
+ inspect(&json!(key)),
881
+ inspect(count_query),
882
+ inspect(&json!(rest))
883
+ ));
884
+ }
885
+
886
+ if present(spec.get("count_scope_arg")).is_none() {
887
+ return Err(format!(
888
+ "{agg_name}'s after_create arg {} names count_query but no count_scope_arg",
889
+ inspect(&json!(key))
890
+ ));
891
+ }
892
+ if present(spec.get("count_scope_value")).is_none() {
893
+ return Err(format!(
894
+ "{agg_name}'s after_create arg {} names count_query but no count_scope_value",
895
+ inspect(&json!(key))
896
+ ));
897
+ }
898
+ Ok(())
899
+ }
900
+
901
+ // `collection` names a collection key, the one `key:` config can override, since a stat sits
902
+ // above any one aggregate the same way a table does.
903
+ fn validate_overview(overview: &Value, aggregates: &[&Value], config: &Value) -> Refusal {
904
+ let by_key: Vec<(String, &Value)> =
905
+ aggregates.iter().map(|a| (ui_schema::collection_key(a, config), *a)).collect();
906
+
907
+ for stat in list(overview.get("stats")) {
908
+ validate_stat(stat, &by_key)?;
909
+ }
910
+ Ok(())
911
+ }
912
+
913
+ fn validate_stat(stat: &Value, by_key: &[(String, &Value)]) -> Refusal {
914
+ let Some(collection) = present(stat.get("collection")) else {
915
+ return Err("an overview stat has no collection named".to_string());
916
+ };
917
+ let key = ruby_to_s(collection);
918
+ let Some((_, aggregate)) = by_key.iter().find(|(k, _)| *k == key) else {
919
+ return Err(format!("overview stat names collection {}, which isn't a real collection key", inspect(collection)));
920
+ };
921
+
922
+ let label = stat.get("label").cloned().unwrap_or(Value::Null);
923
+ let fold = present(stat.get("as")).map(ruby_to_s).unwrap_or_else(|| "count".to_string());
924
+ if !VALID_FOLDS.contains(&fold.as_str()) {
925
+ return Err(format!(
926
+ "overview stat {}'s as: {} isn't one of {}",
927
+ inspect(&label),
928
+ inspect(&json!(fold)),
929
+ VALID_FOLDS.join(", ")
930
+ ));
931
+ }
932
+ if fold == "money_sum" && present(stat.get("field")).is_none() {
933
+ return Err(format!("overview stat {} folds as money_sum but names no field", inspect(&label)));
934
+ }
935
+
936
+ validate_where(stat.get("where").unwrap_or(&Value::Null), aggregate, by_key, &label)
937
+ }
938
+
939
+ // Recurses through the same shape index.html's `matchesWhere` reads at evaluation time: `not:`
940
+ // wraps another whole clause, and `has_related`/`not_has_related` is checked below.
941
+ fn validate_where(clause: &Value, aggregate: &Value, by_key: &[(String, &Value)], label: &Value) -> Refusal {
942
+ let Some(object) = clause.as_object() else { return Ok(()) };
943
+ if let Some(negated) = object.get("not") {
944
+ return validate_where(negated, aggregate, by_key, label);
945
+ }
946
+
947
+ for (key, condition) in object {
948
+ if key == "has_related" || key == "not_has_related" {
949
+ validate_has_related(condition, aggregate, by_key, label)?;
950
+ continue;
951
+ }
952
+ if key != "state" {
953
+ continue;
954
+ }
955
+
956
+ let known = ui_schema::lifecycle_states(aggregate);
957
+ let named = match condition {
958
+ Value::Object(map) => map
959
+ .get("eq")
960
+ .or_else(|| map.get("ne"))
961
+ .or_else(|| map.get("in").and_then(|v| v.as_array()).and_then(|a| a.first()))
962
+ .cloned()
963
+ .unwrap_or(Value::Null),
964
+ other => other.clone(),
965
+ };
966
+ if named.is_null() || known.iter().any(|s| *s == ruby_to_s(&named)) {
967
+ continue;
968
+ }
969
+ return Err(format!(
970
+ "overview stat {}'s where: state {} isn't one of {}'s own states ({})",
971
+ inspect(label),
972
+ inspect(&named),
973
+ ui_schema::agg_name(aggregate),
974
+ known.join(", ")
975
+ ));
976
+ }
977
+ Ok(())
978
+ }
979
+
980
+ // Spelled `field:`, not `on:` — YAML 1.1 reads a bare `on:` as the boolean `true`.
981
+ fn validate_has_related(spec: &Value, aggregate: &Value, by_key: &[(String, &Value)], label: &Value) -> Refusal {
982
+ let Some(target_key) = present(spec.get("collection")) else {
983
+ return Err(format!("overview stat {}'s has_related names no collection", inspect(label)));
984
+ };
985
+ let Some((_, target)) = by_key.iter().find(|(k, _)| *k == ruby_to_s(target_key)) else {
986
+ return Err(format!(
987
+ "overview stat {}'s has_related names collection {}, which isn't real",
988
+ inspect(label),
989
+ inspect(target_key)
990
+ ));
991
+ };
992
+
993
+ let Some(field) = present(spec.get("field")) else {
994
+ return Err(format!("overview stat {}'s has_related names no field:", inspect(label)));
995
+ };
996
+ if ui_schema::find_attribute(target, &ruby_to_s(field)).is_none() {
997
+ return Err(format!(
998
+ "overview stat {}'s has_related.field {} isn't a real field on {}",
999
+ inspect(label),
1000
+ inspect(field),
1001
+ ui_schema::agg_name(target)
1002
+ ));
1003
+ }
1004
+
1005
+ let equals = present(spec.get("equals")).map(ruby_to_s).unwrap_or_else(|| "id".to_string());
1006
+ if equals == "id" || ui_schema::find_attribute(aggregate, &equals).is_some() {
1007
+ return Ok(());
1008
+ }
1009
+ Err(format!(
1010
+ "overview stat {}'s has_related.equals {} isn't a real field on {}",
1011
+ inspect(label),
1012
+ inspect(&json!(equals)),
1013
+ ui_schema::agg_name(aggregate)
1014
+ ))
1015
+ }
1016
+
1017
+ // Ruby's `#inspect`: the refusal messages this module reproduces embed it directly, so any
1018
+ // other spelling would be a visible divergence.
1019
+ fn inspect(value: &Value) -> String {
1020
+ match value {
1021
+ Value::Null => "nil".to_string(),
1022
+ Value::Bool(flag) => flag.to_string(),
1023
+ // Ruby prints a whole Float with a trailing `.0`; serde_json
1024
+ // prints `4.0` for an f64 and `4` for an integer, which is the
1025
+ // same distinction, so the number's own JSON spelling is right.
1026
+ Value::Number(number) => number.to_string(),
1027
+ Value::String(text) => format!("{text:?}"),
1028
+ Value::Array(items) => format!("[{}]", items.iter().map(inspect).collect::<Vec<_>>().join(", ")),
1029
+ Value::Object(map) => format!(
1030
+ "{{{}}}",
1031
+ map.iter().map(|(key, value)| format!("{key:?} => {}", inspect(value))).collect::<Vec<_>>().join(", ")
1032
+ ),
1033
+ }
1034
+ }
1035
+
1036
+ #[cfg(test)]
1037
+ mod tests {
1038
+ use super::*;
1039
+
1040
+ // Enough shape for every rule above to have something real to check against: a
1041
+ // lifecycle-less aggregate, a reference, a rows-shaped field, and a no-arg query.
1042
+ fn domain() -> Value {
1043
+ json!({
1044
+ "name": "Console",
1045
+ "aggregates": [
1046
+ {
1047
+ "name": "Event",
1048
+ "identified_by": ["slug.value"],
1049
+ "attributes": [
1050
+ {"name": "slug", "type": "Slug", "list": false},
1051
+ {"name": "name", "type": "EventName", "list": false},
1052
+ {"name": "price", "type": "Money", "list": false},
1053
+ {"name": "seats", "type": "Seat", "list": true},
1054
+ {"name": "organizer", "type": "Reference<Person>", "list": false}
1055
+ ],
1056
+ "value_objects": [
1057
+ {"name": "Slug", "attributes": [{"name": "value", "type": "String"}]},
1058
+ {"name": "EventName", "attributes": [{"name": "value", "type": "String"}]},
1059
+ {"name": "Money", "attributes": [
1060
+ {"name": "cents", "type": "Integer"}, {"name": "currency", "type": "String"}]},
1061
+ {"name": "Seat", "attributes": [
1062
+ {"name": "row", "type": "String"}, {"name": "number", "type": "Integer"}]}
1063
+ ],
1064
+ "entities": [],
1065
+ "commands": [],
1066
+ "queries": [
1067
+ {"name": "Everywhere", "attributes": []},
1068
+ {"name": "ByOrganizer", "attributes": [{"name": "organizer", "type": "Slug"}]}
1069
+ ],
1070
+ "lifecycle": {"field": "status", "default": "open",
1071
+ "transitions": [{"command": "Close", "to_state": "closed", "from_state": "open"}]}
1072
+ },
1073
+ {
1074
+ "name": "Person",
1075
+ "identified_by": ["email.value"],
1076
+ "attributes": [{"name": "email", "type": "Email", "list": false}],
1077
+ "value_objects": [{"name": "Email", "attributes": [{"name": "value", "type": "String"}]}],
1078
+ "entities": [], "commands": [], "queries": [], "lifecycle": null
1079
+ }
1080
+ ]
1081
+ })
1082
+ }
1083
+
1084
+ // The smallest config this domain accepts: every real state styled.
1085
+ fn complete() -> Value {
1086
+ json!({"states": {"Event": {"open": {}, "closed": {}}}})
1087
+ }
1088
+
1089
+ fn refusal(config: Value) -> String {
1090
+ validate(&config, &domain()).expect_err("this config should have been refused")
1091
+ }
1092
+
1093
+ #[test]
1094
+ fn a_complete_config_validates() {
1095
+ assert!(validate(&complete(), &domain()).is_ok());
1096
+ }
1097
+
1098
+ #[test]
1099
+ fn every_real_state_needs_an_entry_even_a_bare_one() {
1100
+ assert_eq!(
1101
+ refusal(json!({"states": {"Event": {"open": {}}}})),
1102
+ "no presentation entry for Event.closed — every real state needs one, even a bare one"
1103
+ );
1104
+ }
1105
+
1106
+ #[test]
1107
+ fn a_lifecycle_less_aggregate_is_never_reported_missing() {
1108
+ // Person declares no lifecycle at all — styling it is not a
1109
+ // thing to be missing, which is what keeps this check from
1110
+ // firing on every domain that has one such aggregate.
1111
+ assert!(validate(&complete(), &domain()).is_ok());
1112
+ }
1113
+
1114
+ #[test]
1115
+ fn a_state_section_naming_an_aggregate_this_domain_does_not_declare_refuses() {
1116
+ assert_eq!(
1117
+ refusal(json!({"states": {"Ghost": {"gone": {}}}})),
1118
+ "Ghost is not a real aggregate this domain declares"
1119
+ );
1120
+ }
1121
+
1122
+ #[test]
1123
+ fn a_state_this_aggregate_does_not_declare_refuses_and_names_the_ones_it_does() {
1124
+ let mut config = complete();
1125
+ config["states"]["Event"]["cancelled"] = json!({});
1126
+ assert_eq!(refusal(config), "Event has no state \"cancelled\" — it declares open, closed");
1127
+ }
1128
+
1129
+ #[test]
1130
+ fn a_tone_outside_the_five_refuses_in_the_consoles_own_words() {
1131
+ let mut config = complete();
1132
+ config["states"]["Event"]["open"] = json!({"tone": "chartreuse"});
1133
+ assert_eq!(
1134
+ refusal(config),
1135
+ "Event.open's tone \"chartreuse\" isn't one of good, warn, danger, muted, accent"
1136
+ );
1137
+ }
1138
+
1139
+ #[test]
1140
+ fn attention_is_a_real_boolean_not_a_string() {
1141
+ let mut config = complete();
1142
+ config["states"]["Event"]["open"] = json!({"attention": "yes"});
1143
+ assert_eq!(refusal(config), "Event.open's attention must be true or false, not \"yes\"");
1144
+ }
1145
+
1146
+ #[test]
1147
+ fn a_column_naming_a_field_the_aggregate_does_not_declare_refuses() {
1148
+ let mut config = complete();
1149
+ config["collections"] = json!({"Event": {"columns": [{"field": "nope"}]}});
1150
+ assert_eq!(
1151
+ refusal(config),
1152
+ "Event has no attribute \"nope\" to put in its columns — it declares slug, name, price, seats, organizer"
1153
+ );
1154
+ }
1155
+
1156
+ #[test]
1157
+ fn the_status_pill_is_a_column_every_aggregate_has() {
1158
+ let mut config = complete();
1159
+ config["collections"] = json!({"Event": {"columns": ["__state__", "slug"]}});
1160
+ assert!(validate(&config, &domain()).is_ok());
1161
+ }
1162
+
1163
+ #[test]
1164
+ fn a_columns_sortable_is_a_boolean_and_its_sort_default_is_one_of_two_words() {
1165
+ let mut config = complete();
1166
+ config["collections"] = json!({"Event": {"columns": [{"field": "slug", "sortable": "true"}]}});
1167
+ assert_eq!(refusal(config), "Event's column \"slug\" has sortable \"true\", not true or false");
1168
+
1169
+ let mut config = complete();
1170
+ config["collections"] = json!({"Event": {"columns": [{"field": "slug", "sort_default": "sideways"}]}});
1171
+ assert_eq!(refusal(config), "Event's column \"slug\" has sort_default \"sideways\", not asc or desc");
1172
+ }
1173
+
1174
+ #[test]
1175
+ fn nav_order_is_a_number() {
1176
+ let mut config = complete();
1177
+ config["collections"] = json!({"Event": {"nav_order": "3"}});
1178
+ assert_eq!(refusal(config), "Event's nav_order must be a number, not \"3\"");
1179
+ }
1180
+
1181
+ #[test]
1182
+ fn a_precondition_only_makes_sense_against_a_reference() {
1183
+ let mut config = complete();
1184
+ config["collections"] = json!({"Event": {"preconditions": [{"field": "slug", "state": "open"}]}});
1185
+ assert_eq!(refusal(config), "Event.slug isn't a reference — a precondition only makes sense against one");
1186
+ }
1187
+
1188
+ #[test]
1189
+ fn a_precondition_against_a_lifecycle_less_target_names_the_states_it_has() {
1190
+ let mut config = complete();
1191
+ config["collections"] = json!({"Event": {"preconditions": [{"field": "organizer", "state": "active"}]}});
1192
+ assert_eq!(refusal(config), "Event.organizer points at Person, which has no state \"active\" — it declares ");
1193
+ }
1194
+
1195
+ #[test]
1196
+ fn a_format_names_a_real_attribute_and_one_of_two_formats() {
1197
+ let mut config = complete();
1198
+ config["collections"] = json!({"Event": {"field_formats": {"nope": "date"}}});
1199
+ assert_eq!(refusal(config), "Event has no attribute \"nope\" to format");
1200
+
1201
+ let mut config = complete();
1202
+ config["collections"] = json!({"Event": {"field_formats": {"slug": "shouty"}}});
1203
+ assert_eq!(refusal(config), "Event.slug's format \"shouty\" isn't one of date, percent");
1204
+ }
1205
+
1206
+ #[test]
1207
+ fn a_format_may_name_a_nested_compound_fields_own_attribute() {
1208
+ let mut config = complete();
1209
+ config["collections"] = json!({"Event": {"field_formats": {"cents": "percent"}}});
1210
+ assert!(validate(&config, &domain()).is_ok());
1211
+ }
1212
+
1213
+ #[test]
1214
+ fn an_identity_rule_names_a_plain_field_and_one_of_three_strategies() {
1215
+ let mut config = complete();
1216
+ config["collections"] = json!({"Event": {"identity": {"field": "organizer", "strategy": "slug"}}});
1217
+ assert_eq!(refusal(config), "Event.organizer is a reference — identity can only derive a plain field");
1218
+
1219
+ let mut config = complete();
1220
+ config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "guesswork"}}});
1221
+ assert_eq!(refusal(config), "Event's identity strategy \"guesswork\" isn't 'slug', 'sequence', or 'port'");
1222
+
1223
+ let mut config = complete();
1224
+ config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "slug"}}});
1225
+ assert_eq!(refusal(config), "Event's identity slug strategy needs a source field");
1226
+
1227
+ let mut config = complete();
1228
+ config["collections"] = json!({"Event": {"identity": {"field": "slug", "strategy": "sequence"}}});
1229
+ assert_eq!(refusal(config), "Event's identity sequence strategy needs a prefix");
1230
+
1231
+ let mut config = complete();
1232
+ config["collections"] =
1233
+ json!({"Event": {"identity": {"field": "slug", "strategy": "slug", "source": "name"}}});
1234
+ assert!(validate(&config, &domain()).is_ok());
1235
+ }
1236
+
1237
+ #[test]
1238
+ fn a_detail_fields_display_is_one_of_five_placements() {
1239
+ let mut config = complete();
1240
+ config["collections"] = json!({"Event": {"detail_fields": [{"field": "name", "display": "interpretive"}]}});
1241
+ assert_eq!(
1242
+ refusal(config),
1243
+ "Event's detail field \"name\" has display \"interpretive\", not one of simple, prose, bullet, mono, rows"
1244
+ );
1245
+ }
1246
+
1247
+ #[test]
1248
+ fn row_columns_are_checked_against_the_rows_shape_the_field_actually_holds() {
1249
+ let mut config = complete();
1250
+ config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "columns": ["row"]}]}});
1251
+ assert!(validate(&config, &domain()).is_ok());
1252
+
1253
+ let mut config = complete();
1254
+ config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "columns": ["aisle"]}]}});
1255
+ assert_eq!(refusal(config), "Event's detail field \"seats\" has no row column \"aisle\" — it has row, number");
1256
+
1257
+ let mut config = complete();
1258
+ config["collections"] = json!({"Event": {"detail_fields": [{"field": "name", "columns": ["row"]}]}});
1259
+ assert_eq!(refusal(config), "Event's detail field \"name\" has columns: but isn't a rows-shaped field");
1260
+ }
1261
+
1262
+ #[test]
1263
+ fn a_state_filter_needs_an_entity_backed_rows_field() {
1264
+ let mut config = complete();
1265
+ config["collections"] = json!({"Event": {"detail_fields": [{"field": "seats", "state_filter": "taken"}]}});
1266
+ assert_eq!(
1267
+ refusal(config),
1268
+ "Event's detail field \"seats\" has state_filter: but isn't an entity-backed rows field"
1269
+ );
1270
+ }
1271
+
1272
+ #[test]
1273
+ fn a_list_query_is_one_of_this_aggregates_own_no_arg_queries() {
1274
+ let mut config = complete();
1275
+ config["collections"] = json!({"Event": {"list_query": "Everywhere"}});
1276
+ assert!(validate(&config, &domain()).is_ok());
1277
+
1278
+ let mut config = complete();
1279
+ config["collections"] = json!({"Event": {"list_query": "Nowhere"}});
1280
+ assert_eq!(refusal(config), "Event has no query \"Nowhere\" to use as its list_query");
1281
+
1282
+ let mut config = complete();
1283
+ config["collections"] = json!({"Event": {"list_query": "ByOrganizer"}});
1284
+ assert_eq!(
1285
+ refusal(config),
1286
+ "Event's list_query \"ByOrganizer\" takes its own arguments (organizer) — \
1287
+ a generic table view has nowhere to get them from"
1288
+ );
1289
+ }
1290
+
1291
+ #[test]
1292
+ fn after_create_is_validated_in_full_and_then_refused_as_unstorable() {
1293
+ let mut config = complete();
1294
+ config["collections"] = json!({"Event": {"after_create": {"dispatch": "Ghost.Add"}}});
1295
+ assert_eq!(
1296
+ refusal(config),
1297
+ "Event's after_create dispatches \"Ghost.Add\", but \"Ghost\" is not a real aggregate this domain declares"
1298
+ );
1299
+
1300
+ // A well-formed one still refuses — with the scope-boundary
1301
+ // message, not a shape complaint.
1302
+ let mut config = complete();
1303
+ config["collections"] = json!({"Event": {"after_create": {"dispatch": "Person.Nope"}}});
1304
+ assert_eq!(
1305
+ refusal(config),
1306
+ "Event's after_create dispatches \"Person.Nope\", but Person declares no command \"Nope\""
1307
+ );
1308
+ }
1309
+
1310
+ #[test]
1311
+ fn a_storable_after_create_still_refuses_because_nothing_stores_it_yet() {
1312
+ let mut ir = domain();
1313
+ ir["aggregates"][1]["commands"] = json!([{"name": "Note", "references": "Person", "attributes": []}]);
1314
+ let mut config = complete();
1315
+ config["collections"] = json!({"Event": {"after_create": {"dispatch": "Person.Note"}}});
1316
+ assert_eq!(
1317
+ validate(&config, &ir).expect_err("still unstorable"),
1318
+ "Event's after_create isn't stored by ConsoleSettings yet \
1319
+ (see console_settings.bluebook's own header) — remove it before saving"
1320
+ );
1321
+ }
1322
+
1323
+ #[test]
1324
+ fn an_overview_stat_names_a_real_collection_key_and_a_real_fold() {
1325
+ let mut config = complete();
1326
+ config["overview"] = json!({"stats": [{"label": "Open", "collection": "nowhere"}]});
1327
+ assert_eq!(refusal(config), "overview stat names collection \"nowhere\", which isn't a real collection key");
1328
+
1329
+ let mut config = complete();
1330
+ config["overview"] = json!({"stats": [{"label": "Open", "collection": "events", "as": "vibes"}]});
1331
+ assert_eq!(refusal(config), "overview stat \"Open\"'s as: \"vibes\" isn't one of count, money_sum");
1332
+
1333
+ let mut config = complete();
1334
+ config["overview"] = json!({"stats": [{"label": "Take", "collection": "events", "as": "money_sum"}]});
1335
+ assert_eq!(refusal(config), "overview stat \"Take\" folds as money_sum but names no field");
1336
+ }
1337
+
1338
+ #[test]
1339
+ fn a_stats_where_clause_is_walked_including_through_a_negation() {
1340
+ let mut config = complete();
1341
+ config["overview"] =
1342
+ json!({"stats": [{"label": "Live", "collection": "events", "where": {"state": "ajar"}}]});
1343
+ assert_eq!(
1344
+ refusal(config),
1345
+ "overview stat \"Live\"'s where: state \"ajar\" isn't one of Event's own states (open, closed)"
1346
+ );
1347
+
1348
+ let mut config = complete();
1349
+ config["overview"] =
1350
+ json!({"stats": [{"label": "Live", "collection": "events", "where": {"not": {"state": {"eq": "ajar"}}}}]});
1351
+ assert_eq!(
1352
+ refusal(config),
1353
+ "overview stat \"Live\"'s where: state \"ajar\" isn't one of Event's own states (open, closed)"
1354
+ );
1355
+
1356
+ let mut config = complete();
1357
+ config["overview"] =
1358
+ json!({"stats": [{"label": "Live", "collection": "events", "where": {"state": {"in": ["open"]}}}]});
1359
+ assert!(validate(&config, &domain()).is_ok());
1360
+ }
1361
+
1362
+ #[test]
1363
+ fn has_related_names_a_real_collection_and_a_real_field_on_it() {
1364
+ let mut config = complete();
1365
+ config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
1366
+ "where": {"has_related": {"collection": "persons", "field": "nope"}}}]});
1367
+ assert_eq!(refusal(config), "overview stat \"Hosted\"'s has_related.field \"nope\" isn't a real field on Person");
1368
+
1369
+ let mut config = complete();
1370
+ config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
1371
+ "where": {"has_related": {"collection": "persons", "field": "email", "equals": "nope"}}}]});
1372
+ assert_eq!(
1373
+ refusal(config),
1374
+ "overview stat \"Hosted\"'s has_related.equals \"nope\" isn't a real field on Event"
1375
+ );
1376
+
1377
+ let mut config = complete();
1378
+ config["overview"] = json!({"stats": [{"label": "Hosted", "collection": "events",
1379
+ "where": {"has_related": {"collection": "persons", "field": "email", "equals": "slug"}}}]});
1380
+ assert!(validate(&config, &domain()).is_ok());
1381
+ }
1382
+
1383
+ #[test]
1384
+ fn a_bare_column_name_and_a_descriptor_both_dispatch_as_a_row() {
1385
+ assert_eq!(
1386
+ columns_for_dispatch(Some(&json!(["slug", {"field": "name", "sortable": true, "head": "Title"}]))),
1387
+ vec![
1388
+ json!({"field": "slug"}),
1389
+ json!({"field": "name", "sortable": "true", "extra_json": "{\"head\":\"Title\"}"})
1390
+ ]
1391
+ );
1392
+ }
1393
+
1394
+ #[test]
1395
+ fn a_rows_fields_sub_columns_flatten_to_one_row_per_pair() {
1396
+ assert_eq!(
1397
+ detail_field_columns_for_dispatch(Some(&json!([
1398
+ {"field": "seats", "columns": ["row", "number"]},
1399
+ {"field": "name"}
1400
+ ]))),
1401
+ vec![
1402
+ json!({"field": "seats", "column": "row"}),
1403
+ json!({"field": "seats", "column": "number"})
1404
+ ]
1405
+ );
1406
+ }
1407
+
1408
+ #[test]
1409
+ fn field_formats_go_from_a_map_to_rows_and_back() {
1410
+ assert_eq!(
1411
+ field_formats_for_dispatch(Some(&json!({"price": "percent"}))),
1412
+ vec![json!({"field": "price", "format": "percent"})]
1413
+ );
1414
+ }
1415
+
1416
+ #[test]
1417
+ fn a_stats_where_clause_rides_through_as_opaque_json_text() {
1418
+ let stats = stats_for_dispatch(Some(&json!([{"label": "Live", "collection": "events",
1419
+ "where": {"state": "open"}}])));
1420
+ assert_eq!(stats[0]["where_json"], json!("{\"state\":\"open\"}"));
1421
+ assert!(stats[0].get("as").is_none(), "an absent fold is absent, not null: {}", stats[0]);
1422
+ }
1423
+
1424
+ #[test]
1425
+ fn inspect_prints_the_way_ruby_prints() {
1426
+ assert_eq!(inspect(&json!("warn")), "\"warn\"");
1427
+ assert_eq!(inspect(&Value::Null), "nil");
1428
+ assert_eq!(inspect(&json!(true)), "true");
1429
+ assert_eq!(inspect(&json!(3)), "3");
1430
+ }
1431
+ }