@intentius/chant 0.46.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/audit/core.d.ts +21 -3
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +3 -2
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/build.d.ts +3 -3
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/lint.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/mcp/resource-handlers.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/plugins.d.ts +1 -1
- package/dist/cli/plugins.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/components/auto-release.d.ts +4 -0
- package/dist/components/auto-release.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 +8 -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/wrangler.d.ts +108 -0
- package/dist/components/verbs/wrangler.d.ts.map +1 -0
- 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/effect-receipt.d.ts +177 -0
- package/dist/effect-receipt.d.ts.map +1 -0
- package/dist/fold/subset.d.ts +15 -2
- 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.d.ts +44 -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/index.d.ts +2 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- 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/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/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 +12 -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/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/okf-read.d.ts +78 -0
- package/dist/okf-read.d.ts.map +1 -0
- package/dist/op/builders.d.ts +98 -1
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -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/receipt-store.d.ts +138 -0
- package/dist/op/receipt-store.d.ts.map +1 -0
- package/dist/op/types.d.ts +31 -1
- package/dist/op/types.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 +11 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/yaml.d.ts.map +1 -1
- package/package.json +4 -1
- 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 +11 -2
- package/src/audit/rules-doc.ts +11 -1
- package/src/build.test.ts +41 -0
- package/src/build.ts +34 -6
- 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/audit.test.ts +53 -0
- package/src/cli/commands/audit.ts +1 -1
- package/src/cli/commands/build.test.ts +80 -0
- package/src/cli/commands/build.ts +106 -8
- package/src/cli/commands/lint.ts +15 -3
- package/src/cli/handlers/explain.test.ts +70 -1
- package/src/cli/handlers/graph.ts +2 -2
- package/src/cli/handlers/lifecycle.test.ts +115 -1
- package/src/cli/handlers/lifecycle.ts +84 -11
- package/src/cli/mcp/resource-handlers.ts +38 -1
- package/src/cli/mcp/server.test.ts +58 -1
- package/src/cli/mcp/tools/explain.ts +51 -2
- package/src/cli/plugins.ts +4 -2
- package/src/cli/reporters/stylish.test.ts +154 -0
- package/src/cli/reporters/stylish.ts +154 -33
- package/src/components/auto-release.ts +6 -0
- 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 +8 -0
- package/src/components/verbs/r2-sync.test.ts +107 -0
- package/src/components/verbs/r2-sync.ts +124 -0
- package/src/components/verbs/wrangler.test.ts +170 -0
- package/src/components/verbs/wrangler.ts +241 -0
- 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/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/subset.test.ts +26 -0
- package/src/fold/subset.ts +45 -19
- package/src/index.ts +4 -0
- package/src/lexicon.ts +48 -3
- package/src/lifecycle/change-set.ts +46 -7
- package/src/lifecycle/index.ts +2 -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/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/pipeline-change-gate.test.ts +144 -0
- package/src/lint/pipeline-change-gate.ts +153 -0
- package/src/lint/post-synth.ts +15 -0
- package/src/lint/receipt-checks.test.ts +101 -0
- package/src/lint/receipt-checks.ts +93 -0
- 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 +27 -0
- package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
- package/src/lint/rules/index.ts +7 -1
- package/src/okf-read.test.ts +149 -0
- package/src/okf-read.ts +197 -0
- package/src/op/builders.ts +139 -1
- package/src/op/effect-step.test.ts +311 -0
- package/src/op/index.ts +10 -3
- package/src/op/local-executor.ts +172 -25
- package/src/op/op.test.ts +25 -2
- package/src/op/receipt-store.ts +211 -0
- package/src/op/types.ts +33 -1
- 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 +12 -0
- package/src/yaml.test.ts +88 -0
- package/src/yaml.ts +76 -6
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
apiVersion: fountain.dev/v1
|
|
2
|
+
kind: Environment
|
|
3
|
+
metadata:
|
|
4
|
+
name: dev
|
|
5
|
+
spec:
|
|
6
|
+
networking_type: limited
|
|
7
|
+
networking_config:
|
|
8
|
+
allowed_hosts:
|
|
9
|
+
- github.com
|
|
10
|
+
env_vars:
|
|
11
|
+
API_URL: https://api.example.com
|
|
12
|
+
---
|
|
13
|
+
apiVersion: fountain.dev/v1
|
|
14
|
+
kind: Agent
|
|
15
|
+
metadata:
|
|
16
|
+
name: researcher
|
|
17
|
+
spec:
|
|
18
|
+
model: anthropic/claude-sonnet-4-6
|
|
19
|
+
runtime: claude
|
|
20
|
+
environment: dev
|
|
@@ -105,6 +105,59 @@ describe("auditCommand", () => {
|
|
|
105
105
|
expect([...ids].some((id) => id.startsWith("WK8"))).toBe(false);
|
|
106
106
|
});
|
|
107
107
|
|
|
108
|
+
test("discovers and audits fountain manifests (not misclassified as k8s) — FTN rules fire (#1566/#1567)", async () => {
|
|
109
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain", import.meta.url));
|
|
110
|
+
const fountain = await discoverLexicon(repo, "fountain");
|
|
111
|
+
expect(fountain.map((f) => f.path)).toEqual(["agents/fleet.yaml"]);
|
|
112
|
+
// The fountain manifest is NOT also picked up as k8s; the plain k8s manifest still is.
|
|
113
|
+
expect((await discoverLexicon(repo, "k8s")).map((f) => f.path)).toEqual(["k8s/deploy.yaml"]);
|
|
114
|
+
|
|
115
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
116
|
+
expect(result.success).toBe(true);
|
|
117
|
+
const ftn = result.findings.filter((f) => f.checkId.startsWith("FTN"));
|
|
118
|
+
const ids = new Set(ftn.map((f) => f.checkId));
|
|
119
|
+
expect(ids).toContain("FTN011"); // unrestricted networking
|
|
120
|
+
expect(ids).toContain("FTN012"); // credential-shaped env_vars key
|
|
121
|
+
expect(ids).toContain("FTN014"); // vault key shadows an environment key
|
|
122
|
+
expect(ftn.find((f) => f.checkId === "FTN012")!.severity).toBe("error");
|
|
123
|
+
for (const f of ftn) {
|
|
124
|
+
expect(f.lexicon).toBe("fountain");
|
|
125
|
+
expect(f.file).toBe("agents/fleet.yaml");
|
|
126
|
+
}
|
|
127
|
+
// No k8s check ran against the fountain manifest.
|
|
128
|
+
for (const f of result.findings.filter((f) => f.lexicon === "k8s")) {
|
|
129
|
+
expect(f.file).not.toBe("agents/fleet.yaml");
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("fountain findings carry their authority citations in json, sarif, and markdown", async () => {
|
|
134
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain", import.meta.url));
|
|
135
|
+
|
|
136
|
+
const json = await auditCommand({ path: repo, format: "json" });
|
|
137
|
+
const envelope = JSON.parse(json.output) as { findings: Array<{ checkId: string; authority: Array<{ url: string }>; severity: string }> };
|
|
138
|
+
const ftn11 = envelope.findings.find((f) => f.checkId === "FTN011")!;
|
|
139
|
+
expect(ftn11.authority.length).toBeGreaterThan(0);
|
|
140
|
+
expect(ftn11.authority[0].url).toContain("fountain");
|
|
141
|
+
expect(envelope.findings.find((f) => f.checkId === "FTN012")!.severity).toBe("error");
|
|
142
|
+
|
|
143
|
+
const sarif = await auditCommand({ path: repo, format: "sarif" });
|
|
144
|
+
const run = (JSON.parse(sarif.output) as { runs: Array<{ tool: { driver: { rules: Array<{ id: string; helpUri?: string }> } }; results: Array<{ ruleId: string }> }> }).runs[0];
|
|
145
|
+
expect(run.results.some((r) => r.ruleId === "FTN011")).toBe(true);
|
|
146
|
+
expect(run.tool.driver.rules.find((r) => r.id === "FTN011")!.helpUri).toContain("fountain");
|
|
147
|
+
|
|
148
|
+
const md = await auditCommand({ path: repo, format: "markdown" });
|
|
149
|
+
expect(md.output).toContain("FTN011");
|
|
150
|
+
expect(md.output).toContain("FTN014");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("a clean fountain manifest set audits quiet", async () => {
|
|
154
|
+
const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain-clean", import.meta.url));
|
|
155
|
+
const result = await auditCommand({ path: repo, format: "stylish" });
|
|
156
|
+
expect(result.success).toBe(true);
|
|
157
|
+
expect(result.findings).toEqual([]);
|
|
158
|
+
expect(result.scanned).toEqual(["fleet.yaml"]);
|
|
159
|
+
});
|
|
160
|
+
|
|
108
161
|
test("discovers and audits a Helm chart (as a bundle, not loose manifests)", async () => {
|
|
109
162
|
const repo = fileURLToPath(new URL("./__fixtures__/audit-helm", import.meta.url));
|
|
110
163
|
const charts = await discoverLexicon(repo, "helm");
|
|
@@ -123,7 +123,7 @@ function renderNoLexicons(target: string, unclaimed: UnclaimedFile[]): string {
|
|
|
123
123
|
lines.push(` ${u.path} -> ${note}`);
|
|
124
124
|
}
|
|
125
125
|
} else {
|
|
126
|
-
lines.push("", "No file under the target looked like CI, Kubernetes, Helm, Docker, CloudFormation, ARM,
|
|
126
|
+
lines.push("", "No file under the target looked like CI, Kubernetes, Helm, Docker, CloudFormation, ARM, Config Connector, or fountain either.");
|
|
127
127
|
}
|
|
128
128
|
lines.push("", "Run it with the lexicons those files need:");
|
|
129
129
|
lines.push(` ${installLine(wanted.length > 0 ? wanted : [...AUDIT_LEXICONS], target)}`);
|
|
@@ -705,6 +705,86 @@ export const testEntity = {
|
|
|
705
705
|
expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
|
|
706
706
|
});
|
|
707
707
|
|
|
708
|
+
describe("committed-encrypted secret sidecars", () => {
|
|
709
|
+
const CIPHERTEXT = 'apiVersion: v1\nkind: Secret\nstringData:\n T: ENC[AES256_GCM,data:xx]\nsops:\n version: 3.9.4\n';
|
|
710
|
+
|
|
711
|
+
/** A serializer standing in for the k8s one: a sidecar plus a primary doc. */
|
|
712
|
+
const sidecarSerializer: Serializer = {
|
|
713
|
+
name: "multi",
|
|
714
|
+
rulePrefix: "MULTI",
|
|
715
|
+
serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": CIPHERTEXT } }),
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
async function writeProject(fileValue: string): Promise<void> {
|
|
719
|
+
const modulePath = resolvePath(
|
|
720
|
+
dirname(fileURLToPath(import.meta.url)),
|
|
721
|
+
"..",
|
|
722
|
+
"..",
|
|
723
|
+
"secret-provenance",
|
|
724
|
+
);
|
|
725
|
+
await writeFile(
|
|
726
|
+
join(testDir, "infra.ts"),
|
|
727
|
+
`import { declareSecret } from ${JSON.stringify(modulePath)};
|
|
728
|
+
export const dbCredentials = declareSecret({ name: "db-credentials", provenance: "committed-encrypted", file: ${JSON.stringify(fileValue)} });
|
|
729
|
+
export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexicon: "multi", kind: "resource", props: {}, attributes: {} };`,
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
test("a build with no --output refuses, naming the flag", async () => {
|
|
734
|
+
await writeProject("secrets/db.sops.yaml");
|
|
735
|
+
|
|
736
|
+
const result = await buildCommand({
|
|
737
|
+
path: testDir,
|
|
738
|
+
format: "yaml",
|
|
739
|
+
serializers: [sidecarSerializer],
|
|
740
|
+
// no `output` — the sidecar would be echoed to stderr and lost
|
|
741
|
+
} as BuildOptions);
|
|
742
|
+
|
|
743
|
+
expect(result.success).toBe(false);
|
|
744
|
+
expect(result.errors.join("\n")).toContain("--output");
|
|
745
|
+
expect(result.errors.join("\n")).toContain("db.sops.yaml");
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
test("with --output the sidecar lands beside the primary, byte for byte", async () => {
|
|
749
|
+
await writeProject("secrets/db.sops.yaml");
|
|
750
|
+
const outputPath = join(testDir, "dist", "manifests.yaml");
|
|
751
|
+
|
|
752
|
+
const result = await buildCommand({
|
|
753
|
+
path: testDir,
|
|
754
|
+
output: outputPath,
|
|
755
|
+
format: "yaml",
|
|
756
|
+
serializers: [sidecarSerializer],
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
expect(result.errors).toEqual([]);
|
|
760
|
+
expect(readFileSync(join(testDir, "dist", "db.sops.yaml"), "utf-8")).toBe(CIPHERTEXT);
|
|
761
|
+
});
|
|
762
|
+
|
|
763
|
+
test("a declared sidecar is never round-tripped through JSON.parse", async () => {
|
|
764
|
+
// YAML is a JSON superset, so a ciphertext file that happens to parse as
|
|
765
|
+
// JSON would otherwise be rewritten key-sorted (or re-emitted as YAML),
|
|
766
|
+
// breaking the `sops` MAC. Declared files skip that path entirely.
|
|
767
|
+
const jsonish = '{"b": 1, "a": 2}\n';
|
|
768
|
+
const jsonSidecar: Serializer = {
|
|
769
|
+
name: "multi",
|
|
770
|
+
rulePrefix: "MULTI",
|
|
771
|
+
serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": jsonish } }),
|
|
772
|
+
};
|
|
773
|
+
await writeProject("secrets/db.sops.yaml");
|
|
774
|
+
const outputPath = join(testDir, "dist", "manifests.yaml");
|
|
775
|
+
|
|
776
|
+
const result = await buildCommand({
|
|
777
|
+
path: testDir,
|
|
778
|
+
output: outputPath,
|
|
779
|
+
format: "yaml",
|
|
780
|
+
serializers: [jsonSidecar],
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
expect(result.errors).toEqual([]);
|
|
784
|
+
expect(readFileSync(join(testDir, "dist", "db.sops.yaml"), "utf-8")).toBe(jsonish);
|
|
785
|
+
});
|
|
786
|
+
});
|
|
787
|
+
|
|
708
788
|
test("op worker files go to <project>/dist/ops even with no --output (#878)", async () => {
|
|
709
789
|
// The generated Op worker must land where `chant run <op> --temporal` reads it
|
|
710
790
|
// (`<project>/dist/ops/<name>/worker.ts`) even when the build has no --output
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
isOwnershipParamRef,
|
|
8
8
|
resolveFoldEnabled,
|
|
9
9
|
resolveSandboxEnabled,
|
|
10
|
+
resolveKnowledgeDir,
|
|
10
11
|
} from "../../config";
|
|
12
|
+
import { loadOkfBundle } from "../../okf-read";
|
|
11
13
|
import { unknownEnvError } from "../../env";
|
|
12
14
|
import type { OwnershipMarker } from "../../ownership";
|
|
13
15
|
import { resolveCliBuildParams } from "../build-params-cli";
|
|
@@ -15,6 +17,10 @@ import type { Serializer, SerializerResult } from "../../serializer";
|
|
|
15
17
|
import type { LexiconPlugin } from "../../lexicon";
|
|
16
18
|
import { resolveLexiconVersions, collectBuildRootContributors } from "../plugins";
|
|
17
19
|
import { runPostSynthChecks } from "../../lint/post-synth";
|
|
20
|
+
import { collectSecretDeclarations } from "../../secret-provenance";
|
|
21
|
+
import { coreReceiptChecks } from "../../lint/receipt-checks";
|
|
22
|
+
import { coreOutputChecks } from "../../lint/output-checks";
|
|
23
|
+
import { coreKnowledgeChecks } from "../../lint/knowledge-checks";
|
|
18
24
|
import { applyConfiguredSeverity } from "../../lint/config";
|
|
19
25
|
import { loadPolicyChecks } from "../../lint/policy";
|
|
20
26
|
import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
|
|
@@ -382,6 +388,66 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
382
388
|
// finding just as it suppresses a pre-synth one. A finding it suppresses is
|
|
383
389
|
// counted, not dropped silently — see `suppressedPostSynthCount` below.
|
|
384
390
|
let suppressedPostSynthCount = 0;
|
|
391
|
+
|
|
392
|
+
// Core-owned post-synth checks over effect receipts (#1833). Receipts are
|
|
393
|
+
// recognized by marker, lexicon-independently, so this set runs over the
|
|
394
|
+
// FULL build result — unscoped, and regardless of which plugins loaded.
|
|
395
|
+
// Same severity-resolution funnel as every other post-synth finding.
|
|
396
|
+
if (result.errors.length === 0) {
|
|
397
|
+
const receiptDiags = runPostSynthChecks(coreReceiptChecks(), result, env);
|
|
398
|
+
const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(receiptDiags, config.lint?.rules);
|
|
399
|
+
suppressedPostSynthCount += suppressed.length;
|
|
400
|
+
for (const diag of activeDiags) {
|
|
401
|
+
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
402
|
+
const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
|
|
403
|
+
if (diag.severity === "error") {
|
|
404
|
+
errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
405
|
+
} else {
|
|
406
|
+
warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Core-owned post-synth check over serialized output text (#1526). The
|
|
412
|
+
// "[object Object]" marker means a reference got stringified instead of
|
|
413
|
+
// resolved; it can appear in any lexicon's emitted text, so this runs over
|
|
414
|
+
// the FULL build result the same way the receipt checks above do.
|
|
415
|
+
if (result.errors.length === 0) {
|
|
416
|
+
const outputDiags = runPostSynthChecks(coreOutputChecks(), result, env);
|
|
417
|
+
const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(outputDiags, config.lint?.rules);
|
|
418
|
+
suppressedPostSynthCount += suppressed.length;
|
|
419
|
+
for (const diag of activeDiags) {
|
|
420
|
+
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
421
|
+
const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
|
|
422
|
+
if (diag.severity === "error") {
|
|
423
|
+
errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
424
|
+
} else {
|
|
425
|
+
warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// Core-owned post-synth check over the project's OKF knowledge bundle
|
|
431
|
+
// (#1865, design #1059): a concept's `binds` naming no discovered entity is
|
|
432
|
+
// a stale binding, surfaced as a COR026 warning. `resolveKnowledgeDir` +
|
|
433
|
+
// `loadOkfBundle` never fail — a missing/absent `knowledge/` directory
|
|
434
|
+
// loads an empty bundle, so this is a no-op for a project that has none.
|
|
435
|
+
if (result.errors.length === 0) {
|
|
436
|
+
const bundle = await loadOkfBundle(resolveKnowledgeDir(config, configDir));
|
|
437
|
+
const knowledgeDiags = runPostSynthChecks(coreKnowledgeChecks(bundle), result, env);
|
|
438
|
+
const { diagnostics: activeDiags, suppressed } = applyConfiguredSeverity(knowledgeDiags, config.lint?.rules);
|
|
439
|
+
suppressedPostSynthCount += suppressed.length;
|
|
440
|
+
for (const diag of activeDiags) {
|
|
441
|
+
const prefix = diag.entity ? `[${diag.entity}] ` : "";
|
|
442
|
+
const lexiconSuffix = diag.lexicon ? ` (${diag.lexicon})` : "";
|
|
443
|
+
if (diag.severity === "error") {
|
|
444
|
+
errors.push(formatError({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
445
|
+
} else {
|
|
446
|
+
warnings.push(formatWarning({ message: `${prefix}${diag.message}${lexiconSuffix}` }));
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
385
451
|
if (result.errors.length === 0 && options.plugins) {
|
|
386
452
|
for (const plugin of options.plugins) {
|
|
387
453
|
if (!plugin.postSynthChecks) continue;
|
|
@@ -482,6 +548,20 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
482
548
|
// Extract primary content and collect additional files from SerializerResult
|
|
483
549
|
const additionalFiles = new Map<string, string>();
|
|
484
550
|
|
|
551
|
+
// Sidecar files that must reach disk BYTE-FOR-BYTE: committed ciphertext
|
|
552
|
+
// (`declareSecret({ provenance: "committed-encrypted", file })`). The
|
|
553
|
+
// additional-file writer below round-trips anything JSON.parse accepts,
|
|
554
|
+
// key-sorting it and possibly re-emitting it as YAML — which would break
|
|
555
|
+
// the `sops` MAC. `file` is restricted to .yaml/.yml, so that parse fails
|
|
556
|
+
// in practice, but naming the files here makes byte identity structural
|
|
557
|
+
// rather than a lucky accident.
|
|
558
|
+
const verbatimFiles = new Set<string>();
|
|
559
|
+
for (const decl of collectSecretDeclarations(result.entities).values()) {
|
|
560
|
+
if (decl.provenance === "committed-encrypted") {
|
|
561
|
+
verbatimFiles.add(decl.file.split(/[\\/]/).pop()!);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
485
565
|
function getPrimaryContent(raw: string | SerializerResult): string {
|
|
486
566
|
if (typeof raw === "string") return raw;
|
|
487
567
|
if (raw.files) {
|
|
@@ -587,15 +667,18 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
587
667
|
const outputDir = dirname(outputPath);
|
|
588
668
|
for (const [filename, content] of additionalFiles) {
|
|
589
669
|
let fileContent = content;
|
|
590
|
-
// Format additional files consistently
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
fileContent =
|
|
670
|
+
// Format additional files consistently — except the ones declared
|
|
671
|
+
// verbatim, which are copied exactly as they were committed.
|
|
672
|
+
if (!verbatimFiles.has(filename)) {
|
|
673
|
+
try {
|
|
674
|
+
const fileParsed = JSON.parse(content);
|
|
675
|
+
fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
|
|
676
|
+
if (options.format === "yaml") {
|
|
677
|
+
fileContent = jsonToYaml(JSON.parse(fileContent));
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
// If not JSON, write as-is
|
|
596
681
|
}
|
|
597
|
-
} catch {
|
|
598
|
-
// If not JSON, write as-is
|
|
599
682
|
}
|
|
600
683
|
const targetPath = join(outputDir, filename);
|
|
601
684
|
mkdirSync(dirname(targetPath), { recursive: true });
|
|
@@ -609,6 +692,21 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
|
|
|
609
692
|
})
|
|
610
693
|
);
|
|
611
694
|
}
|
|
695
|
+
} else if (verbatimFiles.size > 0) {
|
|
696
|
+
// A build carrying committed ciphertext and no --output has nowhere to
|
|
697
|
+
// put the sidecar. Echoing it to a terminal is not useful and dropping
|
|
698
|
+
// it silently is worse: the primary output would reference a Secret
|
|
699
|
+
// whose file never got written, and the miss would surface as a pod
|
|
700
|
+
// failing to start, far from its cause. Refuse, naming the flag.
|
|
701
|
+
const names = [...verbatimFiles].sort().join(", ");
|
|
702
|
+
errors.push(
|
|
703
|
+
formatError({
|
|
704
|
+
message:
|
|
705
|
+
`This build emits committed-encrypted secret file(s) (${names}) as sidecars, ` +
|
|
706
|
+
`which need a directory to be written into. Re-run with --output <path> ` +
|
|
707
|
+
`(the sidecars land beside it).`,
|
|
708
|
+
}),
|
|
709
|
+
);
|
|
612
710
|
} else {
|
|
613
711
|
// Print to stdout
|
|
614
712
|
console.log(output);
|
package/src/cli/commands/lint.ts
CHANGED
|
@@ -20,8 +20,9 @@ import { GENERATED_MARKER } from "../../discovery/files";
|
|
|
20
20
|
|
|
21
21
|
// Import config loader
|
|
22
22
|
import { loadConfig, resolveRulesForFile, resolveConfiguredSeverity, findProjectRoot } from "../../lint/config";
|
|
23
|
-
import { loadChantConfig } from "../../config";
|
|
23
|
+
import { loadChantConfig, resolveKnowledgeDir } from "../../config";
|
|
24
24
|
import type { LintProjectConfig } from "../../lint/rule";
|
|
25
|
+
import { loadOkfBundle, type OkfBundle } from "../../okf-read";
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Type guard to check if a value conforms to the LintRule interface.
|
|
@@ -454,10 +455,21 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
454
455
|
// runLint() call below via LintContext.projectConfig. Best-effort: a
|
|
455
456
|
// directory with no project config lints with those rules silent.
|
|
456
457
|
let projectConfig: LintProjectConfig | undefined;
|
|
458
|
+
// chant #1866 — the loaded OKF knowledge bundle, threaded into
|
|
459
|
+
// `formatStylish` below so its suppressed section can resolve `okf:`
|
|
460
|
+
// citations. Best-effort like `projectConfig` above (same
|
|
461
|
+
// `loadChantConfig` call): a directory with no chant config, or no
|
|
462
|
+
// `knowledge/` bundle, lints with citations rendering unresolved rather
|
|
463
|
+
// than a failure — `loadOkfBundle` already treats a missing directory as
|
|
464
|
+
// an empty bundle.
|
|
465
|
+
let knowledgeBundle: OkfBundle | undefined;
|
|
457
466
|
try {
|
|
458
|
-
|
|
467
|
+
const chantConfig = (await loadChantConfig(projectRoot)).config;
|
|
468
|
+
projectConfig = chantConfig as LintProjectConfig;
|
|
469
|
+
knowledgeBundle = await loadOkfBundle(resolveKnowledgeDir(chantConfig, projectRoot));
|
|
459
470
|
} catch {
|
|
460
471
|
projectConfig = undefined;
|
|
472
|
+
knowledgeBundle = undefined;
|
|
461
473
|
}
|
|
462
474
|
|
|
463
475
|
// Load all rules from lexicon plugins (core "chant" + lexicon-specific)
|
|
@@ -588,7 +600,7 @@ export async function lintCommand(options: LintOptions): Promise<LintResult> {
|
|
|
588
600
|
break;
|
|
589
601
|
case "stylish":
|
|
590
602
|
default:
|
|
591
|
-
output = formatStylish(diagnostics);
|
|
603
|
+
output = formatStylish(diagnostics, suppressed, knowledgeBundle);
|
|
592
604
|
break;
|
|
593
605
|
}
|
|
594
606
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
-
import { mkdtemp, readFile, rm, stat } from "node:fs/promises";
|
|
2
|
+
import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
5
5
|
import { DECLARABLE_MARKER, type Declarable } from "../../declarable";
|
|
@@ -90,4 +90,73 @@ describe("runExplain", () => {
|
|
|
90
90
|
const code = await runExplain(ctx({ format: "okf" }));
|
|
91
91
|
expect(code).toBe(1);
|
|
92
92
|
});
|
|
93
|
+
|
|
94
|
+
describe("knowledge sections (#1867)", () => {
|
|
95
|
+
let projectDir: string;
|
|
96
|
+
|
|
97
|
+
beforeEach(async () => {
|
|
98
|
+
projectDir = await mkdtemp(join(tmpdir(), "chant-explain-knowledge-"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
afterEach(async () => {
|
|
102
|
+
await rm(projectDir, { recursive: true, force: true });
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("omits the Knowledge section cleanly when no bundle exists", async () => {
|
|
106
|
+
const code = await runExplain(ctx({ path: projectDir }));
|
|
107
|
+
expect(code).toBe(0);
|
|
108
|
+
expect(logSpy.mock.calls[0][0]).not.toContain("## Knowledge");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("json omits the knowledge field cleanly when no bundle exists", async () => {
|
|
112
|
+
const code = await runExplain(ctx({ path: projectDir, format: "json" }));
|
|
113
|
+
expect(code).toBe(0);
|
|
114
|
+
const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
115
|
+
expect(parsed.knowledge).toBeUndefined();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("markdown includes a per-entity Knowledge section for bound concepts", async () => {
|
|
119
|
+
await mkdir(join(projectDir, "knowledge", "decisions"), { recursive: true });
|
|
120
|
+
await writeFile(
|
|
121
|
+
join(projectDir, "knowledge", "decisions", "public-vpc.md"),
|
|
122
|
+
"---\ntype: decision\ntitle: Public VPC\nbinds: vpc\n---\nWhy the VPC is public.\n",
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
const code = await runExplain(ctx({ path: projectDir }));
|
|
126
|
+
expect(code).toBe(0);
|
|
127
|
+
const out = logSpy.mock.calls[0][0] as string;
|
|
128
|
+
expect(out).toContain("## Knowledge");
|
|
129
|
+
expect(out).toContain("### `vpc`");
|
|
130
|
+
expect(out).toContain("decision: Public VPC (`decisions/public-vpc.md`)");
|
|
131
|
+
expect(out).not.toContain("### `subnet`");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("json carries type/title/path per bound entity", async () => {
|
|
135
|
+
await mkdir(join(projectDir, "knowledge", "decisions"), { recursive: true });
|
|
136
|
+
await writeFile(
|
|
137
|
+
join(projectDir, "knowledge", "decisions", "public-vpc.md"),
|
|
138
|
+
"---\ntype: decision\ntitle: Public VPC\nbinds: vpc\n---\nWhy the VPC is public.\n",
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const code = await runExplain(ctx({ path: projectDir, format: "json" }));
|
|
142
|
+
expect(code).toBe(0);
|
|
143
|
+
const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
144
|
+
expect(parsed.knowledge).toEqual({
|
|
145
|
+
vpc: [{ type: "decision", title: "Public VPC", path: "decisions/public-vpc.md" }],
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("--format okf output is untouched by a present bundle", async () => {
|
|
150
|
+
await mkdir(join(projectDir, "knowledge"), { recursive: true });
|
|
151
|
+
await writeFile(
|
|
152
|
+
join(projectDir, "knowledge", "note.md"),
|
|
153
|
+
"---\ntype: decision\ntitle: Note\nbinds: vpc\n---\nBody.\n",
|
|
154
|
+
);
|
|
155
|
+
|
|
156
|
+
const code = await runExplain(ctx({ path: projectDir, format: "okf" }));
|
|
157
|
+
expect(code).toBe(0);
|
|
158
|
+
const parsed = JSON.parse(logSpy.mock.calls[0][0] as string);
|
|
159
|
+
expect(Object.keys(parsed.files).sort()).toEqual(["gcp/subnet.md", "gcp/vpc.md", "index.md"]);
|
|
160
|
+
});
|
|
161
|
+
});
|
|
93
162
|
});
|
|
@@ -77,7 +77,7 @@ async function graphBuildParams(
|
|
|
77
77
|
async function graphBuildRootContributors(
|
|
78
78
|
ctx: CommandContext,
|
|
79
79
|
projectPath: string,
|
|
80
|
-
): Promise<Array<
|
|
80
|
+
): Promise<Array<import("../../lexicon").BuildRootContributor>> {
|
|
81
81
|
const { config, configPath } = await loadChantConfigUpward(projectPath).catch(
|
|
82
82
|
() => ({ config: {} as ChantConfig, configPath: undefined }),
|
|
83
83
|
);
|
|
@@ -109,7 +109,7 @@ async function graphBuildRootContributors(
|
|
|
109
109
|
*/
|
|
110
110
|
async function mergeGraphBuildRoots(
|
|
111
111
|
entities: Map<string, import("../../declarable").Declarable>,
|
|
112
|
-
contributors: Array<
|
|
112
|
+
contributors: Array<import("../../lexicon").BuildRootContributor>,
|
|
113
113
|
): Promise<boolean> {
|
|
114
114
|
if (contributors.length === 0) return true;
|
|
115
115
|
const merged = await mergeBuildRootEntities(entities, contributors);
|
|
@@ -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
|
|