@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
|
@@ -821,6 +821,120 @@ describe("runLifecyclePlan", () => {
|
|
|
821
821
|
expect(byName["sg-0abc123"].name).toBe("sg-0abc123");
|
|
822
822
|
});
|
|
823
823
|
|
|
824
|
+
// #1832 — effect receipts are declared, diffed, and observed like any
|
|
825
|
+
// resource, but observe-only to the generic apply path: the plan compares
|
|
826
|
+
// live value to resolved expectation and proposes the fire, never a create.
|
|
827
|
+
describe("effect receipts (#1832)", () => {
|
|
828
|
+
const receiptEntity = (lexicon: string) => ({
|
|
829
|
+
lexicon,
|
|
830
|
+
entityType: lexicon === "chant" ? "Chant::EffectReceipt" : `${lexicon}::Receipt`,
|
|
831
|
+
name: "dbMigrated",
|
|
832
|
+
effect: "db-migrate",
|
|
833
|
+
flavor: "existence" as const,
|
|
834
|
+
inputs: {},
|
|
835
|
+
[Symbol.for("chant.declarable")]: true,
|
|
836
|
+
[Symbol.for("chant.effect-receipt")]: true,
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
const buildWithReceipt = (lexicon: string): BuildResult => {
|
|
840
|
+
const base = makeBuildResult({ aws: ["bucket"] });
|
|
841
|
+
base.entities.set("dbMigrated", receiptEntity(lexicon) as unknown as Parameters<typeof base.entities.set>[1]);
|
|
842
|
+
if (lexicon !== "aws") (base.manifest.lexicons as string[]).push(lexicon);
|
|
843
|
+
return base;
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
test("an absent receipt is an effect row, never a create", async () => {
|
|
847
|
+
buildMock.mockResolvedValue(buildWithReceipt("aws"));
|
|
848
|
+
const plugins: LexiconPlugin[] = [
|
|
849
|
+
createMockPlugin({
|
|
850
|
+
name: "aws",
|
|
851
|
+
emulator: awsEmulatorStub,
|
|
852
|
+
describeResources: async () => ({
|
|
853
|
+
observation: "v1" as const,
|
|
854
|
+
resources: { bucket: meta() },
|
|
855
|
+
// dbMigrated neither returned nor unobserved: confirmed absent.
|
|
856
|
+
}),
|
|
857
|
+
}),
|
|
858
|
+
];
|
|
859
|
+
const exit = await runLifecyclePlan({
|
|
860
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
861
|
+
plugins,
|
|
862
|
+
serializers: plugins.map((p) => p.serializer),
|
|
863
|
+
});
|
|
864
|
+
expect(exit).toBe(0);
|
|
865
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
866
|
+
const row = plan.entries.find((e: { name: string }) => e.name === "dbMigrated");
|
|
867
|
+
expect(row).toMatchObject({ action: "effect", effect: "db-migrate", effectReason: "receipt-absent", lexicon: "aws" });
|
|
868
|
+
expect(plan.entries.filter((e: { name: string; action: string }) => e.name === "dbMigrated")).toHaveLength(1);
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
test("a live receipt carrying the existence marker is a clean noop, and the render says nothing fires", async () => {
|
|
872
|
+
buildMock.mockResolvedValue(buildWithReceipt("aws"));
|
|
873
|
+
const plugins: LexiconPlugin[] = [
|
|
874
|
+
createMockPlugin({
|
|
875
|
+
name: "aws",
|
|
876
|
+
emulator: awsEmulatorStub,
|
|
877
|
+
describeResources: async () => ({
|
|
878
|
+
observation: "v1" as const,
|
|
879
|
+
resources: {
|
|
880
|
+
bucket: meta(),
|
|
881
|
+
dbMigrated: meta({ type: "aws::Receipt", attributes: { value: "chant.effect-receipt:exists" } }),
|
|
882
|
+
},
|
|
883
|
+
}),
|
|
884
|
+
}),
|
|
885
|
+
];
|
|
886
|
+
const exit = await runLifecyclePlan({
|
|
887
|
+
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
888
|
+
plugins,
|
|
889
|
+
serializers: plugins.map((p) => p.serializer),
|
|
890
|
+
});
|
|
891
|
+
expect(exit).toBe(0);
|
|
892
|
+
expect(stdoutBuf.join("\n")).toContain("0 effect");
|
|
893
|
+
expect(stdoutBuf.join("\n")).not.toContain("effect will fire");
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
test("a stale receipt renders effect will fire", async () => {
|
|
897
|
+
buildMock.mockResolvedValue(buildWithReceipt("aws"));
|
|
898
|
+
const plugins: LexiconPlugin[] = [
|
|
899
|
+
createMockPlugin({
|
|
900
|
+
name: "aws",
|
|
901
|
+
emulator: awsEmulatorStub,
|
|
902
|
+
describeResources: async () => ({
|
|
903
|
+
observation: "v1" as const,
|
|
904
|
+
resources: {
|
|
905
|
+
bucket: meta(),
|
|
906
|
+
dbMigrated: meta({ type: "aws::Receipt", attributes: { value: "something-else" } }),
|
|
907
|
+
},
|
|
908
|
+
}),
|
|
909
|
+
}),
|
|
910
|
+
];
|
|
911
|
+
const exit = await runLifecyclePlan({
|
|
912
|
+
args: makeArgs({ path: "plan", extraPositional: "prod" }),
|
|
913
|
+
plugins,
|
|
914
|
+
serializers: plugins.map((p) => p.serializer),
|
|
915
|
+
});
|
|
916
|
+
expect(exit).toBe(0);
|
|
917
|
+
expect(stdoutBuf.join("\n")).toContain("effect will fire: db-migrate");
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("a receipt no loaded lexicon observes is unobserved, loudly", async () => {
|
|
921
|
+
buildMock.mockResolvedValue(buildWithReceipt("chant"));
|
|
922
|
+
const plugins: LexiconPlugin[] = [
|
|
923
|
+
createMockPlugin({ name: "aws", emulator: awsEmulatorStub, describeResources: staticDescribeResources({ bucket: meta() }) }),
|
|
924
|
+
];
|
|
925
|
+
const exit = await runLifecyclePlan({
|
|
926
|
+
args: makeArgs({ path: "plan", extraPositional: "prod", json: true }),
|
|
927
|
+
plugins,
|
|
928
|
+
serializers: plugins.map((p) => p.serializer),
|
|
929
|
+
});
|
|
930
|
+
expect(exit).toBe(0);
|
|
931
|
+
const plan = JSON.parse(stdoutBuf.join("\n"));
|
|
932
|
+
const row = plan.entries.find((e: { name: string }) => e.name === "dbMigrated");
|
|
933
|
+
expect(row).toMatchObject({ action: "unobserved", unobservedReason: "unsupported-kind" });
|
|
934
|
+
expect(stderrBuf.join("\n")).toContain("could not be observed");
|
|
935
|
+
});
|
|
936
|
+
});
|
|
937
|
+
|
|
824
938
|
// #1166 — plan is always a live read (no `--live` flag of its own), so a
|
|
825
939
|
// declared environment endpoint applies here exactly as it does for
|
|
826
940
|
// `chant graph --live` / `chant lifecycle diff --live`.
|
|
@@ -1279,7 +1393,7 @@ describe("runLifecycleTeardown (#1222 — plan only)", () => {
|
|
|
1279
1393
|
const out = stdoutBuf.join("\n");
|
|
1280
1394
|
expect(out).toContain("Outcomes:");
|
|
1281
1395
|
expect(out).toContain("deleted");
|
|
1282
|
-
expect(out).toContain("1 deleted, 0 failed, 0 not prunable, 0 skipped");
|
|
1396
|
+
expect(out).toContain("1 deleted, 0 failed, 0 not prunable, 0 retained, 0 skipped");
|
|
1283
1397
|
expect(out).not.toContain("plan only");
|
|
1284
1398
|
});
|
|
1285
1399
|
|
|
@@ -3,8 +3,9 @@ import { commandBuildParams } from "../build-params-cli";
|
|
|
3
3
|
import { build } from "../../build";
|
|
4
4
|
import { takeSnapshot } from "../../lifecycle/snapshot";
|
|
5
5
|
import { readSnapshot, readSnapshotAt, readEnvironmentSnapshots, listSnapshots, fetchLifecycle, pushLifecycle, snapshotStorageKey, StaleLifecycleBranchError } from "../../lifecycle/git";
|
|
6
|
-
import { deepDiffForLexicon } from "../../lifecycle/deep-observe";
|
|
6
|
+
import { deepDiffForLexicon, type DeclaredEntities } from "../../lifecycle/deep-observe";
|
|
7
7
|
import { countPropertyDrift, type DeepDiffResult } from "../../lifecycle/deep-diff";
|
|
8
|
+
import { describePathOrigin, getPathProvenance } from "../../provenance";
|
|
8
9
|
import {
|
|
9
10
|
acceptDeviations,
|
|
10
11
|
baselineForLexicon,
|
|
@@ -18,6 +19,8 @@ import {
|
|
|
18
19
|
import { computeBuildDigest, diffDigests } from "../../lifecycle/digest";
|
|
19
20
|
import { diffLive, diffLiveArtifacts, diffSnapshots, type LiveDiffResult, type LiveArtifactDiffResult, type SnapshotDiffResult } from "../../lifecycle/live-diff";
|
|
20
21
|
import { buildChangeSet, renderChangeSet, gitlabMrReport, summarize, type ChangeSet } from "../../lifecycle/change-set";
|
|
22
|
+
import { mergeReceiptEntries, observedValueResolver, planReceipts, readReceiptValue, type ReceiptReading } from "../../lifecycle/receipt-plan";
|
|
23
|
+
import { collectEffectReceipts, isEffectReceipt } from "../../effect-receipt";
|
|
21
24
|
import {
|
|
22
25
|
formatUnobserved,
|
|
23
26
|
mergeObservations,
|
|
@@ -53,7 +56,7 @@ import type { ChantConfig } from "../../config";
|
|
|
53
56
|
* then `config.sourceDir`, then "." (the root). Snapshot/diff/plan all use this
|
|
54
57
|
* so their build digests stay consistent.
|
|
55
58
|
*/
|
|
56
|
-
function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
59
|
+
export function resolveBuildRoot(args: ParsedArgs, config: ChantConfig): string {
|
|
57
60
|
return resolve(args.src ?? config.sourceDir ?? ".");
|
|
58
61
|
}
|
|
59
62
|
|
|
@@ -358,6 +361,10 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
358
361
|
// committed set. Absent is the normal state (nothing accepted yet).
|
|
359
362
|
const baseline = args.live ? await readObservationBaseline(environment) : null;
|
|
360
363
|
const accepted: Record<string, DeviationToAccept[]> = {};
|
|
364
|
+
// Effect receipts across every target's build (#1833) — `--update-baseline`
|
|
365
|
+
// refuses to accept drift on them (the effect step is their only writer),
|
|
366
|
+
// and recognition is marker-based so materialized lexicon rows are caught.
|
|
367
|
+
const receiptEntities = new Set<string>();
|
|
361
368
|
|
|
362
369
|
// #1166 — an environment can declare its own endpoint (a local emulator like
|
|
363
370
|
// Floci), so `--live` is self-sufficient even when the ambient shell never
|
|
@@ -430,6 +437,9 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
430
437
|
for (const [lexicon, deviations] of Object.entries(r.toAccept)) {
|
|
431
438
|
(accepted[lexicon] ??= []).push(...deviations);
|
|
432
439
|
}
|
|
440
|
+
if (args.updateBaseline) {
|
|
441
|
+
for (const name of collectEffectReceipts(buildResult.entities).keys()) receiptEntities.add(name);
|
|
442
|
+
}
|
|
433
443
|
if (json) {
|
|
434
444
|
if (target.stack) perStackJson[target.stack] = r.byLexicon;
|
|
435
445
|
else combinedLexiconsJson = r.byLexicon;
|
|
@@ -443,7 +453,7 @@ export async function runLifecycleDiff(ctx: CommandContext): Promise<number> {
|
|
|
443
453
|
// accepted, so it stops re-alerting. Runs before the summary lines so the
|
|
444
454
|
// "no drift" verdict below still describes the run that produced it.
|
|
445
455
|
if (args.live && args.updateBaseline) {
|
|
446
|
-
await recordAcceptedBaseline(environment, baseline, accepted, json);
|
|
456
|
+
await recordAcceptedBaseline(environment, baseline, accepted, json, receiptEntities);
|
|
447
457
|
}
|
|
448
458
|
|
|
449
459
|
if (args.live) {
|
|
@@ -495,6 +505,7 @@ async function recordAcceptedBaseline(
|
|
|
495
505
|
existing: ObservationBaseline | null,
|
|
496
506
|
accepted: Record<string, DeviationToAccept[]>,
|
|
497
507
|
json: boolean,
|
|
508
|
+
receipts?: ReadonlySet<string>,
|
|
498
509
|
): Promise<void> {
|
|
499
510
|
const total = Object.values(accepted).reduce((n, d) => n + d.length, 0);
|
|
500
511
|
if (total === 0) {
|
|
@@ -505,11 +516,14 @@ async function recordAcceptedBaseline(
|
|
|
505
516
|
}
|
|
506
517
|
return;
|
|
507
518
|
}
|
|
508
|
-
let next = existing ?? emptyBaseline(environment);
|
|
509
|
-
for (const [lexicon, deviations] of Object.entries(accepted)) {
|
|
510
|
-
next = acceptDeviations(next, lexicon, deviations);
|
|
511
|
-
}
|
|
512
519
|
try {
|
|
520
|
+
// `acceptDeviations` throws on an effect-receipt deviation (#1833) —
|
|
521
|
+
// inside the try so the refusal reaches the operator as a formatted
|
|
522
|
+
// error, with no baseline written at all.
|
|
523
|
+
let next = existing ?? emptyBaseline(environment);
|
|
524
|
+
for (const [lexicon, deviations] of Object.entries(accepted)) {
|
|
525
|
+
next = acceptDeviations(next, lexicon, deviations, { receipts });
|
|
526
|
+
}
|
|
513
527
|
await writeObservationBaseline(next);
|
|
514
528
|
const pushed = await pushLifecycle();
|
|
515
529
|
if (!json) {
|
|
@@ -520,7 +534,7 @@ async function recordAcceptedBaseline(
|
|
|
520
534
|
}
|
|
521
535
|
} catch (err) {
|
|
522
536
|
console.error(formatError({
|
|
523
|
-
message: `--update-baseline:
|
|
537
|
+
message: `--update-baseline: baseline not updated — ${err instanceof Error ? err.message : String(err)}`,
|
|
524
538
|
}));
|
|
525
539
|
}
|
|
526
540
|
}
|
|
@@ -790,13 +804,17 @@ async function runLifecycleDiffLive(args: LiveDiffArgs): Promise<LiveDiffOutcome
|
|
|
790
804
|
// declared name the reader can never resolve reads as missing (see
|
|
791
805
|
// lifecycle/observe.ts).
|
|
792
806
|
const declared = new Set<string>();
|
|
793
|
-
const entities = new Map
|
|
807
|
+
const entities: DeclaredEntities = new Map();
|
|
794
808
|
for (const [name, entity] of args.buildResult.entities) {
|
|
795
809
|
if (entity.lexicon === lexiconName && isResourceDeclarable(entity)) {
|
|
796
810
|
declared.add(name);
|
|
811
|
+
// Path origins are carried across explicitly (#1443): this map is plain
|
|
812
|
+
// objects, and the symbol-keyed provenance channel does not survive it.
|
|
813
|
+
const pathOrigins = getPathProvenance(entity);
|
|
797
814
|
entities.set(name, {
|
|
798
815
|
entityType: entity.entityType,
|
|
799
816
|
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
817
|
+
...(pathOrigins ? { pathOrigins } : {}),
|
|
800
818
|
});
|
|
801
819
|
}
|
|
802
820
|
}
|
|
@@ -951,7 +969,10 @@ function renderDeepDiff(lexiconName: string, deep: DeepDiffResult): void {
|
|
|
951
969
|
const declared = "declared" in change ? formatValue(change.declared) : "<undeclared>";
|
|
952
970
|
const live = "live" in change ? formatValue(change.live) : "<absent>";
|
|
953
971
|
const baseline = "baseline" in change ? ` [accepted: ${formatValue(change.baseline)}]` : "";
|
|
954
|
-
|
|
972
|
+
// Both sides of the attribution, where each is known (#1443/#1189).
|
|
973
|
+
const from = change.origin ? ` [from: ${describePathOrigin(change.origin)}]` : "";
|
|
974
|
+
const owner = change.owner ? ` [owner: ${change.owner}]` : "";
|
|
975
|
+
console.log(` ${change.path}: ${declared} → ${live}${baseline}${from}${owner}`);
|
|
955
976
|
}
|
|
956
977
|
}
|
|
957
978
|
}
|
|
@@ -1126,6 +1147,15 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1126
1147
|
const merged: ChangeSet = { env: environment, entries: [] };
|
|
1127
1148
|
let checked = 0;
|
|
1128
1149
|
|
|
1150
|
+
// Effect receipts (#1832): declared, diffed, and observed like any resource,
|
|
1151
|
+
// but observe-only to the generic apply path — the plan compares live value
|
|
1152
|
+
// to resolved expectation and proposes the fire, never a create/update.
|
|
1153
|
+
const receipts = collectEffectReceipts(buildResult.entities);
|
|
1154
|
+
const receiptReadings = new Map<string, ReceiptReading>();
|
|
1155
|
+
// Every lexicon's observed resources merged, for resolving a receipt's
|
|
1156
|
+
// reference inputs against observed values / stack outputs at plan time.
|
|
1157
|
+
const allObservedResources: Record<string, ResourceMetadata> = {};
|
|
1158
|
+
|
|
1129
1159
|
// #1166 — same self-sufficiency as `chant graph --live`: an environment can
|
|
1130
1160
|
// declare its own endpoint, applied here unless the ambient shell already
|
|
1131
1161
|
// set it. `chant lifecycle plan` is always a live read (no `--live` flag of
|
|
@@ -1156,11 +1186,14 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1156
1186
|
const declared = new Set<string>();
|
|
1157
1187
|
const entities = new Map<string, { entityType: string; props: Record<string, unknown> }>();
|
|
1158
1188
|
for (const [name, entity] of buildResult.entities) {
|
|
1159
|
-
|
|
1189
|
+
// A receipt has no `props` payload of its own but is declared, diffed,
|
|
1190
|
+
// and observed like any resource (#1832) — it joins the declared axis
|
|
1191
|
+
// so its lexicon's observation can confirm presence or absence.
|
|
1192
|
+
if (entity.lexicon === lexiconName && (isResourceDeclarable(entity) || isEffectReceipt(entity))) {
|
|
1160
1193
|
declared.add(name);
|
|
1161
1194
|
entities.set(name, {
|
|
1162
1195
|
entityType: entity.entityType,
|
|
1163
|
-
props: (entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
1196
|
+
props: (isResourceDeclarable(entity) && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
1164
1197
|
});
|
|
1165
1198
|
}
|
|
1166
1199
|
}
|
|
@@ -1185,6 +1218,37 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1185
1218
|
...(args.namespace ? { namespace: args.namespace } : {}),
|
|
1186
1219
|
});
|
|
1187
1220
|
|
|
1221
|
+
Object.assign(allObservedResources, observed.resources);
|
|
1222
|
+
for (const [receiptName, receipt] of receipts) {
|
|
1223
|
+
if (receipt.lexicon !== lexiconName) continue;
|
|
1224
|
+
const live = observed.resources[receiptName];
|
|
1225
|
+
const hole = observed.unobserved[receiptName];
|
|
1226
|
+
if (live) {
|
|
1227
|
+
receiptReadings.set(receiptName, {
|
|
1228
|
+
observed: true,
|
|
1229
|
+
present: true,
|
|
1230
|
+
value: readReceiptValue(live.attributes),
|
|
1231
|
+
type: live.type,
|
|
1232
|
+
...(live.physicalId ? { physicalId: live.physicalId } : {}),
|
|
1233
|
+
lexicon: lexiconName,
|
|
1234
|
+
});
|
|
1235
|
+
} else if (hole) {
|
|
1236
|
+
receiptReadings.set(receiptName, {
|
|
1237
|
+
observed: false,
|
|
1238
|
+
present: false,
|
|
1239
|
+
lexicon: lexiconName,
|
|
1240
|
+
...(hole.type ? { type: hole.type } : {}),
|
|
1241
|
+
unobservedReason: hole.reason,
|
|
1242
|
+
...(hole.detail ? { unobservedDetail: hole.detail } : {}),
|
|
1243
|
+
});
|
|
1244
|
+
} else {
|
|
1245
|
+
// Neither returned nor named unobserved: the lexicon looked and
|
|
1246
|
+
// confirmed the receipt absent — the same claim the change set
|
|
1247
|
+
// reads off a bare observation (#1089).
|
|
1248
|
+
receiptReadings.set(receiptName, { observed: true, present: false, lexicon: lexiconName });
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1188
1252
|
const content = await readSnapshot(environment, lexiconName);
|
|
1189
1253
|
const observedThen = content ? (JSON.parse(content) as LifecycleSnapshot).resources : undefined;
|
|
1190
1254
|
|
|
@@ -1214,6 +1278,16 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1214
1278
|
return 1;
|
|
1215
1279
|
}
|
|
1216
1280
|
|
|
1281
|
+
// Receipt classification (#1832): live value vs resolved expectation.
|
|
1282
|
+
// Absent/stale proposes the fire; unobservable is a loud hole; whatever the
|
|
1283
|
+
// generic classification proposed for a receipt (a create, a delete) is
|
|
1284
|
+
// replaced — the effect step is the sole writer, and a receipt is never a
|
|
1285
|
+
// prune candidate.
|
|
1286
|
+
if (receipts.size > 0) {
|
|
1287
|
+
const receiptEntries = planReceipts(receipts, receiptReadings, observedValueResolver(allObservedResources));
|
|
1288
|
+
mergeReceiptEntries(merged, receipts, receiptEntries);
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1217
1291
|
merged.entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
1218
1292
|
|
|
1219
1293
|
// Say it on stderr too, so `--json` and `--report gitlab-mr` consumers (whose
|
|
@@ -1250,7 +1324,7 @@ export async function runLifecyclePlan(ctx: CommandContext): Promise<number> {
|
|
|
1250
1324
|
* with the requested env identity. Stateless — live markers only, no build, no
|
|
1251
1325
|
* snapshot. Without `--yes` nothing is deleted; with it the planned set is
|
|
1252
1326
|
* executed per lexicon with one bounded retry pass over failures, and every
|
|
1253
|
-
* candidate's outcome is reported (deleted / failed / not-prunable / skipped —
|
|
1327
|
+
* candidate's outcome is reported (deleted / failed / not-prunable / retained / skipped —
|
|
1254
1328
|
* never silence). A production-like environment name additionally requires an
|
|
1255
1329
|
* interactive confirmation, or `--confirm-prod` non-interactively.
|
|
1256
1330
|
*/
|
|
@@ -1410,10 +1484,11 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
|
|
|
1410
1484
|
}
|
|
1411
1485
|
}
|
|
1412
1486
|
|
|
1413
|
-
const counts = { deleted: 0, failed: 0, "not-prunable": 0, skipped: 0 };
|
|
1487
|
+
const counts = { deleted: 0, failed: 0, "not-prunable": 0, retained: 0, skipped: 0 };
|
|
1414
1488
|
for (const o of report.outcomes) counts[o.outcome]++;
|
|
1415
1489
|
console.log(
|
|
1416
|
-
`\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable,
|
|
1490
|
+
`\n${counts.deleted} deleted, ${counts.failed} failed, ${counts["not-prunable"]} not prunable, ` +
|
|
1491
|
+
`${counts.retained} retained, ${counts.skipped} skipped`,
|
|
1417
1492
|
);
|
|
1418
1493
|
|
|
1419
1494
|
if (report.unimplemented.length > 0) {
|
|
@@ -1421,6 +1496,12 @@ export async function runLifecycleTeardown(ctx: CommandContext): Promise<number>
|
|
|
1421
1496
|
message: `Not executed (no teardown execution in these lexicons yet): ${report.unimplemented.join(", ")} — their candidates are reported as skipped, not deleted.`,
|
|
1422
1497
|
}));
|
|
1423
1498
|
}
|
|
1499
|
+
if (counts.retained > 0) {
|
|
1500
|
+
console.error(formatWarning({
|
|
1501
|
+
message: `${counts.retained} resource(s) retained — owned by this env but deliberately kept (generated-once secrets are never swept). ` +
|
|
1502
|
+
`The environment is NOT clean while they exist; delete them explicitly (e.g. kubectl delete) if you mean to.`,
|
|
1503
|
+
}));
|
|
1504
|
+
}
|
|
1424
1505
|
if (plan.holes.length > 0) {
|
|
1425
1506
|
console.error(formatWarning({
|
|
1426
1507
|
message: "This teardown ran over an incomplete plan (holes above) — the environment cannot be called clean.",
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { ParsedArgs } from "../registry";
|
|
3
|
+
|
|
4
|
+
const discoverConvergeOpsMock = vi.fn();
|
|
5
|
+
const runOperatorRoundMock = vi.fn();
|
|
6
|
+
const runOperatorForeverMock = vi.fn();
|
|
7
|
+
const discoverOpsMock = vi.fn();
|
|
8
|
+
const loadActivitiesMock = vi.fn();
|
|
9
|
+
const loadProfilesMock = vi.fn();
|
|
10
|
+
const loadChantConfigMock = vi.fn();
|
|
11
|
+
const readLeaseMock = vi.fn();
|
|
12
|
+
const readConvergeLedgerMock = vi.fn();
|
|
13
|
+
const appendGateResolutionMock = vi.fn();
|
|
14
|
+
const readGateResolutionsMock = vi.fn();
|
|
15
|
+
const pushLifecycleMock = vi.fn();
|
|
16
|
+
|
|
17
|
+
vi.mock("../../op/operator", async () => {
|
|
18
|
+
const actual = await vi.importActual<typeof import("../../op/operator")>("../../op/operator");
|
|
19
|
+
return {
|
|
20
|
+
...actual,
|
|
21
|
+
discoverConvergeOps: (...args: unknown[]) => discoverConvergeOpsMock(...args),
|
|
22
|
+
runOperatorRound: (...args: unknown[]) => runOperatorRoundMock(...args),
|
|
23
|
+
runOperatorForever: (...args: unknown[]) => runOperatorForeverMock(...args),
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
vi.mock("../../op/discover", () => ({ discoverOps: () => discoverOpsMock() }));
|
|
27
|
+
vi.mock("../../op/activity-registry", () => ({
|
|
28
|
+
loadActivities: (...args: unknown[]) => loadActivitiesMock(...args),
|
|
29
|
+
loadProfiles: (...args: unknown[]) => loadProfilesMock(...args),
|
|
30
|
+
}));
|
|
31
|
+
vi.mock("../../config", async () => {
|
|
32
|
+
const actual = await vi.importActual<typeof import("../../config")>("../../config");
|
|
33
|
+
return { ...actual, loadChantConfig: (...args: unknown[]) => loadChantConfigMock(...args) };
|
|
34
|
+
});
|
|
35
|
+
vi.mock("../../lifecycle/lease", async () => {
|
|
36
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/lease")>("../../lifecycle/lease");
|
|
37
|
+
return { ...actual, readLease: (...args: unknown[]) => readLeaseMock(...args) };
|
|
38
|
+
});
|
|
39
|
+
vi.mock("../../lifecycle/converge-ledger", async () => {
|
|
40
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/converge-ledger")>("../../lifecycle/converge-ledger");
|
|
41
|
+
return { ...actual, readConvergeLedger: (...args: unknown[]) => readConvergeLedgerMock(...args) };
|
|
42
|
+
});
|
|
43
|
+
vi.mock("../../lifecycle/gate-ledger", async () => {
|
|
44
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/gate-ledger")>("../../lifecycle/gate-ledger");
|
|
45
|
+
return {
|
|
46
|
+
...actual,
|
|
47
|
+
appendGateResolution: (...args: unknown[]) => appendGateResolutionMock(...args),
|
|
48
|
+
readGateResolutions: (...args: unknown[]) => readGateResolutionsMock(...args),
|
|
49
|
+
};
|
|
50
|
+
});
|
|
51
|
+
vi.mock("../../lifecycle/git", async () => {
|
|
52
|
+
const actual = await vi.importActual<typeof import("../../lifecycle/git")>("../../lifecycle/git");
|
|
53
|
+
return { ...actual, pushLifecycle: (...args: unknown[]) => pushLifecycleMock(...args) };
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// Imported after the mocks above are registered.
|
|
57
|
+
const { runOperator, runOperatorStatus, runApprove } = await import("./operator");
|
|
58
|
+
|
|
59
|
+
function ctx(args: Partial<ParsedArgs>) {
|
|
60
|
+
return {
|
|
61
|
+
args: { command: "", path: ".", format: "", fix: false, watch: false, verbose: false, help: false, live: false, ...args } as ParsedArgs,
|
|
62
|
+
plugins: [],
|
|
63
|
+
serializers: [],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
beforeEach(() => {
|
|
68
|
+
vi.clearAllMocks();
|
|
69
|
+
loadChantConfigMock.mockResolvedValue({ config: { lexicons: [] } });
|
|
70
|
+
loadActivitiesMock.mockResolvedValue(new Map());
|
|
71
|
+
loadProfilesMock.mockResolvedValue({});
|
|
72
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map([["fountain-apply", {}]]), errors: [] });
|
|
73
|
+
pushLifecycleMock.mockResolvedValue(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("runOperator", () => {
|
|
77
|
+
test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
|
|
78
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
|
|
79
|
+
const code = await runOperator(ctx({}));
|
|
80
|
+
expect(code).toBe(0);
|
|
81
|
+
expect(runOperatorRoundMock).not.toHaveBeenCalled();
|
|
82
|
+
expect(runOperatorForeverMock).not.toHaveBeenCalled();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("--once runs exactly one round and exits 0 when nothing failed", async () => {
|
|
86
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
|
|
87
|
+
runOperatorRoundMock.mockResolvedValue([{ kind: "ticked", op: "staging-converge", env: "staging", result: { op: "staging-converge", records: [], totalMs: 1, ok: true } }]);
|
|
88
|
+
|
|
89
|
+
const code = await runOperator(ctx({ once: true }));
|
|
90
|
+
expect(code).toBe(0);
|
|
91
|
+
expect(runOperatorRoundMock).toHaveBeenCalledTimes(1);
|
|
92
|
+
expect(runOperatorForeverMock).not.toHaveBeenCalled();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("--once exits 1 when any op's tick failed this round", async () => {
|
|
96
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
|
|
97
|
+
runOperatorRoundMock.mockResolvedValue([{ kind: "tick-failed", op: "staging-converge", env: "staging", error: "boom" }]);
|
|
98
|
+
|
|
99
|
+
const code = await runOperator(ctx({ once: true }));
|
|
100
|
+
expect(code).toBe(1);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("without --once, runs the daemon loop (runOperatorForever) and exits 0 when it returns", async () => {
|
|
104
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
|
|
105
|
+
runOperatorForeverMock.mockResolvedValue(undefined);
|
|
106
|
+
|
|
107
|
+
const code = await runOperator(ctx({}));
|
|
108
|
+
expect(code).toBe(0);
|
|
109
|
+
expect(runOperatorForeverMock).toHaveBeenCalledTimes(1);
|
|
110
|
+
expect(runOperatorRoundMock).not.toHaveBeenCalled();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("--interval and --lease-ttl are parsed as durations and threaded through", async () => {
|
|
114
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
|
|
115
|
+
runOperatorForeverMock.mockResolvedValue(undefined);
|
|
116
|
+
|
|
117
|
+
await runOperator(ctx({ interval: "30s", leaseTtl: "2m" }));
|
|
118
|
+
const passed = runOperatorForeverMock.mock.calls[0][0];
|
|
119
|
+
expect(passed.intervalMs).toBe(30_000);
|
|
120
|
+
expect(passed.leaseTtlMs).toBe(120_000);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("--env is passed through to discovery and the round/loop", async () => {
|
|
124
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "prod-converge" } }], errors: [] });
|
|
125
|
+
runOperatorRoundMock.mockResolvedValue([]);
|
|
126
|
+
|
|
127
|
+
await runOperator(ctx({ once: true, env: "prod" }));
|
|
128
|
+
expect(discoverConvergeOpsMock).toHaveBeenCalledWith({ env: "prod" });
|
|
129
|
+
expect(runOperatorRoundMock.mock.calls[0][0].env).toBe("prod");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("a failure loading activities is reported and exits 1", async () => {
|
|
133
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [{ config: { name: "staging-converge" } }], errors: [] });
|
|
134
|
+
loadActivitiesMock.mockRejectedValue(new Error("no activities registered"));
|
|
135
|
+
|
|
136
|
+
const code = await runOperator(ctx({ once: true }));
|
|
137
|
+
expect(code).toBe(1);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("runOperatorStatus", () => {
|
|
142
|
+
test("prints a warning and exits 0 when no ConvergeOps are discovered", async () => {
|
|
143
|
+
discoverConvergeOpsMock.mockResolvedValue({ ops: [], errors: [] });
|
|
144
|
+
const code = await runOperatorStatus(ctx({}));
|
|
145
|
+
expect(code).toBe(0);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("--json emits one row per discovered ConvergeOp with last tick, lease, and pending gates", async () => {
|
|
149
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
150
|
+
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
151
|
+
errors: [],
|
|
152
|
+
});
|
|
153
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
154
|
+
records: [
|
|
155
|
+
{
|
|
156
|
+
version: 1,
|
|
157
|
+
op: "staging-converge",
|
|
158
|
+
env: "staging",
|
|
159
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
160
|
+
firedRuleIds: ["drift-apply"],
|
|
161
|
+
outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
|
|
162
|
+
summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
|
|
163
|
+
log: "converge(staging): ...",
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
malformed: 0,
|
|
167
|
+
});
|
|
168
|
+
readLeaseMock.mockResolvedValue({ sha: "abc", record: { op: "staging-converge", holder: "op-a", token: "t1", acquiredAt: "x", expiresAt: "y" } });
|
|
169
|
+
readGateResolutionsMock.mockResolvedValue({ records: [], malformed: 0 }); // no resolution yet — still pending
|
|
170
|
+
|
|
171
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
172
|
+
const code = await runOperatorStatus(ctx({ json: true }));
|
|
173
|
+
expect(code).toBe(0);
|
|
174
|
+
|
|
175
|
+
const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
176
|
+
expect(printed).toHaveLength(1);
|
|
177
|
+
expect(printed[0].op).toBe("staging-converge");
|
|
178
|
+
expect(printed[0].lease).toEqual({ holder: "op-a", expiresAt: "y" });
|
|
179
|
+
expect(printed[0].pendingGates).toEqual([{ rule: "drift-apply", op: "fountain-apply", gate: "rollout-gate" }]);
|
|
180
|
+
logSpy.mockRestore();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a gate resolved after the tick that recorded it is no longer pending", async () => {
|
|
184
|
+
discoverConvergeOpsMock.mockResolvedValue({
|
|
185
|
+
ops: [{ config: { name: "staging-converge", searchAttributes: { Env: "staging" } } }],
|
|
186
|
+
errors: [],
|
|
187
|
+
});
|
|
188
|
+
readConvergeLedgerMock.mockResolvedValue({
|
|
189
|
+
records: [
|
|
190
|
+
{
|
|
191
|
+
version: 1,
|
|
192
|
+
op: "staging-converge",
|
|
193
|
+
env: "staging",
|
|
194
|
+
timestamp: "2026-01-01T00:00:00.000Z",
|
|
195
|
+
firedRuleIds: ["drift-apply"],
|
|
196
|
+
outcomes: [{ ruleId: "drift-apply", action: "gated", op: "fountain-apply", gateName: "rollout-gate" }],
|
|
197
|
+
summary: { drifted: 1, remediated: 0, reported: 0, skippedBudget: 0, skippedFlap: 0, unobserved: 0, adopted: 0, gated: 1 },
|
|
198
|
+
log: "converge(staging): ...",
|
|
199
|
+
},
|
|
200
|
+
],
|
|
201
|
+
malformed: 0,
|
|
202
|
+
});
|
|
203
|
+
readLeaseMock.mockResolvedValue({ sha: null, record: undefined });
|
|
204
|
+
readGateResolutionsMock.mockResolvedValue({
|
|
205
|
+
records: [{ version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-02T00:00:00.000Z" }],
|
|
206
|
+
malformed: 0,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const logSpy = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
210
|
+
await runOperatorStatus(ctx({ json: true }));
|
|
211
|
+
const printed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
212
|
+
expect(printed[0].pendingGates).toEqual([]);
|
|
213
|
+
logSpy.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
describe("runApprove", () => {
|
|
218
|
+
test("requires both <op> and <gate>", async () => {
|
|
219
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
220
|
+
expect(await runApprove(ctx({ path: "." }))).toBe(1);
|
|
221
|
+
expect(await runApprove(ctx({ path: "fountain-apply" }))).toBe(1);
|
|
222
|
+
errSpy.mockRestore();
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("appends a gate-resolution record and pushes, resolving --actor over env fallbacks", async () => {
|
|
226
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
227
|
+
commit: "sha",
|
|
228
|
+
record: { version: 1, op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
229
|
+
});
|
|
230
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
231
|
+
|
|
232
|
+
const code = await runApprove(ctx({ path: "fountain-apply", extraPositional: "rollout-gate", actor: "alex", note: "https://pr/1" }));
|
|
233
|
+
|
|
234
|
+
expect(code).toBe(0);
|
|
235
|
+
expect(appendGateResolutionMock).toHaveBeenCalledWith(
|
|
236
|
+
expect.objectContaining({ op: "fountain-apply", gate: "rollout-gate", resolvedBy: "alex", note: "https://pr/1" }),
|
|
237
|
+
);
|
|
238
|
+
expect(pushLifecycleMock).toHaveBeenCalled();
|
|
239
|
+
errSpy.mockRestore();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("warns (but still records) when the op isn't among discovered *.op.ts declarations", async () => {
|
|
243
|
+
discoverOpsMock.mockResolvedValue({ ops: new Map(), errors: [] });
|
|
244
|
+
appendGateResolutionMock.mockResolvedValue({
|
|
245
|
+
commit: "sha",
|
|
246
|
+
record: { version: 1, op: "unknown-op", gate: "g", resolvedBy: "unknown", timestamp: "2026-01-01T00:00:00.000Z" },
|
|
247
|
+
});
|
|
248
|
+
const errSpy = vi.spyOn(console, "error").mockImplementation(() => undefined);
|
|
249
|
+
|
|
250
|
+
const code = await runApprove(ctx({ path: "unknown-op", extraPositional: "g" }));
|
|
251
|
+
expect(code).toBe(0);
|
|
252
|
+
expect(appendGateResolutionMock).toHaveBeenCalled();
|
|
253
|
+
errSpy.mockRestore();
|
|
254
|
+
});
|
|
255
|
+
});
|