hecks 3.9.1 → 3.11.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 (504) hide show
  1. checksums.yaml +4 -4
  2. data/examples/pizzas/bluebook/pizzas.bluebook +29 -0
  3. data/examples/pizzas/bluebook/pizzas.hecksagon +7 -0
  4. data/exe/hecks +4 -4
  5. data/lib/hecks/adapters/driven/d1.rb +2 -10
  6. data/lib/hecks/adapters/driven/heki.adapter +1 -1
  7. data/lib/hecks/adapters/driven/heki.rb +2 -12
  8. data/lib/hecks/adapters/driven/lambda.adapter +1 -1
  9. data/lib/hecks/adapters/driven/lambda.rb +3 -18
  10. data/lib/hecks/adapters/driven/postgres.rb +4 -11
  11. data/lib/hecks/adapters/driven/sqlite.adapter +1 -1
  12. data/lib/hecks/adapters/driven/sqlite.rb +2 -10
  13. data/lib/hecks/adapters/driving/admission.rb +46 -0
  14. data/lib/hecks/adapters/driving/cli/nesting.rb +36 -0
  15. data/lib/hecks/adapters/driving/cli/shorthand.rb +88 -0
  16. data/lib/hecks/adapters/driving/cli.rb +127 -0
  17. data/lib/hecks/adapters/driving/cli_runner/answers.rb +160 -0
  18. data/lib/hecks/adapters/driving/cli_runner/resolution.rb +155 -0
  19. data/lib/hecks/adapters/driving/cli_runner/settling.rb +120 -0
  20. data/lib/hecks/adapters/driving/cli_runner/streaming.rb +93 -0
  21. data/lib/hecks/adapters/driving/cli_runner/suggestions.rb +47 -0
  22. data/lib/hecks/adapters/driving/cli_runner.rb +69 -0
  23. data/lib/hecks/adapters/driving/command_request.rb +121 -0
  24. data/lib/hecks/adapters/driving/handle.rb +214 -0
  25. data/lib/hecks/adapters/driving/json.rb +136 -0
  26. data/lib/hecks/adapters/driving/launcher_options.rb +267 -0
  27. data/lib/hecks/adapters/driving/mcp/properties.rb +49 -0
  28. data/lib/hecks/adapters/driving/mcp/tool_calls.rb +116 -0
  29. data/lib/hecks/adapters/driving/mcp/tool_definitions/auditing.rb +53 -0
  30. data/lib/hecks/adapters/driving/mcp/tool_definitions/calling.rb +71 -0
  31. data/lib/hecks/adapters/driving/mcp/tool_definitions/reading.rb +99 -0
  32. data/lib/hecks/adapters/driving/mcp/tool_definitions.rb +18 -0
  33. data/lib/hecks/adapters/driving/mcp.rb +159 -0
  34. data/lib/hecks/adapters/driving/mcp_guide_cache.rb +78 -0
  35. data/lib/hecks/adapters/driving/mcp_scope/argument_policy.rb +97 -0
  36. data/lib/hecks/adapters/driving/mcp_scope/env_problems.rb +69 -0
  37. data/lib/hecks/adapters/driving/mcp_scope/presenter.rb +83 -0
  38. data/lib/hecks/adapters/driving/mcp_scope.rb +213 -0
  39. data/lib/hecks/adapters/driving/ruby/aggregate_module.rb +171 -0
  40. data/lib/hecks/adapters/driving/ruby/chapter.rb +91 -0
  41. data/lib/hecks/adapters/driving/ruby.rb +83 -0
  42. data/lib/hecks/adapters/driving/usage_cache.rb +144 -0
  43. data/lib/hecks/{doors.rb → adapters/driving.rb} +10 -6
  44. data/lib/hecks/behaviors/expectations/boot.rb +1 -1
  45. data/lib/hecks/bench/ruby_runner.rb +1 -1
  46. data/lib/hecks/bluebook/ask_resolution.rb +130 -0
  47. data/lib/hecks/bluebook/assembly/aggregate_assembly.rb +8 -2
  48. data/lib/hecks/bluebook/assembly/contracts/rules.rb +4 -2
  49. data/lib/hecks/bluebook/assembly/contracts/structure.rb +3 -1
  50. data/lib/hecks/bluebook/assembly/marks.rb +1 -0
  51. data/lib/hecks/bluebook/attribute.rb +1 -1
  52. data/lib/hecks/bluebook/behaviour/command.rb +13 -8
  53. data/lib/hecks/bluebook/behaviour/policy.rb +30 -0
  54. data/lib/hecks/bluebook/capabilities/attribute_values.rb +72 -0
  55. data/lib/hecks/bluebook/capabilities.rb +91 -9
  56. data/lib/hecks/bluebook/domain_port.rb +16 -4
  57. data/lib/hecks/bluebook/dsl/adapter_builder.rb +18 -6
  58. data/lib/hecks/bluebook/dsl/binding_proxy.rb +13 -0
  59. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/computed_sources.rb +98 -0
  60. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/fan_out_rows.rb +42 -0
  61. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/projected_fields.rb +14 -2
  62. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/provisions.rb +69 -3
  63. data/lib/hecks/bluebook/dsl/bluebook_builder/validation/with_projections.rb +17 -10
  64. data/lib/hecks/bluebook/dsl/bluebook_builder/validation.rb +5 -0
  65. data/lib/hecks/bluebook/dsl/bootstrap_table.rb +2 -0
  66. data/lib/hecks/bluebook/dsl/command_builder/mutations.rb +8 -1
  67. data/lib/hecks/bluebook/dsl/command_builder/operands.rb +121 -0
  68. data/lib/hecks/bluebook/dsl/command_builder.rb +2 -0
  69. data/lib/hecks/bluebook/dsl/hecksagon_builder.rb +10 -1
  70. data/lib/hecks/bluebook/dsl/lifecycle_builder.rb +45 -2
  71. data/lib/hecks/bluebook/dsl/policy_builder.rb +47 -10
  72. data/lib/hecks/bluebook/dsl/rule_reference.rb +7 -4
  73. data/lib/hecks/bluebook/dsl/world_builder.rb +1 -1
  74. data/lib/hecks/bluebook/expression/ast_json/emitters.rb +6 -0
  75. data/lib/hecks/bluebook/expression/ast_json.rb +9 -6
  76. data/lib/hecks/bluebook/expression/ast_reader.rb +14 -9
  77. data/lib/hecks/bluebook/expression/projection.json +36 -0
  78. data/lib/hecks/bluebook/expression/resolver/operations.rb +31 -0
  79. data/lib/hecks/bluebook/expression/resolver/parsing.rb +38 -5
  80. data/lib/hecks/bluebook/expression/resolver/scanning.rb +22 -5
  81. data/lib/hecks/bluebook/expression/resolver/values.rb +10 -0
  82. data/lib/hecks/bluebook/expression/resolver.rb +20 -1
  83. data/lib/hecks/bluebook/hecksagon.rb +23 -3
  84. data/lib/hecks/bluebook/lifecycle.rb +22 -3
  85. data/lib/hecks/bluebook/meta_validator/adapter_judge.rb +13 -1
  86. data/lib/hecks/bluebook/meta_validator/modes.rb +4 -1
  87. data/lib/hecks/bluebook/meta_validator/readings.rb +18 -1
  88. data/lib/hecks/bluebook/meta_validator/reconstruction/heads.rb +2 -1
  89. data/lib/hecks/bluebook/meta_validator/shapes.rb +5 -0
  90. data/lib/hecks/bluebook/meta_validator/verdict_cache.rb +1 -1
  91. data/lib/hecks/bluebook/meta_validator.rb +12 -12
  92. data/lib/hecks/bluebook/model_check/ask_checks.rb +71 -0
  93. data/lib/hecks/bluebook/model_check/policy_checks.rb +2 -1
  94. data/lib/hecks/bluebook/model_check/reference_checks.rb +70 -0
  95. data/lib/hecks/bluebook/model_check.rb +8 -2
  96. data/lib/hecks/bluebook/policy.rb +4 -2
  97. data/lib/hecks/bluebook/smoke_test.rb +1 -1
  98. data/lib/hecks/bluebook/synthesizer.rb +2 -0
  99. data/lib/hecks/bluebook.rb +1 -0
  100. data/lib/hecks/cli/command_table.rb +1 -1
  101. data/lib/hecks/cli/console.rb +2 -1
  102. data/lib/hecks/cli/document.rb +1 -1
  103. data/lib/hecks/cli/launcher_words.rb +4 -4
  104. data/lib/hecks/cli/mcp.rb +4 -4
  105. data/lib/hecks/cli/project_cli/launcher_source.rb +2 -2
  106. data/lib/hecks/cli/project_cli/templates.rb +5 -5
  107. data/lib/hecks/cli/project_cli.rb +9 -6
  108. data/lib/hecks/cli/run.rb +2 -2
  109. data/lib/hecks/cli/smoke_http/checks.rb +4 -1
  110. data/lib/hecks/cli/smoke_http/transport.rb +3 -3
  111. data/lib/hecks/cli/smoke_http.rb +3 -0
  112. data/lib/hecks/codemod.rb +5 -2
  113. data/lib/hecks/corpus/routes.rb +7 -0
  114. data/lib/hecks/corpus/rust.rb +3 -2
  115. data/lib/hecks/deploy/bluebook/deploy.bluebook +124 -2
  116. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  117. data/lib/hecks/deploy/oidc.json +16 -3
  118. data/lib/hecks/doc/reference/rendering.rb +6 -3
  119. data/lib/hecks/forms/app/commands.rb +8 -7
  120. data/lib/hecks/forms/app/queries.rb +2 -1
  121. data/lib/hecks/forms/app.rb +1 -1
  122. data/lib/hecks/forms/record_renderer.rb +1 -1
  123. data/lib/hecks/framework/bluebook/privacy.bluebook +11 -0
  124. data/lib/hecks/fuzzing/concurrent_dispatch.rb +4 -1
  125. data/lib/hecks/fuzzing/failure_triage.rb +2 -1
  126. data/lib/hecks/fuzzing/mutation/run.rb +11 -4
  127. data/lib/hecks/fuzzing/properties/catalog.rb +6 -4
  128. data/lib/hecks/fuzzing/properties/guaranteed_by_construction.rb +18 -11
  129. data/lib/hecks/fuzzing/properties/guards.rb +1 -1
  130. data/lib/hecks/fuzzing/self_consistency/saga_redelivery.rb +1 -1
  131. data/lib/hecks/fuzzing/self_consistency.rb +6 -2
  132. data/lib/hecks/fuzzing/shrinker.rb +3 -1
  133. data/lib/hecks/fuzzing/value_generator.rb +19 -5
  134. data/lib/hecks/gate/stages.yml +4 -10
  135. data/lib/hecks/grammar/evolve.rb +1 -1
  136. data/lib/hecks/grammar/expression_operators.json +372 -0
  137. data/lib/hecks/grammar/operators.rb +3 -1
  138. data/lib/hecks/hecks/adapters/codebase/corpus_tasks.rb +10 -10
  139. data/lib/hecks/hecks/adapters/codebase/inspection/rubycritic.rb +35 -0
  140. data/lib/hecks/hecks/adapters/codebase/inspection.rb +104 -0
  141. data/lib/hecks/hecks/adapters/codebase/publishing.rb +2 -1
  142. data/lib/hecks/hecks/adapters/codebase/release_facts.rb +12 -2
  143. data/lib/hecks/hecks/adapters/codebase/source_tree.rb +2 -1
  144. data/lib/hecks/hecks/adapters/codebase/test_runner.rb +1 -1
  145. data/lib/hecks/hecks/adapters/cost_explorer.rb +8 -5
  146. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  147. data/lib/hecks/hecks/adapters/deploy_toolchain/statuses.rb +2 -1
  148. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  149. data/lib/hecks/hecks/adapters/finding_github.rb +13 -6
  150. data/lib/hecks/hecks/adapters/first_admin.rb +2 -2
  151. data/lib/hecks/hecks/adapters/github_rulesets.rb +6 -3
  152. data/lib/hecks/hecks/adapters/in_process_boot.rb +1 -1
  153. data/lib/hecks/hecks/adapters/journal_store/compaction.rb +1 -1
  154. data/lib/hecks/hecks/adapters/journal_store/edge_audit.rb +10 -20
  155. data/lib/hecks/hecks/adapters/journal_store/held_domain.rb +28 -5
  156. data/lib/hecks/hecks/adapters/local_files.rb +2 -2
  157. data/lib/hecks/hecks/adapters/rehearsal/audit_report.rb +107 -0
  158. data/lib/hecks/hecks/adapters/rehearsal/census.rb +71 -0
  159. data/lib/hecks/hecks/adapters/rehearsal/declaration.rb +133 -0
  160. data/lib/hecks/hecks/adapters/rehearsal/environment.rb +83 -0
  161. data/lib/hecks/hecks/adapters/rehearsal/inquiry.rb +142 -0
  162. data/lib/hecks/hecks/adapters/rehearsal/memory_store.rb +96 -0
  163. data/lib/hecks/hecks/adapters/rehearsal/postgres_census.rb +100 -0
  164. data/lib/hecks/hecks/adapters/rehearsal/postgres_reading.rb +69 -0
  165. data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +129 -0
  166. data/lib/hecks/hecks/adapters/rehearsal/rows.rb +41 -0
  167. data/lib/hecks/hecks/adapters/rehearsal/rust_audit.rb +83 -0
  168. data/lib/hecks/hecks/adapters/rehearsal/subject.rb +104 -0
  169. data/lib/hecks/hecks/adapters/rehearsal.rb +54 -0
  170. data/lib/hecks/hecks/adapters/rehearsal_store.adapter +7 -0
  171. data/lib/hecks/hecks/adapters/rehearsal_store.rb +81 -0
  172. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  173. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  174. data/lib/hecks/hecks/adapters/terminal.rb +13 -11
  175. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  176. data/lib/hecks/hecks/codebase.behaviors +13 -1
  177. data/lib/hecks/hecks/codebase.bluebook +239 -5
  178. data/lib/hecks/hecks/custodian.behaviors +24 -6
  179. data/lib/hecks/hecks/custodian.bluebook +6 -1227
  180. data/lib/hecks/hecks/deploy.behaviors +1 -1
  181. data/lib/hecks/hecks/deploy.world +1 -3
  182. data/lib/hecks/hecks/hecks.behaviors +1 -1
  183. data/lib/hecks/hecks/hecks.hecksagon +61 -18
  184. data/lib/hecks/hecks/hecks.world +11 -10
  185. data/lib/hecks/hecks/rehearsal.bluebook +477 -0
  186. data/lib/hecks/hecks/site.behaviors +1 -1
  187. data/lib/hecks/hecks/site.world +0 -1
  188. data/lib/hecks/hecks/tooling.bluebook +1238 -0
  189. data/lib/hecks/indifferent_key.rb +20 -0
  190. data/lib/hecks/language/adapter.bluebook +13 -3
  191. data/lib/hecks/language/bluebook/aggregate.bluebook +23 -0
  192. data/lib/hecks/language/bluebook/bluebook.bluebook +15 -0
  193. data/lib/hecks/language/bluebook/entity.bluebook +18 -0
  194. data/lib/hecks/language/bluebook/policy.bluebook +9 -1
  195. data/lib/hecks/language/bluebook/query.bluebook +1 -1
  196. data/lib/hecks/language/bluebook/vocabulary.bluebook +11 -4
  197. data/lib/hecks/language/hecksagon/domain_port.bluebook +1 -1
  198. data/lib/hecks/language/hecksagon/hecksagon.bluebook +22 -0
  199. data/lib/hecks/language/oidc.json +10 -0
  200. data/lib/hecks/language/port.bluebook +4 -4
  201. data/lib/hecks/literal.rb +12 -3
  202. data/lib/hecks/ports/key_vault.rb +7 -2
  203. data/lib/hecks/ports/persistence/plugins/era/era_check.rb +21 -10
  204. data/lib/hecks/ports/persistence/plugins/era/postgres_era/boot.rb +4 -2
  205. data/lib/hecks/ports/persistence/plugins/era/postgres_era/lineage_manager/era_resolver.rb +2 -1
  206. data/lib/hecks/ports/persistence/repository_factory.rb +6 -3
  207. data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +29 -0
  208. data/lib/hecks/projections/deploy/box/templates/deploy-service.sh.tmpl +1 -1
  209. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  210. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  211. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  212. data/lib/hecks/projections/deploy/preview/preview.sh.erb +1 -1
  213. data/lib/hecks/projections/deploy/shared.rb +5 -2
  214. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  215. data/lib/hecks/projections/model/deviations.rb +2 -2
  216. data/lib/hecks/projections/model.rb +2 -2
  217. data/lib/hecks/projections/oidc.rb +13 -1
  218. data/lib/hecks/projections/shape.rb +5 -2
  219. data/lib/hecks/projections/site/cms_editor/attributes.rb +95 -0
  220. data/lib/hecks/projections/site/cms_editor/blocks.rb +157 -0
  221. data/lib/hecks/projections/site/cms_editor/checks.rb +77 -0
  222. data/lib/hecks/projections/site/cms_editor/clearing.rb +70 -0
  223. data/lib/hecks/projections/site/cms_editor/color.rb +124 -0
  224. data/lib/hecks/projections/site/cms_editor/composition.rb +42 -0
  225. data/lib/hecks/projections/site/cms_editor/destructive.rb +61 -0
  226. data/lib/hecks/projections/site/cms_editor/drafts.rb +102 -0
  227. data/lib/hecks/projections/site/cms_editor/hints.rb +74 -0
  228. data/lib/hecks/projections/site/cms_editor/icons.rb +86 -0
  229. data/lib/hecks/projections/site/cms_editor/key_kinds.rb +31 -0
  230. data/lib/hecks/projections/site/cms_editor/limits.rb +72 -0
  231. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  232. data/lib/hecks/projections/site/cms_editor/moment.rb +34 -0
  233. data/lib/hecks/projections/site/cms_editor/palette.rb +115 -0
  234. data/lib/hecks/projections/site/cms_editor/pickers.rb +85 -0
  235. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  236. data/lib/hecks/projections/site/cms_editor/preview.rb +91 -0
  237. data/lib/hecks/projections/site/cms_editor/relations.rb +92 -0
  238. data/lib/hecks/projections/site/cms_editor/scale_checks.rb +66 -0
  239. data/lib/hecks/projections/site/cms_editor/scheduling.rb +150 -0
  240. data/lib/hecks/projections/site/cms_editor/schema.rb +161 -0
  241. data/lib/hecks/projections/site/cms_editor/setting.rb +130 -0
  242. data/lib/hecks/projections/site/cms_editor/targets.rb +86 -0
  243. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +36 -0
  244. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +370 -0
  245. data/lib/hecks/projections/site/cms_editor/templates/src/assets.ts.tmpl +98 -0
  246. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +87 -0
  247. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  248. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  249. data/lib/hecks/projections/site/cms_editor/templates/src/browser/app.css.tmpl +412 -0
  250. data/lib/hecks/projections/site/cms_editor/templates/src/browser/autosave.js.tmpl +144 -0
  251. data/lib/hecks/projections/site/cms_editor/templates/src/browser/blocks.js.tmpl +500 -0
  252. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_doc.js.tmpl +175 -0
  253. data/lib/hecks/projections/site/cms_editor/templates/src/browser/body_editor.js.tmpl +418 -0
  254. data/lib/hecks/projections/site/cms_editor/templates/src/browser/epoch.js.tmpl +61 -0
  255. data/lib/hecks/projections/site/cms_editor/templates/src/browser/forms.js.tmpl +210 -0
  256. data/lib/hecks/projections/site/cms_editor/templates/src/browser/lists.js.tmpl +40 -0
  257. data/lib/hecks/projections/site/cms_editor/templates/src/browser/main.js.tmpl +26 -0
  258. data/lib/hecks/projections/site/cms_editor/templates/src/browser/media_picker.js.tmpl +232 -0
  259. data/lib/hecks/projections/site/cms_editor/templates/src/browser/moments.js.tmpl +73 -0
  260. data/lib/hecks/projections/site/cms_editor/templates/src/browser/pickers.js.tmpl +152 -0
  261. data/lib/hecks/projections/site/cms_editor/templates/src/browser/preview.js.tmpl +98 -0
  262. data/lib/hecks/projections/site/cms_editor/templates/src/browser/shell.js.tmpl +140 -0
  263. data/lib/hecks/projections/site/cms_editor/templates/src/browser/toast.js.tmpl +66 -0
  264. data/lib/hecks/projections/site/cms_editor/templates/src/choices.ts.tmpl +99 -0
  265. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +133 -0
  266. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +29 -0
  267. data/lib/hecks/projections/site/cms_editor/templates/src/flash.ts.tmpl +52 -0
  268. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +43 -0
  269. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +177 -0
  270. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  271. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  272. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  273. data/lib/hecks/projections/site/cms_editor/templates/src/names.ts.tmpl +36 -0
  274. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +214 -0
  275. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  276. data/lib/hecks/projections/site/cms_editor/templates/src/ui/availability.ts.tmpl +15 -0
  277. data/lib/hecks/projections/site/cms_editor/templates/src/ui/block_list.js.tmpl +213 -0
  278. data/lib/hecks/projections/site/cms_editor/templates/src/ui/blocks.ts.tmpl +275 -0
  279. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +123 -0
  280. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  281. data/lib/hecks/projections/site/cms_editor/templates/src/ui/detail.ts.tmpl +88 -0
  282. data/lib/hecks/projections/site/cms_editor/templates/src/ui/dialogs.ts.tmpl +43 -0
  283. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +275 -0
  284. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +61 -0
  285. data/lib/hecks/projections/site/cms_editor/templates/src/ui/icons.ts.tmpl +15 -0
  286. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +92 -0
  287. data/lib/hecks/projections/site/cms_editor/templates/src/ui/links.ts.tmpl +100 -0
  288. data/lib/hecks/projections/site/cms_editor/templates/src/ui/moments.ts.tmpl +44 -0
  289. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +63 -0
  290. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +213 -0
  291. data/lib/hecks/projections/site/cms_editor/templates/src/ui/paging.ts.tmpl +82 -0
  292. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pickers.ts.tmpl +110 -0
  293. data/lib/hecks/projections/site/cms_editor/templates/src/ui/preview.ts.tmpl +87 -0
  294. data/lib/hecks/projections/site/cms_editor/templates/src/ui/related.ts.tmpl +97 -0
  295. data/lib/hecks/projections/site/cms_editor/templates/src/ui/schedule.ts.tmpl +160 -0
  296. data/lib/hecks/projections/site/cms_editor/templates/src/ui/shell.ts.tmpl +211 -0
  297. data/lib/hecks/projections/site/cms_editor/templates/src/ui/table.ts.tmpl +151 -0
  298. data/lib/hecks/projections/site/cms_editor/templates/src/ui/theme.js.tmpl +16 -0
  299. data/lib/hecks/projections/site/cms_editor/templates/src/ui/values.ts.tmpl +69 -0
  300. data/lib/hecks/projections/site/cms_editor/templates/src/ui/words.ts.tmpl +97 -0
  301. data/lib/hecks/projections/site/cms_editor/templates/src/ui/world.ts.tmpl +35 -0
  302. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +17 -0
  303. data/lib/hecks/projections/site/cms_editor/theme.rb +29 -0
  304. data/lib/hecks/projections/site/cms_editor/theme_checks.rb +57 -0
  305. data/lib/hecks/projections/site/cms_editor.rb +133 -0
  306. data/lib/hecks/projections/site/role_probe.rb +74 -0
  307. data/lib/hecks/projector/cli_audience.rb +2 -1
  308. data/lib/hecks/projector/cli_projector/listing.rb +1 -1
  309. data/lib/hecks/projector/cli_projector/option_specs.rb +1 -1
  310. data/lib/hecks/projector/docs_projector/closing.rb +1 -1
  311. data/lib/hecks/projector/docs_projector.rb +1 -1
  312. data/lib/hecks/projector/exporter/bindings.rb +40 -10
  313. data/lib/hecks/projector/exporter/capability_values.rb +55 -0
  314. data/lib/hecks/projector/narrate_projector/reactions.rb +1 -1
  315. data/lib/hecks/projector/target.rb +11 -0
  316. data/lib/hecks/projector.rb +32 -5
  317. data/lib/hecks/quality_control/adapters/github_checks.rb +12 -8
  318. data/lib/hecks/quality_control/adapters/github_issues.rb +8 -4
  319. data/lib/hecks/quality_control/cli/qa_open_pr.rb +2 -2
  320. data/lib/hecks/quality_control/cli/qa_sweep/checks/shrinking.rb +1 -1
  321. data/lib/hecks/quality_control/cli/qa_sweep/finding_report.rb +1 -1
  322. data/lib/hecks/quality_control/cli/qa_sweep.rb +1 -1
  323. data/lib/hecks/quality_control/quality_control.bluebook +6 -2
  324. data/lib/hecks/release/approval.rb +64 -0
  325. data/lib/hecks/router/{door.rb → aggregate_reader.rb} +5 -4
  326. data/lib/hecks/router/namespace_installer.rb +16 -16
  327. data/lib/hecks/router.rb +1 -1
  328. data/lib/hecks/runtime/best_effort.rb +22 -0
  329. data/lib/hecks/runtime/command_interpreter/mutation_applier.rb +12 -2
  330. data/lib/hecks/runtime/command_interpreter.rb +3 -2
  331. data/lib/hecks/runtime/command_rules/emission.rb +1 -1
  332. data/lib/hecks/runtime/dispatcher.rb +4 -4
  333. data/lib/hecks/runtime/entity_element/location.rb +1 -1
  334. data/lib/hecks/runtime/event.rb +5 -0
  335. data/lib/hecks/runtime/identity.rb +7 -6
  336. data/lib/hecks/runtime/interpreting.rb +2 -1
  337. data/lib/hecks/runtime/invocation_builder.rb +1 -1
  338. data/lib/hecks/runtime/loader/boot_steps.rb +8 -5
  339. data/lib/hecks/runtime/loader.rb +10 -10
  340. data/lib/hecks/runtime/policy_interpreter/arguments.rb +7 -4
  341. data/lib/hecks/runtime/policy_interpreter/fan_out.rb +3 -3
  342. data/lib/hecks/runtime/policy_interpreter.rb +6 -6
  343. data/lib/hecks/runtime/port_operation_interpreter.rb +1 -1
  344. data/lib/hecks/runtime/reaction_invocation.rb +1 -1
  345. data/lib/hecks/runtime/rebuild_sweep.rb +11 -1
  346. data/lib/hecks/runtime/registry/ask_verification.rb +35 -0
  347. data/lib/hecks/runtime/registry/hecksagon_rules.rb +10 -0
  348. data/lib/hecks/runtime/registry/merging.rb +1 -1
  349. data/lib/hecks/runtime/registry/providers.rb +16 -0
  350. data/lib/hecks/runtime/registry/read_repository.rb +6 -4
  351. data/lib/hecks/runtime/registry/verification.rb +4 -1
  352. data/lib/hecks/runtime/registry/world_defaults.rb +29 -0
  353. data/lib/hecks/runtime/registry.rb +1 -1
  354. data/lib/hecks/runtime/saga_interpreter/compensation.rb +1 -1
  355. data/lib/hecks/runtime/saga_interpreter/delivery.rb +4 -4
  356. data/lib/hecks/runtime/saga_interpreter/transitions.rb +1 -1
  357. data/lib/hecks/runtime/saga_interpreter.rb +3 -3
  358. data/lib/hecks/runtime/tenant_check.rb +5 -4
  359. data/lib/hecks/runtime/value/coercion.rb +2 -2
  360. data/lib/hecks/runtime/value/validation.rb +16 -4
  361. data/lib/hecks/runtime.rb +9 -9
  362. data/lib/hecks/rust_build/project_rust/target_ir.rb +1 -1
  363. data/lib/hecks/rust_build/wasm.rb +1 -1
  364. data/lib/hecks/rust_build/wasm_browser.rb +1 -1
  365. data/lib/hecks/rust_build.rb +11 -0
  366. data/lib/hecks/served_options.rb +53 -0
  367. data/lib/hecks/site/bluebook/site.bluebook +29 -1
  368. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  369. data/lib/hecks/storehouse/audit.rb +1 -1
  370. data/lib/hecks/storehouse/dispatching.rb +8 -6
  371. data/lib/hecks/storehouse/introspection.rb +2 -2
  372. data/lib/hecks/storehouse/querying.rb +6 -4
  373. data/lib/hecks/storehouse.rb +4 -4
  374. data/lib/hecks/tools/comment_style/source_file.rb +4 -1
  375. data/lib/hecks/tools/deploy_recipe_lint/fixtures.rb +4 -1
  376. data/lib/hecks/tools/lanes/rows.rb +6 -0
  377. data/lib/hecks/tools/oidc_manifests.rb +4 -5
  378. data/lib/hecks/tools/regeneration_run.rb +11 -4
  379. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  380. data/lib/hecks/tools/site_routes/editor_files.rb +45 -0
  381. data/lib/hecks/tools/site_routes.rb +14 -7
  382. data/lib/hecks/tools/tenant_provisioning.rb +2 -2
  383. data/lib/hecks/tools/tools_doc.rb +4 -4
  384. data/lib/hecks/version.rb +1 -1
  385. data/lib/hecks/vocabulary.rb +4 -4
  386. data/lib/hecks.rb +15 -15
  387. data/lib/rubocop/cop/hecks/env_dump.rb +69 -0
  388. data/lib/rubocop/cop/hecks/monotonic_duration.rb +53 -0
  389. data/lib/rubocop/cop/hecks/no_wall_clock_in_runtime.rb +34 -0
  390. data/lib/rubocop/cop/hecks/raw_shell_interpolation.rb +79 -0
  391. data/lib/rubocop/cop/hecks/sleep_in_spec.rb +34 -0
  392. data/lib/rubocop/cop/hecks/swallowed_rescue.rb +37 -0
  393. data/lib/rubocop/cop/hecks/symbol_string_key_mix.rb +50 -0
  394. data/lib/rubocop/cop/hecks/untyped_raise.rb +37 -0
  395. data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
  396. data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
  397. data/prebuilt/verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json +1 -0
  398. data/rust/.gitignore +6 -0
  399. data/rust/Cargo.toml +9 -1
  400. data/rust/build/src/binding_pass.rs +103 -0
  401. data/rust/build/src/cargo_sync.rs +6 -0
  402. data/rust/build/src/lineage_pass.rs +3 -3
  403. data/rust/build/src/main.rs +17 -5
  404. data/rust/build/src/pipeline.rs +48 -5
  405. data/rust/build/src/seams_pass.rs +123 -0
  406. data/rust/build/src/translation_pass.rs +167 -0
  407. data/rust/codegen/src/asks.rs +103 -0
  408. data/rust/codegen/src/bridging.rs +1 -1
  409. data/rust/codegen/src/commands.rs +13 -8
  410. data/rust/codegen/src/constraints.rs +25 -0
  411. data/rust/codegen/src/domain_generator.rs +11 -9
  412. data/rust/codegen/src/expr_emitter.rs +13 -0
  413. data/rust/codegen/src/json_codec.rs +54 -15
  414. data/rust/codegen/src/main.rs +2 -1
  415. data/rust/codegen/src/mutations.rs +174 -4
  416. data/rust/codegen/src/naming.rs +30 -0
  417. data/rust/codegen/src/queries.rs +11 -2
  418. data/rust/codegen/src/reactions.rs +1 -1
  419. data/rust/codegen/src/types.rs +166 -8
  420. data/rust/host/HECKS_RELEASE +1 -1
  421. data/rust/host/src/api.rs +4 -4
  422. data/rust/host/src/bin/validate_harness.rs +43 -0
  423. data/rust/host/src/boundary_fuzz/expr_json.rs +2 -2
  424. data/rust/host/src/boundary_fuzz/ir.rs +1 -0
  425. data/rust/host/src/boundary_fuzz/server.rs +1 -1
  426. data/rust/host/src/checkout.rs +35 -19
  427. data/rust/host/src/commerce_ir.rs +408 -2
  428. data/rust/host/src/expr_json.rs +118 -0
  429. data/rust/host/src/mint.rs +1 -1
  430. data/rust/host/src/payments/tests.rs +27 -1
  431. data/rust/host/src/payments.rs +12 -4
  432. data/rust/host/src/query_step.rs +22 -4
  433. data/rust/host/src/reference_validate.rs +653 -108
  434. data/rust/host/src/resend.rs +2 -0
  435. data/rust/host/src/server.rs +19 -6
  436. data/rust/host/src/ui_schema.rs +132 -5
  437. data/rust/host/src/web/newsletter.rs +7 -8
  438. data/rust/host/src/web/newsletter_send.rs +38 -13
  439. data/rust/host/src/web/registrations.rs +229 -26
  440. data/rust/host/src/web.rs +3 -3
  441. data/rust/parser/src/emit.rs +38 -6
  442. data/rust/parser/src/expr/ast_json.rs +7 -1
  443. data/rust/parser/src/expr/resolver.rs +171 -6
  444. data/rust/parser/src/ir.rs +7 -0
  445. data/rust/parser/src/keywords.rs +24 -0
  446. data/rust/parser/src/main.rs +3 -0
  447. data/rust/parser/src/parse/aggregate.rs +2 -1
  448. data/rust/parser/src/parse/chapter.rs +2 -2
  449. data/rust/parser/src/parse/command.rs +2 -0
  450. data/rust/parser/src/parse/hecksagon.rs +5 -2
  451. data/rust/parser/src/parse/lifecycle.rs +81 -0
  452. data/rust/parser/src/parse/mod.rs +15 -1
  453. data/rust/parser/src/parse/policy.rs +13 -2
  454. data/rust/parser/src/parse/value_object.rs +13 -8
  455. data/rust/parser/src/ruby_value.rs +1 -1
  456. data/rust/src/exemplar/commands.rs +4 -4
  457. data/rust/src/exemplar/constraints.rs +1 -1
  458. data/rust/src/exemplar/json.rs +43 -1
  459. data/rust/src/kernel/cli.rs +7 -5
  460. data/rust/src/kernel/dispatch.rs +2 -2
  461. data/rust/src/kernel/dispatch_guard_tests.rs +1 -1
  462. data/rust/src/kernel/expr.rs +73 -4
  463. data/rust/src/kernel/expression_operators/arithmetic.rs +130 -5
  464. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  465. data/rust/src/kernel/expression_operators/text.rs +26 -2
  466. data/rust/src/kernel/json.rs +15 -0
  467. data/rust/src/kernel/mod.rs +1 -1
  468. data/rust/src/kernel/orchestrate.rs +54 -5
  469. data/rust/src/kernel/orchestrate_guard_tests.rs +67 -0
  470. data/rust/src/kernel/pattern.rs +60 -0
  471. data/rust/src/kernel/reference_lookup.rs +90 -7
  472. metadata +174 -36
  473. data/lib/hecks/doors/cli_door/nesting.rb +0 -34
  474. data/lib/hecks/doors/cli_door/shorthand.rb +0 -85
  475. data/lib/hecks/doors/cli_door.rb +0 -124
  476. data/lib/hecks/doors/cli_runner/answers.rb +0 -138
  477. data/lib/hecks/doors/cli_runner/resolution.rb +0 -146
  478. data/lib/hecks/doors/cli_runner/settling.rb +0 -115
  479. data/lib/hecks/doors/cli_runner/streaming.rb +0 -90
  480. data/lib/hecks/doors/cli_runner/suggestions.rb +0 -43
  481. data/lib/hecks/doors/cli_runner.rb +0 -66
  482. data/lib/hecks/doors/command_request.rb +0 -119
  483. data/lib/hecks/doors/handle.rb +0 -208
  484. data/lib/hecks/doors/json_door.rb +0 -132
  485. data/lib/hecks/doors/launcher_options.rb +0 -247
  486. data/lib/hecks/doors/mcp_door/properties.rb +0 -47
  487. data/lib/hecks/doors/mcp_door/tool_calls.rb +0 -113
  488. data/lib/hecks/doors/mcp_door/tool_definitions/auditing.rb +0 -51
  489. data/lib/hecks/doors/mcp_door/tool_definitions/calling.rb +0 -69
  490. data/lib/hecks/doors/mcp_door/tool_definitions/reading.rb +0 -97
  491. data/lib/hecks/doors/mcp_door/tool_definitions.rb +0 -15
  492. data/lib/hecks/doors/mcp_door.rb +0 -156
  493. data/lib/hecks/doors/mcp_door_scope/argument_policy.rb +0 -92
  494. data/lib/hecks/doors/mcp_door_scope/env_problems.rb +0 -55
  495. data/lib/hecks/doors/mcp_door_scope/presenter.rb +0 -78
  496. data/lib/hecks/doors/mcp_door_scope.rb +0 -201
  497. data/lib/hecks/doors/mcp_guide_cache.rb +0 -75
  498. data/lib/hecks/doors/ruby_door/aggregate_door.rb +0 -162
  499. data/lib/hecks/doors/ruby_door/chapter.rb +0 -89
  500. data/lib/hecks/doors/ruby_door.rb +0 -80
  501. data/lib/hecks/doors/usage_cache.rb +0 -139
  502. data/prebuilt/0ee7f83cba59ce7d1c45c65ba166b071779f70143449f584f33ff7989f64eccb.marshal +0 -0
  503. data/prebuilt/6e4a6bcf0aa880e2e6b056e1846b70e5a768090b9dc998bbadeaf5d6bcfd3db8.marshal +0 -0
  504. data/prebuilt/verdicts-91aaf93ce8981bd9a754948f075d95650dc2ec6435015376f5c60992b36a24b1.json +0 -1
@@ -1,5 +1,11 @@
1
1
  //! Layer 1 of the mint audit: a translated record must fit its aggregate's types, patterns,
2
- //! admits, invariants and lifecycle. Reads `ir.json` generically, like `ir.rs`.
2
+ //! closed sets, admits, invariants and lifecycle. Reads `ir.json` generically, like `ir.rs`.
3
+ //!
4
+ //! Holds a stored value to what dispatch holds an offered one to (`Value::Validation#validate!`
5
+ //! and `Value::Admission` in Ruby; the generated `from_json` and `check_invariants` in Rust), in
6
+ //! the same order: a name the value object does not declare, a field left out, closed-set
7
+ //! membership, a declared set named by `admits:`, list and scalar shape, `pattern:`, then its own
8
+ //! invariants. A refusal is worded as dispatch words it, after the `Aggregate#id:` prefix.
3
9
 
4
10
  // Depends on `expr_json`, not the `rust` kernel crate: that crate's generated modules are not
5
11
  // feature-gated, so depending on it would link every domain into every Lambda binary.
@@ -9,14 +15,22 @@ use serde_json::Value;
9
15
  use std::collections::HashMap;
10
16
 
11
17
  /// Every structural violation in one translated record; empty means it satisfies its aggregate.
12
- pub fn validate(aggregate_ir: &Value, id: &str, state: &Value) -> Vec<String> {
18
+ ///
19
+ /// `domain_ir` is the whole `ir.json`: a declared set named by `admits: "Aggregate::Set"` and a
20
+ /// value object another aggregate of the chapter declares are looked up there.
21
+ pub fn validate(domain_ir: &Value, aggregate_ir: &Value, id: &str, state: &Value) -> Vec<String> {
13
22
  let name = aggregate_ir.get("name").and_then(Value::as_str).unwrap_or("");
14
- let value_objects = index_by_name(aggregate_ir, "value_objects");
15
- let entities = index_by_name(aggregate_ir, "entities");
16
23
  let attributes = aggregate_ir.get("attributes").and_then(Value::as_array).cloned().unwrap_or_default();
24
+ let checker = Checker {
25
+ domain: domain_ir,
26
+ aggregate_name: name,
27
+ id,
28
+ value_objects: index_by_name(aggregate_ir, "value_objects"),
29
+ entities: index_by_name(aggregate_ir, "entities"),
30
+ };
17
31
 
18
32
  let mut violations = Vec::new();
19
- check_attributes(name, id, state, &attributes, &value_objects, &entities, &mut violations);
33
+ checker.check_attributes(name, Owner::Record, state, &attributes, &mut violations);
20
34
 
21
35
  if let Some(lifecycle) = aggregate_ir.get("lifecycle") {
22
36
  check_lifecycle(name, id, state, lifecycle, &mut violations);
@@ -31,138 +45,392 @@ fn index_by_name<'a>(node: &'a Value, key: &str) -> HashMap<&'a str, &'a Value>
31
45
  .unwrap_or_default()
32
46
  }
33
47
 
34
- fn check_attributes(
35
- aggregate_name: &str,
36
- id: &str,
37
- state: &Value,
38
- attributes: &[Value],
39
- value_objects: &HashMap<&str, &Value>,
40
- entities: &HashMap<&str, &Value>,
41
- violations: &mut Vec<String>,
42
- ) {
43
- let Some(state_obj) = state.as_object() else { return };
44
- for attr in attributes {
45
- let attr_name = attr.get("name").and_then(Value::as_str).unwrap_or("");
46
- let type_name = attr.get("type").and_then(Value::as_str).unwrap_or("");
47
- let list = attr.get("list").and_then(Value::as_bool).unwrap_or(false);
48
- let optional = attr.get("optional").and_then(Value::as_bool).unwrap_or(false);
49
- let Some(raw) = state_obj.get(attr_name) else { continue };
50
-
51
- if raw.is_null() {
52
- if !optional {
53
- violations.push(format!("{aggregate_name}#{id}: {attr_name} is null, not declared optional"));
54
- }
55
- continue;
48
+ fn declared_attributes(node: &Value) -> &[Value] {
49
+ node.get("attributes").and_then(Value::as_array).map(Vec::as_slice).unwrap_or_default()
50
+ }
51
+
52
+ fn flag(node: &Value, key: &str) -> bool {
53
+ node.get(key).and_then(Value::as_bool).unwrap_or(false)
54
+ }
55
+
56
+ fn attribute_name(attr: &Value) -> &str {
57
+ attr.get("name").and_then(Value::as_str).unwrap_or("")
58
+ }
59
+
60
+ /// What an attribute's owner is: a record (the aggregate or one of its entities), whose absent
61
+ /// slots are skipped, or a value object, which dispatch builds from input and so holds to its
62
+ /// declared names and required fields.
63
+ #[derive(Clone, Copy, PartialEq)]
64
+ enum Owner {
65
+ Record,
66
+ ValueObject,
67
+ }
68
+
69
+ struct Checker<'a> {
70
+ domain: &'a Value,
71
+ aggregate_name: &'a str,
72
+ id: &'a str,
73
+ value_objects: HashMap<&'a str, &'a Value>,
74
+ entities: HashMap<&'a str, &'a Value>,
75
+ }
76
+
77
+ impl<'a> Checker<'a> {
78
+ fn refuse(&self, violations: &mut Vec<String>, message: String) {
79
+ violations.push(format!("{}#{}: {message}", self.aggregate_name, self.id));
80
+ }
81
+
82
+ /// The aggregate's own value object of that name, else the one every aggregate of the chapter
83
+ /// that declares it agrees on (`Value::Coercion#value_object_for`).
84
+ fn value_object(&self, type_name: &str) -> Option<&'a Value> {
85
+ if let Some(vo) = self.value_objects.get(type_name) {
86
+ return Some(vo);
56
87
  }
88
+ let declared: Vec<&'a Value> = self
89
+ .domain
90
+ .get("aggregates")
91
+ .and_then(Value::as_array)
92
+ .map(|aggregates| {
93
+ aggregates
94
+ .iter()
95
+ .filter_map(|aggregate| index_by_name(aggregate, "value_objects").get(type_name).copied())
96
+ .collect()
97
+ })
98
+ .unwrap_or_default();
99
+ let first = *declared.first()?;
100
+ declared.iter().all(|other| same_shape(other, first)).then_some(first)
101
+ }
57
102
 
58
- if list {
59
- match raw.as_array() {
60
- Some(items) => {
61
- for item in items {
62
- check_value(aggregate_name, id, attr_name, type_name, item, attr, value_objects, entities, violations);
103
+ fn check_attributes(&self, owner_name: &str, owner: Owner, state: &Value, attributes: &[Value], violations: &mut Vec<String>) {
104
+ let Some(state_obj) = state.as_object() else { return };
105
+ for attr in attributes {
106
+ let attr_name = attribute_name(attr);
107
+ let type_name = attr.get("type").and_then(Value::as_str).unwrap_or("");
108
+ let list = flag(attr, "list");
109
+ let optional = flag(attr, "optional");
110
+ let Some(raw) = state_obj.get(attr_name) else { continue };
111
+
112
+ if raw.is_null() {
113
+ // A value object's required fields were judged as a whole; a list is never
114
+ // refused for being null (`check_required_fields` skips lists).
115
+ if owner == Owner::Record && !optional && !list {
116
+ self.refuse_null(owner_name, attr_name, type_name, violations);
117
+ }
118
+ continue;
119
+ }
120
+
121
+ if list {
122
+ match raw.as_array() {
123
+ Some(items) => {
124
+ for item in items {
125
+ self.check_value(owner_name, attr_name, type_name, item, attr, violations);
126
+ }
63
127
  }
128
+ None => self.refuse(violations, format!("{owner_name}.{attr_name} expects list_of({type_name}), got {}", ruby_inspect(raw))),
64
129
  }
65
- None => violations.push(format!("{aggregate_name}#{id}: {attr_name} is declared a list, but the translated value isn't one")),
130
+ } else {
131
+ self.check_value(owner_name, attr_name, type_name, raw, attr, violations);
132
+ self.check_admits(attr, raw, violations);
66
133
  }
67
- } else {
68
- check_value(aggregate_name, id, attr_name, type_name, raw, attr, value_objects, entities, violations);
69
134
  }
70
135
  }
71
- }
72
136
 
73
- /// Checks a value object's `invariants` through `expr_json`. Fails closed: a malformed or
74
- /// unsupported `ast` is a violation, since an invariant that cannot be evaluated must not
75
- /// be minted past.
76
- fn check_invariants(aggregate_name: &str, id: &str, attr_name: &str, type_name: &str, value: &Value, vo: &Value, violations: &mut Vec<String>) {
77
- let Some(invariants) = vo.get("invariants").and_then(Value::as_array) else { return };
78
-
79
- for invariant in invariants {
80
- let description = invariant.get("description").and_then(Value::as_str).unwrap_or("");
81
- // An older `ir.json` has no `ast`: nothing to check, so not a violation.
82
- let Some(ast) = invariant.get("ast") else { continue };
83
-
84
- let expr = match expr_json::parse(ast) {
85
- Ok(expr) => expr,
86
- Err(error) => {
87
- violations.push(format!(
88
- "{aggregate_name}#{id}: {attr_name} ({type_name})'s own invariant {description:?} has a malformed ast — {error}"
89
- ));
90
- continue;
91
- }
137
+ /// A required slot holding null. A value object reads as built from no fields at all, as
138
+ /// `Value::Coercion#nil_argument` builds it: refused for the first required field it lacks,
139
+ /// or accepted when it has none.
140
+ fn refuse_null(&self, owner_name: &str, attr_name: &str, type_name: &str, violations: &mut Vec<String>) {
141
+ match self.value_object(type_name) {
142
+ Some(vo) => self.check_value_object(attr_name, type_name, vo, &Value::Object(serde_json::Map::new()), violations),
143
+ None => self.refuse(violations, format!("{owner_name}.{attr_name} expects {type_name}, got nil")),
144
+ }
145
+ }
146
+
147
+ /// `admits: "Aggregate::Set"`: the value must be one of the named closed set's first-column
148
+ /// members, compared as text (`Value::Admission#admit_declared_set`).
149
+ fn check_admits(&self, attr: &Value, raw: &Value, violations: &mut Vec<String>) {
150
+ let Some(admits) = attr.get("admits").and_then(Value::as_str) else { return };
151
+ let name = attribute_name(attr);
152
+ let Some(admitted) = self.admitted_members(admits) else {
153
+ self.refuse(
154
+ violations,
155
+ format!(
156
+ "{name} admits {admits}, which this chapter does not declare — a closed set is named \
157
+ Aggregate::SetName, and it must be one the bluebook actually holds"
158
+ ),
159
+ );
160
+ return;
92
161
  };
93
162
 
94
- match expr_json::interpret(&expr, value) {
95
- Ok(result) if result.truthy() => {}
96
- Ok(_) => violations.push(format!("{aggregate_name}#{id}: {attr_name} ({type_name}) violates its own invariant — {description}")),
97
- Err(error) => violations.push(format!(
98
- "{aggregate_name}#{id}: {attr_name} ({type_name})'s own invariant {description:?} could not be checked — {error}"
99
- )),
163
+ let offered = match raw.as_object() {
164
+ Some(fields) if fields.len() == 1 => fields.values().next().unwrap_or(raw),
165
+ _ => raw,
166
+ };
167
+ if admitted.contains(&ruby_to_s(offered)) {
168
+ return;
100
169
  }
170
+ let listed = admitted.iter().map(|member| inspect_text(member)).collect::<Vec<_>>().join(", ");
171
+ self.refuse(violations, format!("{name} admits {admits} — {listed} — got {}", ruby_inspect(offered)));
101
172
  }
102
- }
103
173
 
104
- #[allow(clippy::too_many_arguments)]
105
- fn check_value(
106
- aggregate_name: &str,
107
- id: &str,
108
- attr_name: &str,
109
- type_name: &str,
110
- value: &Value,
111
- attr: &Value,
112
- value_objects: &HashMap<&str, &Value>,
113
- entities: &HashMap<&str, &Value>,
114
- violations: &mut Vec<String>,
115
- ) {
116
- if let Some(vo) = value_objects.get(type_name) {
117
- let nested = vo.get("attributes").and_then(Value::as_array).cloned().unwrap_or_default();
174
+ /// The first column of each member row of the set `"Aggregate::Set"` names, as text.
175
+ fn admitted_members(&self, admits: &str) -> Option<Vec<String>> {
176
+ let (aggregate_name, set_name) = admits.split_once("::")?;
177
+ let aggregate = self
178
+ .domain
179
+ .get("aggregates")
180
+ .and_then(Value::as_array)?
181
+ .iter()
182
+ .find(|aggregate| aggregate.get("name").and_then(Value::as_str) == Some(aggregate_name))?;
183
+ let set = *index_by_name(aggregate, "value_objects").get(set_name)?;
184
+ let discriminant = attribute_name(declared_attributes(set).first()?);
185
+ Some(member_rows(set).iter().map(|row| ruby_to_s(row.get(discriminant).unwrap_or(&Value::Null))).collect())
186
+ }
187
+
188
+ fn check_value(&self, owner_name: &str, attr_name: &str, type_name: &str, value: &Value, attr: &Value, violations: &mut Vec<String>) {
189
+ if let Some(vo) = self.value_object(type_name) {
190
+ self.check_value_object(attr_name, type_name, vo, value, violations);
191
+ return;
192
+ }
193
+ if let Some(entity) = self.entities.get(type_name) {
194
+ self.check_attributes(type_name, Owner::Record, value, declared_attributes(entity), violations);
195
+ return;
196
+ }
197
+
198
+ // A scalar leaf. Unrecognized type names are unconstrained: a false positive would block a
199
+ // real mint.
200
+ if let Some(refusal) = scalar_refusal(owner_name, attr_name, type_name, value) {
201
+ self.refuse(violations, refusal);
202
+ return;
203
+ }
204
+ if let (Some(pattern), Some(text)) = (attr.get("pattern").and_then(Value::as_str), value.as_str()) {
205
+ if matches!(regex::Regex::new(pattern), Ok(re) if !re.is_match(text)) {
206
+ self.refuse(violations, format!("{owner_name}.{attr_name} must match {pattern}, got {}", ruby_inspect(value)));
207
+ }
208
+ }
209
+ }
210
+
211
+ /// One stored value object, held to what `Value::Validation#validate!` holds an offered one to.
212
+ fn check_value_object(&self, attr_name: &str, type_name: &str, vo: &Value, raw: &Value, violations: &mut Vec<String>) {
213
+ let declared = declared_attributes(vo);
214
+
215
+ // A bare scalar stands in for a single-field value object's one field.
216
+ let wrapped;
217
+ let fields = if raw.is_object() {
218
+ raw
219
+ } else if declared.len() == 1 {
220
+ wrapped = Value::Object(std::iter::once((attribute_name(&declared[0]).to_string(), raw.clone())).collect());
221
+ &wrapped
222
+ } else {
223
+ self.refuse(violations, format!("{attr_name} is a {type_name} — pass its fields as an object, not {}", ruby_inspect(raw)));
224
+ return;
225
+ };
226
+ let Some(given) = fields.as_object() else { return };
227
+
228
+ let mut unknown: Vec<&str> = given.keys().map(String::as_str).filter(|key| !declared.iter().any(|attr| attribute_name(attr) == *key)).collect();
229
+ if !unknown.is_empty() {
230
+ unknown.sort();
231
+ let takes = declared.iter().map(attribute_name).collect::<Vec<_>>().join(", ");
232
+ self.refuse(violations, format!("{type_name} does not declare {} — it takes {takes}", unknown.join(", ")));
233
+ return;
234
+ }
235
+
236
+ let filled = with_defaults(declared, given);
118
237
  let before = violations.len();
119
- check_attributes(aggregate_name, id, value, &nested, value_objects, entities, violations);
238
+ // The first required field left out, as `check_required_fields` refuses it.
239
+ let missing = declared.iter().find(|attr| {
240
+ let present = filled.get(attribute_name(attr)).is_some_and(|value| !value.is_null());
241
+ !present && !flag(attr, "optional") && !flag(attr, "list")
242
+ });
243
+ if let Some(attr) = missing {
244
+ let expected = attr.get("type").and_then(Value::as_str).unwrap_or("");
245
+ self.refuse(violations, format!("{type_name}.{} expects {expected}, got nil", attribute_name(attr)));
246
+ return;
247
+ }
248
+
249
+ let filled = Value::Object(filled);
250
+ if flag(vo, "closed_set") && !member_rows(vo).is_empty() {
251
+ self.check_member(type_name, vo, &filled, violations);
252
+ if violations.len() > before {
253
+ return;
254
+ }
255
+ }
256
+ self.check_attributes(type_name, Owner::ValueObject, &filled, declared, violations);
257
+
120
258
  // Invariants run only when the structure check found nothing: a mistyped value would
121
259
  // double-report, or bury the real type error under a "could not be checked".
122
260
  if violations.len() == before {
123
- check_invariants(aggregate_name, id, attr_name, type_name, value, vo, violations);
261
+ self.check_invariants(attr_name, type_name, &filled, vo, violations);
124
262
  }
125
- return;
126
- }
127
- if let Some(entity) = entities.get(type_name) {
128
- let nested = entity.get("attributes").and_then(Value::as_array).cloned().unwrap_or_default();
129
- check_attributes(aggregate_name, id, value, &nested, value_objects, entities, violations);
130
- return;
131
263
  }
132
264
 
133
- // A scalar leaf. Unrecognized type names are unconstrained: a false positive would block a
134
- // real mint.
135
- if !scalar_type_matches(type_name, value) {
136
- violations.push(format!("{aggregate_name}#{id}: {attr_name} is {value}, not a {type_name}"));
137
- return;
265
+ /// `Value::Admission#admit_member`: some member row matches on every field it names, compared
266
+ /// as text. The refusal quotes the first column only.
267
+ fn check_member(&self, type_name: &str, vo: &Value, fields: &Value, violations: &mut Vec<String>) {
268
+ let rows = member_rows(vo);
269
+ let matches_row = |row: &serde_json::Map<String, Value>| {
270
+ row.iter().all(|(field, member)| ruby_to_s(fields.get(field).unwrap_or(&Value::Null)) == ruby_to_s(member))
271
+ };
272
+ if rows.iter().any(matches_row) {
273
+ return;
274
+ }
275
+ let Some(first) = declared_attributes(vo).first().map(attribute_name) else { return };
276
+ let admitted = rows.iter().map(|row| ruby_inspect(row.get(first).unwrap_or(&Value::Null))).collect::<Vec<_>>().join(", ");
277
+ let offered = ruby_inspect(fields.get(first).unwrap_or(&Value::Null));
278
+ self.refuse(violations, format!("{type_name} admits {admitted} — got {offered}"));
138
279
  }
139
- if let Some(pattern) = attr.get("pattern").and_then(Value::as_str) {
140
- if let Some(text) = value.as_str() {
141
- match regex::Regex::new(pattern) {
142
- Ok(re) if !re.is_match(text) => {
143
- violations.push(format!("{aggregate_name}#{id}: {attr_name} ({value}) doesn't match its own declared pattern"));
280
+
281
+ /// Checks a value object's `invariants` through `expr_json`. Fails closed: a malformed or
282
+ /// unsupported `ast` is a violation, since an invariant that cannot be evaluated must not
283
+ /// be minted past.
284
+ fn check_invariants(&self, attr_name: &str, type_name: &str, value: &Value, vo: &Value, violations: &mut Vec<String>) {
285
+ let Some(invariants) = vo.get("invariants").and_then(Value::as_array) else { return };
286
+ let known = with_absent_optionals(vo, value);
287
+
288
+ for invariant in invariants {
289
+ let description = invariant.get("description").and_then(Value::as_str).unwrap_or("");
290
+ // An older `ir.json` has no `ast`: nothing to check, so not a violation.
291
+ let Some(ast) = invariant.get("ast") else { continue };
292
+
293
+ let expr = match expr_json::parse(ast) {
294
+ Ok(expr) => expr,
295
+ Err(error) => {
296
+ self.refuse(violations, format!("{attr_name} ({type_name})'s own invariant {description:?} has a malformed ast — {error}"));
297
+ continue;
298
+ }
299
+ };
300
+
301
+ match expr_json::interpret(&expr, &known) {
302
+ Ok(result) if result.truthy() => {}
303
+ Ok(_) => self.refuse(violations, format!("{attr_name} ({type_name}) violates its own invariant — {description}")),
304
+ Err(error) => {
305
+ self.refuse(violations, format!("{attr_name} ({type_name})'s own invariant {description:?} could not be checked — {error}"))
144
306
  }
145
- _ => {}
146
307
  }
147
308
  }
148
309
  }
149
- if let Some(admits) = attr.get("admits").and_then(Value::as_array) {
150
- if !admits.is_empty() && !admits.iter().any(|allowed| allowed == value) {
151
- violations.push(format!("{aggregate_name}#{id}: {attr_name} is {value}, not one of its declared admits"));
310
+ }
311
+
312
+ /// Whether two declarations of one value object name agree on every attribute's name, type, list
313
+ /// and optional flags (`Value::Coercion#agreed_value_object`).
314
+ fn same_shape(left: &Value, right: &Value) -> bool {
315
+ let shape = |vo: &Value| -> Vec<(String, String, bool, bool)> {
316
+ declared_attributes(vo)
317
+ .iter()
318
+ .map(|attr| {
319
+ (
320
+ attribute_name(attr).to_string(),
321
+ attr.get("type").and_then(Value::as_str).unwrap_or("").to_string(),
322
+ flag(attr, "list"),
323
+ flag(attr, "optional"),
324
+ )
325
+ })
326
+ .collect()
327
+ };
328
+ shape(left) == shape(right)
329
+ }
330
+
331
+ /// A closed set's member rows, each as a field-to-value map. The IR writes a row as a list of
332
+ /// `[field, value]` pairs.
333
+ fn member_rows(vo: &Value) -> Vec<serde_json::Map<String, Value>> {
334
+ vo.get("members")
335
+ .and_then(Value::as_array)
336
+ .map(|rows| {
337
+ rows.iter()
338
+ .map(|row| {
339
+ row.as_array()
340
+ .map(|pairs| {
341
+ pairs
342
+ .iter()
343
+ .filter_map(Value::as_array)
344
+ .filter_map(|pair| Some((pair.first()?.as_str()?.to_string(), pair.get(1)?.clone())))
345
+ .collect()
346
+ })
347
+ .unwrap_or_default()
348
+ })
349
+ .collect()
350
+ })
351
+ .unwrap_or_default()
352
+ }
353
+
354
+ /// The value as the Ruby runtime would hold it once it has defaulted what was left out: every
355
+ /// declared attribute missing from `given` takes its own `default:`, and every required list
356
+ /// reads as empty (`Value::Validation#apply_defaults`). An optional slot stays absent.
357
+ fn with_defaults(declared: &[Value], given: &serde_json::Map<String, Value>) -> serde_json::Map<String, Value> {
358
+ let mut filled = given.clone();
359
+ for attr in declared {
360
+ let name = attribute_name(attr);
361
+ if filled.contains_key(name) {
362
+ continue;
363
+ }
364
+ match attr.get("default") {
365
+ Some(default) if !default.is_null() => {
366
+ filled.insert(name.to_string(), default.clone());
367
+ }
368
+ _ if flag(attr, "list") && !flag(attr, "optional") => {
369
+ filled.insert(name.to_string(), Value::Array(Vec::new()));
370
+ }
371
+ _ => {}
152
372
  }
153
373
  }
374
+ filled
154
375
  }
155
376
 
156
- fn scalar_type_matches(type_name: &str, value: &Value) -> bool {
377
+ /// A scalar that does not fit its declared type, worded as dispatch words it; `None` when it fits
378
+ /// or the type is not a plain scalar. A leaf is a String, a number or a boolean, never a list or
379
+ /// an object standing in for one.
380
+ fn scalar_refusal(owner_name: &str, attr_name: &str, type_name: &str, value: &Value) -> Option<String> {
381
+ let mismatch = || format!("{owner_name}.{attr_name} expects {type_name}, got {}", ruby_inspect(value));
157
382
  match type_name {
158
- "String" => value.is_string(),
159
- "Integer" => value.is_i64() || value.is_u64(),
160
- "Float" => value.is_f64() || value.is_i64() || value.is_u64(),
161
- "Boolean" => value.is_boolean(),
162
- _ => true,
383
+ "String" if !value.is_string() => Some(mismatch()),
384
+ "Integer" if value.is_i64() => None,
385
+ "Integer" if value.is_u64() => Some(format!("{owner_name}.{attr_name} must fit in a 64-bit integer, got {}", ruby_inspect(value))),
386
+ "Integer" => Some(mismatch()),
387
+ "Float" if value.is_number() => None,
388
+ "Float" => Some(mismatch()),
389
+ "Boolean" if !value.is_boolean() => Some(mismatch()),
390
+ _ => None,
163
391
  }
164
392
  }
165
393
 
394
+ /// `Object#to_s` of the Ruby value a JSON value decodes to; text compares by this.
395
+ fn ruby_to_s(value: &Value) -> String {
396
+ match value {
397
+ Value::String(text) => text.clone(),
398
+ Value::Null => String::new(),
399
+ other => other.to_string(),
400
+ }
401
+ }
402
+
403
+ /// `Object#inspect` of the Ruby value a JSON value decodes to.
404
+ fn ruby_inspect(value: &Value) -> String {
405
+ match value {
406
+ Value::Null => "nil".to_string(),
407
+ Value::String(text) => inspect_text(text),
408
+ Value::Array(items) => format!("[{}]", items.iter().map(ruby_inspect).collect::<Vec<_>>().join(", ")),
409
+ other => other.to_string(),
410
+ }
411
+ }
412
+
413
+ fn inspect_text(text: &str) -> String {
414
+ Value::String(text.to_string()).to_string()
415
+ }
416
+
417
+ /// The value an invariant reads: `value` with every declared optional slot it lacks set to null,
418
+ /// as the Ruby runtime's own validator does (`Value::Validation#with_absent_optionals`). Absence
419
+ /// stays absence in the stored state; an invariant reads it as unset, so `x.set?`, `x.unset?`,
420
+ /// `x.nil?` and a comparison with nil all answer for a slot a writer never sent. A name the value
421
+ /// object does not declare is not filled, so a lookup of it still refuses.
422
+ fn with_absent_optionals(vo: &Value, value: &Value) -> Value {
423
+ let Some(fields) = value.as_object() else { return value.clone() };
424
+ let mut known = fields.clone();
425
+ for attribute in declared_attributes(vo) {
426
+ let Some(name) = attribute.get("name").and_then(Value::as_str) else { continue };
427
+ if flag(attribute, "optional") && !known.contains_key(name) {
428
+ known.insert(name.to_string(), Value::Null);
429
+ }
430
+ }
431
+ Value::Object(known)
432
+ }
433
+
166
434
  fn check_lifecycle(aggregate_name: &str, id: &str, state: &Value, lifecycle: &Value, violations: &mut Vec<String>) {
167
435
  let field = lifecycle.get("field").and_then(Value::as_str).unwrap_or("");
168
436
  if field.is_empty() {
@@ -193,6 +461,15 @@ mod tests {
193
461
  use super::*;
194
462
  use serde_json::json;
195
463
 
464
+ // The aggregate alone is the whole domain here; `domain_validate` takes the domain apart.
465
+ fn validate(aggregate_ir: &Value, id: &str, state: &Value) -> Vec<String> {
466
+ domain_validate(&json!({"aggregates": [aggregate_ir]}), aggregate_ir, id, state)
467
+ }
468
+
469
+ fn domain_validate(domain_ir: &Value, aggregate_ir: &Value, id: &str, state: &Value) -> Vec<String> {
470
+ super::validate(domain_ir, aggregate_ir, id, state)
471
+ }
472
+
196
473
  fn order_ir() -> Value {
197
474
  json!({
198
475
  "name": "Order",
@@ -204,12 +481,15 @@ mod tests {
204
481
  "value_objects": [
205
482
  {"name": "Pizza", "attributes": [
206
483
  {"name": "cents", "type": "Integer", "list": false, "optional": false},
207
- {"name": "size", "type": "String", "list": false, "optional": false, "admits": ["small", "large"]}
484
+ {"name": "size", "type": "String", "list": false, "optional": false, "admits": "Order::Size"}
208
485
  ], "invariants": [
209
486
  {"description": "a price is never negative", "canonical": "cents >= 0",
210
487
  "ast": {"op": "compare", "cmp": {"less_than": true, "equal": false, "negated": true},
211
488
  "left": {"op": "lookup", "path": ["cents"]}, "right": {"op": "int", "value": 0}}}
212
489
  ]},
490
+ {"name": "Size", "closed_set": true, "invariants": [],
491
+ "attributes": [{"name": "value", "type": "String", "list": false, "optional": false}],
492
+ "members": [[["value", "small"]], [["value", "large"]]]},
213
493
  {"name": "Topping", "attributes": [
214
494
  {"name": "name", "type": "String", "list": false, "optional": false, "pattern": "[^ \\t\\n\\r]"}
215
495
  ]}
@@ -242,11 +522,20 @@ mod tests {
242
522
  }
243
523
 
244
524
  #[test]
245
- fn a_value_outside_its_own_admits_set_is_caught() {
525
+ fn a_value_outside_the_declared_set_it_admits_is_caught_in_dispatchs_words() {
246
526
  let state = json!({ "pizza": { "cents": 1200, "size": "medium" }, "toppings": [], "status": "available" });
247
527
  let violations = validate(&order_ir(), "p1", &state);
528
+ assert_eq!(violations, vec![r#"Order#p1: size admits Order::Size — "small", "large" — got "medium""#.to_string()]);
529
+ }
530
+
531
+ #[test]
532
+ fn an_admits_naming_a_set_the_chapter_does_not_declare_refuses_every_value() {
533
+ let mut ir = order_ir();
534
+ ir["value_objects"][0]["attributes"][1]["admits"] = json!("Order::Missing");
535
+ let state = json!({ "pizza": { "cents": 1200, "size": "small" }, "toppings": [], "status": "available" });
536
+ let violations = validate(&ir, "p1", &state);
248
537
  assert_eq!(violations.len(), 1);
249
- assert!(violations[0].contains("size"), "{violations:?}");
538
+ assert!(violations[0].contains("size admits Order::Missing, which this chapter does not declare"), "{violations:?}");
250
539
  }
251
540
 
252
541
  #[test]
@@ -333,6 +622,262 @@ mod tests {
333
622
  let state = json!({ "pizza": null, "toppings": [], "status": "available" });
334
623
  let violations = validate(&order_ir(), "p1", &state);
335
624
  assert_eq!(violations.len(), 1);
336
- assert!(violations[0].contains("pizza"), "{violations:?}");
625
+ assert!(violations[0].contains("Pizza.cents expects Integer, got nil"), "{violations:?}");
626
+ }
627
+
628
+ // A page of panels, each optionally holding a note, which optionally holds a link; a panel
629
+ // also lists cells that may hold a note. Every rule reads a slot a stored value may leave out.
630
+ fn layout_ir() -> Value {
631
+ let attr = |name: &str, ty: &str, list: bool, optional: bool| {
632
+ json!({"name": name, "type": ty, "list": list, "optional": optional})
633
+ };
634
+ let lookup = |path: &[&str]| json!({"op": "lookup", "path": path});
635
+ let unset = |path: &[&str]| json!({"op": "assignment", "receiver": lookup(path), "negated": true});
636
+ let set = |path: &[&str]| json!({"op": "assignment", "receiver": lookup(path), "negated": false});
637
+ let not_empty = |path: &[&str]| json!({"op": "not", "expr": {"op": "empty", "receiver": {"op": "to_s", "receiver": lookup(path)}}});
638
+ let either = |left: Value, right: Value| json!({"op": "or", "left": left, "right": right});
639
+ let differs = |path: &[&str], text: &str| json!({"op": "compare", "cmp": {"less_than": false, "equal": true, "negated": true},
640
+ "left": lookup(path), "right": {"op": "str", "value": text}});
641
+ let rule = |description: &str, ast: Value| json!({"description": description, "canonical": "", "ast": ast});
642
+ json!({
643
+ "name": "Layout",
644
+ "attributes": [attr("key", "LayoutKey", false, false), attr("panels", "Panel", true, false)],
645
+ "entities": [],
646
+ "value_objects": [
647
+ {"name": "LayoutKey", "attributes": [attr("value", "String", false, false)], "invariants": []},
648
+ {"name": "Panel",
649
+ "attributes": [attr("kind", "String", false, false), attr("title", "String", false, true),
650
+ attr("note", "Note", false, true), attr("cells", "Cell", true, false)],
651
+ "invariants": [
652
+ rule("a title is not blank when given", either(unset(&["title"]), not_empty(&["title"]))),
653
+ rule("a callout carries a note", either(differs(&["kind"], "callout"), set(&["note"]))),
654
+ rule("a quiet panel has no loud note", either(differs(&["kind"], "quiet"), differs(&["note", "tone"], "loud")))]},
655
+ {"name": "Cell",
656
+ "attributes": [attr("label", "String", false, false), attr("note", "Note", false, true)],
657
+ "invariants": [rule("a cell's note has words", either(unset(&["note"]), not_empty(&["note", "text"])))]},
658
+ {"name": "Note",
659
+ "attributes": [attr("text", "String", false, false), attr("tone", "String", false, true),
660
+ attr("link", "Link", false, true)],
661
+ "invariants": [rule("a tone is calm or loud", either(unset(&["tone"]), json!({"op": "compare",
662
+ "cmp": {"less_than": false, "equal": true, "negated": false}, "left": lookup(&["tone"]),
663
+ "right": {"op": "str", "value": "calm"}})))]},
664
+ {"name": "Link",
665
+ "attributes": [attr("href", "String", false, false), attr("label", "String", false, true)],
666
+ "invariants": [rule("a link label is not blank when given", either(unset(&["label"]), not_empty(&["label"])))]}
667
+ ]
668
+ })
669
+ }
670
+
671
+ fn layout_violations(panels: Value) -> Vec<String> {
672
+ validate(&layout_ir(), "l1", &json!({"key": {"value": "l1"}, "panels": panels}))
673
+ }
674
+
675
+ #[test]
676
+ fn a_stored_panel_with_every_optional_slot_absent_is_accepted() {
677
+ assert_eq!(layout_violations(json!([{"kind": "plain", "cells": [{"label": "c"}]}])), Vec::<String>::new());
678
+ }
679
+
680
+ #[test]
681
+ fn an_absent_slot_reads_the_same_as_an_explicit_null() {
682
+ let absent = layout_violations(json!([{"kind": "callout", "cells": []}]));
683
+ let null = layout_violations(json!([{"kind": "callout", "note": null, "title": null, "cells": []}]));
684
+ assert_eq!(absent.len(), 1, "{absent:?}");
685
+ assert_eq!(absent, null);
686
+ }
687
+
688
+ #[test]
689
+ fn a_rule_that_needs_an_absent_slot_refuses_with_its_description_not_a_lookup_error() {
690
+ let violations = layout_violations(json!([{"kind": "callout", "cells": []}]));
691
+ assert_eq!(violations.len(), 1, "{violations:?}");
692
+ assert!(violations[0].contains("violates its own invariant — a callout carries a note"), "{violations:?}");
693
+ }
694
+
695
+ #[test]
696
+ fn a_path_through_an_absent_value_object_reads_as_nil() {
697
+ assert_eq!(layout_violations(json!([{"kind": "quiet", "cells": []}])), Vec::<String>::new());
698
+ }
699
+
700
+ #[test]
701
+ fn an_absent_slot_three_levels_down_is_accepted_and_a_present_one_is_still_checked() {
702
+ let absent = json!([{"kind": "plain", "cells": [{"label": "c", "note": {"text": "n", "link": {"href": "/a"}}}]}]);
703
+ let blank = json!([{"kind": "plain", "cells": [{"label": "c", "note": {"text": "n", "link": {"href": "/a", "label": ""}}}]}]);
704
+ assert_eq!(layout_violations(absent), Vec::<String>::new());
705
+ let violations = layout_violations(blank);
706
+ assert!(violations.len() == 1 && violations[0].contains("a link label is not blank when given"), "{violations:?}");
707
+ }
708
+
709
+ #[test]
710
+ fn absent_slots_inside_list_members_are_accepted_and_a_bad_member_is_named() {
711
+ let members = json!([{"kind": "plain", "cells": [{"label": "a"}, {"label": "b", "note": {"text": "n"}}]}]);
712
+ let bad = json!([{"kind": "plain", "cells": [{"label": "a"}, {"label": "b", "note": {"text": ""}}]}]);
713
+ assert_eq!(layout_violations(members), Vec::<String>::new());
714
+ let violations = layout_violations(bad);
715
+ assert!(violations.iter().any(|v| v.contains("a cell's note has words")), "{violations:?}");
716
+ }
717
+
718
+ #[test]
719
+ fn a_name_the_value_object_does_not_declare_still_refuses_to_resolve() {
720
+ let mut ir = layout_ir();
721
+ ir["value_objects"][1]["invariants"] =
722
+ json!([{"description": "a typo", "canonical": "", "ast": {"op": "lookup", "path": ["tittle"]}}]);
723
+ let state = json!({"key": {"value": "l1"}, "panels": [{"kind": "plain", "cells": []}]});
724
+ let violations = validate(&ir, "l1", &state);
725
+ assert_eq!(violations.len(), 1, "{violations:?}");
726
+ assert!(violations[0].contains("could not be checked — cannot resolve \"tittle\" — no such attribute or argument"), "{violations:?}");
727
+ }
728
+
729
+ #[test]
730
+ fn a_required_slot_left_out_is_refused_by_name_not_filled_in_for_an_invariant_to_read() {
731
+ let mut ir = layout_ir();
732
+ ir["value_objects"][1]["invariants"] =
733
+ json!([{"description": "a kind", "canonical": "", "ast": {"op": "lookup", "path": ["kind"]}}]);
734
+ let state = json!({"key": {"value": "l1"}, "panels": [{"cells": []}]});
735
+ assert_eq!(validate(&ir, "l1", &state), vec!["Layout#l1: Panel.kind expects String, got nil".to_string()]);
736
+ }
737
+
738
+ // A closed set, a multi-field closed set and a value object that holds both, three deep and in a
739
+ // list, with a required list, a defaulted field, an optional field and a list of patterned
740
+ // Strings: every constraint an attribute can declare, reached through a stored value.
741
+ fn constrained_ir() -> Value {
742
+ let attr = |name: &str, ty: &str, list: bool, optional: bool| json!({"name": name, "type": ty, "list": list, "optional": optional});
743
+ json!({
744
+ "name": "Shelf",
745
+ "attributes": [attr("band", "Band", false, false), attr("panels", "Panel", true, false)],
746
+ "entities": [],
747
+ "value_objects": [
748
+ {"name": "Band", "closed_set": true, "invariants": [], "attributes": [attr("size", "String", false, false)],
749
+ "members": [[["size", "small"]], [["size", "large"]]]},
750
+ {"name": "Pairing", "closed_set": true, "invariants": [],
751
+ "attributes": [attr("code", "String", false, false), attr("label", "String", false, false)],
752
+ "members": [[["code", "p"], ["label", "Pee"]], [["code", "q"], ["label", "Cue"]]]},
753
+ {"name": "Panel", "closed_set": false, "invariants": [],
754
+ "attributes": [attr("band", "Band", false, false), attr("count", "Integer", false, false),
755
+ {"name": "slug", "type": "String", "list": false, "optional": false, "pattern": "^[a-z]+$"},
756
+ {"name": "labels", "type": "String", "list": true, "optional": false, "pattern": "^[a-z]+$"},
757
+ {"name": "pairing", "type": "Pairing", "list": false, "optional": true},
758
+ {"name": "tone", "type": "String", "list": false, "optional": false, "default": "calm"},
759
+ attr("inner", "Panel", false, true)]}
760
+ ]
761
+ })
762
+ }
763
+
764
+ fn panel() -> Value {
765
+ json!({"band": {"size": "small"}, "count": 1, "slug": "ok", "labels": ["a", "b"]})
766
+ }
767
+
768
+ fn shelf_violations(panels: Value) -> Vec<String> {
769
+ validate(&constrained_ir(), "s1", &json!({"band": {"size": "large"}, "panels": panels}))
770
+ }
771
+
772
+ fn assert_refused_as(violations: Vec<String>, expected: &str) {
773
+ assert_eq!(violations, vec![format!("Shelf#s1: {expected}")]);
774
+ }
775
+
776
+ #[test]
777
+ fn a_value_that_meets_every_constraint_is_accepted_at_every_depth() {
778
+ let mut inner = panel();
779
+ inner["pairing"] = json!({"code": "q", "label": "Cue"});
780
+ let mut outer = panel();
781
+ outer["inner"] = json!({"band": {"size": "large"}, "count": 2, "slug": "in", "labels": [], "inner": inner});
782
+ assert_eq!(shelf_violations(json!([outer, panel()])), Vec::<String>::new());
783
+ }
784
+
785
+ #[test]
786
+ fn a_closed_set_refuses_a_non_member_alone_in_a_list_member_and_three_deep() {
787
+ let violations = validate(&constrained_ir(), "s1", &json!({"band": {"size": "huge"}, "panels": []}));
788
+ assert_refused_as(violations, r#"Band admits "small", "large" — got "huge""#);
789
+
790
+ let mut bad = panel();
791
+ bad["band"] = json!({"size": "huge"});
792
+ assert_refused_as(shelf_violations(json!([panel(), bad.clone()])), r#"Band admits "small", "large" — got "huge""#);
793
+
794
+ let mut deep = panel();
795
+ deep["inner"] = json!({"band": {"size": "small"}, "count": 2, "slug": "in", "labels": [], "inner": {"band": {"size": "small"}, "count": 3, "slug": "x", "labels": [], "inner": bad}});
796
+ assert_refused_as(shelf_violations(json!([deep])), r#"Band admits "small", "large" — got "huge""#);
797
+ }
798
+
799
+ #[test]
800
+ fn a_bare_scalar_stands_in_for_a_one_field_set_and_is_checked_the_same() {
801
+ let mut bare = panel();
802
+ bare["band"] = json!("large");
803
+ assert_eq!(shelf_violations(json!([bare])), Vec::<String>::new());
804
+ let mut wrong = panel();
805
+ wrong["band"] = json!("huge");
806
+ assert_refused_as(shelf_violations(json!([wrong])), r#"Band admits "small", "large" — got "huge""#);
807
+ }
808
+
809
+ #[test]
810
+ fn a_multi_field_member_must_match_on_every_column_and_quotes_the_first() {
811
+ let mut mixed = panel();
812
+ mixed["pairing"] = json!({"code": "p", "label": "Cue"});
813
+ assert_refused_as(shelf_violations(json!([mixed])), r#"Pairing admits "p", "q" — got "p""#);
814
+ }
815
+
816
+ #[test]
817
+ fn a_name_the_value_object_does_not_declare_is_refused_before_anything_else() {
818
+ let mut extra = panel();
819
+ extra["bogus"] = json!(1);
820
+ assert_refused_as(shelf_violations(json!([extra])), "Panel does not declare bogus — it takes band, count, slug, labels, pairing, tone, inner");
821
+
822
+ let violations = validate(&constrained_ir(), "s1", &json!({"band": {"size": "small", "bogus": 1}, "panels": []}));
823
+ assert_refused_as(violations, "Band does not declare bogus — it takes size");
824
+ }
825
+
826
+ #[test]
827
+ fn a_required_field_left_out_is_refused_while_an_optional_one_a_default_and_a_list_are_not() {
828
+ let mut absent = panel();
829
+ absent.as_object_mut().unwrap().remove("count");
830
+ assert_refused_as(shelf_violations(json!([absent])), "Panel.count expects Integer, got nil");
831
+
832
+ let mut sparse = panel();
833
+ sparse.as_object_mut().unwrap().remove("labels");
834
+ assert_eq!(shelf_violations(json!([sparse])), Vec::<String>::new());
835
+ }
836
+
837
+ #[test]
838
+ fn a_pattern_is_held_by_every_member_of_a_list_and_by_the_whole_value() {
839
+ let mut bad_label = panel();
840
+ bad_label["labels"] = json!(["a", "B"]);
841
+ assert_refused_as(shelf_violations(json!([bad_label])), r#"Panel.labels must match ^[a-z]+$, got "B""#);
842
+
843
+ let mut newline = panel();
844
+ newline["slug"] = json!("ok\n");
845
+ assert_refused_as(shelf_violations(json!([newline])), r#"Panel.slug must match ^[a-z]+$, got "ok\n""#);
846
+ }
847
+
848
+ #[test]
849
+ fn a_number_past_signed_64_bits_and_a_whole_float_are_not_integers() {
850
+ let mut big = panel();
851
+ big["count"] = json!(9223372036854775808u64);
852
+ assert_refused_as(shelf_violations(json!([big])), "Panel.count must fit in a 64-bit integer, got 9223372036854775808");
853
+
854
+ let mut whole = panel();
855
+ whole["count"] = json!(3.0);
856
+ assert_refused_as(shelf_violations(json!([whole])), "Panel.count expects Integer, got 3.0");
857
+
858
+ let mut largest = panel();
859
+ largest["count"] = json!(i64::MAX);
860
+ assert_eq!(shelf_violations(json!([largest])), Vec::<String>::new());
861
+ }
862
+
863
+ #[test]
864
+ fn a_list_offered_a_lone_value_is_a_shape_error_and_a_null_list_is_not() {
865
+ assert_refused_as(shelf_violations(json!("x")), r#"Shelf.panels expects list_of(Panel), got "x""#);
866
+ let mut unset = panel();
867
+ unset["labels"] = Value::Null;
868
+ assert_eq!(shelf_violations(json!([unset])), Vec::<String>::new());
869
+ }
870
+
871
+ #[test]
872
+ fn a_value_object_another_aggregate_of_the_chapter_declares_is_held_to_its_constraints() {
873
+ let shelf = json!({"name": "Shelf", "attributes": [{"name": "band", "type": "Band", "list": false, "optional": false}],
874
+ "entities": [], "value_objects": []});
875
+ let vocabulary = json!({"name": "Vocabulary", "attributes": [], "entities": [], "value_objects": [
876
+ {"name": "Band", "closed_set": true, "invariants": [], "attributes": [{"name": "size", "type": "String", "list": false, "optional": false}],
877
+ "members": [[["size", "small"]]]}]});
878
+ let domain = json!({"aggregates": [shelf, vocabulary]});
879
+ let refused = domain_validate(&domain, &shelf, "s1", &json!({"band": {"size": "huge"}}));
880
+ assert_refused_as(refused, r#"Band admits "small" — got "huge""#);
881
+ assert_eq!(domain_validate(&domain, &shelf, "s1", &json!({"band": {"size": "small"}})), Vec::<String>::new());
337
882
  }
338
883
  }