@intentius/chant 0.46.0 → 0.50.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +30 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +9 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +12 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/tools/explain.d.ts +6 -0
- package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +1 -1
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/cli/reporters/stylish.d.ts +15 -1
- package/dist/cli/reporters/stylish.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/starter-plugin.d.ts +2 -0
- package/dist/components/starter-plugin.d.ts.map +1 -1
- package/dist/components/verbs/ensure-secret.d.ts +50 -0
- package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
- package/dist/components/verbs/index.d.ts +13 -0
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/r2-sync.d.ts +76 -0
- package/dist/components/verbs/r2-sync.d.ts.map +1 -0
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/components/verbs/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/config.d.ts +26 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +14 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +20 -0
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +137 -3
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +33 -5
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +6 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/observation-baseline.d.ts +21 -3
- package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
- package/dist/lifecycle/receipt-plan.d.ts +62 -0
- package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
- package/dist/lifecycle/release-ledger.d.ts +20 -0
- package/dist/lifecycle/release-ledger.d.ts.map +1 -1
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lifecycle/teardown.d.ts +6 -4
- package/dist/lifecycle/teardown.d.ts.map +1 -1
- package/dist/lifecycle/unobserved-gate.d.ts +67 -0
- package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
- package/dist/lint/knowledge-checks.d.ts +48 -0
- package/dist/lint/knowledge-checks.d.ts.map +1 -0
- package/dist/lint/output-checks.d.ts +5 -0
- package/dist/lint/output-checks.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/pipeline-change-gate.d.ts +101 -0
- package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +41 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/receipt-checks.d.ts +9 -0
- package/dist/lint/receipt-checks.d.ts.map +1 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
- package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
- package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
- package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
- package/dist/lint/rules/index.d.ts +3 -1
- package/dist/lint/rules/index.d.ts.map +1 -1
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +140 -3
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +18 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts +2 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +49 -2
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/secret-materialization.d.ts +138 -0
- package/dist/secret-materialization.d.ts.map +1 -0
- package/dist/secret-provenance.d.ts +218 -0
- package/dist/secret-provenance.d.ts.map +1 -0
- package/dist/serializer.d.ts +29 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.test.ts +57 -0
- package/src/audit/core.ts +0 -0
- package/src/audit/detect-bundle.test.ts +1 -1
- package/src/audit/discover.test.ts +24 -0
- package/src/audit/discover.ts +40 -4
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +13 -1
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.test.ts +41 -0
- package/src/build.ts +39 -6
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
- package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
- package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +268 -1
- package/src/cli/commands/audit.ts +87 -18
- package/src/cli/commands/build.test.ts +245 -0
- package/src/cli/commands/build.ts +210 -21
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +12 -3
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +96 -15
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +323 -3
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/plugins.ts +4 -2
- package/src/cli/registry.ts +14 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/auto-release.ts +6 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/registry.test.ts +7 -2
- package/src/components/starter-plugin.ts +17 -0
- package/src/components/verbs/ensure-secret.test.ts +130 -0
- package/src/components/verbs/ensure-secret.ts +79 -0
- package/src/components/verbs/index.ts +13 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- package/src/composite.ts +31 -2
- package/src/config.test.ts +15 -0
- package/src/config.ts +30 -0
- package/src/deep-observation.test.ts +19 -0
- package/src/deep-observation.ts +17 -0
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/effect-receipt-exclusion.test.ts +190 -0
- package/src/effect-receipt.test.ts +419 -0
- package/src/effect-receipt.ts +412 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +95 -6
- package/src/fold/subset.ts +66 -2
- package/src/index.ts +4 -0
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +151 -5
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +6 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/observation-baseline.test.ts +46 -0
- package/src/lifecycle/observation-baseline.ts +33 -1
- package/src/lifecycle/receipt-plan.test.ts +250 -0
- package/src/lifecycle/receipt-plan.ts +249 -0
- package/src/lifecycle/release-ledger.ts +20 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lifecycle/teardown.test.ts +31 -0
- package/src/lifecycle/teardown.ts +6 -4
- package/src/lifecycle/unobserved-gate.test.ts +109 -0
- package/src/lifecycle/unobserved-gate.ts +102 -0
- package/src/lint/knowledge-checks.test.ts +80 -0
- package/src/lint/knowledge-checks.ts +74 -0
- package/src/lint/output-checks.test.ts +85 -0
- package/src/lint/output-checks.ts +99 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +60 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
- package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
- package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
- package/src/lint/rules/evl001-non-literal-expression.test.ts +35 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +7 -0
- package/src/lint/rules/index.ts +7 -1
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +198 -6
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +40 -3
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +212 -29
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/op.test.ts +25 -2
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/receipt-store.ts +211 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +51 -2
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/secret-materialization.test.ts +199 -0
- package/src/secret-materialization.ts +235 -0
- package/src/secret-provenance.test.ts +388 -0
- package/src/secret-provenance.ts +475 -0
- package/src/serializer.ts +30 -0
- package/src/toml.test.ts +157 -384
- package/src/toml.ts +371 -5
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plan scenarios (chant #1292): a declared expectation about what a change
|
|
3
|
+
* SHOULD do, checkable offline against a fixture snapshot.
|
|
4
|
+
*
|
|
5
|
+
* Everything else chant validates is about shape — is this declaration
|
|
6
|
+
* well-formed, does this reference resolve. Nothing answers "what will this
|
|
7
|
+
* change do" as something source can assert about itself, until now. A
|
|
8
|
+
* `Scenario` pairs a `given` (a stand-in for live observation) with an
|
|
9
|
+
* `expect` (a claim about the resulting change set) — `chant scenario check`
|
|
10
|
+
* (../cli/handlers/scenario.ts) builds the project, replays `given` in place
|
|
11
|
+
* of a live read, and evaluates `expect` against the resulting `ChangeSet`
|
|
12
|
+
* (./change-set.ts) via `evaluateScenario` (./scenario-eval.ts).
|
|
13
|
+
*
|
|
14
|
+
* Modeled precisely on the other non-resource Declarables (../effect-receipt.ts,
|
|
15
|
+
* ../secret-provenance.ts): discovery collects a `Scenario` like any entity
|
|
16
|
+
* (it is a Declarable), but it carries no resource payload and no serializer
|
|
17
|
+
* ever emits it — `partitionByLexicon` (../build.ts) excludes it from every
|
|
18
|
+
* lexicon's partition, the same way it excludes a secret declaration.
|
|
19
|
+
*
|
|
20
|
+
* This module is pure and does no I/O: `snapshot()` records where the fixture
|
|
21
|
+
* comes from without reading it, and the factory validates and freezes. The
|
|
22
|
+
* CLI handler is where the fixture is actually read, replayed, and compared —
|
|
23
|
+
* the same synthesis/plan split every other lifecycle primitive here follows.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { DECLARABLE_MARKER, type Declarable } from "../declarable";
|
|
27
|
+
import type { Ownership } from "./change-set";
|
|
28
|
+
|
|
29
|
+
/** Marker symbol identifying a scenario declaration. `Symbol.for` so it
|
|
30
|
+
* survives the entity-wire codec, the same reason ../effect-receipt.ts and
|
|
31
|
+
* ../secret-provenance.ts use `Symbol.for` for their markers. */
|
|
32
|
+
export const SCENARIO_MARKER = Symbol.for("chant.scenario");
|
|
33
|
+
|
|
34
|
+
/** The `entityType` every scenario declaration carries. */
|
|
35
|
+
export const SCENARIO_ENTITY_TYPE = "Chant::Scenario";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* `given: snapshot("fixtures/prod-baseline.json")` — a checked-in fixture, in
|
|
39
|
+
* the same {@link LifecycleSnapshot} JSON shape `chant lifecycle snapshot`
|
|
40
|
+
* writes (a single lexicon's recorded resources). Path-relative to the
|
|
41
|
+
* project root, read at check time — never here.
|
|
42
|
+
*/
|
|
43
|
+
export interface ScenarioGivenFile {
|
|
44
|
+
readonly kind: "file";
|
|
45
|
+
/** Repo-relative path to the fixture JSON file. */
|
|
46
|
+
readonly path: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* `given: snapshot("prod")` — the last snapshot recorded for that environment
|
|
51
|
+
* on the `chant/lifecycle` orphan branch (one file per lexicon, read via
|
|
52
|
+
* `readSnapshot`/`readEnvironmentSnapshots`, ../lifecycle/git.ts), replayed
|
|
53
|
+
* exactly as `chant lifecycle plan` reads its own prior snapshot.
|
|
54
|
+
*/
|
|
55
|
+
export interface ScenarioGivenEnv {
|
|
56
|
+
readonly kind: "env";
|
|
57
|
+
/** Environment name, matching a `chant.config.ts` `environments` entry. */
|
|
58
|
+
readonly env: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a scenario's `given` stands in for — a fixture file or a recorded environment. */
|
|
62
|
+
export type ScenarioGiven = ScenarioGivenFile | ScenarioGivenEnv;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Declare `given` from a single string. Pure and offline: no filesystem
|
|
66
|
+
* access, no git — the factory only classifies the string's SHAPE, never
|
|
67
|
+
* checks whether it exists. That is what keeps `Scenario` foldable under
|
|
68
|
+
* `--sandbox`, the same discipline `declareSecret`'s committed-encrypted path
|
|
69
|
+
* validation follows (../secret-provenance.ts).
|
|
70
|
+
*
|
|
71
|
+
* The classification is structural: a string containing a path separator or
|
|
72
|
+
* ending in `.json` is a fixture file; anything else (a bare token like
|
|
73
|
+
* `"prod"`) is an environment name. A `chant.config.ts` environment name that
|
|
74
|
+
* happens to contain a slash or end in `.json` is not supported by this
|
|
75
|
+
* heuristic — pass `{ kind: "env", env }` directly in that unlikely case.
|
|
76
|
+
*/
|
|
77
|
+
export function snapshot(source: string): ScenarioGiven {
|
|
78
|
+
if (typeof source !== "string" || source.length === 0) {
|
|
79
|
+
throw new Error("snapshot: `source` must be a non-empty string");
|
|
80
|
+
}
|
|
81
|
+
const looksLikeFile = source.includes("/") || source.includes("\\") || source.toLowerCase().endsWith(".json");
|
|
82
|
+
return looksLikeFile ? { kind: "file", path: source } : { kind: "env", env: source };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** One named delete an `expect.deletes` clause requires — the resource must
|
|
86
|
+
* be proposed for deletion, and with exactly this ownership verdict. */
|
|
87
|
+
export interface ScenarioDeleteExpectation {
|
|
88
|
+
/** The change-set entry name (ChangeSetEntry.name) expected to delete. */
|
|
89
|
+
readonly name: string;
|
|
90
|
+
/** The ownership verdict (./change-set.ts's `Ownership`) the delete must carry. */
|
|
91
|
+
readonly ownership: Ownership;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* How a scenario treats the change set's `unobserved` rows (#1089):
|
|
96
|
+
*
|
|
97
|
+
* - `"refuse"` — any unobserved entry fails the scenario. The plan admitting
|
|
98
|
+
* a hole is never a pass, the same fail-closed instinct
|
|
99
|
+
* `evaluateUnobservedGate`'s default takes (./unobserved-gate.ts).
|
|
100
|
+
* - `{ allow: [names] }` — the named entities may be unobserved without
|
|
101
|
+
* failing the scenario; any unobserved entity NOT in the list still fails
|
|
102
|
+
* it. Unlike `evaluateUnobservedGate`'s policy (which allows by *reason*),
|
|
103
|
+
* this allows by entity *name* — the natural unit for a scenario written
|
|
104
|
+
* against one fixture, where "this specific resource has no binding in the
|
|
105
|
+
* fixture" is a known, accepted gap rather than a class of reason to trust
|
|
106
|
+
* everywhere.
|
|
107
|
+
*/
|
|
108
|
+
export type ScenarioUnobservedPolicy = "refuse" | { readonly allow: readonly string[] };
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The assertion vocabulary (#1292 research, settled shape). Every clause is
|
|
112
|
+
* independently optional and clauses compose within one `expect` object — a
|
|
113
|
+
* scenario can assert `noop`, exact counts, specific named deletes, and an
|
|
114
|
+
* unobserved policy together. At least one clause must be present; an empty
|
|
115
|
+
* `expect` asserts nothing and is refused as a likely mistake.
|
|
116
|
+
*/
|
|
117
|
+
export interface ScenarioExpect {
|
|
118
|
+
/** The plan proposes no create, update, or delete, and no declared effect
|
|
119
|
+
* receipt (#1832) fires — the majority-value case (refactors, upgrades,
|
|
120
|
+
* no-op deploys). Equivalent to `{ create: 0, update: 0, delete: 0 }` plus
|
|
121
|
+
* "no receipt classifies as `effect`", but reads as the specific claim it
|
|
122
|
+
* is: a pending effect fire is a real proposed action even though it never
|
|
123
|
+
* joins the create/update/delete triad. */
|
|
124
|
+
readonly noop?: true;
|
|
125
|
+
/** Exact count of `create` rows the plan must propose. Omitted = unconstrained. */
|
|
126
|
+
readonly create?: number;
|
|
127
|
+
/** Exact count of `update` rows the plan must propose. Omitted = unconstrained. */
|
|
128
|
+
readonly update?: number;
|
|
129
|
+
/** Exact count of `delete` rows the plan must propose. Omitted = unconstrained. */
|
|
130
|
+
readonly delete?: number;
|
|
131
|
+
/** Exactly these resources must delete, each with the stated ownership —
|
|
132
|
+
* and no OTHER delete may be proposed. The highest-value assertion in the
|
|
133
|
+
* vocabulary: "this plan deletes nothing foreign", made precise per-name. */
|
|
134
|
+
readonly deletes?: readonly ScenarioDeleteExpectation[];
|
|
135
|
+
/** How the scenario treats unobserved rows. Omitted = unconstrained (an
|
|
136
|
+
* unobserved entity neither passes nor fails the scenario on its own). */
|
|
137
|
+
readonly unobserved?: ScenarioUnobservedPolicy;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Every recognized `expect` clause key — for the excess-key check below and
|
|
141
|
+
* for exhaustiveness at call sites. */
|
|
142
|
+
const EXPECT_KEYS = ["noop", "create", "update", "delete", "deletes", "unobserved"] as const;
|
|
143
|
+
|
|
144
|
+
const OWNERSHIP_VALUES: readonly Ownership[] = ["owned", "foreign", "unknown"];
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* A scenario declaration — a Declarable, so discovery collects it and `chant
|
|
148
|
+
* list` shows it, but it carries no resource payload: `partitionByLexicon`
|
|
149
|
+
* (../build.ts) excludes it from every serializer partition, so no lexicon
|
|
150
|
+
* ever emits it and no apply-bound document ever contains one.
|
|
151
|
+
*/
|
|
152
|
+
export interface ScenarioDeclaration extends Declarable {
|
|
153
|
+
readonly [SCENARIO_MARKER]: true;
|
|
154
|
+
readonly lexicon: "chant";
|
|
155
|
+
readonly entityType: typeof SCENARIO_ENTITY_TYPE;
|
|
156
|
+
/** The scenario's own name — what `chant scenario check` reports pass/fail against. */
|
|
157
|
+
readonly name: string;
|
|
158
|
+
readonly given: ScenarioGiven;
|
|
159
|
+
readonly expect: ScenarioExpect;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Factory options for {@link Scenario}. */
|
|
163
|
+
export interface ScenarioOptions {
|
|
164
|
+
/** What stands in for live observation — a fixture file or a recorded environment. */
|
|
165
|
+
readonly given: ScenarioGiven;
|
|
166
|
+
/** The claim about the resulting change set. At least one clause required. */
|
|
167
|
+
readonly expect: ScenarioExpect;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Declare a plan scenario. The returned object is a locked Declarable:
|
|
172
|
+
* declared fields are immutable and nested structures are frozen, the same
|
|
173
|
+
* shape ../effect-receipt.ts and ../secret-provenance.ts lock down; the
|
|
174
|
+
* top-level object stays extensible so discovery can still stamp its own
|
|
175
|
+
* symbol-keyed metadata.
|
|
176
|
+
*/
|
|
177
|
+
export function Scenario(name: string, options: ScenarioOptions): ScenarioDeclaration {
|
|
178
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
179
|
+
throw new Error("Scenario: `name` must be a non-empty string");
|
|
180
|
+
}
|
|
181
|
+
const given = validateGiven(name, options?.given);
|
|
182
|
+
const expect = validateExpect(name, options?.expect);
|
|
183
|
+
|
|
184
|
+
const decl: ScenarioDeclaration = {
|
|
185
|
+
[DECLARABLE_MARKER]: true,
|
|
186
|
+
[SCENARIO_MARKER]: true,
|
|
187
|
+
lexicon: "chant",
|
|
188
|
+
entityType: SCENARIO_ENTITY_TYPE,
|
|
189
|
+
name,
|
|
190
|
+
given,
|
|
191
|
+
expect,
|
|
192
|
+
};
|
|
193
|
+
for (const key of Object.keys(decl)) {
|
|
194
|
+
Object.defineProperty(decl, key, { writable: false, configurable: false });
|
|
195
|
+
}
|
|
196
|
+
return decl;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function validateGiven(name: string, given: unknown): ScenarioGiven {
|
|
200
|
+
if (typeof given !== "object" || given === null) {
|
|
201
|
+
throw new Error(`Scenario("${name}"): \`given\` is required — use snapshot(path) or snapshot(env)`);
|
|
202
|
+
}
|
|
203
|
+
const g = given as Partial<ScenarioGiven>;
|
|
204
|
+
if (g.kind === "file") {
|
|
205
|
+
if (typeof g.path !== "string" || g.path.length === 0) {
|
|
206
|
+
throw new Error(`Scenario("${name}"): \`given\` of kind "file" requires a non-empty \`path\``);
|
|
207
|
+
}
|
|
208
|
+
return Object.freeze({ kind: "file", path: g.path });
|
|
209
|
+
}
|
|
210
|
+
if (g.kind === "env") {
|
|
211
|
+
if (typeof g.env !== "string" || g.env.length === 0) {
|
|
212
|
+
throw new Error(`Scenario("${name}"): \`given\` of kind "env" requires a non-empty \`env\``);
|
|
213
|
+
}
|
|
214
|
+
return Object.freeze({ kind: "env", env: g.env });
|
|
215
|
+
}
|
|
216
|
+
throw new Error(
|
|
217
|
+
`Scenario("${name}"): \`given\` must come from snapshot(path) or snapshot(env), got kind "${String((g as { kind?: unknown }).kind)}"`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function validateExpect(name: string, expect: unknown): ScenarioExpect {
|
|
222
|
+
if (typeof expect !== "object" || expect === null || Array.isArray(expect)) {
|
|
223
|
+
throw new Error(`Scenario("${name}"): \`expect\` must be a plain object`);
|
|
224
|
+
}
|
|
225
|
+
const e = expect as Record<string, unknown>;
|
|
226
|
+
for (const key of Object.keys(e)) {
|
|
227
|
+
if (!(EXPECT_KEYS as readonly string[]).includes(key)) {
|
|
228
|
+
throw new Error(
|
|
229
|
+
`Scenario("${name}"): unknown \`expect\` clause "${key}" — expected one of ${EXPECT_KEYS.join(", ")}`,
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
if (Object.keys(e).length === 0) {
|
|
234
|
+
throw new Error(
|
|
235
|
+
`Scenario("${name}"): \`expect\` must include at least one clause (${EXPECT_KEYS.join(", ")}) — an empty expect asserts nothing`,
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const out: Record<string, unknown> = {};
|
|
240
|
+
|
|
241
|
+
if ("noop" in e) {
|
|
242
|
+
if (e.noop !== true) {
|
|
243
|
+
throw new Error(`Scenario("${name}"): \`expect.noop\`, when present, must be exactly \`true\``);
|
|
244
|
+
}
|
|
245
|
+
out.noop = true;
|
|
246
|
+
}
|
|
247
|
+
for (const countKey of ["create", "update", "delete"] as const) {
|
|
248
|
+
if (countKey in e) {
|
|
249
|
+
const v = e[countKey];
|
|
250
|
+
if (typeof v !== "number" || !Number.isInteger(v) || v < 0) {
|
|
251
|
+
throw new Error(`Scenario("${name}"): \`expect.${countKey}\`, when present, must be a non-negative integer`);
|
|
252
|
+
}
|
|
253
|
+
out[countKey] = v;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
if ("deletes" in e) {
|
|
257
|
+
if (!Array.isArray(e.deletes)) {
|
|
258
|
+
throw new Error(`Scenario("${name}"): \`expect.deletes\` must be an array`);
|
|
259
|
+
}
|
|
260
|
+
out.deletes = Object.freeze(
|
|
261
|
+
e.deletes.map((d, i) => {
|
|
262
|
+
if (typeof d !== "object" || d === null) {
|
|
263
|
+
throw new Error(`Scenario("${name}"): \`expect.deletes[${i}]\` must be an object`);
|
|
264
|
+
}
|
|
265
|
+
const { name: deleteName, ownership } = d as Record<string, unknown>;
|
|
266
|
+
if (typeof deleteName !== "string" || deleteName.length === 0) {
|
|
267
|
+
throw new Error(`Scenario("${name}"): \`expect.deletes[${i}].name\` must be a non-empty string`);
|
|
268
|
+
}
|
|
269
|
+
if (!(OWNERSHIP_VALUES as readonly unknown[]).includes(ownership)) {
|
|
270
|
+
throw new Error(
|
|
271
|
+
`Scenario("${name}"): \`expect.deletes[${i}].ownership\` must be one of ${OWNERSHIP_VALUES.join(", ")}`,
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
return Object.freeze({ name: deleteName, ownership: ownership as Ownership });
|
|
275
|
+
}),
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
if ("unobserved" in e) {
|
|
279
|
+
const u = e.unobserved;
|
|
280
|
+
if (u === "refuse") {
|
|
281
|
+
out.unobserved = "refuse";
|
|
282
|
+
} else if (typeof u === "object" && u !== null && Array.isArray((u as { allow?: unknown }).allow)) {
|
|
283
|
+
const allow = (u as { allow: unknown[] }).allow;
|
|
284
|
+
allow.forEach((entry, i) => {
|
|
285
|
+
if (typeof entry !== "string" || entry.length === 0) {
|
|
286
|
+
throw new Error(`Scenario("${name}"): \`expect.unobserved.allow[${i}]\` must be a non-empty string`);
|
|
287
|
+
}
|
|
288
|
+
});
|
|
289
|
+
out.unobserved = Object.freeze({ allow: Object.freeze([...allow]) });
|
|
290
|
+
} else {
|
|
291
|
+
throw new Error(
|
|
292
|
+
`Scenario("${name}"): \`expect.unobserved\` must be "refuse" or { allow: [names] }`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
return Object.freeze(out) as ScenarioExpect;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Type guard for a scenario declaration. */
|
|
301
|
+
export function isScenario(value: unknown): value is ScenarioDeclaration {
|
|
302
|
+
return (
|
|
303
|
+
typeof value === "object" &&
|
|
304
|
+
value !== null &&
|
|
305
|
+
SCENARIO_MARKER in value &&
|
|
306
|
+
(value as Record<symbol, unknown>)[SCENARIO_MARKER] === true
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Extract the scenario declarations from a discovered entity map — the read
|
|
312
|
+
* surface `chant scenario check` (../cli/handlers/scenario.ts) uses. Keyed by
|
|
313
|
+
* entity name (export name), the same key `DiscoveryResult.entities` uses.
|
|
314
|
+
*/
|
|
315
|
+
export function collectScenarios(entities: ReadonlyMap<string, Declarable>): Map<string, ScenarioDeclaration> {
|
|
316
|
+
const out = new Map<string, ScenarioDeclaration>();
|
|
317
|
+
for (const [name, entity] of entities) {
|
|
318
|
+
if (isScenario(entity)) out.set(name, entity);
|
|
319
|
+
}
|
|
320
|
+
return out;
|
|
321
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, test, expect } from "vitest";
|
|
2
|
+
import { deriveSymptoms, worstStatus, CONVERGE_SYMPTOM_FIELDS } from "./symptoms";
|
|
3
|
+
import type { ChangeSet, ChangeSetEntry } from "./change-set";
|
|
4
|
+
import type { ComponentStatusRow } from "./status";
|
|
5
|
+
|
|
6
|
+
function entry(overrides: Partial<ChangeSetEntry> & Pick<ChangeSetEntry, "name" | "action">): ChangeSetEntry {
|
|
7
|
+
return {
|
|
8
|
+
ownership: "unknown",
|
|
9
|
+
evidence: { declared: true, inSnapshot: true, live: true, observed: true },
|
|
10
|
+
...overrides,
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function statusRow(component: string, reconciliation: ComponentStatusRow["reconciliation"]): ComponentStatusRow {
|
|
15
|
+
return { component, env: "staging", reconciliation, detail: "test fixture" };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe("worstStatus", () => {
|
|
19
|
+
test("reconciled for no rows", () => {
|
|
20
|
+
expect(worstStatus([])).toBe("reconciled");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("unknown outranks everything else", () => {
|
|
24
|
+
const rows = [statusRow("a", "reconciled"), statusRow("b", "drifted"), statusRow("c", "unknown")];
|
|
25
|
+
expect(worstStatus(rows)).toBe("unknown");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("drifted outranks stale and unrecorded", () => {
|
|
29
|
+
const rows = [statusRow("a", "stale"), statusRow("b", "unrecorded"), statusRow("c", "drifted")];
|
|
30
|
+
expect(worstStatus(rows)).toBe("drifted");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("stale outranks unrecorded", () => {
|
|
34
|
+
expect(worstStatus([statusRow("a", "unrecorded"), statusRow("b", "stale")])).toBe("stale");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("all reconciled -> reconciled", () => {
|
|
38
|
+
expect(worstStatus([statusRow("a", "reconciled"), statusRow("b", "reconciled")])).toBe("reconciled");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe("deriveSymptoms", () => {
|
|
43
|
+
test("a quiet environment: no entries, all-reconciled rows", () => {
|
|
44
|
+
const cs: ChangeSet = { env: "staging", entries: [] };
|
|
45
|
+
const rows = [statusRow("a", "reconciled")];
|
|
46
|
+
const s = deriveSymptoms("staging", cs, rows);
|
|
47
|
+
expect(s.env).toBe("staging");
|
|
48
|
+
expect(s.status).toBe("reconciled");
|
|
49
|
+
expect(s.createCount).toBe(0);
|
|
50
|
+
expect(s.updateCount).toBe(0);
|
|
51
|
+
expect(s.deleteCount).toBe(0);
|
|
52
|
+
expect(s.adoptCount).toBe(0);
|
|
53
|
+
expect(s.unobservedCount).toBe(0);
|
|
54
|
+
expect(s.unobservedReasons).toEqual([]);
|
|
55
|
+
expect(s.totalCount).toBe(0);
|
|
56
|
+
expect(s.components).toEqual(rows);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("counts change-set actions per type", () => {
|
|
60
|
+
const cs: ChangeSet = {
|
|
61
|
+
env: "staging",
|
|
62
|
+
entries: [
|
|
63
|
+
entry({ name: "a", action: "create" }),
|
|
64
|
+
entry({ name: "b", action: "update" }),
|
|
65
|
+
entry({ name: "c", action: "update" }),
|
|
66
|
+
entry({ name: "d", action: "delete", ownership: "owned" }),
|
|
67
|
+
entry({ name: "e", action: "adopt" }),
|
|
68
|
+
entry({ name: "f", action: "adopt" }),
|
|
69
|
+
entry({ name: "g", action: "runtime" }),
|
|
70
|
+
entry({ name: "h", action: "noop" }),
|
|
71
|
+
],
|
|
72
|
+
};
|
|
73
|
+
const s = deriveSymptoms("staging", cs, [statusRow("x", "drifted")]);
|
|
74
|
+
expect(s.createCount).toBe(1);
|
|
75
|
+
expect(s.updateCount).toBe(2);
|
|
76
|
+
expect(s.deleteCount).toBe(1);
|
|
77
|
+
expect(s.adoptCount).toBe(2);
|
|
78
|
+
expect(s.runtimeCount).toBe(1);
|
|
79
|
+
expect(s.totalCount).toBe(8);
|
|
80
|
+
expect(s.status).toBe("drifted");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test("unobserved entries surface count and deduplicated, sorted reasons", () => {
|
|
84
|
+
const cs: ChangeSet = {
|
|
85
|
+
env: "staging",
|
|
86
|
+
entries: [
|
|
87
|
+
entry({ name: "a", action: "unobserved", unobservedReason: "no-credentials" }),
|
|
88
|
+
entry({ name: "b", action: "unobserved", unobservedReason: "read-failed" }),
|
|
89
|
+
entry({ name: "c", action: "unobserved", unobservedReason: "no-credentials" }),
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
const s = deriveSymptoms("staging", cs, [statusRow("x", "unknown")]);
|
|
93
|
+
expect(s.unobservedCount).toBe(3);
|
|
94
|
+
expect(s.unobservedReasons).toHaveLength(2);
|
|
95
|
+
expect(s.unobservedReasons).toContain("no-credentials");
|
|
96
|
+
expect(s.unobservedReasons).toContain("read-failed");
|
|
97
|
+
expect(s.status).toBe("unknown");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("adopt entries never affect status directly — reported via adoptCount only", () => {
|
|
101
|
+
const cs: ChangeSet = { env: "staging", entries: [entry({ name: "a", action: "adopt" })] };
|
|
102
|
+
const s = deriveSymptoms("staging", cs, [statusRow("x", "reconciled")]);
|
|
103
|
+
expect(s.adoptCount).toBe(1);
|
|
104
|
+
expect(s.status).toBe("reconciled");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
describe("CONVERGE_SYMPTOM_FIELDS", () => {
|
|
109
|
+
test("names every field deriveSymptoms actually produces", () => {
|
|
110
|
+
const cs: ChangeSet = { env: "staging", entries: [] };
|
|
111
|
+
const s = deriveSymptoms("staging", cs, []);
|
|
112
|
+
for (const key of Object.keys(s)) {
|
|
113
|
+
expect(CONVERGE_SYMPTOM_FIELDS.has(key)).toBe(true);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Symptom derivation (#1484, epic #1487 feature 5) — one typed record joining
|
|
3
|
+
* the status join (./status.ts), change-set summaries (./change-set.ts), and
|
|
4
|
+
* unobserved evidence (../observation.ts) into the shape `ConvergeOp`'s rule
|
|
5
|
+
* table evaluates against.
|
|
6
|
+
*
|
|
7
|
+
* Derive-only, per the issue's leaning on its own open question 4: no new
|
|
8
|
+
* `LexiconPlugin.symptoms?()` interface in v1 — everything here is a pure
|
|
9
|
+
* projection of structures chant already computes (`ChangeSet`,
|
|
10
|
+
* `ComponentStatusRow[]`). No I/O in this module; a caller (the `convergeTick`
|
|
11
|
+
* activity, lexicons/temporal/src/op/activities/converge.ts) gathers those
|
|
12
|
+
* inputs and hands them here.
|
|
13
|
+
*
|
|
14
|
+
* One record per tick, not per component: the issue's own rule sketch reads
|
|
15
|
+
* `s.status`/`s.backupVerifiedAge` as a single symptom `s` a rule closes
|
|
16
|
+
* over, and epic feature 5 calls it "one typed record" (singular). A
|
|
17
|
+
* multi-component environment still resolves to one record per tick —
|
|
18
|
+
* `components` carries the per-component detail behind the aggregate for
|
|
19
|
+
* reporting, and `status` is the worst verdict across all of them (see
|
|
20
|
+
* {@link worstStatus}), so "any component is unknown" is enough to trip
|
|
21
|
+
* `unknown never remediates` for the whole tick.
|
|
22
|
+
*/
|
|
23
|
+
import type { ChangeAction, ChangeSet } from "./change-set";
|
|
24
|
+
import { summarize } from "./change-set";
|
|
25
|
+
import type { ComponentStatusRow } from "./status";
|
|
26
|
+
import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Verdict priority, most-cautious first: if anything is `unknown`, the tick
|
|
30
|
+
* as a whole must be treated as `unknown` — "unknown never remediates" is a
|
|
31
|
+
* whole-tick property, not a per-component one, since a rule table has no
|
|
32
|
+
* way to act on "some of the environment" only. `drifted` outranks `stale`/
|
|
33
|
+
* `unrecorded` because it is the actionable case ApplyOp's re-apply fixes;
|
|
34
|
+
* `reconciled` is the quiet-environment default.
|
|
35
|
+
*/
|
|
36
|
+
const STATUS_PRIORITY: readonly ComponentStatusRow["reconciliation"][] = [
|
|
37
|
+
"unknown",
|
|
38
|
+
"drifted",
|
|
39
|
+
"stale",
|
|
40
|
+
"unrecorded",
|
|
41
|
+
"reconciled",
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
/** The worst (most-cautious) reconciliation verdict across a set of component rows. `"reconciled"` for an empty set — nothing to converge is a quiet environment. */
|
|
45
|
+
export function worstStatus(rows: ComponentStatusRow[]): ComponentStatusRow["reconciliation"] {
|
|
46
|
+
const present = new Set(rows.map((r) => r.reconciliation));
|
|
47
|
+
for (const candidate of STATUS_PRIORITY) {
|
|
48
|
+
if (present.has(candidate)) return candidate;
|
|
49
|
+
}
|
|
50
|
+
return "reconciled";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* One tick's typed symptom record. Every field a `ConvergeOp` rule may
|
|
55
|
+
* reference — see `../op/converge-rule.ts`'s `SymptomPredicate<S>`, where
|
|
56
|
+
* `S` is this interface, so a rule whose predicate names a field outside
|
|
57
|
+
* this shape fails TypeScript at the author's call site (the "symptom field
|
|
58
|
+
* nothing produces" build-time refusal).
|
|
59
|
+
*/
|
|
60
|
+
export interface ConvergeSymptom {
|
|
61
|
+
env: string;
|
|
62
|
+
/** The worst reconciliation verdict across every component row this tick observed (see {@link worstStatus}). */
|
|
63
|
+
status: ComponentStatusRow["reconciliation"];
|
|
64
|
+
/** Per-component detail behind `status`, for reporting — never read by a rule predicate directly (rules read the flat counts below). */
|
|
65
|
+
components: ComponentStatusRow[];
|
|
66
|
+
/** Change-set action counts for the tick (./change-set.ts's `summarize`). */
|
|
67
|
+
createCount: number;
|
|
68
|
+
updateCount: number;
|
|
69
|
+
deleteCount: number;
|
|
70
|
+
/** Live but undeclared, ownership unestablished — a candidate to adopt into source. Never auto-claimed; see the issue's "adopt is reported, never auto-claimed" honesty requirement. */
|
|
71
|
+
adoptCount: number;
|
|
72
|
+
runtimeCount: number;
|
|
73
|
+
effectCount: number;
|
|
74
|
+
/** Declared, but the lexicon could not observe it (#1089) — the count backing `status: "unknown"`. */
|
|
75
|
+
unobservedCount: number;
|
|
76
|
+
/** Every distinct reason behind `unobservedCount`, for a report action's detail. */
|
|
77
|
+
unobservedReasons: UnobservedReason[];
|
|
78
|
+
/** Total resource count under change-set evidence for this tick (sum of the six counts above). */
|
|
79
|
+
totalCount: number;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Join a `ChangeSet` and `ComponentStatusRow[]` for one tick into a
|
|
84
|
+
* `ConvergeSymptom`. Pure — no I/O.
|
|
85
|
+
*/
|
|
86
|
+
export function deriveSymptoms(env: string, cs: ChangeSet, statusRows: ComponentStatusRow[]): ConvergeSymptom {
|
|
87
|
+
const counts: Record<ChangeAction, number> = summarize(cs);
|
|
88
|
+
const unobservedReasons = [
|
|
89
|
+
...new Set(
|
|
90
|
+
cs.entries
|
|
91
|
+
.filter((e): e is typeof e & { unobservedReason: UnobservedReason } => e.action === "unobserved" && e.unobservedReason !== undefined)
|
|
92
|
+
.map((e) => e.unobservedReason),
|
|
93
|
+
),
|
|
94
|
+
].sort((a, b) => unobservedReasonText(a).localeCompare(unobservedReasonText(b)));
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
env,
|
|
98
|
+
status: worstStatus(statusRows),
|
|
99
|
+
components: statusRows,
|
|
100
|
+
createCount: counts.create,
|
|
101
|
+
updateCount: counts.update,
|
|
102
|
+
deleteCount: counts.delete,
|
|
103
|
+
adoptCount: counts.adopt,
|
|
104
|
+
runtimeCount: counts.runtime,
|
|
105
|
+
effectCount: counts.effect,
|
|
106
|
+
unobservedCount: counts.unobserved,
|
|
107
|
+
unobservedReasons,
|
|
108
|
+
totalCount: cs.entries.length,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Every field name a `ConvergeSymptom` produces — the runtime whitelist `TMP014` (lexicons/temporal) re-validates a serialized rule table's predicates against. Kept in sync with {@link ConvergeSymptom} by hand (deliberately small, changes rarely). */
|
|
113
|
+
export const CONVERGE_SYMPTOM_FIELDS: ReadonlySet<string> = new Set([
|
|
114
|
+
"env",
|
|
115
|
+
"status",
|
|
116
|
+
"components",
|
|
117
|
+
"createCount",
|
|
118
|
+
"updateCount",
|
|
119
|
+
"deleteCount",
|
|
120
|
+
"adoptCount",
|
|
121
|
+
"runtimeCount",
|
|
122
|
+
"effectCount",
|
|
123
|
+
"unobservedCount",
|
|
124
|
+
"unobservedReasons",
|
|
125
|
+
"totalCount",
|
|
126
|
+
]);
|
|
@@ -281,6 +281,37 @@ describe("executeTeardown — the execution half (#1222)", () => {
|
|
|
281
281
|
expect(report.unimplemented).toEqual([]);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
+
test("a retained outcome (#1830) passes through as the loud keep — reported, never retried, never counted failed", async () => {
|
|
285
|
+
const candidateSecret = { name: "prod/master-key", type: "K8s::Core::Secret", marker: { stack: "shop", env: "dev" } };
|
|
286
|
+
const execute = vi.fn(async (): Promise<TeardownExecution> => ({
|
|
287
|
+
outcomes: [
|
|
288
|
+
{ name: "prod/master-key", outcome: "retained", detail: "generated-once secret — deliberately kept, never swept" },
|
|
289
|
+
],
|
|
290
|
+
}));
|
|
291
|
+
const report = await executeTeardown({
|
|
292
|
+
environment: "dev",
|
|
293
|
+
stack: "shop",
|
|
294
|
+
plugins: [
|
|
295
|
+
createMockPlugin({
|
|
296
|
+
name: "k8s",
|
|
297
|
+
teardownOwned: async () => ({ candidates: [candidateSecret] }),
|
|
298
|
+
executeTeardown: execute,
|
|
299
|
+
}),
|
|
300
|
+
],
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
// One pass only: retained is a verdict, not a failure to retry.
|
|
304
|
+
expect(execute).toHaveBeenCalledTimes(1);
|
|
305
|
+
expect(report.outcomes).toEqual([
|
|
306
|
+
{
|
|
307
|
+
lexicon: "k8s",
|
|
308
|
+
...candidateSecret,
|
|
309
|
+
outcome: "retained",
|
|
310
|
+
detail: "generated-once secret — deliberately kept, never swept",
|
|
311
|
+
},
|
|
312
|
+
]);
|
|
313
|
+
});
|
|
314
|
+
|
|
284
315
|
test("reuses a handed-in plan instead of re-reading", async () => {
|
|
285
316
|
const teardownOwned = vi.fn();
|
|
286
317
|
const execute = vi.fn(async (): Promise<TeardownExecution> => ({
|
|
@@ -191,11 +191,13 @@ export async function planTeardown(opts: PlanTeardownOptions): Promise<TeardownP
|
|
|
191
191
|
export interface TeardownOutcomeEntry extends TeardownPlanEntry {
|
|
192
192
|
/**
|
|
193
193
|
* `skipped` is core's verdict for a candidate whose lexicon implements no
|
|
194
|
-
* `executeTeardown` yet; the
|
|
195
|
-
* {@link TeardownOutcome}).
|
|
194
|
+
* `executeTeardown` yet; the others come from the lexicon (see
|
|
195
|
+
* {@link TeardownOutcome}). `retained` is the loud keep (#1365 decision 5):
|
|
196
|
+
* owned, no longer declared, deliberately not deleted — a `generated-once`
|
|
197
|
+
* secret survives every sweep and says so with a row.
|
|
196
198
|
*/
|
|
197
|
-
outcome: "deleted" | "failed" | "not-prunable" | "skipped";
|
|
198
|
-
/** The error for `failed`, the reason for `not-prunable`/`skipped`. */
|
|
199
|
+
outcome: "deleted" | "failed" | "not-prunable" | "retained" | "skipped";
|
|
200
|
+
/** The error for `failed`, the reason for `not-prunable`/`retained`/`skipped`. */
|
|
199
201
|
detail?: string;
|
|
200
202
|
/** True when this final outcome came from the bounded retry pass. */
|
|
201
203
|
retried?: boolean;
|