@intentius/chant 0.91.0 → 0.94.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/graph.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +6 -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-plugins.d.ts +6 -6
- package/dist/cli/mcp/workspace-tools.d.ts +2 -1
- 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 +12 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/pinned-upgrade.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/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/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 +201 -0
- 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/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 +11 -4
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +29 -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 +2 -0
- 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 +97 -1
- package/dist/op/operator.d.ts.map +1 -1
- package/dist/op/runtime.d.ts +37 -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/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 +22 -1
- package/dist/op/steward.d.ts.map +1 -1
- package/dist/op/types.d.ts +7 -0
- package/dist/op/types.d.ts.map +1 -1
- package/dist/op/work-lease-run.d.ts +1 -1
- package/dist/op/work-lease-run.d.ts.map +1 -1
- 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-intent.d.ts +85 -0
- package/dist/workspace/box-intent.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 +104 -0
- package/dist/workspace/chant-migrations.d.ts.map +1 -0
- package/dist/workspace/checks/boxes.d.ts +47 -1
- package/dist/workspace/checks/boxes.d.ts.map +1 -1
- 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 +7 -0
- package/dist/workspace/checks/records.d.ts.map +1 -1
- package/dist/workspace/checks.d.ts +7 -0
- 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 +49 -4
- package/dist/workspace/decide.d.ts.map +1 -1
- package/dist/workspace/decision-points.schema.json +3 -3
- package/dist/workspace/declaration.d.ts +58 -0
- package/dist/workspace/declaration.d.ts.map +1 -1
- package/dist/workspace/declaration.schema.json +119 -1
- package/dist/workspace/declared-kinds.d.ts +20 -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-joins.d.ts +5 -5
- 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 +2 -0
- package/dist/workspace/points-cli.d.ts.map +1 -1
- package/dist/workspace/points.d.ts +4 -6
- package/dist/workspace/points.d.ts.map +1 -1
- package/dist/workspace/reason-codes.d.ts +16 -2
- package/dist/workspace/reason-codes.d.ts.map +1 -1
- package/dist/workspace/records-cli.d.ts +11 -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 +34 -4
- 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 +65 -1
- package/dist/workspace/status-stewards.d.ts.map +1 -1
- package/dist/workspace/status.d.ts +24 -0
- package/dist/workspace/status.d.ts.map +1 -1
- package/dist/workspace/work-cli.d.ts +113 -2
- package/dist/workspace/work-cli.d.ts.map +1 -1
- 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/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +16 -2
- package/src/cli/handlers/operator.ts +34 -2
- package/src/cli/handlers/run.test.ts +154 -2
- package/src/cli/handlers/run.ts +121 -2
- package/src/cli/main.test.ts +10 -0
- package/src/cli/main.ts +23 -3
- package/src/cli/mcp/workspace-plugins.ts +6 -6
- package/src/cli/mcp/workspace-tools.test.ts +35 -19
- package/src/cli/mcp/workspace-tools.ts +62 -4
- package/src/cli/plugins.ts +11 -3
- package/src/cli/registry.ts +12 -2
- package/src/cli/serve-mcp-workspace.test.ts +6 -6
- 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/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/run-ledger.ts +6 -0
- package/src/lifecycle/symptoms.ts +95 -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 +308 -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 +119 -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/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 +18 -3
- package/src/op/local-executor.ts +151 -15
- package/src/op/local-output.test.ts +33 -0
- package/src/op/local-output.ts +20 -2
- package/src/op/op-ir.test.ts +10 -0
- package/src/op/op-ir.ts +4 -0
- package/src/op/op-verb-class.ts +1 -0
- package/src/op/operator.ts +290 -11
- package/src/op/runtime.ts +31 -8
- package/src/op/runtimes/local.ts +3 -1
- package/src/op/source-archive.ts +161 -0
- package/src/op/steward-points.test.ts +364 -0
- package/src/op/steward-points.ts +194 -0
- package/src/op/steward-turn.ts +79 -0
- package/src/op/steward.test.ts +87 -3
- package/src/op/steward.ts +25 -1
- package/src/op/types.ts +7 -0
- package/src/op/work-lease-run.test.ts +20 -1
- package/src/op/work-lease-run.ts +2 -2
- 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-intent.test.ts +205 -0
- package/src/workspace/box-intent.ts +159 -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 +169 -0
- package/src/workspace/check-contract.test.ts +4 -2
- package/src/workspace/check.schema.json +19 -5
- package/src/workspace/checks/boxes.test.ts +82 -2
- package/src/workspace/checks/boxes.ts +202 -1
- package/src/workspace/checks/diagrams.test.ts +134 -0
- package/src/workspace/checks/diagrams.ts +108 -0
- package/src/workspace/checks/records.ts +47 -0
- package/src/workspace/checks.ts +20 -2
- 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 +1 -1
- package/src/workspace/decide.ts +156 -23
- package/src/workspace/decision-points.schema.json +3 -3
- package/src/workspace/declaration.schema.json +119 -1
- package/src/workspace/declaration.test.ts +58 -0
- package/src/workspace/declaration.ts +112 -0
- package/src/workspace/declared-kinds.test.ts +31 -5
- package/src/workspace/declared-kinds.ts +46 -1
- 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-joins.test.ts +5 -5
- package/src/workspace/intent-joins.ts +5 -5
- package/src/workspace/intent.schema.json +8 -2
- 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/points-cli.ts +17 -3
- package/src/workspace/points-write.schema.json +11 -1
- package/src/workspace/points.schema.json +51 -0
- package/src/workspace/points.test.ts +16 -15
- package/src/workspace/points.ts +5 -17
- package/src/workspace/read-contract.test.ts +9 -1
- package/src/workspace/reason-codes.test.ts +18 -8
- package/src/workspace/reason-codes.ts +21 -2
- package/src/workspace/records-amend.schema.json +2 -0
- package/src/workspace/records-cli.ts +27 -4
- package/src/workspace/records-close.schema.json +2 -0
- package/src/workspace/records-formats.test.ts +15 -15
- 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-since.test.ts +8 -7
- 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 +36 -2
- package/src/workspace/records.test.ts +49 -4
- package/src/workspace/records.ts +109 -3
- package/src/workspace/status-contract.test.ts +14 -8
- package/src/workspace/status-gates.ts +9 -4
- package/src/workspace/status-stewards.ts +155 -5
- package/src/workspace/status.schema.json +129 -8
- package/src/workspace/status.ts +46 -1
- package/src/workspace/work-acceptance.test.ts +274 -0
- package/src/workspace/{work-cli.test.ts → work-cli.e2e.test.ts} +1 -1
- package/src/workspace/work-cli.ts +193 -10
- 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-readiness-chud.test.ts → work-readiness.test.ts} +30 -32
- package/src/workspace/work.test.ts +20 -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,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box's intent (#2850): the decision record the box block names, as
|
|
3
|
+
* `status --json` reports it, as `check` finds it (WSP126, WSP127) and as
|
|
4
|
+
* `graph --intent` shows it for the box's files.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
import { afterAll, describe, expect, test } from "vitest";
|
|
10
|
+
import { cleanScratch, commitAll, contract, REPO, repo } from "./__fixtures__/contract-repo";
|
|
11
|
+
import { constrainsBox, constrainsWorkspace } from "./box-intent";
|
|
12
|
+
import { runDeclarationChecks } from "./checks";
|
|
13
|
+
import { parseDeclaration } from "./declaration";
|
|
14
|
+
import { intentGraph } from "./intent";
|
|
15
|
+
import { parseFrontMatter } from "./records";
|
|
16
|
+
import { workspaceStatus, type StatusDocument } from "./status";
|
|
17
|
+
import statusSchema from "./status.schema.json";
|
|
18
|
+
|
|
19
|
+
afterAll(cleanScratch);
|
|
20
|
+
|
|
21
|
+
const REF = join(REPO, "reference-workspace", "decisions");
|
|
22
|
+
const BASE = (() => {
|
|
23
|
+
const fm = parseFrontMatter(readFileSync(join(REF, "ref-001-how-the-app-is-deployed.md"), "utf-8"));
|
|
24
|
+
if (!fm.ok) throw new Error(fm.message);
|
|
25
|
+
return fm.value as Record<string, unknown>;
|
|
26
|
+
})();
|
|
27
|
+
|
|
28
|
+
const QUESTION = "What is this box for?";
|
|
29
|
+
const CHOICE = { option: "a", reason: "The person who planted the box answered it." };
|
|
30
|
+
const ANSWER = "a chant project with the docker lexicon";
|
|
31
|
+
|
|
32
|
+
/** A decision record from ref-001: proposed with no choice, or decided by alex. JSON is YAML, so the front matter is JSON. */
|
|
33
|
+
function decision(id: string, state: "proposed" | "decided", constrains: string[]): string {
|
|
34
|
+
const data = {
|
|
35
|
+
...BASE,
|
|
36
|
+
id,
|
|
37
|
+
title: `Intent ${id}`,
|
|
38
|
+
state,
|
|
39
|
+
question: QUESTION,
|
|
40
|
+
choice: state === "decided" ? CHOICE : null,
|
|
41
|
+
rejected: [],
|
|
42
|
+
evidence: [],
|
|
43
|
+
decided_by: state === "decided" ? "alex" : null,
|
|
44
|
+
decided_on: state === "decided" ? "2026-09-26" : null,
|
|
45
|
+
constrains,
|
|
46
|
+
};
|
|
47
|
+
return `---\n${JSON.stringify(data, null, 2)}\n---\n\n# ${id}\n`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function workspace(box: Record<string, unknown>, records: Record<string, string>, declareKind = true): string {
|
|
51
|
+
return repo({
|
|
52
|
+
"chant.workspace.json": JSON.stringify(
|
|
53
|
+
{
|
|
54
|
+
name: "acme",
|
|
55
|
+
schema: 1,
|
|
56
|
+
members: [{ name: "app", dir: "app", kind: "other", because: "the box", box }],
|
|
57
|
+
...(declareKind ? { records: [{ kind: "decisions/decision.kind.mjs" }] } : {}),
|
|
58
|
+
},
|
|
59
|
+
null,
|
|
60
|
+
2,
|
|
61
|
+
),
|
|
62
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
63
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
|
|
64
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
|
|
65
|
+
...records,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* A workspace shaped like the studio template (studio#112, #2857): the box
|
|
71
|
+
* block sits on member `box`, the steward and its Ops, and a separate
|
|
72
|
+
* member `app` is the app the box runs. `records` overlays the decision
|
|
73
|
+
* files, keyed the same way `repo()` takes every other file.
|
|
74
|
+
*/
|
|
75
|
+
function studioShapedWorkspace(records: Record<string, string>): string {
|
|
76
|
+
return repo({
|
|
77
|
+
"chant.workspace.json": JSON.stringify(
|
|
78
|
+
{
|
|
79
|
+
name: "acme",
|
|
80
|
+
schema: 1,
|
|
81
|
+
members: [
|
|
82
|
+
{ name: "box", dir: "box", kind: "other", because: "the box's steward and its Ops", box: { intent: "box-001" } },
|
|
83
|
+
{ name: "app", dir: "app", kind: "other", because: "the app the box runs" },
|
|
84
|
+
],
|
|
85
|
+
records: [{ kind: "decisions/decision.kind.mjs" }],
|
|
86
|
+
},
|
|
87
|
+
null,
|
|
88
|
+
2,
|
|
89
|
+
),
|
|
90
|
+
"box/ops.mjs": "export const ops = true;\n",
|
|
91
|
+
"app/server.mjs": "export const port = 8080;\n",
|
|
92
|
+
"decisions/decision.kind.mjs": readFileSync(join(REF, "decision.kind.mjs"), "utf-8"),
|
|
93
|
+
"decisions/decision.schema.json": readFileSync(join(REF, "decision.schema.json"), "utf-8"),
|
|
94
|
+
...records,
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const intentFindings = async (root: string) =>
|
|
99
|
+
(await runDeclarationChecks(root)).diagnostics.filter((d) => d.ruleId === "WSP126" || d.ruleId === "WSP127").map((d) => [d.ruleId, d.severity, d.code, d.entity]);
|
|
100
|
+
|
|
101
|
+
const status = contract(statusSchema);
|
|
102
|
+
async function boxOf(root: string) {
|
|
103
|
+
const doc: StatusDocument = await workspaceStatus({ cwd: root, env: "prod" });
|
|
104
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
105
|
+
status.expectValid(doc);
|
|
106
|
+
return doc.members[0].box;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("the box block's intent", () => {
|
|
110
|
+
test("is parsed into the box declaration, and is null when the block names none", () => {
|
|
111
|
+
const parse = (box: unknown) =>
|
|
112
|
+
parseDeclaration(JSON.stringify({ name: "acme", schema: 1, members: [{ name: "app", dir: "app", kind: "other", because: "x", box }] }), "chant.workspace.json").members[0].box;
|
|
113
|
+
expect(parse({ intent: "box-001" })?.intent).toBe("box-001");
|
|
114
|
+
expect(parse({})?.intent).toBeNull();
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("covers the box through member:, or a path: at, above or inside its directory", () => {
|
|
118
|
+
const m = { name: "app", dir: "apps/app" };
|
|
119
|
+
expect(["member:app", "path:apps/app", "path:apps", "path:apps/app/server.mjs"].map((c) => constrainsBox(c, m))).toEqual([true, true, true, true]);
|
|
120
|
+
expect(["member:web", "path:apps/application", "path:web", "issue:1"].map((c) => constrainsBox(c, m))).toEqual([false, false, false, false]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("constrainsWorkspace: an entry names a member or path of the workspace whether or not it's the box's own (#2857)", () => {
|
|
124
|
+
const members = [
|
|
125
|
+
{ name: "box", dir: "box" },
|
|
126
|
+
{ name: "app", dir: "app" },
|
|
127
|
+
];
|
|
128
|
+
expect(["member:box", "member:app", "path:app", "path:box/ops.mjs"].map((c) => constrainsWorkspace(c, members))).toEqual([true, true, true, true]);
|
|
129
|
+
expect(["member:web", "path:docs", "issue:1"].map((c) => constrainsWorkspace(c, members))).toEqual([false, false, false]);
|
|
130
|
+
expect(constrainsWorkspace("member:app", [])).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("a box planted as a question", () => {
|
|
135
|
+
test("a proposed intent reports its question and no answer, and passes the checks", async () => {
|
|
136
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
137
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "proposed", question: QUESTION, choice: null, answer: null, decided_by: null, decided_on: null });
|
|
138
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("a decided intent reports the answer and who gave it, and graph --intent shows it for the box's files", async () => {
|
|
142
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "decided", ["member:app"]) });
|
|
143
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-001", state: "decided", question: QUESTION, choice: CHOICE, answer: ANSWER, decided_by: "alex", decided_on: "2026-09-26" });
|
|
144
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
145
|
+
commitAll(root, "plant the box");
|
|
146
|
+
const { doc } = await intentGraph({ cwd: root, region: "app" });
|
|
147
|
+
if ("error" in doc) throw new Error(doc.error.message);
|
|
148
|
+
expect(doc.nodes.filter((n) => n.kind === "decision").map((n) => (n.kind === "decision" ? [n.record, n.state, n.decided_by] : []))).toEqual([["box-001", "decided", "alex"]]);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a decided intent whose choice names an option not in options[] reports a null answer", async () => {
|
|
152
|
+
const data = { ...BASE, id: "box-001", title: "Intent box-001", state: "decided", question: QUESTION, choice: { option: "z", reason: "no such option" }, rejected: [], evidence: [], decided_by: "alex", decided_on: "2026-09-26", constrains: ["member:app"] };
|
|
153
|
+
const record = `---\n${JSON.stringify(data, null, 2)}\n---\n\n# box-001\n`;
|
|
154
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": record });
|
|
155
|
+
expect((await boxOf(root))?.intent?.answer).toBeNull();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("an intent no decision record has fails WSP126, and status reports only its id", async () => {
|
|
159
|
+
const root = workspace({ intent: "box-009" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
160
|
+
expect((await boxOf(root))?.intent).toEqual({ id: "box-009", state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null });
|
|
161
|
+
expect(await intentFindings(root)).toEqual([["WSP126", "error", "box-intent-unknown", "app"]]);
|
|
162
|
+
const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
|
|
163
|
+
expect(d.message).toContain("no record of decisions/decision.kind.mjs has that id");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("an intent in a workspace that declares no decision kind fails WSP126", async () => {
|
|
167
|
+
const root = workspace({ intent: "box-001" }, {}, false);
|
|
168
|
+
const [d] = (await runDeclarationChecks(root)).diagnostics.filter((x) => x.ruleId === "WSP126");
|
|
169
|
+
expect(d.message).toContain("the declaration names no record kind called decision");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("an intent whose constrains names no member or path of the workspace warns WSP127", async () => {
|
|
173
|
+
const root = workspace({ intent: "box-001" }, { "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:docs"]) });
|
|
174
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "app"]]);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("an intent with empty constrains warns WSP127", async () => {
|
|
178
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", []) });
|
|
179
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
|
|
180
|
+
const d = (await runDeclarationChecks(root)).diagnostics.find((x) => x.ruleId === "WSP127")!;
|
|
181
|
+
expect(d.message).toContain("whose constrains is empty");
|
|
182
|
+
expect(d.message).toContain("add member:<name> for the member the intent is about");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("an intent naming an unknown member warns WSP127", async () => {
|
|
186
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:web"]) });
|
|
187
|
+
expect(await intentFindings(root)).toEqual([["WSP127", "warning", "box-intent-unconstrained", "box"]]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("an intent that constrains the app a box runs passes WSP127, on a workspace shaped like the studio template (#2857)", async () => {
|
|
191
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["member:app"]) });
|
|
192
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("an intent with a path: entry inside another member's directory passes WSP127 (#2857)", async () => {
|
|
196
|
+
const root = studioShapedWorkspace({ "decisions/box-001-what-app-is-for.md": decision("box-001", "proposed", ["path:app/server.mjs"]) });
|
|
197
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test("a box with no intent reports null", async () => {
|
|
201
|
+
const root = workspace({ capabilities: [] }, {});
|
|
202
|
+
expect((await boxOf(root))?.intent).toBeNull();
|
|
203
|
+
expect(await intentFindings(root)).toEqual([]);
|
|
204
|
+
});
|
|
205
|
+
});
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A box's intent (#2850): the decision record its box block names.
|
|
3
|
+
*
|
|
4
|
+
* A new box starts as a question, and the first answer to it is what the box
|
|
5
|
+
* is for. That answer is a decision record, so the intent graph, hud and a
|
|
6
|
+
* box's runtime all read the same thing. The box block names the record's
|
|
7
|
+
* id in `intent`; the record is looked for among the records of every
|
|
8
|
+
* declared kind named `decision` (the entry's `name`, or the kind file's
|
|
9
|
+
* `recordKind.name`), read from the working tree through the same reader as
|
|
10
|
+
* `chant workspace records`.
|
|
11
|
+
*
|
|
12
|
+
* `chant workspace status --json` reports the record's state and answer
|
|
13
|
+
* under the member's `box.intent`, and `chant workspace check` fails when no
|
|
14
|
+
* decision record has the id (WSP126) and warns when the record constrains
|
|
15
|
+
* no member or path of this workspace at all (WSP127, #2857): the member it
|
|
16
|
+
* names need not be the box's own, since a decision naming another member
|
|
17
|
+
* can be what the box is for, such as the app it runs.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { loadDeclaredKinds, type DeclaredKind } from "./declared-kinds";
|
|
21
|
+
import type { Declaration, Member } from "./declaration";
|
|
22
|
+
import { constraintCovers, isWorkspacePath } from "./record-assets";
|
|
23
|
+
import { workingTree } from "./tree";
|
|
24
|
+
|
|
25
|
+
/** The declared kind name a box's intent is looked for in. */
|
|
26
|
+
export const INTENT_KIND_NAME = "decision";
|
|
27
|
+
|
|
28
|
+
/** A box's intent as `status --json` reports it: the record's id, state and answer. */
|
|
29
|
+
export interface BoxIntent {
|
|
30
|
+
id: string;
|
|
31
|
+
/** The record's state, such as proposed or decided, or null when no decision record has the id. */
|
|
32
|
+
state: string | null;
|
|
33
|
+
question: string | null;
|
|
34
|
+
/** The record's choice as written, such as `{ option, reason }`, or null while it is proposed. */
|
|
35
|
+
choice: unknown;
|
|
36
|
+
/** The chosen option's label, from `options[]`, or null while no option is chosen. */
|
|
37
|
+
answer: string | null;
|
|
38
|
+
decided_by: string | null;
|
|
39
|
+
decided_on: string | null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The decision record a box's intent names, as the checks read it. */
|
|
43
|
+
export interface ResolvedBoxIntent {
|
|
44
|
+
member: string;
|
|
45
|
+
id: string;
|
|
46
|
+
/** The `intent` field's JSON Pointer in the declaration. */
|
|
47
|
+
pointer: string;
|
|
48
|
+
/** The record, or null when no decision record has the id. */
|
|
49
|
+
record: {
|
|
50
|
+
/** The kind file that holds it, from the workspace root. */
|
|
51
|
+
kind: string;
|
|
52
|
+
/** The record file, from the repository root. */
|
|
53
|
+
path: string;
|
|
54
|
+
/** The record's constrains entries, as written. */
|
|
55
|
+
constrains: string[];
|
|
56
|
+
intent: BoxIntent;
|
|
57
|
+
} | null;
|
|
58
|
+
/** Why no record was found when none was, such as no declared decision kind. Empty when one was found. */
|
|
59
|
+
why: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
|
|
63
|
+
|
|
64
|
+
/** The label of `options[]`'s entry whose id is `choice.option`, or null when no option is chosen. */
|
|
65
|
+
function answerOf(choice: unknown, options: unknown): string | null {
|
|
66
|
+
const chosen = choice && typeof choice === "object" ? (choice as { option?: unknown }).option : null;
|
|
67
|
+
if (typeof chosen !== "string" || !Array.isArray(options)) return null;
|
|
68
|
+
const found = options.find((o): o is { id: unknown; label: unknown } => !!o && typeof o === "object" && (o as { id?: unknown }).id === chosen);
|
|
69
|
+
return found && typeof found.label === "string" ? found.label : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The intent of `id` with no record: every field but the id null. */
|
|
73
|
+
export function unresolvedIntent(id: string): BoxIntent {
|
|
74
|
+
return { id, state: null, question: null, choice: null, answer: null, decided_by: null, decided_on: null };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Resolve the intent of every box that names one, reading the records of each
|
|
79
|
+
* declared decision kind once. `kinds` are the declared kinds already loaded,
|
|
80
|
+
* when the caller has them. Only the working tree is read.
|
|
81
|
+
*/
|
|
82
|
+
export async function resolveBoxIntents(declaration: Declaration, root: string, kinds?: readonly DeclaredKind[]): Promise<ResolvedBoxIntent[]> {
|
|
83
|
+
const boxes = declaration.members.filter((m): m is Member & { box: NonNullable<Member["box"]> & { intent: string } } => m.box?.intent != null);
|
|
84
|
+
if (boxes.length === 0) return [];
|
|
85
|
+
const loaded = kinds ?? (await loadDeclaredKinds(declaration, workingTree(root), root));
|
|
86
|
+
const decisionKinds = loaded.filter((k) => (k.declared.name ?? k.kind) === INTENT_KIND_NAME);
|
|
87
|
+
const found = new Map<string, NonNullable<ResolvedBoxIntent["record"]>>();
|
|
88
|
+
const problems: string[] = [];
|
|
89
|
+
const { readRecordsFor } = await import("./records-cli");
|
|
90
|
+
const { RecordReadError } = await import("./records");
|
|
91
|
+
for (const k of decisionKinds) {
|
|
92
|
+
if (k.reason) {
|
|
93
|
+
problems.push(`${k.declared.path} can't be loaded: ${k.reason.message}`);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
const read = await readRecordsFor({ kind: k.file, cwd: root, workGaps: false });
|
|
98
|
+
const field = read.loaded.kind.constrains?.field ?? "constrains";
|
|
99
|
+
for (const r of read.result.records) {
|
|
100
|
+
if (r.id === null || found.has(r.id)) continue;
|
|
101
|
+
const data = r.data ?? {};
|
|
102
|
+
const constrains = Array.isArray(data[field]) ? (data[field] as unknown[]).filter((c): c is string => typeof c === "string") : [];
|
|
103
|
+
found.set(r.id, {
|
|
104
|
+
kind: k.declared.path,
|
|
105
|
+
path: r.path,
|
|
106
|
+
constrains,
|
|
107
|
+
intent: {
|
|
108
|
+
id: r.id,
|
|
109
|
+
state: r.state,
|
|
110
|
+
question: str(data.question),
|
|
111
|
+
choice: data.choice ?? null,
|
|
112
|
+
answer: answerOf(data.choice, data.options),
|
|
113
|
+
decided_by: str(data.decided_by),
|
|
114
|
+
decided_on: str(data.decided_on),
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
if (!(err instanceof RecordReadError)) throw err;
|
|
120
|
+
problems.push(`${k.declared.path} can't be read: ${err.message}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const why =
|
|
124
|
+
decisionKinds.length === 0
|
|
125
|
+
? `the declaration names no record kind called ${INTENT_KIND_NAME}`
|
|
126
|
+
: `no record of ${decisionKinds.map((k) => k.declared.path).join(" or ")} has that id` + (problems.length > 0 ? ` (${problems.join("; ")})` : "");
|
|
127
|
+
return boxes.map((m) => {
|
|
128
|
+
const record = found.get(m.box.intent) ?? null;
|
|
129
|
+
return { member: m.name, id: m.box.intent, pointer: `${m.box.pointer}/intent`, record, why: record ? "" : why };
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether a constrains entry covers something of the box: `member:<name>`, or
|
|
135
|
+
* a `path:` entry that is the member's directory, a directory above it or a
|
|
136
|
+
* path inside it.
|
|
137
|
+
*/
|
|
138
|
+
export function constrainsBox(entry: string, member: { name: string; dir: string }): boolean {
|
|
139
|
+
if (entry === `member:${member.name}`) return true;
|
|
140
|
+
if (!entry.startsWith("path:")) return false;
|
|
141
|
+
const path = entry.slice("path:".length);
|
|
142
|
+
if (!isWorkspacePath(path)) return false;
|
|
143
|
+
// The root member holds every path.
|
|
144
|
+
if (member.dir === ".") return true;
|
|
145
|
+
return constraintCovers(path, member.dir) || constraintCovers(member.dir, path);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Whether a constrains entry names a member or path of this workspace at
|
|
150
|
+
* all: `member:<name>` for any declared member, or a `path:` entry that is
|
|
151
|
+
* some member's directory, a directory above it or a path inside it. An
|
|
152
|
+
* intent's constrains can govern a member other than the one whose box
|
|
153
|
+
* names it (a box run by one member can be what a decision about another
|
|
154
|
+
* member's app is for), so this asks only whether the entry is real, not
|
|
155
|
+
* whether it reaches a particular member.
|
|
156
|
+
*/
|
|
157
|
+
export function constrainsWorkspace(entry: string, members: readonly { name: string; dir: string }[]): boolean {
|
|
158
|
+
return members.some((m) => constrainsBox(entry, m));
|
|
159
|
+
}
|
|
@@ -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
|
+
}
|