@intentius/chant 0.90.0 → 0.93.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/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +8 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +20 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -1
- package/dist/cli/handlers/run.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/workspace-tools.d.ts +7 -4
- package/dist/cli/mcp/workspace-tools.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +42 -1
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/pinned-upgrade.d.ts.map +1 -1
- package/dist/components/verbs/vuln-scan.d.ts +72 -0
- package/dist/components/verbs/vuln-scan.d.ts.map +1 -1
- package/dist/config.d.ts +22 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/lexicon-module.d.ts +22 -0
- package/dist/lexicon-module.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +19 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -1
- package/dist/lifecycle/git.d.ts +40 -5
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +72 -16
- package/dist/lifecycle/lease.d.ts.map +1 -1
- package/dist/lifecycle/member-ledger.d.ts +3 -2
- package/dist/lifecycle/member-ledger.d.ts.map +1 -1
- package/dist/lifecycle/plan-ledger.d.ts +114 -0
- package/dist/lifecycle/plan-ledger.d.ts.map +1 -0
- package/dist/lifecycle/run-ledger.d.ts +2 -0
- package/dist/lifecycle/run-ledger.d.ts.map +1 -1
- package/dist/lifecycle/symptoms.d.ts +51 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -1
- package/dist/lifecycle/work-lease.d.ts +140 -0
- package/dist/lifecycle/work-lease.d.ts.map +1 -0
- package/dist/lint/rules/index.d.ts +4 -2
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lint/rules/op/index.d.ts +14 -3
- package/dist/lint/rules/op/index.d.ts.map +1 -1
- package/dist/lint/rules/op/ops014-converge-rule-refusals.d.ts.map +1 -1
- package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts +29 -0
- package/dist/lint/rules/op/sys010-key-over-plain-http.d.ts.map +1 -0
- package/dist/lint/rules/sys001-no-literal-key.d.ts +3 -0
- package/dist/lint/rules/sys001-no-literal-key.d.ts.map +1 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts +53 -0
- package/dist/op/__fixtures__/decide-stub-backend.d.ts.map +1 -0
- package/dist/op/activities/activity-contracts.d.ts +203 -2
- package/dist/op/activities/activity-contracts.d.ts.map +1 -1
- package/dist/op/activities/apply.d.ts.map +1 -1
- package/dist/op/activities/change-coverage.d.ts +44 -0
- package/dist/op/activities/change-coverage.d.ts.map +1 -0
- package/dist/op/activities/converge.d.ts +24 -1
- package/dist/op/activities/converge.d.ts.map +1 -1
- package/dist/op/activities/decide.d.ts +107 -0
- package/dist/op/activities/decide.d.ts.map +1 -0
- package/dist/op/activities/index.d.ts +10 -0
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/shell.d.ts +32 -0
- package/dist/op/activities/shell.d.ts.map +1 -1
- package/dist/op/activities/source-release.d.ts +86 -0
- package/dist/op/activities/source-release.d.ts.map +1 -0
- package/dist/op/activities/source-rollback.d.ts +100 -0
- package/dist/op/activities/source-rollback.d.ts.map +1 -0
- package/dist/op/activities/work-evidence.d.ts +42 -0
- package/dist/op/activities/work-evidence.d.ts.map +1 -0
- package/dist/op/activity-contract.d.ts +1 -0
- package/dist/op/activity-contract.d.ts.map +1 -1
- package/dist/op/activity-profiles.d.ts +16 -0
- package/dist/op/activity-profiles.d.ts.map +1 -1
- package/dist/op/builders.d.ts +84 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/composites/converge-op.d.ts +31 -4
- package/dist/op/composites/converge-op.d.ts.map +1 -1
- package/dist/op/decide-backend.d.ts +116 -0
- package/dist/op/decide-backend.d.ts.map +1 -0
- package/dist/op/decide-config.d.ts +120 -0
- package/dist/op/decide-config.d.ts.map +1 -0
- package/dist/op/decide-read-inputs.d.ts +40 -0
- package/dist/op/decide-read-inputs.d.ts.map +1 -0
- package/dist/op/discover.d.ts +25 -0
- package/dist/op/discover.d.ts.map +1 -1
- package/dist/op/gate-wait.d.ts +43 -0
- package/dist/op/gate-wait.d.ts.map +1 -0
- package/dist/op/gate.d.ts +5 -2
- package/dist/op/gate.d.ts.map +1 -1
- package/dist/op/index.d.ts +18 -5
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/lifecycle-receipt-store.d.ts +34 -0
- package/dist/op/lifecycle-receipt-store.d.ts.map +1 -0
- package/dist/op/local-executor.d.ts +48 -4
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/local-output.d.ts +2 -0
- package/dist/op/local-output.d.ts.map +1 -1
- package/dist/op/op-ir.d.ts +12 -1
- package/dist/op/op-ir.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts.map +1 -1
- package/dist/op/operator.d.ts +126 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +46 -5
- package/dist/op/runtime.d.ts.map +1 -1
- package/dist/op/runtimes/local.d.ts.map +1 -1
- package/dist/op/source-archive.d.ts +63 -0
- package/dist/op/source-archive.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +2 -2
- package/dist/op/step-output-ref.d.ts.map +1 -1
- package/dist/op/steward-points.d.ts +136 -0
- package/dist/op/steward-points.d.ts.map +1 -0
- package/dist/op/steward-turn.d.ts +53 -0
- package/dist/op/steward-turn.d.ts.map +1 -0
- package/dist/op/steward.d.ts +161 -0
- package/dist/op/steward.d.ts.map +1 -0
- package/dist/op/types.d.ts +58 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-decl.d.ts +18 -0
- package/dist/op/work-lease-decl.d.ts.map +1 -0
- package/dist/op/work-lease-run.d.ts +173 -0
- package/dist/op/work-lease-run.d.ts.map +1 -0
- package/dist/workspace/__fixtures__/decide-workspace.d.ts +100 -0
- package/dist/workspace/__fixtures__/decide-workspace.d.ts.map +1 -0
- package/dist/workspace/answers-ledger.d.ts +76 -0
- package/dist/workspace/answers-ledger.d.ts.map +1 -0
- package/dist/workspace/box-isolation.d.ts +99 -0
- package/dist/workspace/box-isolation.d.ts.map +1 -0
- package/dist/workspace/changes-cli.d.ts +16 -0
- package/dist/workspace/changes-cli.d.ts.map +1 -0
- package/dist/workspace/changes.d.ts +200 -0
- package/dist/workspace/changes.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts +59 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-fly-site.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts +51 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-live-names.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts +26 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-rollback.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts +59 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts +22 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit-ship-policy.d.ts.map +1 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts +71 -0
- package/dist/workspace/chant-migrations/chud-lexicon-exit.d.ts.map +1 -0
- package/dist/workspace/chant-migrations.d.ts +99 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -0
- package/dist/workspace/checks/box-isolation.d.ts +18 -0
- package/dist/workspace/checks/box-isolation.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +104 -0
- package/dist/workspace/checks/boxes.d.ts.map +1 -0
- package/dist/workspace/checks/diagrams.d.ts +31 -0
- package/dist/workspace/checks/diagrams.d.ts.map +1 -0
- package/dist/workspace/checks/records.d.ts +8 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +13 -1
- package/dist/workspace/checks.d.ts.map +1 -1
- package/dist/workspace/compose-graph.d.ts +2 -0
- package/dist/workspace/compose-graph.d.ts.map +1 -1
- package/dist/workspace/conformance/index.d.ts.map +1 -1
- package/dist/workspace/decide.d.ts +229 -0
- package/dist/workspace/decide.d.ts.map +1 -0
- package/dist/workspace/decision-points.schema.json +137 -0
- package/dist/workspace/declaration.d.ts +103 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +285 -0
- package/dist/workspace/declared-kinds.d.ts +32 -0
- package/dist/workspace/declared-kinds.d.ts.map +1 -1
- package/dist/workspace/generated-files.d.ts +7 -0
- package/dist/workspace/generated-files.d.ts.map +1 -1
- package/dist/workspace/intent.d.ts.map +1 -1
- package/dist/workspace/lineage-check.d.ts +3 -0
- package/dist/workspace/lineage-check.d.ts.map +1 -1
- package/dist/workspace/lineage-upgrade.d.ts +6 -1
- package/dist/workspace/lineage-upgrade.d.ts.map +1 -1
- package/dist/workspace/ls.d.ts +39 -1
- package/dist/workspace/ls.d.ts.map +1 -1
- package/dist/workspace/point-answer.schema.json +95 -0
- package/dist/workspace/points-cli.d.ts +113 -0
- package/dist/workspace/points-cli.d.ts.map +1 -0
- package/dist/workspace/points.d.ts +320 -0
- package/dist/workspace/points.d.ts.map +1 -0
- package/dist/workspace/reason-codes.d.ts +38 -0
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/record-assets.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +23 -1
- package/dist/workspace/records-cli.d.ts.map +1 -1
- package/dist/workspace/records-close.d.ts +1 -1
- package/dist/workspace/records-close.d.ts.map +1 -1
- package/dist/workspace/records-write.d.ts +11 -5
- package/dist/workspace/records-write.d.ts.map +1 -1
- package/dist/workspace/records.d.ts +42 -6
- package/dist/workspace/records.d.ts.map +1 -1
- package/dist/workspace/status-gates.d.ts +7 -2
- package/dist/workspace/status-gates.d.ts.map +1 -1
- package/dist/workspace/status-stewards.d.ts +179 -0
- package/dist/workspace/status-stewards.d.ts.map +1 -0
- package/dist/workspace/status.d.ts +68 -1
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +189 -0
- package/dist/workspace/work-cli.d.ts.map +1 -0
- package/dist/workspace/work-evidence.d.ts +74 -0
- package/dist/workspace/work-evidence.d.ts.map +1 -0
- package/dist/workspace/work.d.ts +43 -1
- package/dist/workspace/work.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/audit.test.ts +9 -1
- package/src/cli/commands/build.ts +3 -2
- package/src/cli/commands/lint.ts +7 -1
- package/src/cli/handlers/components.test.ts +93 -0
- package/src/cli/handlers/components.ts +44 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +16 -2
- package/src/cli/handlers/operator.ts +141 -4
- package/src/cli/handlers/run.test.ts +173 -2
- package/src/cli/handlers/run.ts +174 -3
- package/src/cli/main.test.ts +50 -0
- package/src/cli/main.ts +97 -2
- package/src/cli/mcp/workspace-tools.test.ts +48 -19
- package/src/cli/mcp/workspace-tools.ts +110 -8
- package/src/cli/plugins.ts +11 -3
- package/src/cli/registry.ts +42 -1
- package/src/codegen/pinned-upgrade.ts +2 -1
- package/src/codegen/release-wiring.test.ts +49 -2
- package/src/components/capability-plugin-loader.ts +3 -3
- package/src/components/cli-support.ts +2 -2
- package/src/components/verbs/vuln-scan.test.ts +124 -1
- package/src/components/verbs/vuln-scan.ts +142 -1
- package/src/config.ts +11 -0
- package/src/lexicon-module.ts +56 -1
- package/src/lexicon-package.test.ts +96 -0
- package/src/lifecycle/converge-ledger.ts +20 -0
- package/src/lifecycle/git.ts +65 -15
- package/src/lifecycle/lease.test.ts +22 -0
- package/src/lifecycle/lease.ts +133 -29
- package/src/lifecycle/member-ledger.ts +3 -2
- package/src/lifecycle/plan-ledger.test.ts +148 -0
- package/src/lifecycle/plan-ledger.ts +158 -0
- package/src/lifecycle/run-ledger.ts +6 -0
- package/src/lifecycle/symptoms.ts +95 -0
- package/src/lifecycle/work-lease.test.ts +236 -0
- package/src/lifecycle/work-lease.ts +426 -0
- package/src/lint/rules/index.ts +6 -2
- package/src/lint/rules/op/index.ts +17 -4
- package/src/lint/rules/op/ops014-converge-rule-refusals.test.ts +23 -1
- package/src/lint/rules/op/ops014-converge-rule-refusals.ts +10 -6
- package/src/lint/rules/op/sys010-key-over-plain-http.test.ts +52 -0
- package/src/lint/rules/op/sys010-key-over-plain-http.ts +100 -0
- package/src/lint/rules/sys001-no-literal-key.test.ts +31 -0
- package/src/lint/rules/sys001-no-literal-key.ts +67 -0
- package/src/op/__fixtures__/decide-stub-backend.ts +143 -0
- package/src/op/activities/activity-contracts.ts +190 -1
- package/src/op/activities/apply.ts +7 -6
- package/src/op/activities/change-coverage.ts +60 -0
- package/src/op/activities/converge.test.ts +55 -1
- package/src/op/activities/converge.ts +208 -29
- package/src/op/activities/decide.live.test.ts +78 -0
- package/src/op/activities/decide.test.ts +300 -0
- package/src/op/activities/decide.ts +267 -0
- package/src/op/activities/index.ts +21 -0
- package/src/op/activities/shell.test.ts +91 -1
- package/src/op/activities/shell.ts +91 -2
- package/src/op/activities/source-release.test.ts +118 -0
- package/src/op/activities/source-release.ts +168 -0
- package/src/op/activities/source-rollback.test.ts +131 -0
- package/src/op/activities/source-rollback.ts +239 -0
- package/src/op/activities/work-evidence.ts +62 -0
- package/src/op/activity-contract-registry.test.ts +2 -0
- package/src/op/activity-contract-registry.ts +2 -2
- package/src/op/activity-contract.test.ts +12 -0
- package/src/op/activity-contract.ts +6 -1
- package/src/op/activity-profiles.ts +29 -0
- package/src/op/activity-registry.test.ts +1 -1
- package/src/op/activity-registry.ts +2 -2
- package/src/op/builders.ts +124 -5
- package/src/op/command-gate.test.ts +192 -0
- package/src/op/composites/converge-op.test.ts +27 -1
- package/src/op/composites/converge-op.ts +69 -15
- package/src/op/decide-backend.test.ts +44 -0
- package/src/op/decide-backend.ts +212 -0
- package/src/op/decide-config.test.ts +52 -0
- package/src/op/decide-config.ts +80 -0
- package/src/op/decide-read-inputs.ts +117 -0
- package/src/op/discover.ts +71 -0
- package/src/op/gate-approval.ts +2 -2
- package/src/op/gate-wait.ts +57 -0
- package/src/op/gate.ts +8 -3
- package/src/op/generate-pipeline.ts +2 -2
- package/src/op/index.ts +34 -6
- package/src/op/lifecycle-receipt-store.test.ts +60 -0
- package/src/op/lifecycle-receipt-store.ts +61 -0
- package/src/op/local-executor.ts +367 -33
- package/src/op/local-output.test.ts +33 -0
- package/src/op/local-output.ts +33 -2
- package/src/op/op-ir.test.ts +10 -0
- package/src/op/op-ir.ts +18 -0
- package/src/op/op-verb-class.ts +1 -0
- package/src/op/operator.ts +360 -10
- package/src/op/runtime.ts +37 -8
- package/src/op/runtimes/local.ts +6 -1
- package/src/op/source-archive.ts +161 -0
- package/src/op/step-output-ref.ts +6 -2
- package/src/op/steward-points.test.ts +313 -0
- package/src/op/steward-points.ts +194 -0
- package/src/op/steward-turn.ts +79 -0
- package/src/op/steward.test.ts +296 -0
- package/src/op/steward.ts +277 -0
- package/src/op/types.ts +60 -0
- package/src/op/work-lease-decl.ts +80 -0
- package/src/op/work-lease-run.test.ts +345 -0
- package/src/op/work-lease-run.ts +395 -0
- package/src/workspace/__fixtures__/decide-workspace.ts +129 -0
- package/src/workspace/__fixtures__/studio-template-84c8b21.json +67 -0
- package/src/workspace/answers-ledger.ts +118 -0
- package/src/workspace/box-isolation.test.ts +261 -0
- package/src/workspace/box-isolation.ts +205 -0
- package/src/workspace/changes-cli.ts +56 -0
- package/src/workspace/changes.schema.json +472 -0
- package/src/workspace/changes.test.ts +315 -0
- package/src/workspace/changes.ts +512 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-fly-site.ts +234 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-live-names.ts +164 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-rollback.ts +223 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-ship-inputs.ts +195 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit-ship-policy.ts +108 -0
- package/src/workspace/chant-migrations/chud-lexicon-exit.ts +882 -0
- package/src/workspace/chant-migrations.e2e.test.ts +1089 -0
- package/src/workspace/chant-migrations.ts +164 -0
- package/src/workspace/check-contract.test.ts +5 -1
- package/src/workspace/check.schema.json +25 -7
- package/src/workspace/checks/box-isolation.ts +68 -0
- package/src/workspace/checks/boxes.test.ts +276 -0
- package/src/workspace/checks/boxes.ts +442 -0
- package/src/workspace/checks/diagrams.test.ts +134 -0
- package/src/workspace/checks/diagrams.ts +108 -0
- package/src/workspace/checks/records.ts +72 -0
- package/src/workspace/checks.test.ts +7 -0
- package/src/workspace/checks.ts +27 -3
- package/src/workspace/compose-graph.ts +2 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.kind.mjs +10 -0
- package/src/workspace/conformance/__fixture__/decisions/decision.schema.json +26 -0
- package/src/workspace/conformance/index.ts +5 -0
- package/src/workspace/decide.test.ts +224 -0
- package/src/workspace/decide.ts +709 -0
- package/src/workspace/decision-points.schema.json +137 -0
- package/src/workspace/declaration.schema.json +285 -0
- package/src/workspace/declaration.test.ts +58 -0
- package/src/workspace/declaration.ts +241 -0
- package/src/workspace/declared-kinds.test.ts +5 -5
- package/src/workspace/declared-kinds.ts +71 -3
- package/src/workspace/generated-files.ts +14 -0
- package/src/workspace/graph-cli.ts +1 -1
- package/src/workspace/graph.schema.json +9 -1
- package/src/workspace/intent.schema.json +12 -3
- package/src/workspace/intent.ts +3 -10
- package/src/workspace/lineage-check.ts +16 -1
- package/src/workspace/lineage-upgrade-cli.ts +1 -1
- package/src/workspace/lineage-upgrade.test.ts +70 -0
- package/src/workspace/lineage-upgrade.ts +81 -10
- package/src/workspace/ls-contract.test.ts +57 -0
- package/src/workspace/ls.schema.json +46 -1
- package/src/workspace/ls.ts +62 -3
- package/src/workspace/point-answer.schema.json +95 -0
- package/src/workspace/points-cli.ts +284 -0
- package/src/workspace/points-write.schema.json +499 -0
- package/src/workspace/points.schema.json +743 -0
- package/src/workspace/points.test.ts +264 -0
- package/src/workspace/points.ts +564 -0
- package/src/workspace/read-contract.test.ts +18 -1
- package/src/workspace/reason-codes.test.ts +24 -1
- package/src/workspace/reason-codes.ts +49 -0
- package/src/workspace/record-assets.test.ts +3 -2
- package/src/workspace/record-assets.ts +4 -1
- package/src/workspace/records-amend.schema.json +2 -0
- package/src/workspace/records-cli.ts +28 -5
- package/src/workspace/records-close.schema.json +2 -0
- package/src/workspace/records-contract.test.ts +3 -2
- package/src/workspace/records-new.schema.json +2 -0
- package/src/workspace/records-review.schema.json +2 -0
- package/src/workspace/records-sessions-write.e2e.test.ts +77 -0
- package/src/workspace/records-sessions-write.test.ts +2 -34
- package/src/workspace/records-write.e2e.test.ts +108 -0
- package/src/workspace/records-write.test.ts +32 -41
- package/src/workspace/records-write.ts +33 -15
- package/src/workspace/records.schema.json +53 -2
- package/src/workspace/records.test.ts +48 -3
- package/src/workspace/records.ts +142 -6
- package/src/workspace/status-contract.test.ts +189 -6
- package/src/workspace/status-gates.ts +9 -4
- package/src/workspace/status-stewards.ts +329 -0
- package/src/workspace/status.schema.json +335 -5
- package/src/workspace/status.ts +142 -6
- package/src/workspace/work-acceptance.test.ts +274 -0
- package/src/workspace/work-cli.e2e.test.ts +180 -0
- package/src/workspace/work-cli.ts +429 -0
- package/src/workspace/work-evidence.schema.json +212 -0
- package/src/workspace/work-evidence.ts +169 -0
- package/src/workspace/work-history.schema.json +349 -0
- package/src/workspace/work-history.test.ts +156 -0
- package/src/workspace/work-lease.schema.json +233 -0
- package/src/workspace/work-readiness-chud.test.ts +145 -0
- package/src/workspace/work.test.ts +3 -2
- package/src/workspace/work.ts +100 -1
- /package/src/cli/{discovery-skip.test.ts → discovery-skip.e2e.test.ts} +0 -0
- /package/src/workspace/conformance/{conformance.test.ts → conformance.e2e.test.ts} +0 -0
- /package/src/workspace/{which-chant.test.ts → which-chant.e2e.test.ts} +0 -0
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Answer records held on the lifecycle ledger (#2786).
|
|
3
|
+
*
|
|
4
|
+
* A steward's writes never touch the checkout's index or working tree
|
|
5
|
+
* (`../op/steward.ts`): the working tree is the coding agent's. A decision
|
|
6
|
+
* point asked in a steward's turn still has to leave its question somewhere a
|
|
7
|
+
* person finds it after the turn ends, and an Op's leased worktree is removed
|
|
8
|
+
* with the run. So a question asked in a steward's turn is written to
|
|
9
|
+
* `chant/lifecycle`, beside the lease histories and run records, under
|
|
10
|
+
* `_answers/<kind name>/<id>.md` in the ledger of the member that owns the
|
|
11
|
+
* answer kind. The file is the same Markdown record the kind's directory
|
|
12
|
+
* would hold, and it is written with git plumbing, like every ledger write.
|
|
13
|
+
*
|
|
14
|
+
* Readers see the ledger's answers as if they sat in the kind's directory:
|
|
15
|
+
* {@link withLedgerAnswers} lays them over the working tree, so `points`,
|
|
16
|
+
* `points --open`, a later `points ask` of the same question and `points
|
|
17
|
+
* answer` all find them. A record on the ledger wins over a file of the same
|
|
18
|
+
* name in the tree, because the ledger copy is the one a steward rewrote.
|
|
19
|
+
* People's answer to a question held on the ledger is written back there, so
|
|
20
|
+
* answering through hud in a box does not touch the checkout either.
|
|
21
|
+
*
|
|
22
|
+
* `points --at <rev>` reads a revision of the working branch, which the
|
|
23
|
+
* ledger is not part of, so it lists the tree's records only.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { basename, dirname } from "node:path";
|
|
27
|
+
import { fetchLifecycleStatus, ledgerDir, listFilesInDir, pushLifecycle, readBlobBySha, readPathSha, writeBlobToPath } from "../lifecycle/git";
|
|
28
|
+
import type { RecordSource } from "./record-source";
|
|
29
|
+
import { overlay } from "./records-write";
|
|
30
|
+
|
|
31
|
+
/** The directory on `chant/lifecycle`, under the member's prefix, that holds answer records. */
|
|
32
|
+
export const ANSWERS_LEDGER_DIR = "_answers";
|
|
33
|
+
|
|
34
|
+
const BRANCH = "chant/lifecycle";
|
|
35
|
+
|
|
36
|
+
/** One answer record on the ledger. */
|
|
37
|
+
export interface LedgerAnswer {
|
|
38
|
+
/** Where the record reads as being: the kind's directory, from the repository root. */
|
|
39
|
+
path: string;
|
|
40
|
+
/** Where it is: `chant/lifecycle:<path on the branch>`, which `git show` reads. */
|
|
41
|
+
ledger: string;
|
|
42
|
+
text: string;
|
|
43
|
+
/** Its blob sha, for a compare-and-set rewrite. */
|
|
44
|
+
sha: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The answer records one kind holds on the ledger. */
|
|
48
|
+
export interface LedgerAnswers {
|
|
49
|
+
/** Where the ledger is resolved from: the kind file's directory, so the member owning the kind owns its answers. */
|
|
50
|
+
cwd: string;
|
|
51
|
+
/** The directory on the branch, relative to the member's prefix. */
|
|
52
|
+
dir: string;
|
|
53
|
+
/** The kind's records directory, from the repository root. */
|
|
54
|
+
dirRel: string;
|
|
55
|
+
/** By the path each record reads as. */
|
|
56
|
+
byPath: Map<string, LedgerAnswer>;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** The ledger directory of the kind named `kindName`. */
|
|
60
|
+
export function answersLedgerDir(kindName: string): string {
|
|
61
|
+
return `${ANSWERS_LEDGER_DIR}/${kindName.replace(/[^A-Za-z0-9._-]/g, "_")}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function treePath(dirRel: string, name: string): string {
|
|
65
|
+
return dirRel === "." ? name : `${dirRel}/${name}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Read the answer records the kind at `kindFile` holds on the ledger. Empty
|
|
70
|
+
* when there are none, when there is no ledger branch, and outside a git
|
|
71
|
+
* repository.
|
|
72
|
+
*/
|
|
73
|
+
export async function readLedgerAnswers(kind: { file: string; name: string; dirRel: string }): Promise<LedgerAnswers> {
|
|
74
|
+
const cwd = dirname(kind.file);
|
|
75
|
+
const dir = answersLedgerDir(kind.name);
|
|
76
|
+
const out: LedgerAnswers = { cwd, dir, dirRel: kind.dirRel, byPath: new Map() };
|
|
77
|
+
try {
|
|
78
|
+
const names = (await listFilesInDir(dir, { cwd })).filter((n) => n.endsWith(".md"));
|
|
79
|
+
if (names.length === 0) return out;
|
|
80
|
+
const full = await ledgerDir(dir, { cwd });
|
|
81
|
+
for (const name of names) {
|
|
82
|
+
const sha = await readPathSha(dir, name, { cwd });
|
|
83
|
+
const text = sha ? await readBlobBySha(sha, { cwd }) : null;
|
|
84
|
+
if (sha === null || text === null) continue;
|
|
85
|
+
const path = treePath(kind.dirRel, name);
|
|
86
|
+
out.byPath.set(path, { path, ledger: `${BRANCH}:${full}/${name}`, text, sha });
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// No repository, or a member ledger that can't be placed: the tree's records are all there is.
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `base` with each ledger answer at the path it reads as, over any file of the same name. */
|
|
95
|
+
export function withLedgerAnswers(base: RecordSource, answers: LedgerAnswers): RecordSource {
|
|
96
|
+
let source = base;
|
|
97
|
+
for (const a of answers.byPath.values()) source = overlay(source, a.path, a.text);
|
|
98
|
+
return source;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Fetch `chant/lifecycle` before a ledger write when it fast-forwards, so a question another clone asked is found. Best effort. */
|
|
102
|
+
export async function refreshLedger(kindFile: string): Promise<void> {
|
|
103
|
+
await fetchLifecycleStatus({ cwd: dirname(kindFile) }).catch(() => undefined);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Write the record read as `path` to the ledger, expecting the blob it had
|
|
108
|
+
* when it was read (`prior`, null for a new record), then push the branch.
|
|
109
|
+
* A changed blob throws `RefCASConflictError` from `../lifecycle/git`. The
|
|
110
|
+
* push is best effort: a question that did not reach the remote is still
|
|
111
|
+
* open here. Returns where the record went.
|
|
112
|
+
*/
|
|
113
|
+
export async function writeLedgerAnswer(answers: LedgerAnswers, path: string, text: string, message: string, prior: string | null): Promise<{ ledger: string; pushed: boolean }> {
|
|
114
|
+
const name = basename(path);
|
|
115
|
+
await writeBlobToPath(answers.dir, name, text, message, { cwd: answers.cwd, expectPriorPathSha: prior });
|
|
116
|
+
const pushed = await pushLifecycle({ cwd: answers.cwd }).catch(() => false);
|
|
117
|
+
return { ledger: `${BRANCH}:${await ledgerDir(answers.dir, { cwd: answers.cwd })}/${name}`, pushed };
|
|
118
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box isolation (#2727): hosts in the declaration and a host, slot, ports,
|
|
3
|
+
* state and cookies in a member's box block, the values derived from a box's
|
|
4
|
+
* identity, `chant workspace check`'s WSP123 and WSP124, and the resolved
|
|
5
|
+
* values in `chant workspace status --json`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { execFileSync } from "node:child_process";
|
|
9
|
+
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
|
10
|
+
import { tmpdir } from "node:os";
|
|
11
|
+
import { dirname, join } from "node:path";
|
|
12
|
+
import Ajv2020 from "ajv/dist/2020";
|
|
13
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
14
|
+
import { boxCollisions, boxLiterals, resolveBoxes, stateRootLiteral, statePathLiteral } from "./box-isolation";
|
|
15
|
+
import { runDeclarationChecks, WORKSPACE_CHECKS } from "./checks";
|
|
16
|
+
import { DEFAULT_STATE_ROOT, parseDeclaration, WorkspaceReadError, type Declaration } from "./declaration";
|
|
17
|
+
import { formatStatus, workspaceStatus } from "./status";
|
|
18
|
+
import statusSchema from "./status.schema.json";
|
|
19
|
+
|
|
20
|
+
const FILE = "chant.workspace.json";
|
|
21
|
+
|
|
22
|
+
const scratch: string[] = [];
|
|
23
|
+
afterAll(() => {
|
|
24
|
+
for (const d of scratch) rmSync(d, { recursive: true, force: true });
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const local = { name: "local", ports: { from: 7100, to: 7999, perBox: 20 } };
|
|
28
|
+
|
|
29
|
+
/** A box member, with the ports, hud state and cookies chaff's spec/box.ts and kernel/door.mjs hard-code today. */
|
|
30
|
+
const chaffBox = (name: string, slot: number, extra: Record<string, unknown> = {}) => ({
|
|
31
|
+
name,
|
|
32
|
+
dir: `boxes/${name}`,
|
|
33
|
+
kind: "other",
|
|
34
|
+
because: "a planted box",
|
|
35
|
+
box: {
|
|
36
|
+
host: "local",
|
|
37
|
+
slot,
|
|
38
|
+
ports: { app: 0, door: 1, proxy: 2, inject: 3, ticker: 4, behold: 5, surface: 6, historian: 7 },
|
|
39
|
+
state: { HUD_IDENTITY_PATH: "hud/identity.json", HUD_DB_PATH: "hud/events.db", CHAFF_HUD_IDENTITY: "hud/identity.json" },
|
|
40
|
+
cookies: ["hud_session", "chaff_door"],
|
|
41
|
+
...extra,
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
type BoxMember = ReturnType<typeof chaffBox>;
|
|
45
|
+
|
|
46
|
+
const declaration = (members: BoxMember[], hosts: unknown[] = [local]) => ({ name: "lobby", schema: 1, members, hosts });
|
|
47
|
+
|
|
48
|
+
/** A committed git repository holding the declaration, and a file in each member's directory. */
|
|
49
|
+
function repo(value: ReturnType<typeof declaration>): string {
|
|
50
|
+
const root = realpathSync(mkdtempSync(join(tmpdir(), "chant-box-isolation-")));
|
|
51
|
+
scratch.push(root);
|
|
52
|
+
const files: Record<string, string> = { [FILE]: JSON.stringify(value, null, 2) };
|
|
53
|
+
for (const m of value.members) files[`${m.dir}/README.md`] = `# ${m.name}\n`;
|
|
54
|
+
for (const [path, text] of Object.entries(files)) {
|
|
55
|
+
mkdirSync(dirname(join(root, path)), { recursive: true });
|
|
56
|
+
writeFileSync(join(root, path), text);
|
|
57
|
+
}
|
|
58
|
+
const git = (args: string[]) =>
|
|
59
|
+
execFileSync("git", ["-c", "user.name=t", "-c", "user.email=t@example.com", "-c", "commit.gpgsign=false", ...args], { cwd: root, stdio: "ignore" });
|
|
60
|
+
git(["init", "-q", "-b", "main"]);
|
|
61
|
+
git(["add", "-A"]);
|
|
62
|
+
git(["commit", "-q", "-m", "init"]);
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The box findings, less WSP009, which every member of kind other gets. */
|
|
67
|
+
async function findings(root: string) {
|
|
68
|
+
const report = await runDeclarationChecks(root);
|
|
69
|
+
return { ...report, diagnostics: report.diagnostics.filter((d) => d.ruleId !== "WSP009") };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parse(value: unknown, reader = "0.91.0"): Declaration {
|
|
73
|
+
return parseDeclaration(JSON.stringify(value, null, 2), FILE, reader);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function refusal(value: unknown): WorkspaceReadError {
|
|
77
|
+
try {
|
|
78
|
+
parse(value);
|
|
79
|
+
} catch (err) {
|
|
80
|
+
if (err instanceof WorkspaceReadError) return err;
|
|
81
|
+
throw err;
|
|
82
|
+
}
|
|
83
|
+
throw new Error("expected the declaration to be refused");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
describe("hosts, and a box's isolation in its block (#2727)", () => {
|
|
87
|
+
test("are read, with the host's state root null when it is the default and no isolation without a host", () => {
|
|
88
|
+
const d = parse({ ...declaration([chaffBox("fern", 0)]), members: [chaffBox("fern", 0), { name: "plain", dir: "plain", kind: "other", because: "x", box: { capabilities: [] } }] });
|
|
89
|
+
expect(d.hosts).toEqual([{ name: "local", ports: { from: 7100, to: 7999, perBox: 20 }, stateRoot: null, pointer: "/hosts/0" }]);
|
|
90
|
+
expect(d.members[0].box?.isolation).toMatchObject({ host: "local", slot: 0, cookies: ["hud_session", "chaff_door"] });
|
|
91
|
+
expect(d.members[1].box?.isolation).toBeNull();
|
|
92
|
+
expect(parse({ name: "plain", schema: 1, members: [] }).hosts).toEqual([]);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("a block with only a host, or only isolation, still reads: capabilities are optional", () => {
|
|
96
|
+
const d = parse(declaration([{ ...chaffBox("fern", 0), box: { host: "local", slot: 0 } } as BoxMember]));
|
|
97
|
+
expect(d.members[0].box).toMatchObject({ capabilities: [], isolation: { host: "local", slot: 0, ports: {}, state: {}, cookies: [] } });
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("the rules the schema can't say are read errors, each at its place", () => {
|
|
101
|
+
const cases: [unknown, string, RegExp][] = [
|
|
102
|
+
[declaration([chaffBox("fern", 0)], [local, local]), "name", /host name "local" is already used/],
|
|
103
|
+
[declaration([chaffBox("fern", 0, { host: "cloud" })]), "host", /names the host "cloud", which hosts does not declare; declared hosts: local/],
|
|
104
|
+
[declaration([chaffBox("fern", 45)]), "slot", /slot 45 on host local, which needs ports 8000 to 8019, past the end of the host's range \(7999\); the range holds 45 slots/],
|
|
105
|
+
[declaration([chaffBox("fern", 0, { ports: { app: 20 } })]), "app", /gives port app offset 20, and host local gives each box 20 ports \(offsets 0 to 19\)/],
|
|
106
|
+
[declaration([], [{ name: "local", ports: { from: 8000, to: 7000, perBox: 1 } }]), "ports", /starts at 8000, after its end 7000/],
|
|
107
|
+
];
|
|
108
|
+
for (const [value, key, message] of cases) {
|
|
109
|
+
const err = refusal(value);
|
|
110
|
+
expect(err.code).toBe("declaration-invalid");
|
|
111
|
+
expect(err.message).toMatch(message);
|
|
112
|
+
expect(JSON.stringify(value, null, 2).split("\n")[err.location!.line - 1]).toContain(`"${key}"`);
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test("the schema refuses an unknown field, a bad cookie name, a host without a slot and ports without a host", () => {
|
|
117
|
+
for (const bad of [
|
|
118
|
+
declaration([chaffBox("fern", 0, { stateDir: "/tmp/x" })]),
|
|
119
|
+
declaration([chaffBox("fern", 0, { cookies: ["hud session"] })]),
|
|
120
|
+
declaration([{ ...chaffBox("fern", 0), box: { host: "local" } } as BoxMember]),
|
|
121
|
+
declaration([{ ...chaffBox("fern", 0), box: { ports: { app: 0 } } } as BoxMember]),
|
|
122
|
+
declaration([], [{ name: "local", ports: { from: 0, to: 10, perBox: 1 } }]),
|
|
123
|
+
]) {
|
|
124
|
+
expect(refusal(bad).code).toBe("declaration-invalid");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("the values derived from a box's identity", () => {
|
|
130
|
+
test("ports from the slot's block, state under <stateRoot>/<name>, cookies suffixed with the name", () => {
|
|
131
|
+
const [fern, moss] = resolveBoxes(parse(declaration([chaffBox("fern", 0), chaffBox("moss", 1)]))).map((b) => b.isolation);
|
|
132
|
+
expect(fern).toEqual({
|
|
133
|
+
host: "local",
|
|
134
|
+
slot: 0,
|
|
135
|
+
portRange: { from: 7100, to: 7119 },
|
|
136
|
+
ports: { app: 7100, door: 7101, proxy: 7102, inject: 7103, ticker: 7104, behold: 7105, surface: 7106, historian: 7107 },
|
|
137
|
+
stateDir: `${DEFAULT_STATE_ROOT}/fern`,
|
|
138
|
+
state: {
|
|
139
|
+
HUD_IDENTITY_PATH: "${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json",
|
|
140
|
+
HUD_DB_PATH: "${XDG_STATE_HOME}/chant/boxes/fern/hud/events.db",
|
|
141
|
+
CHAFF_HUD_IDENTITY: "${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json",
|
|
142
|
+
},
|
|
143
|
+
cookies: { hud_session: "hud_session_fern", chaff_door: "chaff_door_fern" },
|
|
144
|
+
});
|
|
145
|
+
expect(moss.portRange).toEqual({ from: 7120, to: 7139 });
|
|
146
|
+
expect(moss.ports.door).toBe(7121);
|
|
147
|
+
expect(moss.state.HUD_IDENTITY_PATH).toBe("${XDG_STATE_HOME}/chant/boxes/moss/hud/identity.json");
|
|
148
|
+
expect(moss.cookies.hud_session).toBe("hud_session_moss");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("are the same for the same identity, whatever other boxes are declared or in what order", () => {
|
|
152
|
+
const alone = resolveBoxes(parse(declaration([chaffBox("moss", 3)])))[0].isolation;
|
|
153
|
+
const among = resolveBoxes(parse(declaration([chaffBox("zinnia", 7), chaffBox("moss", 3), chaffBox("fern", 0)])))[1].isolation;
|
|
154
|
+
expect(among).toEqual(alone);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("a host's own state root is used, trailing slash or not", () => {
|
|
158
|
+
const [b] = resolveBoxes(parse(declaration([chaffBox("fern", 0)], [{ ...local, stateRoot: "${BOX_STATE}/" }])));
|
|
159
|
+
expect(b.isolation.stateDir).toBe("${BOX_STATE}/fern");
|
|
160
|
+
expect(b.isolation.state.HUD_DB_PATH).toBe("${BOX_STATE}/fern/hud/events.db");
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
describe("literal machine paths", () => {
|
|
165
|
+
test("a state root starts with an environment reference other than $HOME", () => {
|
|
166
|
+
expect(stateRootLiteral("${XDG_STATE_HOME}/chant/boxes")).toBeUndefined();
|
|
167
|
+
expect(stateRootLiteral("$XDG_STATE_HOME")).toBeUndefined();
|
|
168
|
+
expect(stateRootLiteral("${HOME}/alecraso/hud-live")).toMatch(/\$HOME/);
|
|
169
|
+
expect(stateRootLiteral("$HOME/.local/state")).toMatch(/\$HOME/);
|
|
170
|
+
expect(stateRootLiteral("/Users/alex/state")).toMatch(/environment reference/);
|
|
171
|
+
expect(stateRootLiteral("~/state")).toMatch(/environment reference/);
|
|
172
|
+
expect(stateRootLiteral("${XDG_STATE_HOME}/../x")).toMatch(/\.\./);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("a state entry is relative to the box's state directory and stays in it", () => {
|
|
176
|
+
expect(statePathLiteral("hud/identity.json")).toBeUndefined();
|
|
177
|
+
expect(statePathLiteral("/Users/alex/.local/state/hud/identity.json")).toMatch(/absolute/);
|
|
178
|
+
expect(statePathLiteral("${HOME}/.local/state/hud/box-chaff/identity.json")).toMatch(/environment reference/);
|
|
179
|
+
expect(statePathLiteral("~/hud")).toMatch(/home/);
|
|
180
|
+
expect(statePathLiteral("../moss/hud/identity.json")).toMatch(/\.\./);
|
|
181
|
+
expect(statePathLiteral("C:\\state")).toMatch(/Windows/);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("chant workspace check: WSP123 and WSP124", () => {
|
|
186
|
+
test("are fixed errors in the catalog, with the names the issue gives them", () => {
|
|
187
|
+
const byId = Object.fromEntries(WORKSPACE_CHECKS.map((c) => [c.id, c]));
|
|
188
|
+
expect([byId.WSP123.name, byId.WSP123.severity, byId.WSP123.configurable]).toEqual(["box-isolation-collision", "error", false]);
|
|
189
|
+
expect([byId.WSP124.name, byId.WSP124.severity, byId.WSP124.configurable]).toEqual(["box-isolation-literal", "error", false]);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("two boxes with distinct names and slots pass, and status --json shows their distinct values", async () => {
|
|
193
|
+
const root = repo(declaration([chaffBox("fern", 0), chaffBox("moss", 1)]));
|
|
194
|
+
const report = await findings(root);
|
|
195
|
+
expect(report.diagnostics).toEqual([]);
|
|
196
|
+
expect(report.ok).toBe(true);
|
|
197
|
+
|
|
198
|
+
const doc = await workspaceStatus({ cwd: root, env: "local" });
|
|
199
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
200
|
+
const validate = new Ajv2020({ strict: true, allErrors: true }).compile(statusSchema);
|
|
201
|
+
expect(validate(doc), JSON.stringify(validate.errors)).toBe(true);
|
|
202
|
+
const [fern, moss] = doc.members.map((m) => m.box!.isolation!);
|
|
203
|
+
for (const what of ["ports", "state", "cookies"] as const) {
|
|
204
|
+
const a = new Set<unknown>(Object.values(fern[what]));
|
|
205
|
+
expect(Object.values(moss[what]).some((v) => a.has(v)), what).toBe(false);
|
|
206
|
+
}
|
|
207
|
+
expect(doc.members[0].box).toMatchObject({ capabilities: [], isolation: { ports: { door: 7101 } } });
|
|
208
|
+
expect(formatStatus(doc)).toContain("fern local 0 7100-7119 ${XDG_STATE_HOME}/chant/boxes/fern");
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("two boxes on one host with the same port range and state root collide", async () => {
|
|
212
|
+
const report = await findings(repo(declaration([chaffBox("fern", 2), chaffBox("moss", 2)])));
|
|
213
|
+
expect(report.ok).toBe(false);
|
|
214
|
+
const found = report.diagnostics.filter((d) => d.ruleId === "WSP123");
|
|
215
|
+
// One finding per shared port; state and cookies stay apart, since the names differ.
|
|
216
|
+
expect(found).toHaveLength(8);
|
|
217
|
+
expect(found[0]).toMatchObject({ severity: "error", entity: "moss", code: "box-isolation-collision" });
|
|
218
|
+
expect(found[0].message).toBe(
|
|
219
|
+
"box-isolation-collision: fern.app and moss.app on host local resolve to the same port 7140; give each box on a host its own slot",
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test("the same slot on two hosts is no collision: hosts share nothing", () => {
|
|
224
|
+
const hosts = [local, { ...local, name: "tailnet" }];
|
|
225
|
+
expect(boxCollisions(parse(declaration([chaffBox("fern", 0), chaffBox("moss", 0, { host: "tailnet" })], hosts)))).toEqual([]);
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("two ports in one box at one offset collide; two state entries naming one file do not", () => {
|
|
229
|
+
const d = parse(declaration([chaffBox("fern", 0, { ports: { app: 0, door: 0 } })]));
|
|
230
|
+
expect(boxCollisions(d).map((c) => [c.what, c.value, c.holders.map((h) => `${h.box}.${h.name}`)])).toEqual([["port", "7100", ["fern.app", "fern.door"]]]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("a state entry spelled into another box's directory collides with it, and is a literal", () => {
|
|
234
|
+
const d = parse(declaration([chaffBox("fern", 0), chaffBox("moss", 1, { state: { HUD_IDENTITY_PATH: "./../fern/hud/identity.json" } })]));
|
|
235
|
+
expect(boxLiterals(d).map((l) => l.pointer)).toEqual(["/members/1/box/state/HUD_IDENTITY_PATH"]);
|
|
236
|
+
const state = boxCollisions(d).filter((c) => c.what === "state");
|
|
237
|
+
expect(state.map((c) => [c.value, c.holders.map((h) => `${h.box}.${h.name}`)])).toEqual([
|
|
238
|
+
["${XDG_STATE_HOME}/chant/boxes/fern/hud/identity.json", ["fern.HUD_IDENTITY_PATH", "moss.HUD_IDENTITY_PATH"]],
|
|
239
|
+
]);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("a hard-coded machine path in a host or a box is WSP124", async () => {
|
|
243
|
+
const hosts = [{ ...local, stateRoot: "${HOME}/alecraso/hud-live" }];
|
|
244
|
+
const report = await findings(repo(declaration([chaffBox("chaff", 0, { state: { HUD_IDENTITY_PATH: "${HOME}/.local/state/hud/box-chaff/identity.json" } })], hosts)));
|
|
245
|
+
expect(report.diagnostics.map((d) => [d.ruleId, d.entity ?? null, d.code])).toEqual([
|
|
246
|
+
["WSP124", "chaff", "box-isolation-literal"],
|
|
247
|
+
["WSP124", null, "box-isolation-literal"],
|
|
248
|
+
]);
|
|
249
|
+
expect(report.diagnostics[1].message).toMatch(/^box-isolation-literal: host local's stateRoot "\$\{HOME\}\/alecraso\/hud-live" starts at \$HOME/);
|
|
250
|
+
expect(report.diagnostics[0].message).toMatch(
|
|
251
|
+
/^box-isolation-literal: member chaff's box gives state HUD_IDENTITY_PATH the path "\$\{HOME\}\/.local\/state\/hud\/box-chaff\/identity.json", which starts with an environment reference/,
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("a fixed box check can't be turned off", async () => {
|
|
256
|
+
const root = repo({ ...declaration([chaffBox("fern", 0), chaffBox("moss", 0)]), checks: { WSP123: "off" } } as ReturnType<typeof declaration>);
|
|
257
|
+
const ids = (await findings(root)).diagnostics.map((d) => d.ruleId);
|
|
258
|
+
expect(ids).toContain("WSP011");
|
|
259
|
+
expect(ids).toContain("WSP123");
|
|
260
|
+
});
|
|
261
|
+
});
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Box isolation (#2727): the ports, state paths and cookie names each box
|
|
3
|
+
* uses, derived from its identity and its host, never chosen by a runtime.
|
|
4
|
+
*
|
|
5
|
+
* A box is a member with a `box` block (#2726), and its name is the
|
|
6
|
+
* member's. A box's identity is its host, its name and its slot, from the
|
|
7
|
+
* block's `host` and `slot`. Everything else
|
|
8
|
+
* follows from those and the host's declaration, and nothing else goes in:
|
|
9
|
+
*
|
|
10
|
+
* - port `p` is `host.ports.from + slot * host.ports.perBox + offset(p)`;
|
|
11
|
+
* - the state directory is `<host.stateRoot>/<box name>`, and state entry
|
|
12
|
+
* `k` is that directory joined with the entry's relative path;
|
|
13
|
+
* - cookie `c` is `<c>_<box name>`.
|
|
14
|
+
*
|
|
15
|
+
* So the same declaration gives the same values on every machine and every
|
|
16
|
+
* run, and adding or removing a box changes no other box's values. The state
|
|
17
|
+
* paths keep their environment reference (`${XDG_STATE_HOME}` by default):
|
|
18
|
+
* the runtime that sets them expands it on its machine, and chant never
|
|
19
|
+
* reads the machine.
|
|
20
|
+
*
|
|
21
|
+
* Distinct names on one host give distinct state paths and cookie names by
|
|
22
|
+
* construction (a member name has no `_`, so `<c>_<box>` splits one way).
|
|
23
|
+
* Ports are the only value two boxes can share: two boxes on one host with
|
|
24
|
+
* the same slot, or two ports in one box at the same offset. `chant
|
|
25
|
+
* workspace check` compares every value anyway (WSP123), so an entry whose
|
|
26
|
+
* path escapes its directory is caught too.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { DEFAULT_STATE_ROOT, type BoxIsolationDeclaration, type Declaration, type Host, type Member } from "./declaration";
|
|
30
|
+
import { pointerToken } from "./jsonc";
|
|
31
|
+
|
|
32
|
+
/** A box's isolation, resolved: what `chant workspace status --json` prints in the member's box block. */
|
|
33
|
+
export interface ResolvedIsolation {
|
|
34
|
+
host: string;
|
|
35
|
+
slot: number;
|
|
36
|
+
/** The box's block of its host's range, inclusive. */
|
|
37
|
+
portRange: { from: number; to: number };
|
|
38
|
+
/** Each declared port's number, in file order. */
|
|
39
|
+
ports: Record<string, number>;
|
|
40
|
+
/** `<stateRoot>/<box name>`, with the environment reference unexpanded. */
|
|
41
|
+
stateDir: string;
|
|
42
|
+
/** Each declared state entry's path, under `stateDir`. */
|
|
43
|
+
state: Record<string, string>;
|
|
44
|
+
/** Each declared cookie's name for this box. */
|
|
45
|
+
cookies: Record<string, string>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The host's state root as written, or the default. */
|
|
49
|
+
export function stateRootOf(host: Host): string {
|
|
50
|
+
return host.stateRoot ?? DEFAULT_STATE_ROOT;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const trimSlash = (s: string) => s.replace(/\/+$/, "");
|
|
54
|
+
|
|
55
|
+
/** Resolve the isolation of the box named `name` on its host. */
|
|
56
|
+
export function resolveIsolation(name: string, box: BoxIsolationDeclaration, host: Host): ResolvedIsolation {
|
|
57
|
+
const first = host.ports.from + box.slot * host.ports.perBox;
|
|
58
|
+
const stateDir = `${trimSlash(stateRootOf(host))}/${name}`;
|
|
59
|
+
return {
|
|
60
|
+
host: box.host,
|
|
61
|
+
slot: box.slot,
|
|
62
|
+
portRange: { from: first, to: first + host.ports.perBox - 1 },
|
|
63
|
+
ports: Object.fromEntries(Object.entries(box.ports).map(([name, offset]) => [name, first + offset])),
|
|
64
|
+
stateDir,
|
|
65
|
+
state: Object.fromEntries(Object.entries(box.state).map(([name, path]) => [name, `${stateDir}/${path.replace(/^\.\/+/, "")}`])),
|
|
66
|
+
cookies: Object.fromEntries(box.cookies.map((c) => [c, `${c}_${name}`])),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A member whose box block declares its isolation, with it resolved. */
|
|
71
|
+
export interface IsolatedBox {
|
|
72
|
+
member: Member;
|
|
73
|
+
isolation: ResolvedIsolation;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Every box that declares its isolation, resolved, in member order. The declaration read already checked each box's host exists. */
|
|
77
|
+
export function resolveBoxes(declaration: Declaration): IsolatedBox[] {
|
|
78
|
+
const hosts = new Map(declaration.hosts.map((h) => [h.name, h]));
|
|
79
|
+
const out: IsolatedBox[] = [];
|
|
80
|
+
for (const m of declaration.members) {
|
|
81
|
+
const iso = m.box?.isolation;
|
|
82
|
+
if (iso) out.push({ member: m, isolation: resolveIsolation(m.name, iso, hosts.get(iso.host)!) });
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Literal machine paths (WSP124) ───────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const ENV_REF = /^\$(\{[A-Za-z_][A-Za-z0-9_]*\}|[A-Za-z_][A-Za-z0-9_]*)/;
|
|
90
|
+
const HOME_REF = /^\$(\{HOME\}|HOME)(?![A-Za-z0-9_])/;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Why a host's `stateRoot` is a literal machine path, or undefined when it is
|
|
94
|
+
* not. A state root starts with an environment reference the runtime expands,
|
|
95
|
+
* and that reference is not `$HOME`: a path under a home directory names one
|
|
96
|
+
* person's machine layout (`${HOME}/<user>/...`), where `${XDG_STATE_HOME}`
|
|
97
|
+
* names the place the machine keeps state.
|
|
98
|
+
*/
|
|
99
|
+
export function stateRootLiteral(root: string): string | undefined {
|
|
100
|
+
if (HOME_REF.test(root)) return "starts at $HOME, which names one person's machine layout; use ${XDG_STATE_HOME} or another variable the runtime sets";
|
|
101
|
+
if (!ENV_REF.test(root)) return "does not start with an environment reference such as ${XDG_STATE_HOME}, so it names one machine's layout";
|
|
102
|
+
if (root.split("/").includes("..")) return "has a .. segment";
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Why a state entry's path is not one relative to the box's state directory,
|
|
108
|
+
* or undefined when it is: absolute, home-relative, starting with an
|
|
109
|
+
* environment reference, a Windows drive path, or with a `..` segment.
|
|
110
|
+
*/
|
|
111
|
+
export function statePathLiteral(path: string): string | undefined {
|
|
112
|
+
if (path.startsWith("/")) return "is absolute";
|
|
113
|
+
if (path.startsWith("~")) return "starts at a home directory";
|
|
114
|
+
if (path.startsWith("$")) return "starts with an environment reference";
|
|
115
|
+
if (/^[A-Za-z]:[\\/]/.test(path) || path.includes("\\")) return "is a Windows path";
|
|
116
|
+
if (path.split("/").includes("..")) return "has a .. segment, which leaves the box's state directory";
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface BoxLiteral {
|
|
121
|
+
/** The box, or null for a host's state root. */
|
|
122
|
+
box: string | null;
|
|
123
|
+
message: string;
|
|
124
|
+
pointer: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every literal machine path in the hosts and boxes. */
|
|
128
|
+
export function boxLiterals(declaration: Declaration): BoxLiteral[] {
|
|
129
|
+
const out: BoxLiteral[] = [];
|
|
130
|
+
for (const h of declaration.hosts) {
|
|
131
|
+
if (h.stateRoot === null) continue;
|
|
132
|
+
const why = stateRootLiteral(h.stateRoot);
|
|
133
|
+
if (why) out.push({ box: null, message: `host ${h.name}'s stateRoot ${JSON.stringify(h.stateRoot)} ${why}`, pointer: `${h.pointer}/stateRoot` });
|
|
134
|
+
}
|
|
135
|
+
for (const m of declaration.members) {
|
|
136
|
+
for (const [name, path] of Object.entries(m.box?.isolation?.state ?? {})) {
|
|
137
|
+
const why = statePathLiteral(path);
|
|
138
|
+
if (why) {
|
|
139
|
+
out.push({
|
|
140
|
+
box: m.name,
|
|
141
|
+
message: `member ${m.name}'s box gives state ${name} the path ${JSON.stringify(path)}, which ${why}; state paths are relative to the box's state directory, which chant derives from the host's stateRoot and the member's name`,
|
|
142
|
+
pointer: `${m.box!.pointer}/state/${pointerToken(name)}`,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Collisions (WSP123) ──────────────────────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
export interface BoxCollision {
|
|
153
|
+
host: string;
|
|
154
|
+
what: "port" | "state" | "cookie";
|
|
155
|
+
/** The shared value. */
|
|
156
|
+
value: string;
|
|
157
|
+
/** Each holder as `<box>.<name>`, in declaration order. */
|
|
158
|
+
holders: { box: string; name: string; pointer: string }[];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Collapse `.`, `..` and repeated slashes, so two spellings of one path compare equal. */
|
|
162
|
+
function normalizePath(path: string): string {
|
|
163
|
+
const out: string[] = [];
|
|
164
|
+
for (const seg of path.split("/")) {
|
|
165
|
+
if (seg === "" || seg === ".") continue;
|
|
166
|
+
if (seg === ".." && out.length > 0 && out[out.length - 1] !== "..") out.pop();
|
|
167
|
+
else out.push(seg);
|
|
168
|
+
}
|
|
169
|
+
return (path.startsWith("/") ? "/" : "") + out.join("/");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every value two holders on one host resolve to. Ports collide within a box
|
|
174
|
+
* too, since two listeners can't share a port; state entries in one box may
|
|
175
|
+
* name one file on purpose (a runtime and the door both reading hud's
|
|
176
|
+
* identity), and cookies are unique in a box by the schema.
|
|
177
|
+
*/
|
|
178
|
+
export function boxCollisions(declaration: Declaration): BoxCollision[] {
|
|
179
|
+
const resolved = resolveBoxes(declaration);
|
|
180
|
+
const seen = new Map<string, BoxCollision>();
|
|
181
|
+
const add = (host: string, what: BoxCollision["what"], value: string, box: string, name: string, pointer: string) => {
|
|
182
|
+
const key = `${host}\u0000${what}\u0000${value}`;
|
|
183
|
+
let c = seen.get(key);
|
|
184
|
+
if (!c) {
|
|
185
|
+
c = { host, what, value, holders: [] };
|
|
186
|
+
seen.set(key, c);
|
|
187
|
+
}
|
|
188
|
+
c.holders.push({ box, name, pointer });
|
|
189
|
+
};
|
|
190
|
+
for (const { member, isolation: r } of resolved) {
|
|
191
|
+
const at = member.box!.pointer;
|
|
192
|
+
const box = member.name;
|
|
193
|
+
for (const [name, port] of Object.entries(r.ports)) add(r.host, "port", String(port), box, name, `${at}/ports/${pointerToken(name)}`);
|
|
194
|
+
const files = new Set<string>();
|
|
195
|
+
for (const [name, path] of Object.entries(r.state)) {
|
|
196
|
+
const file = normalizePath(path);
|
|
197
|
+
if (files.has(file)) continue;
|
|
198
|
+
files.add(file);
|
|
199
|
+
add(r.host, "state", file, box, name, `${at}/state/${pointerToken(name)}`);
|
|
200
|
+
}
|
|
201
|
+
Object.entries(r.cookies).forEach(([name, cookie], i) => add(r.host, "cookie", cookie, box, name, `${at}/cookies/${i}`));
|
|
202
|
+
}
|
|
203
|
+
// A state file is counted once per box above, so two holders are two boxes.
|
|
204
|
+
return [...seen.values()].filter((c) => c.holders.length > 1);
|
|
205
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `chant workspace check --changes <base>..<head> [--work <id>] [--severity
|
|
3
|
+
* off|warn|fail] [--kind <kind file>...] [--json]` (#2773): the forward
|
|
4
|
+
* coverage check (`changes.ts`), printed as JSON with `--json` or as one line
|
|
5
|
+
* per changed path, then the findings.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { resolve } from "node:path";
|
|
9
|
+
import { formatError } from "../cli/format";
|
|
10
|
+
import type { CommandContext } from "../cli/registry";
|
|
11
|
+
import { CHANGE_SEVERITIES, checkChanges, type ChangesDocument } from "./changes";
|
|
12
|
+
import type { ChangeSeverity } from "./declaration";
|
|
13
|
+
|
|
14
|
+
const USAGE = "chant workspace check --changes <base>..<head> [--work <id>] [--severity off|warn|fail] [--kind <kind file>...] [--json]";
|
|
15
|
+
|
|
16
|
+
type Result = Exclude<ChangesDocument, { error: unknown }>;
|
|
17
|
+
|
|
18
|
+
/** One line per changed path, then the findings and a summary. */
|
|
19
|
+
export function formatChanges(doc: Result): string {
|
|
20
|
+
const out: string[] = [];
|
|
21
|
+
const work = doc.work ? `, work item ${doc.work.record} in hand` : "";
|
|
22
|
+
out.push(`changes ${doc.range.base.slice(0, 8)}..${doc.range.head.slice(0, 8)} (${doc.range.spec}), severity ${doc.severity}${work}`);
|
|
23
|
+
for (const p of doc.paths) {
|
|
24
|
+
const why =
|
|
25
|
+
p.status === "covered"
|
|
26
|
+
? `by ${p.coveredBy.map((c) => `${c.record} (${c.entry})`).join(", ")}`
|
|
27
|
+
: p.status === "out-of-scope"
|
|
28
|
+
? `by ${p.outOfScopeBy.map((c) => `${c.record} (${c.entry})`).join(", ")}`
|
|
29
|
+
: p.status === "ignored"
|
|
30
|
+
? `by ${p.ignoredBy}`
|
|
31
|
+
: "";
|
|
32
|
+
out.push(`${p.status.padEnd(12)} ${p.change.padEnd(8)} ${p.path}${p.from ? ` (from ${p.from})` : ""}${why ? `; ${why}` : ""}`);
|
|
33
|
+
}
|
|
34
|
+
for (const f of doc.findings) out.push(`${f.severity === "fail" ? "error " : "warning"} ${f.code}: ${f.message}`);
|
|
35
|
+
const s = doc.summary;
|
|
36
|
+
out.push(`${s.paths} changed paths: ${s.covered} covered, ${s.uncovered} uncovered, ${s.outOfScope} out of scope, ${s.ignored} ignored, ${s.records} records; ${doc.findings.length} findings`);
|
|
37
|
+
return out.join("\n");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function runWorkspaceChanges(ctx: CommandContext, cwd: string): Promise<number> {
|
|
41
|
+
const { args } = ctx;
|
|
42
|
+
const severity = args.severity as ChangeSeverity | undefined;
|
|
43
|
+
if (severity !== undefined && !(CHANGE_SEVERITIES as readonly string[]).includes(severity)) {
|
|
44
|
+
console.error(formatError({ message: `--severity ${severity} is not off, warn or fail`, hint: USAGE }));
|
|
45
|
+
return 1;
|
|
46
|
+
}
|
|
47
|
+
const kinds = args.kinds ?? (args.kind !== undefined ? [args.kind] : undefined);
|
|
48
|
+
const { doc, failed } = await checkChanges({ cwd, range: args.changes!, kinds: kinds?.map((k) => resolve(k)), work: args.work, severity });
|
|
49
|
+
if (args.json || args.format === "json") console.log(JSON.stringify(doc, null, 2));
|
|
50
|
+
if ("error" in doc) {
|
|
51
|
+
console.error(formatError({ message: `${doc.error.code}: ${doc.error.message}`, hint: USAGE }));
|
|
52
|
+
return 1;
|
|
53
|
+
}
|
|
54
|
+
if (!args.json && args.format !== "json") console.log(formatChanges(doc));
|
|
55
|
+
return failed ? 1 : 0;
|
|
56
|
+
}
|