@intentius/chant 0.45.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 (223) 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/check-lexicon.d.ts +14 -0
  10. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  11. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  12. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  13. package/dist/cli/commands/lint.d.ts.map +1 -1
  14. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  15. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  16. package/dist/cli/handlers/search.d.ts.map +1 -1
  17. package/dist/cli/main.d.ts.map +1 -1
  18. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  19. package/dist/cli/mcp/tools/explain.d.ts +6 -0
  20. package/dist/cli/mcp/tools/explain.d.ts.map +1 -1
  21. package/dist/cli/plugins.d.ts +1 -1
  22. package/dist/cli/plugins.d.ts.map +1 -1
  23. package/dist/cli/registry.d.ts +7 -0
  24. package/dist/cli/registry.d.ts.map +1 -1
  25. package/dist/cli/reporters/stylish.d.ts +15 -1
  26. package/dist/cli/reporters/stylish.d.ts.map +1 -1
  27. package/dist/codegen/lexicon-regen.d.ts +11 -0
  28. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  29. package/dist/codegen/validate.d.ts +10 -0
  30. package/dist/codegen/validate.d.ts.map +1 -1
  31. package/dist/components/auto-release.d.ts +4 -0
  32. package/dist/components/auto-release.d.ts.map +1 -1
  33. package/dist/components/starter-plugin.d.ts +2 -0
  34. package/dist/components/starter-plugin.d.ts.map +1 -1
  35. package/dist/components/verbs/ensure-secret.d.ts +50 -0
  36. package/dist/components/verbs/ensure-secret.d.ts.map +1 -0
  37. package/dist/components/verbs/index.d.ts +8 -0
  38. package/dist/components/verbs/index.d.ts.map +1 -1
  39. package/dist/components/verbs/r2-sync.d.ts +76 -0
  40. package/dist/components/verbs/r2-sync.d.ts.map +1 -0
  41. package/dist/components/verbs/wrangler.d.ts +108 -0
  42. package/dist/components/verbs/wrangler.d.ts.map +1 -0
  43. package/dist/config.d.ts +54 -0
  44. package/dist/config.d.ts.map +1 -1
  45. package/dist/deep-observation.d.ts +14 -0
  46. package/dist/deep-observation.d.ts.map +1 -1
  47. package/dist/effect-receipt.d.ts +177 -0
  48. package/dist/effect-receipt.d.ts.map +1 -0
  49. package/dist/env.d.ts +12 -1
  50. package/dist/env.d.ts.map +1 -1
  51. package/dist/fold/subset.d.ts +15 -2
  52. package/dist/fold/subset.d.ts.map +1 -1
  53. package/dist/index.d.ts +4 -0
  54. package/dist/index.d.ts.map +1 -1
  55. package/dist/lexicon.d.ts +223 -2
  56. package/dist/lexicon.d.ts.map +1 -1
  57. package/dist/lifecycle/change-set.d.ts +33 -5
  58. package/dist/lifecycle/change-set.d.ts.map +1 -1
  59. package/dist/lifecycle/index.d.ts +3 -0
  60. package/dist/lifecycle/index.d.ts.map +1 -1
  61. package/dist/lifecycle/observation-baseline.d.ts +21 -3
  62. package/dist/lifecycle/observation-baseline.d.ts.map +1 -1
  63. package/dist/lifecycle/receipt-plan.d.ts +62 -0
  64. package/dist/lifecycle/receipt-plan.d.ts.map +1 -0
  65. package/dist/lifecycle/release-ledger.d.ts +20 -0
  66. package/dist/lifecycle/release-ledger.d.ts.map +1 -1
  67. package/dist/lifecycle/teardown.d.ts +132 -0
  68. package/dist/lifecycle/teardown.d.ts.map +1 -0
  69. package/dist/lifecycle/unobserved-gate.d.ts +67 -0
  70. package/dist/lifecycle/unobserved-gate.d.ts.map +1 -0
  71. package/dist/lint/engine.d.ts +6 -2
  72. package/dist/lint/engine.d.ts.map +1 -1
  73. package/dist/lint/knowledge-checks.d.ts +48 -0
  74. package/dist/lint/knowledge-checks.d.ts.map +1 -0
  75. package/dist/lint/output-checks.d.ts +5 -0
  76. package/dist/lint/output-checks.d.ts.map +1 -0
  77. package/dist/lint/pipeline-change-gate.d.ts +101 -0
  78. package/dist/lint/pipeline-change-gate.d.ts.map +1 -0
  79. package/dist/lint/post-synth.d.ts +12 -0
  80. package/dist/lint/post-synth.d.ts.map +1 -1
  81. package/dist/lint/receipt-checks.d.ts +9 -0
  82. package/dist/lint/receipt-checks.d.ts.map +1 -0
  83. package/dist/lint/rule.d.ts +31 -0
  84. package/dist/lint/rule.d.ts.map +1 -1
  85. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  86. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  87. package/dist/lint/rules/cor022-receipt-leaf.d.ts +13 -0
  88. package/dist/lint/rules/cor022-receipt-leaf.d.ts.map +1 -0
  89. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts +3 -0
  90. package/dist/lint/rules/cor024-receipt-secret-pointer.d.ts.map +1 -0
  91. package/dist/lint/rules/evl001-non-literal-expression.d.ts.map +1 -1
  92. package/dist/lint/rules/index.d.ts +4 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/okf-read.d.ts +78 -0
  95. package/dist/okf-read.d.ts.map +1 -0
  96. package/dist/op/builders.d.ts +134 -8
  97. package/dist/op/builders.d.ts.map +1 -1
  98. package/dist/op/index.d.ts +4 -2
  99. package/dist/op/index.d.ts.map +1 -1
  100. package/dist/op/local-executor.d.ts +2 -1
  101. package/dist/op/local-executor.d.ts.map +1 -1
  102. package/dist/op/receipt-store.d.ts +138 -0
  103. package/dist/op/receipt-store.d.ts.map +1 -0
  104. package/dist/op/types.d.ts +31 -1
  105. package/dist/op/types.d.ts.map +1 -1
  106. package/dist/secret-materialization.d.ts +138 -0
  107. package/dist/secret-materialization.d.ts.map +1 -0
  108. package/dist/secret-provenance.d.ts +218 -0
  109. package/dist/secret-provenance.d.ts.map +1 -0
  110. package/dist/serializer.d.ts +11 -0
  111. package/dist/serializer.d.ts.map +1 -1
  112. package/dist/testing.d.ts +136 -0
  113. package/dist/testing.d.ts.map +1 -0
  114. package/dist/yaml.d.ts.map +1 -1
  115. package/package.json +9 -1
  116. package/src/audit/core.test.ts +57 -0
  117. package/src/audit/core.ts +0 -0
  118. package/src/audit/detect-bundle.test.ts +1 -1
  119. package/src/audit/discover.test.ts +24 -0
  120. package/src/audit/discover.ts +11 -2
  121. package/src/audit/rules-doc.ts +11 -1
  122. package/src/build.test.ts +41 -0
  123. package/src/build.ts +34 -6
  124. package/src/cli/commands/__fixtures__/audit-fountain/agents/fleet.yaml +27 -0
  125. package/src/cli/commands/__fixtures__/audit-fountain/k8s/deploy.yaml +16 -0
  126. package/src/cli/commands/__fixtures__/audit-fountain-clean/fleet.yaml +20 -0
  127. package/src/cli/commands/audit.test.ts +53 -0
  128. package/src/cli/commands/audit.ts +1 -1
  129. package/src/cli/commands/build.test.ts +211 -0
  130. package/src/cli/commands/build.ts +126 -8
  131. package/src/cli/commands/check-lexicon.test.ts +45 -1
  132. package/src/cli/commands/check-lexicon.ts +45 -0
  133. package/src/cli/commands/lexicon-surface-diff.ts +9 -0
  134. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  135. package/src/cli/commands/lint.ts +32 -7
  136. package/src/cli/handlers/explain.test.ts +70 -1
  137. package/src/cli/handlers/graph.ts +4 -4
  138. package/src/cli/handlers/lifecycle.test.ts +345 -1
  139. package/src/cli/handlers/lifecycle.ts +300 -11
  140. package/src/cli/handlers/search.ts +5 -2
  141. package/src/cli/main.ts +12 -1
  142. package/src/cli/mcp/resource-handlers.ts +38 -1
  143. package/src/cli/mcp/server.test.ts +58 -1
  144. package/src/cli/mcp/tools/explain.ts +51 -2
  145. package/src/cli/plugins.ts +4 -2
  146. package/src/cli/registry.ts +7 -0
  147. package/src/cli/reporters/stylish.test.ts +154 -0
  148. package/src/cli/reporters/stylish.ts +154 -33
  149. package/src/codegen/lexicon-regen.ts +19 -1
  150. package/src/codegen/validate.test.ts +33 -0
  151. package/src/codegen/validate.ts +21 -2
  152. package/src/components/auto-release.ts +6 -0
  153. package/src/components/registry.test.ts +7 -2
  154. package/src/components/starter-plugin.ts +17 -0
  155. package/src/components/verbs/ensure-secret.test.ts +130 -0
  156. package/src/components/verbs/ensure-secret.ts +79 -0
  157. package/src/components/verbs/index.ts +8 -0
  158. package/src/components/verbs/r2-sync.test.ts +107 -0
  159. package/src/components/verbs/r2-sync.ts +124 -0
  160. package/src/components/verbs/wrangler.test.ts +170 -0
  161. package/src/components/verbs/wrangler.ts +241 -0
  162. package/src/config.test.ts +55 -0
  163. package/src/config.ts +88 -1
  164. package/src/deep-observation.test.ts +19 -0
  165. package/src/deep-observation.ts +17 -0
  166. package/src/effect-receipt-exclusion.test.ts +190 -0
  167. package/src/effect-receipt.test.ts +419 -0
  168. package/src/effect-receipt.ts +412 -0
  169. package/src/env.test.ts +35 -1
  170. package/src/env.ts +17 -3
  171. package/src/fold/subset.test.ts +26 -0
  172. package/src/fold/subset.ts +45 -19
  173. package/src/index.ts +4 -0
  174. package/src/lexicon.ts +227 -2
  175. package/src/lifecycle/change-set.ts +46 -7
  176. package/src/lifecycle/index.ts +3 -0
  177. package/src/lifecycle/observation-baseline.test.ts +46 -0
  178. package/src/lifecycle/observation-baseline.ts +33 -1
  179. package/src/lifecycle/receipt-plan.test.ts +250 -0
  180. package/src/lifecycle/receipt-plan.ts +249 -0
  181. package/src/lifecycle/release-ledger.ts +20 -0
  182. package/src/lifecycle/teardown.test.ts +568 -0
  183. package/src/lifecycle/teardown.ts +359 -0
  184. package/src/lifecycle/unobserved-gate.test.ts +109 -0
  185. package/src/lifecycle/unobserved-gate.ts +102 -0
  186. package/src/lint/engine.ts +7 -1
  187. package/src/lint/knowledge-checks.test.ts +80 -0
  188. package/src/lint/knowledge-checks.ts +74 -0
  189. package/src/lint/output-checks.test.ts +85 -0
  190. package/src/lint/output-checks.ts +99 -0
  191. package/src/lint/pipeline-change-gate.test.ts +144 -0
  192. package/src/lint/pipeline-change-gate.ts +153 -0
  193. package/src/lint/post-synth.ts +15 -0
  194. package/src/lint/receipt-checks.test.ts +101 -0
  195. package/src/lint/receipt-checks.ts +93 -0
  196. package/src/lint/rule.ts +23 -0
  197. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  198. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  199. package/src/lint/rules/cor022-receipt-leaf.test.ts +116 -0
  200. package/src/lint/rules/cor022-receipt-leaf.ts +130 -0
  201. package/src/lint/rules/cor024-receipt-secret-pointer.test.ts +121 -0
  202. package/src/lint/rules/cor024-receipt-secret-pointer.ts +218 -0
  203. package/src/lint/rules/evl001-non-literal-expression.test.ts +27 -0
  204. package/src/lint/rules/evl001-non-literal-expression.ts +8 -1
  205. package/src/lint/rules/index.ts +10 -1
  206. package/src/okf-read.test.ts +149 -0
  207. package/src/okf-read.ts +197 -0
  208. package/src/op/builders.ts +179 -8
  209. package/src/op/effect-step.test.ts +311 -0
  210. package/src/op/index.ts +10 -3
  211. package/src/op/local-executor.ts +172 -25
  212. package/src/op/op.test.ts +25 -2
  213. package/src/op/receipt-store.ts +211 -0
  214. package/src/op/types.ts +33 -1
  215. package/src/secret-materialization.test.ts +199 -0
  216. package/src/secret-materialization.ts +235 -0
  217. package/src/secret-provenance.test.ts +388 -0
  218. package/src/secret-provenance.ts +475 -0
  219. package/src/serializer.ts +12 -0
  220. package/src/testing.test.ts +261 -0
  221. package/src/testing.ts +338 -0
  222. package/src/yaml.test.ts +88 -0
  223. package/src/yaml.ts +76 -6
@@ -42,6 +42,29 @@ describe("discoverByDetection (unified, detectTemplate-driven)", () => {
42
42
  expect(found.some((i) => i.lexicon === "k8s")).toBe(false);
43
43
  });
44
44
 
45
+ test("fountain wins over k8s in a mixed repo — each manifest classifies to its own lexicon (#1566)", async () => {
46
+ const plugins = await loadAuditPlugins();
47
+ const found = discoverByDetection(fixture("audit-fountain"), plugins);
48
+ expect(targets(found)).toEqual(["fountain:agents/fleet.yaml", "k8s:k8s/deploy.yaml"]);
49
+ });
50
+
51
+ test("a multi-document fountain file classifies as one fountain input (no document dropped)", async () => {
52
+ const plugins = await loadAuditPlugins();
53
+ const found = discoverByDetection(fixture("audit-fountain"), plugins);
54
+ const fountain = found.find((i) => i.lexicon === "fountain")!;
55
+ // All three documents ride along in the input's content.
56
+ expect(fountain.content.match(/apiVersion: fountain\.dev\/v1/g)).toHaveLength(3);
57
+ });
58
+
59
+ test("without the fountain plugin, fountain YAML falls to k8s (not-installed convention)", () => {
60
+ const files = [{ path: "fleet.yaml", content: "apiVersion: fountain.dev/v1\nkind: Environment\nmetadata:\n name: dev\n" }];
61
+ const k8s = { name: "k8s", detectTemplate: (d: unknown) => !!d && typeof d === "object" && "apiVersion" in (d as object) && "kind" in (d as object) };
62
+ expect(classifyFiles(files, [k8s]).map((i) => i.lexicon)).toEqual(["k8s"]);
63
+ // with a fountain plugin present, fountain claims it first
64
+ const fountain = { name: "fountain", detectTemplate: (d: unknown) => (d as { apiVersion?: unknown })?.apiVersion === "fountain.dev/v1" };
65
+ expect(classifyFiles(files, [k8s, fountain]).map((i) => i.lexicon)).toEqual(["fountain"]);
66
+ });
67
+
45
68
  test("detects a Helm chart as a bundle, not loose manifests", async () => {
46
69
  const plugins = await loadAuditPlugins();
47
70
  const found = discoverByDetection(fixture("audit-helm"), plugins);
@@ -80,6 +103,7 @@ describe("lexicon hints for unclaimed files (#1623)", () => {
80
103
  expect(hintLexiconForFile("charts/x/Chart.yaml", "name: x\n")).toBe("helm");
81
104
  expect(hintLexiconForFile("infra/main.tf", "resource {}\n")).toBe("terraform");
82
105
  expect(hintLexiconForFile("k8s/deploy.yaml", "apiVersion: apps/v1\nkind: Deployment\n")).toBe("k8s");
106
+ expect(hintLexiconForFile("agents/fleet.yaml", "apiVersion: fountain.dev/v1\nkind: Environment\n")).toBe("fountain");
83
107
  expect(hintLexiconForFile("gcp/bucket.yaml", "apiVersion: storage.cnrm.cloud.google.com/v1beta1\nkind: StorageBucket\n")).toBe("gcp");
84
108
  expect(hintLexiconForFile("cfn/stack.yaml", "AWSTemplateFormatVersion: '2010-09-09'\nResources: {}\n")).toBe("aws");
85
109
  expect(hintLexiconForFile("cfn/stack.json", '{"Resources":{"B":{"Type":"AWS::S3::Bucket"}}}')).toBe("aws");
@@ -22,7 +22,8 @@
22
22
  * - Helm charts are a directory BUNDLE keyed by `Chart.yaml`; the helm checks
23
23
  * read `output.files`, so the whole chart is one AuditInput.
24
24
  * - k8s `detectTemplate` matches any `apiVersion`+`kind`, including GCP Config
25
- * Connector (`cnrm.cloud.google.com`) resources, so gcp must be tried first.
25
+ * Connector (`cnrm.cloud.google.com`) resources and fountain
26
+ * (`fountain.dev/v1`) manifests, so gcp and fountain must be tried first.
26
27
  * - aws/azure checks `JSON.parse` their input, so YAML/templated content is
27
28
  * normalized to a JSON string here.
28
29
  */
@@ -34,7 +35,7 @@ import type { LexiconPlugin } from "../lexicon";
34
35
  import type { AuditInput, AuditLexicon } from "./core";
35
36
 
36
37
  /** Lexicons the auditor knows how to detect and run checks for. */
37
- export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm"] as const;
38
+ export const AUDIT_LEXICONS = ["github", "gitlab", "forgejo", "k8s", "docker", "aws", "azure", "gcp", "helm", "fountain"] as const;
38
39
 
39
40
  /**
40
41
  * Load the plugins used for detection. Each is loaded in isolation (no
@@ -170,6 +171,13 @@ const CONTENT_DETECTORS: ContentDetector[] = [
170
171
  accepts: isYaml,
171
172
  detect: (plugin, _name, content) => (anyDoc(plugin, content) ? content : null),
172
173
  },
174
+ {
175
+ // fountain (`apiVersion: fountain.dev/v1`) — must win over k8s, whose
176
+ // detectTemplate matches any apiVersion+kind document (#1566).
177
+ lexicon: "fountain",
178
+ accepts: isYaml,
179
+ detect: (plugin, _name, content) => (anyDoc(plugin, content) ? content : null),
180
+ },
173
181
  {
174
182
  lexicon: "k8s",
175
183
  accepts: isYaml,
@@ -263,6 +271,7 @@ export function hintLexiconForFile(path: string, content: string): LexiconHint |
263
271
  if (/\.tf$/i.test(name)) return "terraform";
264
272
  const head = content.slice(0, 64 * 1024);
265
273
  if (/cnrm\.cloud\.google\.com/.test(head)) return "gcp";
274
+ if (/fountain\.dev\/v1/.test(head)) return "fountain";
266
275
  if (/AWSTemplateFormatVersion|["']?Type["']?\s*:\s*["']AWS::/.test(head)) return "aws";
267
276
  if (/deploymentTemplate\.json/.test(head)) return "azure";
268
277
  if (isYaml(name)) {
@@ -20,14 +20,24 @@ const GROUPS: Array<{ heading: string; prefixes: string[]; blurb: string }> = [
20
20
  { heading: "Azure ARM (AZR)", prefixes: ["AZR"], blurb: "Run against ARM deployment templates (JSON)." },
21
21
  { heading: "GCP Config Connector (WGC)", prefixes: ["WGC"], blurb: "Run against Config Connector (cnrm.cloud.google.com) manifests." },
22
22
  { heading: "Helm (WHM)", prefixes: ["WHM"], blurb: "Run against Helm charts (Chart.yaml + templates)." },
23
+ { heading: "fountain (FTN)", prefixes: ["FTN"], blurb: "Run against fountain manifests (`apiVersion: fountain.dev/v1`) — standalone `fountain apply` YAML is parsed back into the entity graph, so the same rules fire on `chant build` and `chant audit`." },
23
24
  ];
24
25
 
26
+ /**
27
+ * MDX treats `{expr}` as a JavaScript expression and `<x` as JSX, so rule
28
+ * text containing literal braces (`${VAR}` substitution references) or angle
29
+ * brackets must be escaped or the docs build fails at render time.
30
+ */
31
+ function mdxEscape(text: string): string {
32
+ return text.replace(/\{/g, "\\{").replace(/</g, "\\<");
33
+ }
34
+
25
35
  function ruleBlock(m: RuleMeta): string {
26
36
  const tags = `${m.tier} · ${m.fixKind}`;
27
37
  const authority = m.authority?.length
28
38
  ? `\n\nAuthority: ${m.authority.map((a) => `[${a.name}](${a.url})`).join(" · ")}`
29
39
  : "";
30
- return `### ${m.id}\n\n**${m.title}** — ${tags}\n\n${m.remediation}${authority}`;
40
+ return `### ${m.id}\n\n**${mdxEscape(m.title)}** — ${tags}\n\n${mdxEscape(m.remediation)}${authority}`;
31
41
  }
32
42
 
33
43
  /**
package/src/build.test.ts CHANGED
@@ -338,6 +338,47 @@ export const discovered = {
338
338
  expect(result.entities.get("discovered")?.entityType).toBe("Discovered");
339
339
  });
340
340
 
341
+ test("a contributor sees the discovered entities, read-only", async () => {
342
+ await writeFile(
343
+ join(testDir, "app.infra.ts"),
344
+ `
345
+ export const discovered = {
346
+ lexicon: "test",
347
+ entityType: "Discovered",
348
+ props: {},
349
+ [Symbol.for("chant.declarable")]: true,
350
+ };
351
+ `,
352
+ );
353
+
354
+ let seenNames: string[] = [];
355
+ const result = await build(testDir, [recordingSerializer([])], undefined, {
356
+ buildRoots: [
357
+ async (ctx) => {
358
+ seenNames = [...(ctx.entities?.keys() ?? [])];
359
+ return { entities: new Map() };
360
+ },
361
+ ],
362
+ });
363
+
364
+ expect(result.errors).toEqual([]);
365
+ expect(seenNames).toContain("discovered");
366
+ });
367
+
368
+ test("a later contributor sees what an earlier one contributed", async () => {
369
+ const seen: string[][] = [];
370
+ await build(testDir, [recordingSerializer([])], undefined, {
371
+ buildRoots: [
372
+ async () => ({ entities: new Map([["first", entity("First")]]) }),
373
+ async (ctx) => {
374
+ seen.push([...(ctx.entities?.keys() ?? [])]);
375
+ return { entities: new Map() };
376
+ },
377
+ ],
378
+ });
379
+ expect(seen[0]).toContain("first");
380
+ });
381
+
341
382
  test("contributors run exactly once per build", async () => {
342
383
  let calls = 0;
343
384
  await build(testDir, [], undefined, {
package/src/build.ts CHANGED
@@ -2,10 +2,12 @@ import type { Declarable } from "./declarable";
2
2
  import type { Serializer, SerializerResult } from "./serializer";
3
3
  import type { OwnershipMarker } from "./ownership";
4
4
  import type { BuildError, DiscoveryErrorType } from "./errors";
5
- import type { IntrinsicDef, BuildRootContribution } from "./lexicon";
5
+ import type { IntrinsicDef, BuildRootContribution, BuildRootContributor } from "./lexicon";
6
6
  import type { BuildParamProvenance } from "./provenance";
7
7
  import { DiscoveryError, BuildError as BuildErrorClass } from "./errors";
8
8
  import { LexiconOutput, isLexiconOutput } from "./lexicon-output";
9
+ import { isSecretDeclaration } from "./secret-provenance";
10
+ import { splitReceiptEntities } from "./effect-receipt";
9
11
  import { AttrRef } from "./attrref";
10
12
  import { isAttrRefLike } from "./utils";
11
13
  import { isChildProject, type ChildProjectInstance } from "./child-project";
@@ -112,7 +114,9 @@ export function computeStackGraph(
112
114
  // Dependency map: node → producers it depends on.
113
115
  const nodes = lexiconNames.length
114
116
  ? [...lexiconNames]
115
- : [...new Set([...entities.values()].map((e) => e.lexicon))];
117
+ : // Secret provenance declarations (#1828) never form a stack — their
118
+ // pseudo-lexicon has no serializer and must not appear in the manifest.
119
+ [...new Set([...entities.values()].filter((e) => !isSecretDeclaration(e)).map((e) => e.lexicon))];
116
120
  const deps = new Map<string, Set<string>>();
117
121
  for (const n of nodes) deps.set(n, new Set());
118
122
  for (const { from, to } of edges) {
@@ -229,7 +233,7 @@ export interface BuildOptions {
229
233
  * binaries); a contributed name colliding with a discovered entity is a
230
234
  * build error, never a silent overwrite.
231
235
  */
232
- buildRoots?: Array<() => Promise<BuildRootContribution>>;
236
+ buildRoots?: BuildRootContributor[];
233
237
  }
234
238
 
235
239
  export interface BuildResult {
@@ -294,6 +298,11 @@ export function partitionByLexicon(
294
298
  for (const [name, entity] of entities) {
295
299
  // LexiconOutput instances are collected separately; skip them here
296
300
  if (isLexiconOutput(entity)) continue;
301
+ // Secret provenance declarations (#1828) are serializer-neutral: data
302
+ // that lint and lexicons read from the entity map, never output. Keeping
303
+ // them out of every partition means no serializer sees them and no
304
+ // "No serializer found" warning fires for their pseudo-lexicon.
305
+ if (isSecretDeclaration(entity)) continue;
297
306
  const lexicon = entity.lexicon;
298
307
  if (!partitions.has(lexicon)) {
299
308
  partitions.set(lexicon, new Map());
@@ -590,13 +599,18 @@ export interface BuildRootMergeResult {
590
599
  */
591
600
  export async function mergeBuildRootEntities(
592
601
  entities: Map<string, Declarable>,
593
- contributors: ReadonlyArray<() => Promise<BuildRootContribution>>,
602
+ contributors: ReadonlyArray<BuildRootContributor>,
594
603
  ): Promise<BuildRootMergeResult> {
595
604
  const warnings: string[] = [];
596
605
  const errors: string[] = [];
597
606
  for (const contribute of contributors) {
598
607
  try {
599
- const contribution = await contribute();
608
+ // The discovered set, read-only, so a contributor can react to what the
609
+ // project DECLARED (a committed-encrypted `declareSecret()` naming a
610
+ // ciphertext file to resolve). Contributors run in order, so this also
611
+ // carries what earlier contributors added; the merge below is still the
612
+ // only writer, which is what keeps the collision refusal meaningful.
613
+ const contribution = await contribute({ entities });
600
614
  warnings.push(...(contribution.warnings ?? []));
601
615
  for (const [name, entity] of contribution.entities) {
602
616
  if (entities.has(name)) {
@@ -785,20 +799,34 @@ async function buildFromDiscoveryResult(
785
799
  const outputs = new Map<string, string | SerializerResult>();
786
800
  for (const [lexiconName, lexiconEntities] of partitions) {
787
801
  const serializer = serializersByName.get(lexiconName);
802
+ // #1832: effect receipts are withheld from the apply-bound entity set at
803
+ // this seam — the narrowest choke point every applier's input flows
804
+ // through, since appliers consume the serialized outputs assembled here.
805
+ // Receipts ride `SerializeContext.receipts` instead, so a lexicon can
806
+ // render them for visibility (#1835) without them ever entering the
807
+ // document an applier writes (or prunes) from. The `effect()` step is the
808
+ // sole receipt writer (epic #1703, decision 3).
809
+ const { applyBound, receipts } = splitReceiptEntities(lexiconEntities);
788
810
  if (serializer) {
789
811
  const lexiconLexiconOutputs = [
790
812
  ...(outputsByLexicon.get(lexiconName) ?? []),
791
813
  ...unassignedOutputs,
792
814
  ];
793
- const serialized = serializer.serialize(lexiconEntities, lexiconLexiconOutputs, {
815
+ const serialized = serializer.serialize(applyBound, lexiconLexiconOutputs, {
794
816
  ownership: options?.ownership,
795
817
  config: options?.config,
818
+ ...(receipts.size > 0 ? { receipts } : {}),
796
819
  });
797
820
  // Collect any non-fatal serializer diagnostics into the build warnings.
798
821
  if (typeof serialized !== "string" && serialized.warnings) {
799
822
  for (const w of serialized.warnings) warnings.push(w);
800
823
  }
801
824
  outputs.set(lexiconName, serialized);
825
+ } else if (applyBound.size === 0 && receipts.size > 0) {
826
+ // A partition holding only receipts (the core factory's "chant"
827
+ // pseudo-lexicon) has nothing apply-bound to serialize; that is the
828
+ // designed shape until a lexicon materializes the receipt (#1835), not
829
+ // a missing-serializer problem.
802
830
  } else {
803
831
  warnings.push(`No serializer found for lexicon "${lexiconName}"`);
804
832
  }
@@ -0,0 +1,27 @@
1
+ apiVersion: fountain.dev/v1
2
+ kind: Environment
3
+ metadata:
4
+ name: dev
5
+ spec:
6
+ networking_type: unrestricted
7
+ env_vars:
8
+ API_URL: https://api.example.com
9
+ AWS_SECRET_ACCESS_KEY: not-a-real-secret
10
+ ---
11
+ apiVersion: fountain.dev/v1
12
+ kind: Vault
13
+ metadata:
14
+ name: staging
15
+ spec:
16
+ secrets:
17
+ - key: API_URL
18
+ value: https://staging.example.com
19
+ ---
20
+ apiVersion: fountain.dev/v1
21
+ kind: Agent
22
+ metadata:
23
+ name: researcher
24
+ spec:
25
+ model: anthropic/claude-sonnet-4-6
26
+ runtime: claude
27
+ environment: dev
@@ -0,0 +1,16 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: web
5
+ spec:
6
+ selector:
7
+ matchLabels:
8
+ app: web
9
+ template:
10
+ metadata:
11
+ labels:
12
+ app: web
13
+ spec:
14
+ containers:
15
+ - name: web
16
+ image: nginx:1.27
@@ -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)}`);
@@ -309,6 +309,137 @@ export const testEntity = {
309
309
  });
310
310
  });
311
311
 
312
+ describe("#1221 — dynamic-env legality against declared environments", () => {
313
+ const thisDir = dirname(fileURLToPath(import.meta.url));
314
+ const runtimePath = resolvePath(thisDir, "../../runtime");
315
+ const paramsPath = resolvePath(thisDir, "../../params");
316
+
317
+ // Serializer exposing the physical name (from params.env interpolation)
318
+ // next to the ownership marker, so one test can assert both are disjoint
319
+ // across two builds.
320
+ const namingSerializer: Serializer = {
321
+ name: "aws",
322
+ rulePrefix: "TEST",
323
+ serialize: (entities, _outputs, context) =>
324
+ JSON.stringify(
325
+ [...entities.values()].map((e) => ({
326
+ name: (e as unknown as { props: { name: string } }).props.name,
327
+ marker: context?.ownership,
328
+ })),
329
+ ),
330
+ };
331
+
332
+ async function writeProject(environments: string) {
333
+ await writeFile(
334
+ join(testDir, "chant.config.ts"),
335
+ `
336
+ export default {
337
+ environments: ${environments},
338
+ ownership: { stack: "billing", env: { param: "env" } },
339
+ buildParams: {
340
+ env: { type: "string", default: "dev" },
341
+ },
342
+ };
343
+ `,
344
+ );
345
+ await writeFile(
346
+ join(testDir, "resources.ts"),
347
+ `
348
+ import { createResource } from ${JSON.stringify(runtimePath)};
349
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
350
+ `,
351
+ );
352
+ await writeFile(
353
+ join(testDir, "main.ts"),
354
+ `
355
+ import { Bucket } from "./resources";
356
+ import { params } from ${JSON.stringify(paramsPath)};
357
+ export const uploads = new Bucket({ name: \`billing-\${params.env}-uploads\` });
358
+ `,
359
+ );
360
+ }
361
+
362
+ function built(): Array<{ name: string; marker?: { stack: string; env?: string } }> {
363
+ return JSON.parse(readFileSync(outputFile, "utf-8"));
364
+ }
365
+
366
+ async function buildWithEnv(env: string) {
367
+ return buildCommand({
368
+ path: testDir,
369
+ output: outputFile,
370
+ format: "json",
371
+ serializers: [namingSerializer],
372
+ params: { env },
373
+ });
374
+ }
375
+
376
+ test("--param env=pr-42 is legal when a pr-* pattern entry is declared", async () => {
377
+ await writeProject(`["dev", "prod", "pr-*"]`);
378
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
379
+ try {
380
+ const result = await buildWithEnv("pr-42");
381
+ expect(result.errors).toEqual([]);
382
+ expect(result.success).toBe(true);
383
+ expect(built()).toEqual([{ name: "billing-pr-42-uploads", marker: { stack: "billing", env: "pr-42" } }]);
384
+ } finally {
385
+ errorSpy.mockRestore();
386
+ }
387
+ });
388
+
389
+ test("--param env outside the declared entries (no pattern covers it) fails the build", async () => {
390
+ await writeProject(`["dev", "prod"]`);
391
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
392
+ try {
393
+ const result = await buildWithEnv("pr-42");
394
+ expect(result.success).toBe(false);
395
+ expect(result.errors.some((e) => e.includes('Unknown environment "pr-42"'))).toBe(true);
396
+ } finally {
397
+ errorSpy.mockRestore();
398
+ }
399
+ });
400
+
401
+ test("a literal entry still matches by equality", async () => {
402
+ await writeProject(`["dev", "prod", "pr-*"]`);
403
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
404
+ try {
405
+ const result = await buildWithEnv("prod");
406
+ expect(result.errors).toEqual([]);
407
+ expect(built()).toEqual([{ name: "billing-prod-uploads", marker: { stack: "billing", env: "prod" } }]);
408
+ } finally {
409
+ errorSpy.mockRestore();
410
+ }
411
+ });
412
+
413
+ test("two builds with different --param env yield disjoint names and disjoint markers", async () => {
414
+ await writeProject(`["dev", "prod", "pr-*"]`);
415
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
416
+ try {
417
+ expect((await buildWithEnv("pr-1")).success).toBe(true);
418
+ const first = built();
419
+ expect((await buildWithEnv("pr-2")).success).toBe(true);
420
+ const second = built();
421
+ expect(first).toEqual([{ name: "billing-pr-1-uploads", marker: { stack: "billing", env: "pr-1" } }]);
422
+ expect(second).toEqual([{ name: "billing-pr-2-uploads", marker: { stack: "billing", env: "pr-2" } }]);
423
+ expect(first[0].name).not.toBe(second[0].name);
424
+ expect(first[0].marker?.env).not.toBe(second[0].marker?.env);
425
+ } finally {
426
+ errorSpy.mockRestore();
427
+ }
428
+ });
429
+
430
+ test("a project with no declared environments accepts any dynamic value, as before", async () => {
431
+ await writeProject(`undefined`);
432
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
433
+ try {
434
+ const result = await buildWithEnv("anything-goes");
435
+ expect(result.errors).toEqual([]);
436
+ expect(built()).toEqual([{ name: "billing-anything-goes-uploads", marker: { stack: "billing", env: "anything-goes" } }]);
437
+ } finally {
438
+ errorSpy.mockRestore();
439
+ }
440
+ });
441
+ });
442
+
312
443
  test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
313
444
  const thisDir = dirname(fileURLToPath(import.meta.url));
314
445
  const runtimePath = resolvePath(thisDir, "../../runtime");
@@ -574,6 +705,86 @@ export const testEntity = {
574
705
  expect(existsSync(join(testDir, "dist", "ops", "alb-deploy", "worker.ts"))).toBe(true);
575
706
  });
576
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
+
577
788
  test("op worker files go to <project>/dist/ops even with no --output (#878)", async () => {
578
789
  // The generated Op worker must land where `chant run <op> --temporal` reads it
579
790
  // (`<project>/dist/ops/<name>/worker.ts`) even when the build has no --output