@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.
Files changed (183) hide show
  1. package/dist/audit/core.d.ts +21 -3
  2. package/dist/audit/core.d.ts.map +1 -1
  3. package/dist/audit/discover.d.ts +3 -2
  4. package/dist/audit/discover.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/build.d.ts +3 -3
  7. package/dist/build.d.ts.map +1 -1
  8. package/dist/cli/commands/build.d.ts.map +1 -1
  9. package/dist/cli/commands/lint.d.ts.map +1 -1
  10. package/dist/cli/handlers/lifecycle.d.ts +1 -1
  11. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  12. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  13. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  14. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  15. package/dist/cli/plugins.d.ts +1 -1
  16. package/dist/cli/plugins.d.ts.map +1 -1
  17. package/dist/cli/reporters/stylish.d.ts +15 -1
  18. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  19. package/dist/components/auto-release.d.ts +4 -0
  20. package/dist/components/auto-release.d.ts.map +1 -1
  21. package/dist/components/starter-plugin.d.ts +2 -0
  22. package/dist/components/starter-plugin.d.ts.map +1 -1
  23. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  24. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  25. package/dist/components/verbs/index.d.ts +8 -0
  26. package/dist/components/verbs/index.d.ts.map +1 -1
  27. package/dist/components/verbs/r2-sync.d.ts +76 -0
  28. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  29. package/dist/components/verbs/wrangler.d.ts +108 -0
  30. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  31. package/dist/config.d.ts +26 -0
  32. package/dist/config.d.ts.map +1 -1
  33. package/dist/deep-observation.d.ts +14 -0
  34. package/dist/deep-observation.d.ts.map +1 -1
  35. package/dist/effect-receipt.d.ts +177 -0
  36. package/dist/effect-receipt.d.ts.map +1 -0
  37. package/dist/fold/subset.d.ts +15 -2
  38. package/dist/fold/subset.d.ts.map +1 -1
  39. package/dist/index.d.ts +4 -0
  40. package/dist/index.d.ts.map +1 -1
  41. package/dist/lexicon.d.ts +44 -3
  42. package/dist/lexicon.d.ts.map +1 -1
  43. package/dist/lifecycle/change-set.d.ts +33 -5
  44. package/dist/lifecycle/change-set.d.ts.map +1 -1
  45. package/dist/lifecycle/index.d.ts +2 -0
  46. package/dist/lifecycle/index.d.ts.map +1 -1
  47. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  48. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  49. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  50. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  51. package/dist/lifecycle/release-ledger.d.ts +20 -0
  52. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  53. package/dist/lifecycle/teardown.d.ts +6 -4
  54. package/dist/lifecycle/teardown.d.ts.map +1 -1
  55. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  56. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  57. package/dist/lint/knowledge-checks.d.ts +48 -0
  58. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  59. package/dist/lint/output-checks.d.ts +5 -0
  60. package/dist/lint/output-checks.d.ts.map +1 -0
  61. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  62. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  63. package/dist/lint/post-synth.d.ts +12 -0
  64. package/dist/lint/post-synth.d.ts.map +1 -1
  65. package/dist/lint/receipt-checks.d.ts +9 -0
  66. package/dist/lint/receipt-checks.d.ts.map +1 -0
  67. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  68. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  69. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  70. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  71. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  72. package/dist/lint/rules/index.d.ts +3 -1
  73. package/dist/lint/rules/index.d.ts.map +1 -1
  74. package/dist/okf-read.d.ts +78 -0
  75. package/dist/okf-read.d.ts.map +1 -0
  76. package/dist/op/builders.d.ts +98 -1
  77. package/dist/op/builders.d.ts.map +1 -1
  78. package/dist/op/index.d.ts +4 -2
  79. package/dist/op/index.d.ts.map +1 -1
  80. package/dist/op/local-executor.d.ts +2 -1
  81. package/dist/op/local-executor.d.ts.map +1 -1
  82. package/dist/op/receipt-store.d.ts +138 -0
  83. package/dist/op/receipt-store.d.ts.map +1 -0
  84. package/dist/op/types.d.ts +31 -1
  85. package/dist/op/types.d.ts.map +1 -1
  86. package/dist/secret-materialization.d.ts +138 -0
  87. package/dist/secret-materialization.d.ts.map +1 -0
  88. package/dist/secret-provenance.d.ts +218 -0
  89. package/dist/secret-provenance.d.ts.map +1 -0
  90. package/dist/serializer.d.ts +11 -0
  91. package/dist/serializer.d.ts.map +1 -1
  92. package/dist/yaml.d.ts.map +1 -1
  93. package/package.json +4 -1
  94. package/src/audit/core.test.ts +57 -0
  95. package/src/audit/core.ts +0 -0
  96. package/src/audit/detect-bundle.test.ts +1 -1
  97. package/src/audit/discover.test.ts +24 -0
  98. package/src/audit/discover.ts +11 -2
  99. package/src/audit/rules-doc.ts +11 -1
  100. package/src/build.test.ts +41 -0
  101. package/src/build.ts +34 -6
  102. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  103. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  104. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  105. package/src/cli/commands/audit.test.ts +53 -0
  106. package/src/cli/commands/audit.ts +1 -1
  107. package/src/cli/commands/build.test.ts +80 -0
  108. package/src/cli/commands/build.ts +106 -8
  109. package/src/cli/commands/lint.ts +15 -3
  110. package/src/cli/handlers/explain.test.ts +70 -1
  111. package/src/cli/handlers/graph.ts +2 -2
  112. package/src/cli/handlers/lifecycle.test.ts +115 -1
  113. package/src/cli/handlers/lifecycle.ts +84 -11
  114. package/src/cli/mcp/resource-handlers.ts +38 -1
  115. package/src/cli/mcp/server.test.ts +58 -1
  116. package/src/cli/mcp/tools/explain.ts +51 -2
  117. package/src/cli/plugins.ts +4 -2
  118. package/src/cli/reporters/stylish.test.ts +154 -0
  119. package/src/cli/reporters/stylish.ts +154 -33
  120. package/src/components/auto-release.ts +6 -0
  121. package/src/components/registry.test.ts +7 -2
  122. package/src/components/starter-plugin.ts +17 -0
  123. package/src/components/verbs/ensure-secret.test.ts +130 -0
  124. package/src/components/verbs/ensure-secret.ts +79 -0
  125. package/src/components/verbs/index.ts +8 -0
  126. package/src/components/verbs/r2-sync.test.ts +107 -0
  127. package/src/components/verbs/r2-sync.ts +124 -0
  128. package/src/components/verbs/wrangler.test.ts +170 -0
  129. package/src/components/verbs/wrangler.ts +241 -0
  130. package/src/config.test.ts +15 -0
  131. package/src/config.ts +30 -0
  132. package/src/deep-observation.test.ts +19 -0
  133. package/src/deep-observation.ts +17 -0
  134. package/src/effect-receipt-exclusion.test.ts +190 -0
  135. package/src/effect-receipt.test.ts +419 -0
  136. package/src/effect-receipt.ts +412 -0
  137. package/src/fold/subset.test.ts +26 -0
  138. package/src/fold/subset.ts +45 -19
  139. package/src/index.ts +4 -0
  140. package/src/lexicon.ts +48 -3
  141. package/src/lifecycle/change-set.ts +46 -7
  142. package/src/lifecycle/index.ts +2 -0
  143. package/src/lifecycle/observation-baseline.test.ts +46 -0
  144. package/src/lifecycle/observation-baseline.ts +33 -1
  145. package/src/lifecycle/receipt-plan.test.ts +250 -0
  146. package/src/lifecycle/receipt-plan.ts +249 -0
  147. package/src/lifecycle/release-ledger.ts +20 -0
  148. package/src/lifecycle/teardown.test.ts +31 -0
  149. package/src/lifecycle/teardown.ts +6 -4
  150. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  151. package/src/lifecycle/unobserved-gate.ts +102 -0
  152. package/src/lint/knowledge-checks.test.ts +80 -0
  153. package/src/lint/knowledge-checks.ts +74 -0
  154. package/src/lint/output-checks.test.ts +85 -0
  155. package/src/lint/output-checks.ts +99 -0
  156. package/src/lint/pipeline-change-gate.test.ts +144 -0
  157. package/src/lint/pipeline-change-gate.ts +153 -0
  158. package/src/lint/post-synth.ts +15 -0
  159. package/src/lint/receipt-checks.test.ts +101 -0
  160. package/src/lint/receipt-checks.ts +93 -0
  161. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  162. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  163. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  164. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  165. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  166. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  167. package/src/lint/rules/index.ts +7 -1
  168. package/src/okf-read.test.ts +149 -0
  169. package/src/okf-read.ts +197 -0
  170. package/src/op/builders.ts +139 -1
  171. package/src/op/effect-step.test.ts +311 -0
  172. package/src/op/index.ts +10 -3
  173. package/src/op/local-executor.ts +172 -25
  174. package/src/op/op.test.ts +25 -2
  175. package/src/op/receipt-store.ts +211 -0
  176. package/src/op/types.ts +33 -1
  177. package/src/secret-materialization.test.ts +199 -0
  178. package/src/secret-materialization.ts +235 -0
  179. package/src/secret-provenance.test.ts +388 -0
  180. package/src/secret-provenance.ts +475 -0
  181. package/src/serializer.ts +12 -0
  182. package/src/yaml.test.ts +88 -0
  183. 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, or Config Connector either.");
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
- try {
592
- const fileParsed = JSON.parse(content);
593
- fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
594
- if (options.format === "yaml") {
595
- fileContent = jsonToYaml(JSON.parse(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);
@@ -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
- projectConfig = (await loadChantConfig(projectRoot)).config as LintProjectConfig;
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<() => Promise<import("../../lexicon").BuildRootContribution>>> {
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<() => Promise<import("../../lexicon").BuildRootContribution>>,
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