@intentius/chant 0.44.13 → 0.45.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 (175) hide show
  1. package/dist/audit/discover.d.ts +26 -2
  2. package/dist/audit/discover.d.ts.map +1 -1
  3. package/dist/audit/report-model.d.ts +10 -0
  4. package/dist/audit/report-model.d.ts.map +1 -1
  5. package/dist/audit/rules-doc.d.ts.map +1 -1
  6. package/dist/cli/build-params-cli.d.ts +16 -7
  7. package/dist/cli/build-params-cli.d.ts.map +1 -1
  8. package/dist/cli/commands/audit.d.ts +20 -0
  9. package/dist/cli/commands/audit.d.ts.map +1 -1
  10. package/dist/cli/commands/build.d.ts +9 -0
  11. package/dist/cli/commands/build.d.ts.map +1 -1
  12. package/dist/cli/commands/check-lexicon-docs.d.ts +13 -0
  13. package/dist/cli/commands/check-lexicon-docs.d.ts.map +1 -1
  14. package/dist/cli/commands/check-lexicon-examples.d.ts +24 -14
  15. package/dist/cli/commands/check-lexicon-examples.d.ts.map +1 -1
  16. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  17. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  20. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  21. package/dist/cli/commands/onboard.d.ts +28 -0
  22. package/dist/cli/commands/onboard.d.ts.map +1 -1
  23. package/dist/cli/handlers/build.d.ts.map +1 -1
  24. package/dist/cli/handlers/graph.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  26. package/dist/cli/handlers/run.d.ts.map +1 -1
  27. package/dist/cli/handlers/search.d.ts +27 -0
  28. package/dist/cli/handlers/search.d.ts.map +1 -1
  29. package/dist/cli/main.d.ts.map +1 -1
  30. package/dist/codegen/docs-pages.d.ts +39 -0
  31. package/dist/codegen/docs-pages.d.ts.map +1 -0
  32. package/dist/codegen/docs-sections.d.ts.map +1 -1
  33. package/dist/codegen/docs-sidebar.d.ts +13 -2
  34. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  35. package/dist/codegen/docs-types.d.ts +30 -10
  36. package/dist/codegen/docs-types.d.ts.map +1 -1
  37. package/dist/codegen/docs.d.ts +14 -2
  38. package/dist/codegen/docs.d.ts.map +1 -1
  39. package/dist/codegen/surface-snapshot.d.ts +27 -3
  40. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  41. package/dist/codegen/validate.d.ts +11 -3
  42. package/dist/codegen/validate.d.ts.map +1 -1
  43. package/dist/components/capability-plugin.d.ts +13 -2
  44. package/dist/components/capability-plugin.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts +38 -0
  46. package/dist/components/driver.d.ts.map +1 -1
  47. package/dist/components/index.d.ts +1 -1
  48. package/dist/components/index.d.ts.map +1 -1
  49. package/dist/components/starter-plugin.d.ts.map +1 -1
  50. package/dist/config.d.ts +57 -5
  51. package/dist/config.d.ts.map +1 -1
  52. package/dist/discovery/fold-import.d.ts.map +1 -1
  53. package/dist/fold/fold.d.ts +100 -1
  54. package/dist/fold/fold.d.ts.map +1 -1
  55. package/dist/graph-ir.d.ts +12 -0
  56. package/dist/graph-ir.d.ts.map +1 -1
  57. package/dist/graph-ops.d.ts +17 -0
  58. package/dist/graph-ops.d.ts.map +1 -0
  59. package/dist/lifecycle/change-set.d.ts +23 -2
  60. package/dist/lifecycle/change-set.d.ts.map +1 -1
  61. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  62. package/dist/lifecycle/observe.d.ts +8 -0
  63. package/dist/lifecycle/observe.d.ts.map +1 -1
  64. package/dist/lifecycle/replay.d.ts.map +1 -1
  65. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  66. package/dist/lifecycle/types.d.ts +7 -0
  67. package/dist/lifecycle/types.d.ts.map +1 -1
  68. package/dist/lint/policy.d.ts.map +1 -1
  69. package/dist/managed-fields.d.ts +33 -21
  70. package/dist/managed-fields.d.ts.map +1 -1
  71. package/dist/observation.d.ts +23 -1
  72. package/dist/observation.d.ts.map +1 -1
  73. package/dist/op/index.d.ts +1 -1
  74. package/dist/op/index.d.ts.map +1 -1
  75. package/dist/yaml.d.ts +0 -8
  76. package/dist/yaml.d.ts.map +1 -1
  77. package/package.json +1 -1
  78. package/src/__snapshots__/okf.test.ts.snap +20 -0
  79. package/src/audit/discover.test.ts +55 -1
  80. package/src/audit/discover.ts +70 -2
  81. package/src/audit/edge-init-safety.test.ts +149 -18
  82. package/src/audit/report-model.ts +12 -1
  83. package/src/audit/rules-doc.ts +1 -0
  84. package/src/build-params.test.ts +52 -0
  85. package/src/cli/build-params-cli.test.ts +25 -8
  86. package/src/cli/build-params-cli.ts +37 -23
  87. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  88. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  89. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  90. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  91. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  92. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  93. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  94. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  95. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  96. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  97. package/src/cli/commands/audit.test.ts +83 -1
  98. package/src/cli/commands/audit.ts +121 -28
  99. package/src/cli/commands/build.test.ts +144 -5
  100. package/src/cli/commands/build.ts +66 -14
  101. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  102. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  103. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  104. package/src/cli/commands/check-lexicon.test.ts +1 -1
  105. package/src/cli/commands/check-lexicon.ts +19 -4
  106. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  107. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  108. package/src/cli/commands/init-lexicon.test.ts +42 -1
  109. package/src/cli/commands/init-lexicon.ts +3 -1
  110. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  111. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  112. package/src/cli/commands/lexicon-surface-diff.ts +1 -1
  113. package/src/cli/commands/onboard.test.ts +319 -225
  114. package/src/cli/commands/onboard.ts +144 -58
  115. package/src/cli/handlers/build.test.ts +3 -3
  116. package/src/cli/handlers/build.ts +2 -0
  117. package/src/cli/handlers/components.ts +2 -2
  118. package/src/cli/handlers/graph.test.ts +44 -0
  119. package/src/cli/handlers/graph.ts +22 -2
  120. package/src/cli/handlers/lifecycle.test.ts +117 -0
  121. package/src/cli/handlers/lifecycle.ts +16 -0
  122. package/src/cli/handlers/run.test.ts +5 -5
  123. package/src/cli/handlers/run.ts +1 -0
  124. package/src/cli/handlers/search-live.test.ts +217 -0
  125. package/src/cli/handlers/search.test.ts +41 -1
  126. package/src/cli/handlers/search.ts +55 -6
  127. package/src/cli/main.ts +5 -3
  128. package/src/cli/security-boundary.test.ts +2 -1
  129. package/src/codegen/docs-pages.test.ts +194 -0
  130. package/src/codegen/docs-pages.ts +138 -0
  131. package/src/codegen/docs-sections.ts +0 -8
  132. package/src/codegen/docs-sidebar.ts +50 -37
  133. package/src/codegen/docs-types.ts +32 -4
  134. package/src/codegen/docs.ts +78 -40
  135. package/src/codegen/lexicon-regen.ts +3 -1
  136. package/src/codegen/rolling-upgrade.test.ts +1 -1
  137. package/src/codegen/rolling-upgrade.ts +1 -1
  138. package/src/codegen/surface-snapshot.test.ts +139 -0
  139. package/src/codegen/surface-snapshot.ts +297 -122
  140. package/src/codegen/validate.test.ts +16 -3
  141. package/src/codegen/validate.ts +17 -7
  142. package/src/components/auto-release.ts +1 -1
  143. package/src/components/capability-plugin.ts +23 -2
  144. package/src/components/driver.test.ts +45 -0
  145. package/src/components/driver.ts +64 -27
  146. package/src/components/index.ts +2 -0
  147. package/src/components/starter-plugin.ts +5 -2
  148. package/src/config.test.ts +56 -1
  149. package/src/config.ts +152 -8
  150. package/src/discovery/fold-import.test.ts +427 -2
  151. package/src/discovery/fold-import.ts +163 -7
  152. package/src/discovery/sandbox/driver.test.ts +11 -0
  153. package/src/fold/fold.test.ts +41 -0
  154. package/src/fold/fold.ts +372 -2
  155. package/src/graph-ir-live.test.ts +42 -0
  156. package/src/graph-ir.ts +42 -2
  157. package/src/graph-ops.test.ts +69 -0
  158. package/src/graph-ops.ts +42 -0
  159. package/src/lifecycle/change-set.test.ts +41 -0
  160. package/src/lifecycle/change-set.ts +30 -2
  161. package/src/lifecycle/deep-observe.ts +4 -0
  162. package/src/lifecycle/observe.ts +29 -2
  163. package/src/lifecycle/replay.ts +3 -0
  164. package/src/lifecycle/snapshot.ts +3 -0
  165. package/src/lifecycle/types.ts +7 -0
  166. package/src/lint/policy.ts +14 -3
  167. package/src/managed-fields.test.ts +15 -42
  168. package/src/managed-fields.ts +43 -26
  169. package/src/observation.test.ts +24 -8
  170. package/src/observation.ts +48 -4
  171. package/src/op/builders-exports.test.ts +60 -0
  172. package/src/op/index.ts +1 -1
  173. package/src/ownership.test.ts +63 -1
  174. package/src/yaml.test.ts +15 -0
  175. package/src/yaml.ts +19 -2
@@ -62,7 +62,8 @@ export async function generate(options?: { verbose?: boolean; force?: boolean })
62
62
  * Write generated files to the package directory.
63
63
  */
64
64
  export function writeGeneratedFiles(result: GenerateResult, pkgDir?: string): void {
65
- const dir = pkgDir ?? dirname(dirname(fileURLToPath(import.meta.url)));
65
+ // This file lives in src/codegen/, so the package root is three levels up.
66
+ const dir = pkgDir ?? dirname(dirname(dirname(fileURLToPath(import.meta.url))));
66
67
  writeGeneratedArtifacts({
67
68
  baseDir: dir,
68
69
  files: {
@@ -65,7 +65,8 @@ export async function generate(options?: { verbose?: boolean; force?: boolean })
65
65
  * Write generated files to the package directory.
66
66
  */
67
67
  export function writeGeneratedFiles(result: GenerateResult, pkgDir?: string): void {
68
- const dir = pkgDir ?? dirname(dirname(fileURLToPath(import.meta.url)));
68
+ // This file lives in src/codegen/, so the package root is three levels up.
69
+ const dir = pkgDir ?? dirname(dirname(dirname(fileURLToPath(import.meta.url))));
69
70
  writeGeneratedArtifacts({
70
71
  baseDir: dir,
71
72
  files: {
@@ -1,6 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { fileURLToPath } from "url";
3
- import { auditCommand, tokenForHost, coverageNotes } from "./audit";
3
+ import { auditCommand, tokenForHost, coverageNotes, installLine, NO_LEXICONS_EXIT_CODE } from "./audit";
4
4
  import { discoverByDetection, loadAuditPlugins } from "../../audit/discover";
5
5
  import { MissingLexiconError, type AuditInput, type AuditLexicon } from "../../audit/core";
6
6
 
@@ -190,6 +190,88 @@ describe("auditCommand", () => {
190
190
  expect(result.error).toMatch(/npm i @intentius\/chant-lexicon-github/);
191
191
  });
192
192
 
193
+ describe("lexicon coverage (#1623)", () => {
194
+ const MIXED = fileURLToPath(new URL("./__fixtures__/audit-coverage", import.meta.url));
195
+
196
+ test("zero lexicons: says it had nothing to look with, names each file's lexicon, prints the npx line, exits 2", async () => {
197
+ const result = await auditCommand({ path: MIXED, plugins: [] });
198
+ expect(result.success).toBe(true);
199
+ expect(result.status).toBe("no-lexicons");
200
+ expect(result.exitCode).toBe(NO_LEXICONS_EXIT_CODE);
201
+ expect(result.stream).toBe("stderr");
202
+ expect(result.findings).toEqual([]);
203
+ expect(result.output).toContain("nothing to look with");
204
+ expect(result.output).not.toMatch(/Audited \d+ files/);
205
+ expect(result.output).toContain(".github/workflows/ci.yml -> github");
206
+ expect(result.output).toContain("k8s/deploy.yaml -> k8s");
207
+ expect(result.output).toContain("infra/stack.json -> aws");
208
+ expect(result.output).toContain("Dockerfile -> docker");
209
+ expect(result.output).toContain("infra/main.tf -> terraform (not audited; see chant carve)");
210
+ // The install line names only the lexicons the files wanted, never terraform.
211
+ expect(result.output).toContain(
212
+ `npx -p @intentius/chant -p @intentius/chant-lexicon-github -p @intentius/chant-lexicon-docker -p @intentius/chant-lexicon-aws -p @intentius/chant-lexicon-k8s chant audit ${MIXED}`,
213
+ );
214
+ expect(result.output).not.toContain("chant-lexicon-terraform");
215
+ });
216
+
217
+ test("zero lexicons with --json: status no-lexicons plus the unclaimed list and install line", async () => {
218
+ const result = await auditCommand({ path: MIXED, plugins: [], format: "json" });
219
+ expect(result.exitCode).toBe(NO_LEXICONS_EXIT_CODE);
220
+ expect(result.stream).toBe("stdout");
221
+ const json = JSON.parse(result.output);
222
+ expect(json.status).toBe("no-lexicons");
223
+ expect(json.findings).toEqual([]);
224
+ expect(json.missingLexicons).toEqual(["github", "docker", "aws", "k8s"]);
225
+ expect(json.unclaimed).toContainEqual({ path: "k8s/deploy.yaml", lexicon: "k8s" });
226
+ expect(json.unclaimed).toContainEqual({ path: "infra/main.tf", lexicon: "terraform" });
227
+ expect(json.install).toMatch(/^npx -p @intentius\/chant /);
228
+ });
229
+
230
+ test("zero lexicons on an empty dir still refuses to report clean", async () => {
231
+ const tmp = join(tmpdir(), `chant-audit-nolex-${process.pid}`);
232
+ const { mkdirSync } = await import("fs");
233
+ mkdirSync(tmp, { recursive: true });
234
+ const result = await auditCommand({ path: tmp, plugins: [] });
235
+ expect(result.status).toBe("no-lexicons");
236
+ expect(result.exitCode).toBe(NO_LEXICONS_EXIT_CODE);
237
+ expect(result.output).toContain("nothing to look with");
238
+ rmSync(tmp, { recursive: true, force: true });
239
+ });
240
+
241
+ test("partial: github loaded, k8s/aws/docker absent -> audit runs and a one-line hint names the gap", async () => {
242
+ const all = await loadAuditPlugins();
243
+ const github = all.filter((p) => p.name === "github");
244
+ expect(github).toHaveLength(1);
245
+ const result = await auditCommand({ path: MIXED, plugins: github });
246
+ expect(result.status).toBe("ok");
247
+ expect(result.exitCode).toBe(0);
248
+ expect(result.scanned).toEqual([".github/workflows/ci.yml"]);
249
+ expect(result.output).toMatch(/^Note: 3 files look like docker\/aws\/k8s but those lexicons are not installed, so they were skipped \(npm i @intentius\/chant-lexicon-docker @intentius\/chant-lexicon-aws @intentius\/chant-lexicon-k8s\)\./);
250
+ expect(result.output).toContain("1 Terraform file skipped; the audit does not read HCL (see chant carve).");
251
+ expect(result.unclaimed).toContainEqual({ path: "k8s/deploy.yaml", lexicon: "k8s" });
252
+ });
253
+
254
+ test("partial with --json: status ok and the unclaimed files ride along", async () => {
255
+ const all = await loadAuditPlugins();
256
+ const result = await auditCommand({ path: MIXED, plugins: all.filter((p) => p.name === "github"), format: "json" });
257
+ const json = JSON.parse(result.output);
258
+ expect(json.status).toBe("ok");
259
+ expect(json.unclaimed.map((u: { lexicon: string }) => u.lexicon).sort()).toEqual(["aws", "docker", "k8s", "terraform"]);
260
+ });
261
+
262
+ test("all lexicons loaded: no coverage note, no unclaimed (terraform aside)", async () => {
263
+ const result = await auditCommand({ path: MIXED, plugins: await loadAuditPlugins() });
264
+ expect(result.status).toBe("ok");
265
+ expect(result.unclaimed).toEqual([{ path: "infra/main.tf", lexicon: "terraform" }]);
266
+ expect(result.output).not.toContain("not installed");
267
+ expect(result.scanned.sort()).toEqual([".github/workflows/ci.yml", "Dockerfile", "infra/stack.json", "k8s/deploy.yaml"]);
268
+ });
269
+
270
+ test("installLine puts every lexicon on the same npx -p path", () => {
271
+ expect(installLine(["github", "gitlab"], ".")).toBe("npx -p @intentius/chant -p @intentius/chant-lexicon-github -p @intentius/chant-lexicon-gitlab chant audit .");
272
+ });
273
+ });
274
+
193
275
  test("a path with no CI files succeeds with a clear message", async () => {
194
276
  const tmp = join(tmpdir(), `chant-audit-empty-${process.pid}`);
195
277
  const { mkdirSync } = await import("fs");
@@ -7,11 +7,11 @@
7
7
 
8
8
  import { existsSync, statSync, writeFileSync } from "fs";
9
9
  import { auditFiles, type AuditInput, type AuditFinding, type ChecksProvider } from "../../audit/core";
10
- import { discoverByDetection, classifyFiles, loadAuditPlugins, AUDIT_LEXICONS } from "../../audit/discover";
10
+ import { AUDIT_LEXICONS, classifyFiles, collectCandidates, loadAuditPlugins, unclaimedFiles, type DetectPlugin, type RepoFile, type UnclaimedFile } from "../../audit/discover";
11
11
  import { RULE_CATALOG, resolveAuditCatalog, type RuleMeta } from "../../audit/catalog";
12
12
  import { renderMarkdown } from "../../audit/report";
13
13
  import { renderHtml, type ReportTheme } from "../../audit/report-html";
14
- import { buildReportJson, type AuditSnapshot } from "../../audit/report-model";
14
+ import { buildReportJson, REPORT_SCHEMA_VERSION, type AuditSnapshot } from "../../audit/report-model";
15
15
  import { fetchRepoFiles, resolveActionSha, resolveImageDigest, resolveRepoCommit, parseRepoUrl, FetchError } from "../../audit/fetch";
16
16
  import { extractUnpinnedActions, extractUnpinnedImages } from "../../audit/proof";
17
17
  import type { ProveOptions } from "../../audit/proof";
@@ -45,6 +45,8 @@ export interface AuditCommandOptions {
45
45
  now?: string;
46
46
  /** Tool version recorded in the HTML snapshot. */
47
47
  toolVersion?: string;
48
+ /** Injectable detection plugins (testing); defaults to every installed audit lexicon. */
49
+ plugins?: DetectPlugin[];
48
50
  }
49
51
 
50
52
  export interface AuditCommandResult {
@@ -58,6 +60,94 @@ export interface AuditCommandResult {
58
60
  error?: string;
59
61
  /** Set when the report was written to a file (via `output`). */
60
62
  wroteTo?: string;
63
+ /**
64
+ * `"no-lexicons"` when not a single audit lexicon resolved, so nothing was
65
+ * inspected (#1623). `output` then carries the diagnostic, not a report.
66
+ */
67
+ status?: "ok" | "no-lexicons";
68
+ /** Candidate files that looked like they wanted a lexicon that is not installed. */
69
+ unclaimed?: UnclaimedFile[];
70
+ /** Where `output` belongs; diagnostics go to stderr, reports to stdout (default). */
71
+ stream?: "stdout" | "stderr";
72
+ }
73
+
74
+ /** Exit code when the audit had no lexicons to look with. Distinct from 1 (findings / failure). */
75
+ export const NO_LEXICONS_EXIT_CODE = 2;
76
+
77
+ /** The npm package that provides an audit lexicon's detection and checks. */
78
+ function lexiconPackage(name: string): string {
79
+ return `@intentius/chant-lexicon-${name}`;
80
+ }
81
+
82
+ /** Missing audit lexicons the unclaimed files pointed at, in first-seen order. `terraform` is not installable. */
83
+ function wantedLexicons(unclaimed: UnclaimedFile[]): string[] {
84
+ return [...new Set(unclaimed.map((u) => u.lexicon))].filter((l) => l !== "terraform");
85
+ }
86
+
87
+ /**
88
+ * The exact one-liner that gives a bare `npx @intentius/chant audit` the
89
+ * lexicons it needs. Every wanted lexicon is a `-p` package so npx puts all of
90
+ * them on the same resolution path.
91
+ */
92
+ export function installLine(lexicons: string[], target: string): string {
93
+ const pkgs = ["@intentius/chant", ...lexicons.map(lexiconPackage)];
94
+ return `npx ${pkgs.map((p) => `-p ${p}`).join(" ")} chant audit ${target}`;
95
+ }
96
+
97
+ /** One-line coverage hint for the partial case: some lexicons loaded, others wanted by files on disk. */
98
+ function missingLexiconHint(unclaimed: UnclaimedFile[]): string | undefined {
99
+ const wanted = wantedLexicons(unclaimed);
100
+ const tf = unclaimed.filter((u) => u.lexicon === "terraform").length;
101
+ const parts: string[] = [];
102
+ if (wanted.length > 0) {
103
+ const n = unclaimed.length - tf;
104
+ parts.push(
105
+ `${n} file${n === 1 ? " looks" : "s look"} like ${wanted.join("/")} but ${wanted.length === 1 ? "that lexicon is" : "those lexicons are"} not installed, so ${n === 1 ? "it was" : "they were"} skipped` +
106
+ ` (npm i ${wanted.map(lexiconPackage).join(" ")}).`,
107
+ );
108
+ }
109
+ if (tf > 0) parts.push(`${tf} Terraform file${tf === 1 ? "" : "s"} skipped; the audit does not read HCL (see chant carve).`);
110
+ return parts.length > 0 ? parts.join(" ") : undefined;
111
+ }
112
+
113
+ /** Human-readable diagnostic for the zero-lexicon case. */
114
+ function renderNoLexicons(target: string, unclaimed: UnclaimedFile[]): string {
115
+ const lines: string[] = [];
116
+ lines.push(`chant audit had nothing to look with: no audit lexicon is installed, so nothing under ${target} was inspected.`);
117
+ lines.push("This is not a clean result. Detection and checks live in the lexicon packages.");
118
+ const wanted = wantedLexicons(unclaimed);
119
+ if (unclaimed.length > 0) {
120
+ lines.push("", "Files that wanted a lexicon:");
121
+ for (const u of unclaimed) {
122
+ const note = u.lexicon === "terraform" ? "terraform (not audited; see chant carve)" : u.lexicon;
123
+ lines.push(` ${u.path} -> ${note}`);
124
+ }
125
+ } else {
126
+ lines.push("", "No file under the target looked like CI, Kubernetes, Helm, Docker, CloudFormation, ARM, or Config Connector either.");
127
+ }
128
+ lines.push("", "Run it with the lexicons those files need:");
129
+ lines.push(` ${installLine(wanted.length > 0 ? wanted : [...AUDIT_LEXICONS], target)}`);
130
+ return lines.join("\n");
131
+ }
132
+
133
+ /** Machine-readable form of the zero-lexicon diagnostic (`status: "no-lexicons"`). */
134
+ function renderNoLexiconsJson(target: string, unclaimed: UnclaimedFile[]): string {
135
+ const wanted = wantedLexicons(unclaimed);
136
+ return JSON.stringify(
137
+ {
138
+ schemaVersion: REPORT_SCHEMA_VERSION,
139
+ tool: { name: "chant-audit" },
140
+ status: "no-lexicons",
141
+ target,
142
+ summary: { total: 0 },
143
+ findings: [],
144
+ unclaimed,
145
+ missingLexicons: wanted,
146
+ install: installLine(wanted.length > 0 ? wanted : [...AUDIT_LEXICONS], target),
147
+ },
148
+ null,
149
+ 2,
150
+ );
61
151
  }
62
152
 
63
153
  /**
@@ -205,20 +295,21 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
205
295
 
206
296
  const isUrl = /^https?:\/\//.test(options.path);
207
297
 
208
- let inputs: AuditInput[];
209
- // Content-detected lexicons whose package isn't installed can't be detected
210
- // (detection needs the plugin's `detectTemplate`), so a local audit would
211
- // silently find nothing. Track the missing packages to turn that into a hint.
212
- let missingHint = "";
298
+ // Detection lives in the lexicon plugins (each one's `detectTemplate`), so a
299
+ // lexicon that isn't installed can't claim its files. Every branch below
300
+ // therefore also computes which candidate files looked like they wanted an
301
+ // absent lexicon, so "nothing found" and "had nothing to look with" never
302
+ // render the same way (#1623).
303
+ const plugins = options.plugins ?? (await loadAuditPlugins());
304
+ let candidates: RepoFile[];
213
305
  if (isUrl) {
214
306
  try {
215
307
  // Fetch the whole repo's candidate files (all lexicons, not just CI) and
216
308
  // run them through the same classifier the local path uses (#420).
217
- const files = await fetchRepoFiles(options.path, {
309
+ candidates = await fetchRepoFiles(options.path, {
218
310
  token: options.token ?? tokenForHost(options.path),
219
311
  fetchImpl: options.fetchImpl,
220
312
  });
221
- inputs = classifyFiles(files, await loadAuditPlugins());
222
313
  } catch (err) {
223
314
  const msg = err instanceof FetchError ? err.message : err instanceof Error ? err.message : String(err);
224
315
  return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: msg };
@@ -227,25 +318,25 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
227
318
  if (!existsSync(options.path)) {
228
319
  return { success: false, output: "", findings: [], scanned: [], exitCode: 1, error: `Path not found: ${options.path}` };
229
320
  }
230
- // One walk, plugin-delegated detection. Each lexicon plugin's own
231
- // `detectTemplate` classifies the files it recognizes; CI (path), Dockerfiles
232
- // (name), and Helm charts (bundle) are handled by discoverByDetection's
233
- // special-cases since content shape alone can't disambiguate them.
234
- const plugins = await loadAuditPlugins();
235
- const loaded = new Set(plugins.map((p) => p.name));
236
- const missing = AUDIT_LEXICONS.filter((n) => !loaded.has(n));
237
- if (missing.length > 0) {
238
- missingHint =
239
- ` Some lexicon packages are not installed, so files of those types were not detected: ${missing.join(", ")}.` +
240
- ` Install what you need, e.g. npm i ${missing.map((n) => `@intentius/chant-lexicon-${n}`).join(" ")}`;
241
- }
242
- inputs = discoverByDetection(options.path, plugins);
321
+ // One walk, plugin-delegated detection. CI (path), Dockerfiles (name), and
322
+ // Helm charts (bundle) are special-cased by the classifier since content
323
+ // shape alone can't disambiguate them.
324
+ candidates = collectCandidates(options.path);
243
325
  }
244
-
326
+ const inputs = classifyFiles(candidates, plugins);
327
+ const unclaimed = unclaimedFiles(candidates, inputs, plugins);
245
328
  const scanned = inputs.map((i) => i.path);
246
329
 
330
+ if (plugins.length === 0) {
331
+ const output = format === "json" ? renderNoLexiconsJson(options.path, unclaimed) : renderNoLexicons(options.path, unclaimed);
332
+ return { success: true, status: "no-lexicons", output, findings: [], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr" };
333
+ }
334
+
335
+ const missingLexiconNote = missingLexiconHint(unclaimed);
336
+
247
337
  if (inputs.length === 0) {
248
- return { success: true, output: `No auditable files found under ${options.path}.${missingHint}`, findings: [], scanned: [], exitCode: 0 };
338
+ const output = `No auditable files found under ${options.path}.${missingLexiconNote ? ` ${missingLexiconNote}` : ""}`;
339
+ return { success: true, status: "ok", output, findings: [], scanned: [], unclaimed, exitCode: 0 };
249
340
  }
250
341
 
251
342
  let findings: AuditFinding[];
@@ -262,6 +353,7 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
262
353
 
263
354
  if (tier === "merge-worthy") findings = findings.filter((f) => isMergeWorthy(f, catalog));
264
355
  const notes = coverageNotes(inputs);
356
+ if (missingLexiconNote) notes.push(missingLexiconNote);
265
357
 
266
358
  // Diff-bearing renderers (markdown, html) need action SHAs / image digests
267
359
  // resolved up front (sync maps so rendering stays synchronous).
@@ -304,7 +396,7 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
304
396
  switch (format) {
305
397
  case "json": {
306
398
  const snapshot = await buildSnapshot(options, scanned, isUrl);
307
- output = JSON.stringify(buildReportJson(findings, { snapshot, toolVersion: options.toolVersion, catalog }), null, 2);
399
+ output = JSON.stringify(buildReportJson(findings, { snapshot, toolVersion: options.toolVersion, catalog, unclaimed }), null, 2);
308
400
  break;
309
401
  }
310
402
  case "sarif":
@@ -329,10 +421,10 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
329
421
  } catch (err) {
330
422
  return { success: false, output, findings, scanned, exitCode: 1, error: `Failed to write ${options.output}: ${err instanceof Error ? err.message : String(err)}` };
331
423
  }
332
- return { success: true, output, findings, scanned, exitCode, wroteTo: options.output };
424
+ return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode, wroteTo: options.output };
333
425
  }
334
426
 
335
- return { success: true, output, findings, scanned, exitCode };
427
+ return { success: true, status: "ok", output, findings, scanned, unclaimed, exitCode };
336
428
  }
337
429
 
338
430
  /** Print an audit result to stdout. */
@@ -345,5 +437,6 @@ export function printAuditResult(result: AuditCommandResult): void {
345
437
  console.error(`Wrote report to ${result.wroteTo}`);
346
438
  return;
347
439
  }
348
- console.log(result.output);
440
+ if (result.stream === "stderr") console.error(result.output);
441
+ else console.log(result.output);
349
442
  }
@@ -169,6 +169,7 @@ export const testEntity = {
169
169
  format: "json",
170
170
  serializers: [awsSerializer],
171
171
  fold: true,
172
+ verbose: true,
172
173
  });
173
174
 
174
175
  expect(result.success).toBe(true);
@@ -183,6 +184,131 @@ export const testEntity = {
183
184
  }
184
185
  });
185
186
 
187
+ describe("#1396 — ownership.env resolves from the env build parameter", () => {
188
+ const thisDir = dirname(fileURLToPath(import.meta.url));
189
+ const runtimePath = resolvePath(thisDir, "../../runtime");
190
+ const paramsPath = resolvePath(thisDir, "../../params");
191
+
192
+ // A label-stamping serializer in miniature: the resource's own `env` prop
193
+ // (from params.env) next to the ownership marker's env (from context).
194
+ const labelSerializer: Serializer = {
195
+ name: "aws",
196
+ rulePrefix: "TEST",
197
+ serialize: (entities, _outputs, context) =>
198
+ JSON.stringify(
199
+ [...entities.values()].map((e) => ({
200
+ label: (e as unknown as { props: { env: string } }).props.env,
201
+ marker: context?.ownership,
202
+ })),
203
+ ),
204
+ };
205
+
206
+ async function writeProject(ownershipEnv: string) {
207
+ await writeFile(
208
+ join(testDir, "chant.config.ts"),
209
+ `
210
+ export default {
211
+ ownership: { stack: "fountain", env: ${ownershipEnv} },
212
+ buildParams: {
213
+ env: { type: "string", default: "dev", env: "CHANT_TEST_1396_ENV" },
214
+ },
215
+ };
216
+ `,
217
+ );
218
+ await writeFile(
219
+ join(testDir, "resources.ts"),
220
+ `
221
+ import { createResource } from ${JSON.stringify(runtimePath)};
222
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
223
+ `,
224
+ );
225
+ await writeFile(
226
+ join(testDir, "main.ts"),
227
+ `
228
+ import { Bucket } from "./resources";
229
+ import { params } from ${JSON.stringify(paramsPath)};
230
+ export const bucket = new Bucket({ env: params.env });
231
+ `,
232
+ );
233
+ }
234
+
235
+ function built(): Array<{ label: string; marker?: { stack: string; env?: string } }> {
236
+ return JSON.parse(readFileSync(outputFile, "utf-8"));
237
+ }
238
+
239
+ test("--param env=prod drives both the label and the ownership marker", async () => {
240
+ await writeProject(`{ param: "env" }`);
241
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
242
+ try {
243
+ const result = await buildCommand({
244
+ path: testDir,
245
+ output: outputFile,
246
+ format: "json",
247
+ serializers: [labelSerializer],
248
+ params: { env: "prod" },
249
+ });
250
+ expect(result.errors).toEqual([]);
251
+ expect(result.success).toBe(true);
252
+ expect(built()).toEqual([{ label: "prod", marker: { stack: "fountain", env: "prod" } }]);
253
+ } finally {
254
+ errorSpy.mockRestore();
255
+ }
256
+ });
257
+
258
+ test("with no parameter supplied, both follow the declared default", async () => {
259
+ await writeProject(`{ param: "env" }`);
260
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
261
+ try {
262
+ const result = await buildCommand({
263
+ path: testDir,
264
+ output: outputFile,
265
+ format: "json",
266
+ serializers: [labelSerializer],
267
+ });
268
+ expect(result.success).toBe(true);
269
+ expect(built()).toEqual([{ label: "dev", marker: { stack: "fountain", env: "dev" } }]);
270
+ } finally {
271
+ errorSpy.mockRestore();
272
+ }
273
+ });
274
+
275
+ test("a literal ownership.env that disagrees with --param env is warned about", async () => {
276
+ await writeProject(`"dev"`);
277
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
278
+ try {
279
+ const result = await buildCommand({
280
+ path: testDir,
281
+ output: outputFile,
282
+ format: "json",
283
+ serializers: [labelSerializer],
284
+ params: { env: "prod" },
285
+ });
286
+ expect(result.success).toBe(true);
287
+ expect(built()).toEqual([{ label: "prod", marker: { stack: "fountain", env: "dev" } }]);
288
+ expect(result.warnings.some((w) => w.includes('ownership.env is "dev"') && w.includes('"prod"'))).toBe(true);
289
+ } finally {
290
+ errorSpy.mockRestore();
291
+ }
292
+ });
293
+
294
+ test("a reference to an undeclared parameter fails the build", async () => {
295
+ await writeProject(`{ param: "environment" }`);
296
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
297
+ try {
298
+ const result = await buildCommand({
299
+ path: testDir,
300
+ output: outputFile,
301
+ format: "json",
302
+ serializers: [labelSerializer],
303
+ });
304
+ expect(result.success).toBe(false);
305
+ expect(result.errors.some((e) => e.includes('build parameter "environment"'))).toBe(true);
306
+ } finally {
307
+ errorSpy.mockRestore();
308
+ }
309
+ });
310
+ });
311
+
186
312
  test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
187
313
  const thisDir = dirname(fileURLToPath(import.meta.url));
188
314
  const runtimePath = resolvePath(thisDir, "../../runtime");
@@ -235,10 +361,10 @@ export const testEntity = {
235
361
  expect(result.resourceCount).toBe(1);
236
362
  expect(result.buildParams).toEqual([{ name: "tier", value: "production", source: "cli" }]);
237
363
 
238
- const loggedParamLine = errorSpy.mock.calls
239
- .map((call) => String(call[0]))
240
- .some((line) => line.includes("[param] tier") && line.includes("production") && line.includes("cli"));
241
- expect(loggedParamLine).toBe(true);
364
+ // #1424 without --verbose the echo is a one-line count by source.
365
+ const logged = errorSpy.mock.calls.map((call) => String(call[0]));
366
+ expect(logged.some((line) => line.includes("1 build parameter resolved (1 from cli)"))).toBe(true);
367
+ expect(logged.some((line) => line.includes("[param] tier"))).toBe(false);
242
368
  } finally {
243
369
  errorSpy.mockRestore();
244
370
  }
@@ -346,6 +472,19 @@ export const testEntity = {
346
472
 
347
473
  expect(result.success).toBe(true);
348
474
  expect(result.resourceCount).toBe(1);
475
+ // #1424 — the default report is one summary line, not one line per file.
476
+ const logged = errorSpy.mock.calls.map((call) => String(call[0]));
477
+ expect(logged.some((line) => /^.*fold: \d+ files? folded, \d+ ran/.test(line))).toBe(true);
478
+ expect(logged.some((line) => line.includes("[fold:"))).toBe(false);
479
+
480
+ errorSpy.mockClear();
481
+ const verboseResult = await buildCommand({
482
+ path: testDir,
483
+ format: "json",
484
+ serializers: [mockSerializer],
485
+ verbose: true,
486
+ });
487
+ expect(verboseResult.success).toBe(true);
349
488
  const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
350
489
  expect(anyFoldLine).toBe(true);
351
490
 
@@ -360,7 +499,7 @@ export const testEntity = {
360
499
  expect(runResult.resourceCount).toBe(1);
361
500
  const anyFoldLineOff = errorSpy.mock.calls
362
501
  .map((call) => String(call[0]))
363
- .some((line) => line.includes("[fold:"));
502
+ .some((line) => line.includes("[fold:") || line.includes("fold: "));
364
503
  expect(anyFoldLineOff).toBe(false);
365
504
  } finally {
366
505
  errorSpy.mockRestore();
@@ -1,5 +1,13 @@
1
1
  import { build } from "../../build";
2
- import { loadChantConfigUpward, resolveOwnershipMarker, resolveFoldEnabled, resolveSandboxEnabled } from "../../config";
2
+ import {
3
+ loadChantConfigUpward,
4
+ resolveOwnershipMarker,
5
+ resolveOwnershipEnv,
6
+ ownershipEnvDisagreement,
7
+ resolveFoldEnabled,
8
+ resolveSandboxEnabled,
9
+ } from "../../config";
10
+ import type { OwnershipMarker } from "../../ownership";
3
11
  import { resolveCliBuildParams } from "../build-params-cli";
4
12
  import type { Serializer, SerializerResult } from "../../serializer";
5
13
  import type { LexiconPlugin } from "../../lexicon";
@@ -141,10 +149,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
141
149
  // its default.
142
150
  const loaded = await loadChantConfigUpward(infraPath);
143
151
  const config = loaded.config;
144
- const ownership = resolveOwnershipMarker(config);
152
+ // The ownership marker is resolved further down, after build parameters —
153
+ // `ownership.env` may reference one (#1396).
145
154
 
146
- // Environment for policy evaluation: explicit --env wins, else ownership.env.
147
- const env = options.env ?? config.ownership?.env;
148
155
  // Project-authored organizational policy checks (lint.policies), run over the
149
156
  // resolved resources during build. Resolve paths relative to the config dir.
150
157
  const configDir = loaded.configPath ? dirname(loaded.configPath) : infraPath;
@@ -197,11 +204,13 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
197
204
  // missing required value, a type/enum mismatch) is reported as a chant
198
205
  // build error naming the parameter, never a thrown error from inside user
199
206
  // source (which is what loomster's hand-rolled `tierFromEnv()`-style
200
- // validators did before migrating to this mechanism). Also logs every
201
- // resolved value (`[param] name = value (source)`) on success.
207
+ // validators did before migrating to this mechanism). Also logs a one-line
208
+ // count on success, or every resolved value (`[param] name = value
209
+ // (source)`) under --verbose (#1424).
202
210
  const paramsResolution = resolveCliBuildParams(config.buildParams, {
203
211
  cli: options.params,
204
212
  paramsFile: options.paramsFile,
213
+ verbose: options.verbose,
205
214
  });
206
215
  if (!paramsResolution.success) {
207
216
  errors.push(...paramsResolution.errors);
@@ -228,6 +237,27 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
228
237
  );
229
238
  }
230
239
 
240
+ // #1396 — opt-in ownership marking, resolved AFTER build parameters so
241
+ // `ownership.env: { param: "env" }` takes the value `--param env=prod` (or
242
+ // the declared env mapping, or the default) bound to params.env — one
243
+ // source for the label and the marker. A reference that cannot be
244
+ // satisfied is a build error, not a marker silently stamped without an env.
245
+ let ownership: OwnershipMarker | undefined;
246
+ // Environment for policy evaluation: explicit --env wins, else ownership.env
247
+ // (read even when marking itself is off, so policy can still branch on it).
248
+ let env: string | undefined;
249
+ try {
250
+ ownership = resolveOwnershipMarker(config, paramsResolution.provenance);
251
+ env = options.env ?? resolveOwnershipEnv(config, paramsResolution.provenance);
252
+ } catch (err) {
253
+ errors.push(formatError({ message: err instanceof Error ? err.message : String(err) }));
254
+ return { success: false, resourceCount: 0, fileCount: 0, errors, warnings };
255
+ }
256
+ // A literal `ownership.env` next to an `env` build parameter that resolved
257
+ // to something else is the silent divergence #1396 reports — say so.
258
+ const disagreement = ownershipEnvDisagreement(config, paramsResolution.provenance);
259
+ if (disagreement) warnings.push(formatWarning({ message: disagreement }));
260
+
231
261
  // #1039 — thread each loaded plugin's registered intrinsics (e.g. AWS's
232
262
  // `Sub`) through to the fold path, so a file using a registered intrinsic
233
263
  // tagged template folds instead of unconditionally falling back to run.
@@ -272,14 +302,22 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
272
302
  });
273
303
 
274
304
  // #1022 — report per-file fold vs run so it's visible what still runs.
275
- if (fold) {
276
- for (const decision of result.foldDecisions) {
277
- const rel = relative(infraPath, decision.file) || decision.file;
278
- const detail =
279
- decision.mode === "fold"
280
- ? `${decision.resourceCount ?? 0} resource(s), no module execution`
281
- : (decision.reason ?? "fell back to run");
282
- console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
305
+ // #1424 — one line by default; the per-file lines and their reasons are
306
+ // --verbose. A file that ran is the expected outcome for source that
307
+ // computes values in functions, and a dozen "is not foldable" notes read
308
+ // like errors to someone meeting the project for the first time.
309
+ if (fold && result.foldDecisions.length > 0) {
310
+ if (options.verbose) {
311
+ for (const decision of result.foldDecisions) {
312
+ const rel = relative(infraPath, decision.file) || decision.file;
313
+ const detail =
314
+ decision.mode === "fold"
315
+ ? `${decision.resourceCount ?? 0} resource(s), no module execution`
316
+ : (decision.reason ?? "fell back to run");
317
+ console.error(formatInfo(`[fold:${decision.mode}] ${rel} — ${detail}`));
318
+ }
319
+ } else {
320
+ console.error(formatInfo(summarizeFoldDecisions(result.foldDecisions)));
283
321
  }
284
322
  }
285
323
 
@@ -587,6 +625,20 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
587
625
  };
588
626
  }
589
627
 
628
+ /**
629
+ * `fold: 8 files folded, 13 ran (--verbose for reasons)` — the non-verbose
630
+ * report of a fold build's per-file decisions (#1424). The "(--verbose for
631
+ * reasons)" hint appears only when something ran, since that is the only case
632
+ * with a reason to read.
633
+ */
634
+ export function summarizeFoldDecisions(decisions: readonly { mode: string }[]): string {
635
+ const folded = decisions.filter((d) => d.mode === "fold").length;
636
+ const ran = decisions.length - folded;
637
+ const files = (n: number) => `${n} file${n === 1 ? "" : "s"}`;
638
+ const hint = ran > 0 ? " (--verbose for reasons)" : "";
639
+ return `fold: ${files(folded)} folded, ${ran} ran${hint}`;
640
+ }
641
+
590
642
  /**
591
643
  * Simple JSON to YAML converter
592
644
  */