@intentius/chant 0.79.0 → 0.81.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.
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +30 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts +5 -0
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +13 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/terraform-state.d.ts +13 -0
- package/dist/audit/terraform-state.d.ts.map +1 -1
- package/dist/cli/command-group.d.ts +6 -0
- package/dist/cli/command-group.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +44 -3
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts +8 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/commands/import-agents.d.ts.map +1 -1
- package/dist/cli/commands/init.d.ts +6 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/commands/onboard.d.ts.map +1 -1
- package/dist/cli/commands/update.d.ts +6 -0
- package/dist/cli/commands/update.d.ts.map +1 -1
- package/dist/cli/commands/vendor.d.ts +5 -0
- package/dist/cli/commands/vendor.d.ts.map +1 -1
- package/dist/cli/conflict-check.d.ts +6 -0
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/dev.d.ts.map +1 -1
- package/dist/cli/handlers/fan-out.d.ts.map +1 -1
- package/dist/cli/handlers/init.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/misc.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +7 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/promote.d.ts +20 -0
- package/dist/cli/handlers/promote.d.ts.map +1 -0
- package/dist/cli/handlers/run-generate.d.ts +39 -0
- package/dist/cli/handlers/run-generate.d.ts.map +1 -0
- package/dist/cli/handlers/run.d.ts +4 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/handlers/vendor.d.ts +8 -3
- package/dist/cli/handlers/vendor.d.ts.map +1 -1
- package/dist/cli/main.d.ts +12 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/op-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +14 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +46 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/fetch.d.ts +10 -0
- package/dist/codegen/fetch.d.ts.map +1 -1
- package/dist/components/capability-plugin-loader.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +3 -1
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/discover.d.ts.map +1 -1
- package/dist/components/driver.d.ts +16 -2
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/fan-out-support.d.ts.map +1 -1
- package/dist/components/gate-plan.d.ts +40 -0
- package/dist/components/gate-plan.d.ts.map +1 -0
- package/dist/components/pilots/alb-ecs.pilot.d.ts +7 -1
- package/dist/components/pilots/alb-ecs.pilot.d.ts.map +1 -1
- package/dist/components/presets/ecs-fargate.d.ts +10 -4
- package/dist/components/presets/ecs-fargate.d.ts.map +1 -1
- package/dist/components/promote.d.ts +264 -0
- package/dist/components/promote.d.ts.map +1 -0
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/components/sandbox/run.d.ts.map +1 -1
- package/dist/components/verbs/build-archive.d.ts +22 -11
- package/dist/components/verbs/build-archive.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts.map +1 -1
- package/dist/config-static.d.ts +59 -0
- package/dist/config-static.d.ts.map +1 -0
- package/dist/config.d.ts +104 -5
- package/dist/config.d.ts.map +1 -1
- package/dist/content-digest.d.ts +17 -0
- package/dist/content-digest.d.ts.map +1 -0
- package/dist/discovery/files.d.ts +48 -4
- package/dist/discovery/files.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +32 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts +20 -0
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/discovery/sandbox/fork.d.ts +20 -0
- package/dist/discovery/sandbox/fork.d.ts.map +1 -1
- package/dist/discovery/walk.d.ts +119 -0
- package/dist/discovery/walk.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +17 -1
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/join-key.d.ts +31 -0
- package/dist/join-key.d.ts.map +1 -0
- package/dist/lexicon-module.d.ts +110 -0
- package/dist/lexicon-module.d.ts.map +1 -0
- package/dist/lexicon.d.ts +82 -1
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/build-ledger-store.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts +5 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +20 -5
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +64 -0
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +8 -1
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/legacy-digest.d.ts +59 -0
- package/dist/lifecycle/legacy-digest.d.ts.map +1 -0
- package/dist/lifecycle/member-ledger.d.ts +67 -0
- package/dist/lifecycle/member-ledger.d.ts.map +1 -0
- package/dist/lifecycle/plan-digest.d.ts +4 -3
- package/dist/lifecycle/plan-digest.d.ts.map +1 -1
- package/dist/lifecycle/release-ledger.d.ts +42 -1
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/status.d.ts +90 -5
- package/dist/lifecycle/status.d.ts.map +1 -1
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts +2 -3
- package/dist/lint/rules/comp/comp003-mutating-no-rollback.d.ts.map +1 -1
- package/dist/op/activities/activity-contracts.d.ts +32 -0
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/index.d.ts +2 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/propose-upgrade.d.ts +75 -0
- package/dist/op/activities/propose-upgrade.d.ts.map +1 -0
- package/dist/op/activity-contract-registry.d.ts.map +1 -1
- package/dist/op/activity-registry.d.ts.map +1 -1
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-approval.d.ts.map +1 -1
- package/dist/op/gate-name.d.ts +7 -0
- package/dist/op/gate-name.d.ts.map +1 -1
- package/dist/op/gate-summary.d.ts +16 -0
- package/dist/op/gate-summary.d.ts.map +1 -1
- package/dist/op/gate.d.ts +16 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/generate-pipeline.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/project-root.d.ts +26 -0
- package/dist/project-root.d.ts.map +1 -1
- package/dist/workspace/__fixtures__/contract-repo.d.ts +37 -0
- package/dist/workspace/__fixtures__/contract-repo.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts +11 -0
- package/dist/workspace/__fixtures__/two-member-workspace.d.ts.map +1 -0
- package/dist/workspace/checks/generated.d.ts +156 -0
- package/dist/workspace/checks/generated.d.ts.map +1 -0
- package/dist/workspace/checks/ledgers.d.ts +78 -0
- package/dist/workspace/checks/ledgers.d.ts.map +1 -0
- package/dist/workspace/checks/links.d.ts +25 -0
- package/dist/workspace/checks/links.d.ts.map +1 -0
- package/dist/workspace/checks/pipelines.d.ts +76 -0
- package/dist/workspace/checks/pipelines.d.ts.map +1 -0
- package/dist/workspace/checks.d.ts +161 -0
- package/dist/workspace/checks.d.ts.map +1 -0
- package/dist/workspace/compose-graph.d.ts +115 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -0
- package/dist/workspace/compose-reports.d.ts +68 -0
- package/dist/workspace/compose-reports.d.ts.map +1 -0
- package/dist/workspace/declaration.d.ts +220 -0
- package/dist/workspace/declaration.d.ts.map +1 -0
- package/dist/workspace/declaration.schema.json +434 -0
- package/dist/workspace/generated-files.d.ts +65 -0
- package/dist/workspace/generated-files.d.ts.map +1 -0
- package/dist/workspace/glob.d.ts +13 -0
- package/dist/workspace/glob.d.ts.map +1 -0
- package/dist/workspace/graph-cli.d.ts +75 -0
- package/dist/workspace/graph-cli.d.ts.map +1 -0
- package/dist/workspace/init.d.ts +96 -0
- package/dist/workspace/init.d.ts.map +1 -0
- package/dist/workspace/jsonc.d.ts +38 -0
- package/dist/workspace/jsonc.d.ts.map +1 -0
- package/dist/workspace/kinds.d.ts +192 -0
- package/dist/workspace/kinds.d.ts.map +1 -0
- package/dist/workspace/lineage-check.d.ts +84 -0
- package/dist/workspace/lineage-check.d.ts.map +1 -0
- package/dist/workspace/lineage-cli.d.ts +31 -0
- package/dist/workspace/lineage-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-init.d.ts +101 -0
- package/dist/workspace/lineage-init.d.ts.map +1 -0
- package/dist/workspace/lineage-lock.d.ts +258 -0
- package/dist/workspace/lineage-lock.d.ts.map +1 -0
- package/dist/workspace/lineage-merge.d.ts +14 -0
- package/dist/workspace/lineage-merge.d.ts.map +1 -0
- package/dist/workspace/lineage-migrations.d.ts +178 -0
- package/dist/workspace/lineage-migrations.d.ts.map +1 -0
- package/dist/workspace/lineage-update.d.ts +59 -0
- package/dist/workspace/lineage-update.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts +40 -0
- package/dist/workspace/lineage-upgrade-cli.d.ts.map +1 -0
- package/dist/workspace/lineage-upgrade.d.ts +146 -0
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -0
- package/dist/workspace/lineage-vendor.d.ts +63 -0
- package/dist/workspace/lineage-vendor.d.ts.map +1 -0
- package/dist/workspace/lineage-version.d.ts +32 -0
- package/dist/workspace/lineage-version.d.ts.map +1 -0
- package/dist/workspace/links.d.ts +150 -0
- package/dist/workspace/links.d.ts.map +1 -0
- package/dist/workspace/ls.d.ts +99 -0
- package/dist/workspace/ls.d.ts.map +1 -0
- package/dist/workspace/member-commands.d.ts +139 -0
- package/dist/workspace/member-commands.d.ts.map +1 -0
- package/dist/workspace/member-handles.d.ts +54 -0
- package/dist/workspace/member-handles.d.ts.map +1 -0
- package/dist/workspace/member-pipeline.d.ts +143 -0
- package/dist/workspace/member-pipeline.d.ts.map +1 -0
- package/dist/workspace/member-run.d.ts +67 -0
- package/dist/workspace/member-run.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +65 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -0
- package/dist/workspace/record-source.d.ts +22 -0
- package/dist/workspace/record-source.d.ts.map +1 -0
- package/dist/workspace/records-cli.d.ts +81 -0
- package/dist/workspace/records-cli.d.ts.map +1 -0
- package/dist/workspace/records.d.ts +118 -0
- package/dist/workspace/records.d.ts.map +1 -0
- package/dist/workspace/root-refusal.d.ts +53 -0
- package/dist/workspace/root-refusal.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +150 -0
- package/dist/workspace/status.d.ts.map +1 -0
- package/dist/workspace/template-manifest.d.ts +90 -0
- package/dist/workspace/template-manifest.d.ts.map +1 -0
- package/dist/workspace/tree.d.ts +36 -0
- package/dist/workspace/tree.d.ts.map +1 -0
- package/dist/workspace/trust/attestor.d.ts +67 -0
- package/dist/workspace/trust/attestor.d.ts.map +1 -0
- package/dist/workspace/trust/policy.d.ts +111 -0
- package/dist/workspace/trust/policy.d.ts.map +1 -0
- package/dist/workspace/trust/provenance.d.ts +67 -0
- package/dist/workspace/trust/provenance.d.ts.map +1 -0
- package/dist/workspace/trust/ssh-commit.d.ts +43 -0
- package/dist/workspace/trust/ssh-commit.d.ts.map +1 -0
- package/dist/workspace/trust/test-repo.d.ts +42 -0
- package/dist/workspace/trust/test-repo.d.ts.map +1 -0
- package/dist/workspace/trust/verify-cli.d.ts +9 -0
- package/dist/workspace/trust/verify-cli.d.ts.map +1 -0
- package/dist/workspace/trust/verify.d.ts +66 -0
- package/dist/workspace/trust/verify.d.ts.map +1 -0
- package/dist/workspace/which-chant.d.ts +58 -0
- package/dist/workspace/which-chant.d.ts.map +1 -0
- package/dist/workspace/workspace-kinds.schema.json +133 -0
- package/package.json +4 -3
- package/src/audit/core.ts +13 -2
- package/src/audit/discover.ts +102 -36
- package/src/audit/report-html.ts +3 -1
- package/src/audit/report-model.ts +14 -1
- package/src/audit/terraform-state.test.ts +81 -1
- package/src/audit/terraform-state.ts +25 -0
- package/src/cli/command-group.ts +7 -0
- package/src/cli/commands/audit-truncation.test.ts +167 -0
- package/src/cli/commands/audit.ts +132 -29
- package/src/cli/commands/check-lexicon-examples.ts +2 -1
- package/src/cli/commands/check-lexicon.ts +24 -0
- package/src/cli/commands/doctor.test.ts +30 -0
- package/src/cli/commands/doctor.ts +21 -0
- package/src/cli/commands/import-agents.ts +15 -4
- package/src/cli/commands/init.ts +76 -25
- package/src/cli/commands/lint.test.ts +52 -1
- package/src/cli/commands/lint.ts +41 -71
- package/src/cli/commands/onboard.ts +15 -0
- package/src/cli/commands/update.ts +18 -5
- package/src/cli/commands/vendor.ts +11 -6
- package/src/cli/conflict-check.test.ts +18 -1
- package/src/cli/conflict-check.ts +13 -0
- package/src/cli/discovery-skip.test.ts +168 -0
- package/src/cli/handlers/__snapshots__/build-member.test.ts.snap +38 -0
- package/src/cli/handlers/build-member.test.ts +127 -0
- package/src/cli/handlers/build.test.ts +11 -0
- package/src/cli/handlers/build.ts +35 -1
- package/src/cli/handlers/components.test.ts +127 -0
- package/src/cli/handlers/components.ts +70 -8
- package/src/cli/handlers/dev.ts +6 -1
- package/src/cli/handlers/fan-out.ts +9 -2
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +4 -2
- package/src/cli/handlers/init.ts +54 -1
- package/src/cli/handlers/lifecycle.ts +2 -1
- package/src/cli/handlers/misc.ts +16 -0
- package/src/cli/handlers/operator.test.ts +68 -0
- package/src/cli/handlers/operator.ts +39 -10
- package/src/cli/handlers/promote.test.ts +377 -0
- package/src/cli/handlers/promote.ts +391 -0
- package/src/cli/handlers/run-generate-member.test.ts +83 -0
- package/src/cli/handlers/run-generate.test.ts +158 -0
- package/src/cli/handlers/run-generate.ts +168 -0
- package/src/cli/handlers/run.test.ts +47 -0
- package/src/cli/handlers/run.ts +41 -6
- package/src/cli/handlers/vendor.ts +147 -43
- package/src/cli/main.test.ts +137 -1
- package/src/cli/main.ts +290 -24
- package/src/cli/mcp/op-tools.ts +2 -1
- package/src/cli/path-lexicon-messages.test.ts +152 -0
- package/src/cli/plugins.ts +43 -5
- package/src/cli/registry.ts +46 -1
- package/src/cli/static-config-read.test.ts +208 -0
- package/src/codegen/fetch.test.ts +63 -1
- package/src/codegen/fetch.ts +30 -1
- package/src/components/__fixtures__/alb-ecs-service.json +0 -8
- package/src/components/capability-plugin-loader.ts +3 -1
- package/src/components/cli-support.ts +9 -3
- package/src/components/discover.test.ts +27 -57
- package/src/components/discover.ts +20 -51
- package/src/components/driver.test.ts +90 -12
- package/src/components/driver.ts +43 -5
- package/src/components/fan-out-support.ts +2 -1
- package/src/components/gate-plan.ts +53 -0
- package/src/components/pilots/README.md +1 -1
- package/src/components/pilots/alb-ecs.pilot.ts +7 -7
- package/src/components/presets/ecs-fargate.ts +10 -5
- package/src/components/promote.test.ts +645 -0
- package/src/components/promote.ts +641 -0
- package/src/components/sandbox/driver.ts +8 -8
- package/src/components/sandbox/run.ts +4 -1
- package/src/components/verbs/build-archive.ts +27 -21
- package/src/components/verbs/run-agent.ts +3 -5
- package/src/config-static.test.ts +136 -0
- package/src/config-static.ts +232 -0
- package/src/config.test.ts +40 -0
- package/src/config.ts +161 -11
- package/src/content-digest.test.ts +120 -0
- package/src/content-digest.ts +21 -0
- package/src/discovery/files.test.ts +127 -1
- package/src/discovery/files.ts +91 -59
- package/src/discovery/fold-import.ts +127 -9
- package/src/discovery/index.ts +1 -0
- package/src/discovery/sandbox/driver.ts +73 -35
- package/src/discovery/sandbox/fork-diagnostic.test.ts +136 -13
- package/src/discovery/sandbox/fork.ts +83 -43
- package/src/discovery/sandbox/path-lexicon-multifile.test.ts +285 -0
- package/src/discovery/sandbox/path-lexicon-parity.test.ts +162 -0
- package/src/discovery/walk.test.ts +263 -0
- package/src/discovery/walk.ts +334 -0
- package/src/effect-receipt.ts +2 -2
- package/src/graph-ir.ts +18 -1
- package/src/join-key.test.ts +25 -0
- package/src/join-key.ts +39 -0
- package/src/lexicon-module.test.ts +203 -0
- package/src/lexicon-module.ts +215 -0
- package/src/lexicon.ts +105 -1
- package/src/lifecycle/build-ledger-store.ts +13 -2
- package/src/lifecycle/digest.ts +9 -5
- package/src/lifecycle/gate-ledger.test.ts +20 -0
- package/src/lifecycle/gate-ledger.ts +28 -6
- package/src/lifecycle/git.test.ts +13 -1
- package/src/lifecycle/git.ts +221 -176
- package/src/lifecycle/lease.ts +25 -10
- package/src/lifecycle/legacy-digest.test.ts +206 -0
- package/src/lifecycle/legacy-digest.ts +103 -0
- package/src/lifecycle/member-ledger.test.ts +319 -0
- package/src/lifecycle/member-ledger.ts +145 -0
- package/src/lifecycle/plan-digest.ts +7 -7
- package/src/lifecycle/release-ledger.test.ts +20 -0
- package/src/lifecycle/release-ledger.ts +53 -3
- package/src/lifecycle/status.test.ts +151 -1
- package/src/lifecycle/status.ts +228 -7
- package/src/lint/rules/comp/comp003-mutating-no-rollback.ts +2 -3
- package/src/op/activities/activity-contracts.ts +37 -0
- package/src/op/activities/index.ts +10 -0
- package/src/op/activities/propose-upgrade.ts +206 -0
- package/src/op/activity-contract-registry.ts +18 -4
- package/src/op/activity-registry.ts +26 -0
- package/src/op/discover.test.ts +14 -4
- package/src/op/discover.ts +19 -23
- package/src/op/gate-approval.ts +3 -1
- package/src/op/gate-name.ts +8 -0
- package/src/op/gate-summary.test.ts +14 -0
- package/src/op/gate-summary.ts +25 -2
- package/src/op/gate.ts +36 -7
- package/src/op/generate-pipeline.test.ts +14 -5
- package/src/op/generate-pipeline.ts +2 -1
- package/src/op/runtimes/local.ts +2 -1
- package/src/project-root.test.ts +66 -1
- package/src/project-root.ts +48 -0
- package/src/workspace/__fixtures__/contract-repo.ts +100 -0
- package/src/workspace/__fixtures__/two-member-workspace.ts +35 -0
- package/src/workspace/behold-kinds.test.ts +162 -0
- package/src/workspace/check-contract.test.ts +127 -0
- package/src/workspace/check.schema.json +347 -0
- package/src/workspace/checks/generated.test.ts +287 -0
- package/src/workspace/checks/generated.ts +475 -0
- package/src/workspace/checks/ledgers.test.ts +139 -0
- package/src/workspace/checks/ledgers.ts +246 -0
- package/src/workspace/checks/links.ts +183 -0
- package/src/workspace/checks/pipelines.test.ts +133 -0
- package/src/workspace/checks/pipelines.ts +225 -0
- package/src/workspace/checks-repo.test.ts +71 -0
- package/src/workspace/checks.test.ts +450 -0
- package/src/workspace/checks.ts +461 -0
- package/src/workspace/compose-graph.test.ts +146 -0
- package/src/workspace/compose-graph.ts +228 -0
- package/src/workspace/compose-reports.test.ts +109 -0
- package/src/workspace/compose-reports.ts +186 -0
- package/src/workspace/declaration.schema.json +434 -0
- package/src/workspace/declaration.test.ts +347 -0
- package/src/workspace/declaration.ts +623 -0
- package/src/workspace/generated-files.test.ts +114 -0
- package/src/workspace/generated-files.ts +94 -0
- package/src/workspace/glob.ts +49 -0
- package/src/workspace/graph-cli.ts +220 -0
- package/src/workspace/graph-contract.test.ts +204 -0
- package/src/workspace/graph.schema.json +662 -0
- package/src/workspace/init.test.ts +223 -0
- package/src/workspace/init.ts +585 -0
- package/src/workspace/jsonc.test.ts +65 -0
- package/src/workspace/jsonc.ts +231 -0
- package/src/workspace/kinds.test.ts +288 -0
- package/src/workspace/kinds.ts +506 -0
- package/src/workspace/lineage-check.ts +264 -0
- package/src/workspace/lineage-cli.ts +114 -0
- package/src/workspace/lineage-init.test.ts +231 -0
- package/src/workspace/lineage-init.ts +365 -0
- package/src/workspace/lineage-lock.test.ts +224 -0
- package/src/workspace/lineage-lock.ts +357 -0
- package/src/workspace/lineage-merge.ts +44 -0
- package/src/workspace/lineage-migrations.test.ts +178 -0
- package/src/workspace/lineage-migrations.ts +343 -0
- package/src/workspace/lineage-update.ts +160 -0
- package/src/workspace/lineage-upgrade-cli.ts +151 -0
- package/src/workspace/lineage-upgrade.test.ts +474 -0
- package/src/workspace/lineage-upgrade.ts +624 -0
- package/src/workspace/lineage-vendor.test.ts +126 -0
- package/src/workspace/lineage-vendor.ts +215 -0
- package/src/workspace/lineage-version.ts +127 -0
- package/src/workspace/links.test.ts +344 -0
- package/src/workspace/links.ts +331 -0
- package/src/workspace/ls-contract.test.ts +200 -0
- package/src/workspace/ls.schema.json +190 -0
- package/src/workspace/ls.ts +254 -0
- package/src/workspace/member-commands.e2e.test.ts +244 -0
- package/src/workspace/member-commands.test.ts +217 -0
- package/src/workspace/member-commands.ts +540 -0
- package/src/workspace/member-handles.test.ts +76 -0
- package/src/workspace/member-handles.ts +228 -0
- package/src/workspace/member-pipeline.test.ts +135 -0
- package/src/workspace/member-pipeline.ts +317 -0
- package/src/workspace/member-run.ts +146 -0
- package/src/workspace/read-contract.test.ts +129 -0
- package/src/workspace/reason-codes.test.ts +113 -0
- package/src/workspace/reason-codes.ts +81 -0
- package/src/workspace/record-source.ts +117 -0
- package/src/workspace/records-cli.ts +198 -0
- package/src/workspace/records-contract.test.ts +93 -0
- package/src/workspace/records.schema.json +150 -0
- package/src/workspace/records.test.ts +243 -0
- package/src/workspace/records.ts +384 -0
- package/src/workspace/root-refusal.ts +97 -0
- package/src/workspace/status-contract.test.ts +308 -0
- package/src/workspace/status.schema.json +210 -0
- package/src/workspace/status.ts +412 -0
- package/src/workspace/template-manifest.test.ts +79 -0
- package/src/workspace/template-manifest.ts +221 -0
- package/src/workspace/tree.ts +132 -0
- package/src/workspace/trust/attestor.ts +93 -0
- package/src/workspace/trust/policy.ts +306 -0
- package/src/workspace/trust/provenance.ts +181 -0
- package/src/workspace/trust/ssh-commit.ts +138 -0
- package/src/workspace/trust/test-repo.ts +138 -0
- package/src/workspace/trust/trust.test.ts +397 -0
- package/src/workspace/trust/verify-cli.ts +56 -0
- package/src/workspace/trust/verify.ts +194 -0
- package/src/workspace/which-chant.test.ts +117 -0
- package/src/workspace/which-chant.ts +151 -0
- package/src/workspace/workspace-kinds.schema.json +133 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { cpSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
|
6
|
+
import { lexiconModulePath } from "../lexicon-module";
|
|
7
|
+
import { gitRevisionSource, gitRoot, resolveRevision, workingTreeSource } from "./record-source";
|
|
8
|
+
import { loadRecordKind, parseFrontMatter, readRecords, RecordReadError, type LoadedRecordKind } from "./records";
|
|
9
|
+
|
|
10
|
+
const REPO = join(import.meta.dirname, "..", "..", "..", "..");
|
|
11
|
+
const DECISIONS = join(REPO, "docs", "design", "decisions");
|
|
12
|
+
const SAMPLE = readFileSync(join(DECISIONS, "ws-003-seal-scope.md"), "utf-8");
|
|
13
|
+
|
|
14
|
+
/** A copy of a real decision with its id, state and supersedes links replaced. */
|
|
15
|
+
function decision(id: string, state = "decided", supersedes: string[] = []): string {
|
|
16
|
+
const links = supersedes.length === 0 ? "supersedes: []" : `supersedes:\n${supersedes.map((d) => ` - decision: "${d}"`).join("\n")}`;
|
|
17
|
+
return SAMPLE.replace(/^id: .*$/m, `id: "${id}"`)
|
|
18
|
+
.replace(/^state: .*$/m, `state: "${state}"`)
|
|
19
|
+
.replace(/^supersedes:(?: \[\])?\n(?: .*\n)*/m, `${links}\n`);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
let dir: string;
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
dir = realpathSync(mkdtempSync(join(tmpdir(), "chant-records-")));
|
|
25
|
+
mkdirSync(join(dir, "decisions"));
|
|
26
|
+
cpSync(join(DECISIONS, "decision.kind.mjs"), join(dir, "decisions", "decision.kind.mjs"));
|
|
27
|
+
cpSync(join(DECISIONS, "decision.schema.json"), join(dir, "decisions", "decision.schema.json"));
|
|
28
|
+
});
|
|
29
|
+
afterEach(() => rmSync(dir, { recursive: true, force: true }));
|
|
30
|
+
|
|
31
|
+
function write(name: string, text: string): void {
|
|
32
|
+
writeFileSync(join(dir, "decisions", name), text);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function read(options: { current?: boolean } = {}) {
|
|
36
|
+
const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
37
|
+
return readRecords(loaded, { root: dir, source: workingTreeSource(dir), ...options });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const codes = (r: { reasons: Array<{ code: string }> }) => r.reasons.map((x) => x.code);
|
|
41
|
+
|
|
42
|
+
describe("parseFrontMatter", () => {
|
|
43
|
+
test("reads the JSON subset of YAML, whatever the line endings", () => {
|
|
44
|
+
const fm = parseFrontMatter('---\r\nid: "a-001"\r\nn: 2\r\nok: true\r\nlist: []\r\n---\r\n# A\r\n');
|
|
45
|
+
expect(fm).toEqual({ ok: true, value: { id: "a-001", n: 2, ok: true, list: [] } });
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("an unquoted date stays a string", () => {
|
|
49
|
+
expect(parseFrontMatter("---\non: 2026-09-23\n---\n")).toEqual({ ok: true, value: { on: "2026-09-23" } });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test.each([
|
|
53
|
+
["no front matter", "# Just a heading\n", /no front matter/],
|
|
54
|
+
["an unclosed block", "---\nid: x\n", /no front matter/],
|
|
55
|
+
["invalid YAML", "---\nid: [unclosed\n---\n", /not valid YAML/],
|
|
56
|
+
["a duplicate key", "---\nid: a\nid: b\n---\n", /not valid YAML/],
|
|
57
|
+
["a list at the top", "---\n- a\n---\n", /must be a mapping/],
|
|
58
|
+
["an alias", "---\na: &x {k: 1}\nb: *x\n---\n", /aliases/],
|
|
59
|
+
])("refuses %s", (_label, text, why) => {
|
|
60
|
+
const fm = parseFrontMatter(text);
|
|
61
|
+
expect(fm.ok).toBe(false);
|
|
62
|
+
if (!fm.ok) expect(fm.message).toMatch(why);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("readRecords", () => {
|
|
67
|
+
test("every well-formed decision is valid, in path order", async () => {
|
|
68
|
+
write("ws-002-b.md", decision("ws-002"));
|
|
69
|
+
write("ws-001-a.md", decision("ws-001"));
|
|
70
|
+
write("README.md", "# not a record\n");
|
|
71
|
+
const result = await read();
|
|
72
|
+
expect(result.records.map((r) => [r.id, r.path, r.valid, r.state])).toEqual([
|
|
73
|
+
["ws-001", "decisions/ws-001-a.md", true, "decided"],
|
|
74
|
+
["ws-002", "decisions/ws-002-b.md", true, "decided"],
|
|
75
|
+
]);
|
|
76
|
+
expect(result.summary).toEqual({ total: 2, valid: 2, invalid: 0, superseded: 0 });
|
|
77
|
+
expect(result.records[0].data?.title).toBe("Seal scope");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test("a malformed file is returned with record-unparseable", async () => {
|
|
81
|
+
write("ws-001-a.md", "no front matter here\n");
|
|
82
|
+
const [r] = (await read()).records;
|
|
83
|
+
expect(r).toMatchObject({ id: null, state: null, valid: false, data: null });
|
|
84
|
+
expect(codes(r)).toEqual(["record-unparseable"]);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("a schema violation is returned with record-schema-invalid, and keeps its data", async () => {
|
|
88
|
+
write("ws-001-a.md", decision("ws-001").replace(/^state: .*$/m, 'state: "maybe"'));
|
|
89
|
+
const [r] = (await read()).records;
|
|
90
|
+
expect(codes(r)).toEqual(["record-schema-invalid"]);
|
|
91
|
+
expect(r.reasons[0].message).toMatch(/\/state/);
|
|
92
|
+
expect(r.id).toBe("ws-001");
|
|
93
|
+
expect(r.data).not.toBeNull();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a supersedes link to a missing id is record-supersedes-unknown", async () => {
|
|
97
|
+
write("ws-002-b.md", decision("ws-002", "decided", ["ws-404"]));
|
|
98
|
+
const [r] = (await read()).records;
|
|
99
|
+
expect(codes(r)).toEqual(["record-supersedes-unknown"]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("a repeated id is flagged on the later file only", async () => {
|
|
103
|
+
write("ws-001-a.md", decision("ws-001"));
|
|
104
|
+
write("ws-001-b.md", decision("ws-001"));
|
|
105
|
+
const records = (await read()).records;
|
|
106
|
+
expect(records.map(codes)).toEqual([[], ["record-id-duplicate"]]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("supersession comes from a closed record's link, never from the old record's own state", async () => {
|
|
110
|
+
write("ws-001-a.md", decision("ws-001", "superseded"));
|
|
111
|
+
write("ws-002-b.md", decision("ws-002", "ratified"));
|
|
112
|
+
write("ws-003-c.md", decision("ws-003", "ratified"));
|
|
113
|
+
write("ws-004-d.md", decision("ws-004", "ratified", ["ws-003"]));
|
|
114
|
+
const all = await read();
|
|
115
|
+
expect(all.records.map((r) => [r.id, r.supersededBy])).toEqual([
|
|
116
|
+
["ws-001", null],
|
|
117
|
+
["ws-002", null],
|
|
118
|
+
["ws-003", "ws-004"],
|
|
119
|
+
["ws-004", null],
|
|
120
|
+
]);
|
|
121
|
+
expect(all.summary.superseded).toBe(1);
|
|
122
|
+
const current = await read({ current: true });
|
|
123
|
+
expect(current.records.map((r) => r.id)).toEqual(["ws-001", "ws-002", "ws-004"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("a link from a decided record does not supersede yet", async () => {
|
|
127
|
+
write("ws-001-a.md", decision("ws-001", "ratified"));
|
|
128
|
+
write("ws-002-b.md", decision("ws-002", "decided", ["ws-001"]));
|
|
129
|
+
const current = await read({ current: true });
|
|
130
|
+
expect(current.records.map((r) => [r.id, r.valid])).toEqual([
|
|
131
|
+
["ws-001", true],
|
|
132
|
+
["ws-002", true],
|
|
133
|
+
]);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("a record superseded twice keeps the first and flags the second", async () => {
|
|
137
|
+
write("ws-001-a.md", decision("ws-001", "ratified"));
|
|
138
|
+
write("ws-002-b.md", decision("ws-002", "ratified", ["ws-001"]));
|
|
139
|
+
write("ws-003-c.md", decision("ws-003", "ratified", ["ws-001"]));
|
|
140
|
+
const records = (await read()).records;
|
|
141
|
+
expect(records[0].supersededBy).toBe("ws-002");
|
|
142
|
+
expect(codes(records[2])).toEqual(["record-supersedes-conflict"]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a missing records directory is location-missing", async () => {
|
|
146
|
+
const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
147
|
+
const moved: LoadedRecordKind = { ...loaded, dir: join(dir, "nowhere") };
|
|
148
|
+
await expect(readRecords(moved, { root: dir, source: workingTreeSource(dir) })).rejects.toMatchObject({ code: "location-missing" });
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
describe("loadRecordKind", () => {
|
|
153
|
+
const load = () => loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
154
|
+
|
|
155
|
+
test("the chant repo's decision kind loads with its schema", async () => {
|
|
156
|
+
const loaded = await loadRecordKind(join(DECISIONS, "decision.kind.mjs"));
|
|
157
|
+
expect(loaded.kind.name).toBe("decision");
|
|
158
|
+
expect(loaded.schema.$id).toBe("urn:intentius:chant:decision:1");
|
|
159
|
+
expect(loaded.dir).toBe(DECISIONS);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("loads through the #2520 path loader and leaves nothing registered", async () => {
|
|
163
|
+
const file = join(DECISIONS, "decision.kind.mjs");
|
|
164
|
+
await loadRecordKind(file);
|
|
165
|
+
expect(lexiconModulePath(`record-kind:${file}`)).toBeUndefined();
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("a missing file is kind-unreadable", async () => {
|
|
169
|
+
await expect(loadRecordKind(join(dir, "none.kind.ts"))).rejects.toMatchObject({ code: "kind-unreadable" });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("a module with no recordKind is kind-invalid", async () => {
|
|
173
|
+
writeFileSync(join(dir, "decisions", "decision.kind.mjs"), "export const other = 1;\n");
|
|
174
|
+
await expect(load()).rejects.toMatchObject({ code: "kind-invalid" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("an unknown field or a closed state outside states is kind-invalid", async () => {
|
|
178
|
+
const text = readFileSync(join(DECISIONS, "decision.kind.mjs"), "utf-8");
|
|
179
|
+
writeFileSync(join(dir, "decisions", "decision.kind.mjs"), text.replace('closedStates: ["ratified", "superseded"]', 'closedStates: ["sealed"]'));
|
|
180
|
+
await expect(load()).rejects.toMatchObject({ code: "kind-invalid" });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a schema whose $id differs is schema-id-mismatch", async () => {
|
|
184
|
+
const schema = JSON.parse(readFileSync(join(DECISIONS, "decision.schema.json"), "utf-8"));
|
|
185
|
+
writeFileSync(join(dir, "decisions", "decision.schema.json"), JSON.stringify({ ...schema, $id: "urn:other" }));
|
|
186
|
+
await expect(load()).rejects.toMatchObject({ code: "schema-id-mismatch" });
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("an unreadable schema is schema-unreadable", async () => {
|
|
190
|
+
writeFileSync(join(dir, "decisions", "decision.schema.json"), "{ not json");
|
|
191
|
+
await expect(load()).rejects.toBeInstanceOf(RecordReadError);
|
|
192
|
+
await expect(load()).rejects.toMatchObject({ code: "schema-unreadable" });
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("reading at a revision", () => {
|
|
197
|
+
const git = (...args: string[]) =>
|
|
198
|
+
execFileSync("git", args, {
|
|
199
|
+
cwd: dir,
|
|
200
|
+
encoding: "utf-8",
|
|
201
|
+
env: { ...process.env, GIT_AUTHOR_NAME: "t", GIT_AUTHOR_EMAIL: "t@example.com", GIT_COMMITTER_NAME: "t", GIT_COMMITTER_EMAIL: "t@example.com" },
|
|
202
|
+
}).trim();
|
|
203
|
+
|
|
204
|
+
test("reads the committed files, not the working tree", async () => {
|
|
205
|
+
write("ws-001-a.md", decision("ws-001"));
|
|
206
|
+
git("init", "-q", "-b", "main");
|
|
207
|
+
git("add", "-A");
|
|
208
|
+
git("commit", "-q", "-m", "one");
|
|
209
|
+
const first = git("rev-parse", "HEAD");
|
|
210
|
+
write("ws-002-b.md", decision("ws-002"));
|
|
211
|
+
write("ws-001-a.md", "broken now\n");
|
|
212
|
+
|
|
213
|
+
expect(gitRoot(join(dir, "decisions"))).toBe(dir);
|
|
214
|
+
const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
215
|
+
const at = await readRecords(loaded, { root: dir, source: gitRevisionSource(dir, resolveRevision(dir, "main")) });
|
|
216
|
+
expect(at.records.map((r) => [r.id, r.valid])).toEqual([["ws-001", true]]);
|
|
217
|
+
expect(resolveRevision(dir, first.slice(0, 8))).toBe(first);
|
|
218
|
+
|
|
219
|
+
const tree = await readRecords(loaded, { root: dir, source: workingTreeSource(dir) });
|
|
220
|
+
expect(tree.records.map((r) => [r.path, r.valid])).toEqual([
|
|
221
|
+
["decisions/ws-001-a.md", false],
|
|
222
|
+
["decisions/ws-002-b.md", true],
|
|
223
|
+
]);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an unknown revision is revision-unknown, and a directory missing at the revision is location-missing", async () => {
|
|
227
|
+
write("ws-001-a.md", decision("ws-001"));
|
|
228
|
+
writeFileSync(join(dir, "other.txt"), "x\n");
|
|
229
|
+
git("init", "-q", "-b", "main");
|
|
230
|
+
git("add", "other.txt");
|
|
231
|
+
git("commit", "-q", "-m", "no records yet");
|
|
232
|
+
expect(() => resolveRevision(dir, "no-such-ref")).toThrow(expect.objectContaining({ code: "revision-unknown" }));
|
|
233
|
+
expect(() => resolveRevision(dir, "--all")).toThrow(expect.objectContaining({ code: "revision-unknown" }));
|
|
234
|
+
const loaded = await loadRecordKind(join(dir, "decisions", "decision.kind.mjs"));
|
|
235
|
+
await expect(
|
|
236
|
+
readRecords(loaded, { root: dir, source: gitRevisionSource(dir, resolveRevision(dir, "HEAD")) }),
|
|
237
|
+
).rejects.toMatchObject({ code: "location-missing" });
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
test("outside a repository there is no git root", () => {
|
|
241
|
+
expect(gitRoot(dir)).toBeUndefined();
|
|
242
|
+
});
|
|
243
|
+
});
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Records read through a record kind (#2524 D4), first-test slice (#2546).
|
|
3
|
+
*
|
|
4
|
+
* A record kind is data. It says where its records live, which JSON Schema
|
|
5
|
+
* they follow and which of their states are closed. This module reads every
|
|
6
|
+
* record a kind locates, parses its front matter as the JSON subset of YAML,
|
|
7
|
+
* validates it against the kind's schema and derives supersession from the
|
|
8
|
+
* records' own `supersedes` links. It never writes a record.
|
|
9
|
+
*
|
|
10
|
+
* A record that fails any of that is still returned, with reason codes, and the
|
|
11
|
+
* read succeeds. Only a failure to read the kind, its schema or the revision is
|
|
12
|
+
* an error. Seals, attestation and the workspace declaration come later (#2546,
|
|
13
|
+
* #2534); nothing here needs a `chant.workspace.json`.
|
|
14
|
+
*
|
|
15
|
+
* Everything under `workspace/` loads only when a `chant workspace` command
|
|
16
|
+
* runs. The level-0 goldens (#2526) fail if a level-0 command loads it.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { readFileSync, statSync } from "node:fs";
|
|
20
|
+
import { dirname, posix, relative, resolve, sep } from "node:path";
|
|
21
|
+
import yaml from "js-yaml";
|
|
22
|
+
import { z } from "zod";
|
|
23
|
+
import { importLexiconModule, registerLexiconDeclarations } from "../lexicon-module";
|
|
24
|
+
import type { ReasonCode } from "./reason-codes";
|
|
25
|
+
import type { RecordSource } from "./record-source";
|
|
26
|
+
|
|
27
|
+
// ── Reason codes ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Why one record is not valid. The list is closed: a reader may switch on it,
|
|
31
|
+
* and a new code is a contract change (#2536).
|
|
32
|
+
*/
|
|
33
|
+
export const RECORD_REASON_CODES = [
|
|
34
|
+
/** No front matter, a YAML error, or a value outside the JSON subset of YAML. */
|
|
35
|
+
"record-unparseable",
|
|
36
|
+
/** The front matter does not match the kind's schema. */
|
|
37
|
+
"record-schema-invalid",
|
|
38
|
+
/** Another record earlier in path order has the same id. */
|
|
39
|
+
"record-id-duplicate",
|
|
40
|
+
/** A `supersedes` link names an id no record has. */
|
|
41
|
+
"record-supersedes-unknown",
|
|
42
|
+
/** A second closed record supersedes a record another one already superseded. */
|
|
43
|
+
"record-supersedes-conflict",
|
|
44
|
+
] as const satisfies readonly ReasonCode[];
|
|
45
|
+
export type RecordReasonCode = (typeof RECORD_REASON_CODES)[number];
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Why the read as a whole failed. Also closed. The command exits 1 with one of
|
|
49
|
+
* these and returns no records.
|
|
50
|
+
*/
|
|
51
|
+
export const READ_ERROR_CODES = [
|
|
52
|
+
/** The kind file is missing or could not be imported. */
|
|
53
|
+
"kind-unreadable",
|
|
54
|
+
/** The kind file exports no `recordKind`, or its shape is wrong. */
|
|
55
|
+
"kind-invalid",
|
|
56
|
+
/** The schema file the kind names is missing or is not JSON. */
|
|
57
|
+
"schema-unreadable",
|
|
58
|
+
/** The schema's `$id` differs from the id the kind names. */
|
|
59
|
+
"schema-id-mismatch",
|
|
60
|
+
/** The schema itself does not compile. */
|
|
61
|
+
"schema-invalid",
|
|
62
|
+
/** The records directory does not exist, in the tree or at the revision. */
|
|
63
|
+
"location-missing",
|
|
64
|
+
/** `--at` was given outside a git repository. */
|
|
65
|
+
"not-a-git-repository",
|
|
66
|
+
/** `--at` names no commit. */
|
|
67
|
+
"revision-unknown",
|
|
68
|
+
] as const satisfies readonly ReasonCode[];
|
|
69
|
+
export type ReadErrorCode = (typeof READ_ERROR_CODES)[number];
|
|
70
|
+
|
|
71
|
+
export class RecordReadError extends Error {
|
|
72
|
+
constructor(
|
|
73
|
+
readonly code: ReadErrorCode,
|
|
74
|
+
message: string,
|
|
75
|
+
) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = "RecordReadError";
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ── The kind ─────────────────────────────────────────────────────────────────
|
|
82
|
+
|
|
83
|
+
const idPattern = /^[a-z][a-z0-9-]*$/;
|
|
84
|
+
|
|
85
|
+
/** The data a kind file exports as `recordKind`. */
|
|
86
|
+
export const recordKindSchema = z
|
|
87
|
+
.object({
|
|
88
|
+
/** The kind's name, such as `decision`. */
|
|
89
|
+
name: z.string().regex(idPattern),
|
|
90
|
+
/** Where the records are, relative to the kind file's directory. */
|
|
91
|
+
location: z
|
|
92
|
+
.object({
|
|
93
|
+
dir: z.string().min(1),
|
|
94
|
+
/** A regular expression the file name must match. Only that directory is read; subdirectories are not. */
|
|
95
|
+
match: z.string().min(1),
|
|
96
|
+
})
|
|
97
|
+
.strict(),
|
|
98
|
+
/** Only Markdown with YAML front matter for now. */
|
|
99
|
+
format: z.literal("markdown-front-matter"),
|
|
100
|
+
schema: z
|
|
101
|
+
.object({
|
|
102
|
+
/** The schema's `$id`. A schema file with a different `$id` is refused. */
|
|
103
|
+
id: z.string().min(1),
|
|
104
|
+
/** The schema file, relative to the kind file's directory. */
|
|
105
|
+
path: z.string().min(1),
|
|
106
|
+
})
|
|
107
|
+
.strict(),
|
|
108
|
+
/** The front-matter field holding the record's id. */
|
|
109
|
+
idField: z.string().min(1),
|
|
110
|
+
/** The front-matter field holding the record's state. */
|
|
111
|
+
stateField: z.string().min(1),
|
|
112
|
+
states: z.array(z.string().min(1)).min(1),
|
|
113
|
+
/** States whose records are final. A `supersedes` link takes effect only from a record in one of them. */
|
|
114
|
+
closedStates: z.array(z.string().min(1)),
|
|
115
|
+
/** The front-matter list of links to superseded records, and the key in each entry that holds the target id. */
|
|
116
|
+
supersedes: z.object({ field: z.string().min(1), key: z.string().min(1) }).strict(),
|
|
117
|
+
})
|
|
118
|
+
.strict()
|
|
119
|
+
.refine((k) => k.closedStates.every((s) => k.states.includes(s)), {
|
|
120
|
+
message: "every closed state must be listed in states",
|
|
121
|
+
path: ["closedStates"],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
export type RecordKind = z.infer<typeof recordKindSchema>;
|
|
125
|
+
|
|
126
|
+
/** A kind as loaded: its data, where it came from, and its schema. */
|
|
127
|
+
export interface LoadedRecordKind {
|
|
128
|
+
kind: RecordKind;
|
|
129
|
+
/** Absolute path of the kind file. */
|
|
130
|
+
file: string;
|
|
131
|
+
/** Absolute path of the records directory in the working tree. */
|
|
132
|
+
dir: string;
|
|
133
|
+
schema: Record<string, unknown>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Import the kind file at `path` through the path loader lexicons use (#2520).
|
|
138
|
+
* The kind is registered under a name no lexicon package can have (npm names
|
|
139
|
+
* hold no `:`), imported and unregistered again, so no lexicon lookup sees it.
|
|
140
|
+
*/
|
|
141
|
+
async function importKindModule(path: string): Promise<Record<string, unknown>> {
|
|
142
|
+
const name = `record-kind:${path}`;
|
|
143
|
+
registerLexiconDeclarations([{ name, module: path }], dirname(path));
|
|
144
|
+
try {
|
|
145
|
+
const mod = await importLexiconModule(name);
|
|
146
|
+
if (!mod) throw new Error(`${path} was not registered`);
|
|
147
|
+
return mod;
|
|
148
|
+
} finally {
|
|
149
|
+
registerLexiconDeclarations([name], dirname(path));
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Load a kind file and the schema it names. `path` is resolved against `cwd`. */
|
|
154
|
+
export async function loadRecordKind(path: string, cwd: string = process.cwd()): Promise<LoadedRecordKind> {
|
|
155
|
+
const file = resolve(cwd, path);
|
|
156
|
+
let mod: Record<string, unknown>;
|
|
157
|
+
try {
|
|
158
|
+
statSync(file);
|
|
159
|
+
mod = await importKindModule(file);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new RecordReadError("kind-unreadable", `kind file ${path} could not be loaded: ${message(err)}`);
|
|
162
|
+
}
|
|
163
|
+
const parsed = recordKindSchema.safeParse(mod.recordKind);
|
|
164
|
+
if (!parsed.success) {
|
|
165
|
+
const detail =
|
|
166
|
+
mod.recordKind === undefined
|
|
167
|
+
? "it has no recordKind export"
|
|
168
|
+
: parsed.error.issues.map((i) => `${i.path.join(".") || "recordKind"}: ${i.message}`).join("; ");
|
|
169
|
+
throw new RecordReadError("kind-invalid", `kind file ${path} is not a record kind: ${detail}`);
|
|
170
|
+
}
|
|
171
|
+
const kind = parsed.data;
|
|
172
|
+
const base = dirname(file);
|
|
173
|
+
const schemaFile = resolve(base, kind.schema.path);
|
|
174
|
+
let schema: Record<string, unknown>;
|
|
175
|
+
try {
|
|
176
|
+
schema = JSON.parse(readFileSync(schemaFile, "utf-8")) as Record<string, unknown>;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
throw new RecordReadError("schema-unreadable", `schema ${kind.schema.path} named by ${path} could not be read: ${message(err)}`);
|
|
179
|
+
}
|
|
180
|
+
if (schema.$id !== kind.schema.id) {
|
|
181
|
+
throw new RecordReadError(
|
|
182
|
+
"schema-id-mismatch",
|
|
183
|
+
`kind ${kind.name} names schema ${kind.schema.id}, but ${kind.schema.path} has $id ${JSON.stringify(schema.$id)}`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
return { kind, file, dir: resolve(base, kind.location.dir), schema };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// ── Front matter ─────────────────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
export type FrontMatter = { ok: true; value: Record<string, unknown> } | { ok: false; message: string };
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The front matter of a Markdown file, limited to what YAML and JSON share
|
|
195
|
+
* (#2524 D4). Line endings are normalised first. Aliases, and any value JSON
|
|
196
|
+
* cannot hold, are refused, so the same text always reads as the same JSON.
|
|
197
|
+
*/
|
|
198
|
+
export function parseFrontMatter(text: string): FrontMatter {
|
|
199
|
+
const normalised = text.replace(/\r\n?/g, "\n");
|
|
200
|
+
const m = normalised.match(/^---\n([\s\S]*?)\n---(?:\n|$)/);
|
|
201
|
+
if (!m) return { ok: false, message: "no front matter: the file must start with a --- line and close it with another" };
|
|
202
|
+
let value: unknown;
|
|
203
|
+
try {
|
|
204
|
+
value = yaml.load(m[1], { schema: yaml.JSON_SCHEMA });
|
|
205
|
+
} catch (err) {
|
|
206
|
+
return { ok: false, message: `front matter is not valid YAML: ${message(err).split("\n")[0]}` };
|
|
207
|
+
}
|
|
208
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
209
|
+
return { ok: false, message: "front matter must be a mapping" };
|
|
210
|
+
}
|
|
211
|
+
const problem = nonJson(value, "", new Set());
|
|
212
|
+
if (problem) return { ok: false, message: problem };
|
|
213
|
+
return { ok: true, value: value as Record<string, unknown> };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The first thing in `v` that JSON cannot say, or that only an alias can produce. */
|
|
217
|
+
function nonJson(v: unknown, at: string, seen: Set<object>): string | undefined {
|
|
218
|
+
if (v === null || typeof v === "string" || typeof v === "boolean") return undefined;
|
|
219
|
+
if (typeof v === "number") return Number.isFinite(v) ? undefined : `${at || "/"}: ${v} is not a JSON number`;
|
|
220
|
+
if (typeof v !== "object") return `${at || "/"}: a ${typeof v} is not a JSON value`;
|
|
221
|
+
if (seen.has(v)) return `${at || "/"}: YAML aliases are not allowed`;
|
|
222
|
+
seen.add(v);
|
|
223
|
+
if (Array.isArray(v)) {
|
|
224
|
+
for (let i = 0; i < v.length; i++) {
|
|
225
|
+
const p = nonJson(v[i], `${at}/${i}`, seen);
|
|
226
|
+
if (p) return p;
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
if (Object.getPrototypeOf(v) !== Object.prototype) return `${at || "/"}: not a plain mapping`;
|
|
231
|
+
for (const [k, x] of Object.entries(v)) {
|
|
232
|
+
const p = nonJson(x, `${at}/${k}`, seen);
|
|
233
|
+
if (p) return p;
|
|
234
|
+
}
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Reading ──────────────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
export interface RecordReason {
|
|
241
|
+
code: RecordReasonCode;
|
|
242
|
+
message: string;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export interface RecordEntry {
|
|
246
|
+
/** The record's id, or null when it could not be read. */
|
|
247
|
+
id: string | null;
|
|
248
|
+
/** Path from the repository root (or the working directory outside git), with `/` separators. */
|
|
249
|
+
path: string;
|
|
250
|
+
/** The record's state as written, or null when it could not be read. */
|
|
251
|
+
state: string | null;
|
|
252
|
+
valid: boolean;
|
|
253
|
+
reasons: RecordReason[];
|
|
254
|
+
/** The id of the closed record whose `supersedes` link replaces this one, or null. */
|
|
255
|
+
supersededBy: string | null;
|
|
256
|
+
/** The front matter as JSON, or null when it could not be parsed. */
|
|
257
|
+
data: Record<string, unknown> | null;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface ReadRecordsOptions {
|
|
261
|
+
/** Where record paths are reported from, and what `source` reads relative to. */
|
|
262
|
+
root: string;
|
|
263
|
+
source: RecordSource;
|
|
264
|
+
/** Leave out records a closed record supersedes. */
|
|
265
|
+
current?: boolean;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export interface ReadRecordsResult {
|
|
269
|
+
records: RecordEntry[];
|
|
270
|
+
summary: { total: number; valid: number; invalid: number; superseded: number };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
type Validator = (data: unknown) => { ok: true } | { ok: false; errors: string[] };
|
|
274
|
+
|
|
275
|
+
async function compileSchema(schema: Record<string, unknown>): Promise<Validator> {
|
|
276
|
+
const mod = (await import("ajv")) as unknown as { default: unknown };
|
|
277
|
+
// ajv is CommonJS; its class is the default export, or that export's own default.
|
|
278
|
+
const Ajv = ((mod.default as { default?: unknown }).default ?? mod.default) as new (opts: object) => {
|
|
279
|
+
compile(s: object): ((d: unknown) => boolean) & { errors?: Array<{ instancePath: string; message?: string }> | null };
|
|
280
|
+
};
|
|
281
|
+
let validate: ReturnType<InstanceType<typeof Ajv>["compile"]>;
|
|
282
|
+
try {
|
|
283
|
+
validate = new Ajv({ allErrors: true, strict: false }).compile(schema);
|
|
284
|
+
} catch (err) {
|
|
285
|
+
throw new RecordReadError("schema-invalid", `the kind's schema does not compile: ${message(err)}`);
|
|
286
|
+
}
|
|
287
|
+
return (data) =>
|
|
288
|
+
validate(data)
|
|
289
|
+
? { ok: true }
|
|
290
|
+
: { ok: false, errors: (validate.errors ?? []).map((e) => `${e.instancePath || "/"} ${e.message ?? "is invalid"}`) };
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Read every record `loaded` locates, through `options.source`. */
|
|
294
|
+
export async function readRecords(loaded: LoadedRecordKind, options: ReadRecordsOptions): Promise<ReadRecordsResult> {
|
|
295
|
+
const { kind } = loaded;
|
|
296
|
+
const dirRel = toPosix(relative(options.root, loaded.dir)) || ".";
|
|
297
|
+
const names = options.source.list(dirRel);
|
|
298
|
+
if (names === undefined) {
|
|
299
|
+
throw new RecordReadError("location-missing", `records directory ${dirRel} does not exist${options.source.label}`);
|
|
300
|
+
}
|
|
301
|
+
const match = new RegExp(kind.location.match);
|
|
302
|
+
const validate = await compileSchema(loaded.schema);
|
|
303
|
+
|
|
304
|
+
const entries: RecordEntry[] = [];
|
|
305
|
+
for (const name of names.filter((n) => match.test(n)).sort()) {
|
|
306
|
+
const path = dirRel === "." ? name : `${dirRel}/${name}`;
|
|
307
|
+
const entry: RecordEntry = { id: null, path, state: null, valid: true, reasons: [], supersededBy: null, data: null };
|
|
308
|
+
entries.push(entry);
|
|
309
|
+
const fm = parseFrontMatter(options.source.read(path));
|
|
310
|
+
if (!fm.ok) {
|
|
311
|
+
entry.reasons.push({ code: "record-unparseable", message: fm.message });
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
entry.data = fm.value;
|
|
315
|
+
const id = fm.value[kind.idField];
|
|
316
|
+
const state = fm.value[kind.stateField];
|
|
317
|
+
if (typeof id === "string") entry.id = id;
|
|
318
|
+
if (typeof state === "string") entry.state = state;
|
|
319
|
+
const result = validate(fm.value);
|
|
320
|
+
if (!result.ok) {
|
|
321
|
+
entry.reasons.push({ code: "record-schema-invalid", message: result.errors.join("; ") });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Ids: the first file in path order keeps an id; later ones are flagged.
|
|
326
|
+
const byId = new Map<string, RecordEntry>();
|
|
327
|
+
for (const e of entries) {
|
|
328
|
+
if (e.id === null) continue;
|
|
329
|
+
const first = byId.get(e.id);
|
|
330
|
+
if (first) e.reasons.push({ code: "record-id-duplicate", message: `id ${e.id} is already used by ${first.path}` });
|
|
331
|
+
else byId.set(e.id, e);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Supersession comes from the new record's links, never from the old record.
|
|
335
|
+
// A link takes effect only from a closed record (#2555: a later ratified
|
|
336
|
+
// decision replaces an earlier one), and a record is superseded at most once
|
|
337
|
+
// (#2524 D4).
|
|
338
|
+
const closed = new Set(kind.closedStates);
|
|
339
|
+
for (const e of entries) {
|
|
340
|
+
const links = e.data?.[kind.supersedes.field];
|
|
341
|
+
if (!Array.isArray(links)) continue;
|
|
342
|
+
for (const link of links) {
|
|
343
|
+
if (link === null || typeof link !== "object") continue;
|
|
344
|
+
const target = (link as Record<string, unknown>)[kind.supersedes.key];
|
|
345
|
+
if (typeof target !== "string") continue;
|
|
346
|
+
const old = byId.get(target);
|
|
347
|
+
if (!old) {
|
|
348
|
+
e.reasons.push({ code: "record-supersedes-unknown", message: `supersedes ${target}, which no record has` });
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
if (e.state === null || !closed.has(e.state) || old === e) continue;
|
|
352
|
+
if (old.supersededBy !== null && old.supersededBy !== e.id) {
|
|
353
|
+
e.reasons.push({
|
|
354
|
+
code: "record-supersedes-conflict",
|
|
355
|
+
message: `supersedes ${target}, which ${old.supersededBy} already supersedes`,
|
|
356
|
+
});
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
old.supersededBy = e.id;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
for (const e of entries) e.valid = e.reasons.length === 0;
|
|
364
|
+
const records = options.current ? entries.filter((e) => e.supersededBy === null) : entries;
|
|
365
|
+
return {
|
|
366
|
+
records,
|
|
367
|
+
summary: {
|
|
368
|
+
total: records.length,
|
|
369
|
+
valid: records.filter((e) => e.valid).length,
|
|
370
|
+
invalid: records.filter((e) => !e.valid).length,
|
|
371
|
+
superseded: entries.filter((e) => e.supersededBy !== null).length,
|
|
372
|
+
},
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
377
|
+
|
|
378
|
+
function message(err: unknown): string {
|
|
379
|
+
return err instanceof Error ? err.message : String(err);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function toPosix(p: string): string {
|
|
383
|
+
return sep === "/" ? p : p.split(sep).join(posix.sep);
|
|
384
|
+
}
|