@intentius/chant 0.44.14 → 0.46.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 (220) 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 +14 -0
  17. package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
  18. package/dist/cli/commands/init-lexicon/templates/codegen.d.ts.map +1 -1
  19. package/dist/cli/commands/init-lexicon/templates/docs.d.ts +5 -0
  20. package/dist/cli/commands/init-lexicon/templates/docs.d.ts.map +1 -1
  21. package/dist/cli/commands/init-lexicon.d.ts.map +1 -1
  22. package/dist/cli/commands/lexicon-surface-diff.d.ts +6 -0
  23. package/dist/cli/commands/lexicon-surface-diff.d.ts.map +1 -1
  24. package/dist/cli/commands/lint.d.ts.map +1 -1
  25. package/dist/cli/commands/onboard.d.ts +28 -0
  26. package/dist/cli/commands/onboard.d.ts.map +1 -1
  27. package/dist/cli/handlers/build.d.ts.map +1 -1
  28. package/dist/cli/handlers/graph.d.ts.map +1 -1
  29. package/dist/cli/handlers/lifecycle.d.ts +13 -0
  30. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  31. package/dist/cli/handlers/run.d.ts.map +1 -1
  32. package/dist/cli/handlers/search.d.ts +27 -0
  33. package/dist/cli/handlers/search.d.ts.map +1 -1
  34. package/dist/cli/main.d.ts.map +1 -1
  35. package/dist/cli/registry.d.ts +7 -0
  36. package/dist/cli/registry.d.ts.map +1 -1
  37. package/dist/codegen/docs-pages.d.ts +39 -0
  38. package/dist/codegen/docs-pages.d.ts.map +1 -0
  39. package/dist/codegen/docs-sections.d.ts.map +1 -1
  40. package/dist/codegen/docs-sidebar.d.ts +13 -2
  41. package/dist/codegen/docs-sidebar.d.ts.map +1 -1
  42. package/dist/codegen/docs-types.d.ts +30 -10
  43. package/dist/codegen/docs-types.d.ts.map +1 -1
  44. package/dist/codegen/docs.d.ts +14 -2
  45. package/dist/codegen/docs.d.ts.map +1 -1
  46. package/dist/codegen/lexicon-regen.d.ts +11 -0
  47. package/dist/codegen/lexicon-regen.d.ts.map +1 -1
  48. package/dist/codegen/surface-snapshot.d.ts +27 -3
  49. package/dist/codegen/surface-snapshot.d.ts.map +1 -1
  50. package/dist/codegen/validate.d.ts +21 -3
  51. package/dist/codegen/validate.d.ts.map +1 -1
  52. package/dist/components/capability-plugin.d.ts +13 -2
  53. package/dist/components/capability-plugin.d.ts.map +1 -1
  54. package/dist/components/driver.d.ts +38 -0
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/index.d.ts +1 -1
  57. package/dist/components/index.d.ts.map +1 -1
  58. package/dist/components/starter-plugin.d.ts.map +1 -1
  59. package/dist/config.d.ts +85 -5
  60. package/dist/config.d.ts.map +1 -1
  61. package/dist/discovery/fold-import.d.ts.map +1 -1
  62. package/dist/env.d.ts +12 -1
  63. package/dist/env.d.ts.map +1 -1
  64. package/dist/fold/fold.d.ts +100 -1
  65. package/dist/fold/fold.d.ts.map +1 -1
  66. package/dist/graph-ir.d.ts +12 -0
  67. package/dist/graph-ir.d.ts.map +1 -1
  68. package/dist/graph-ops.d.ts +17 -0
  69. package/dist/graph-ops.d.ts.map +1 -0
  70. package/dist/lexicon.d.ts +182 -2
  71. package/dist/lexicon.d.ts.map +1 -1
  72. package/dist/lifecycle/change-set.d.ts +23 -2
  73. package/dist/lifecycle/change-set.d.ts.map +1 -1
  74. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  75. package/dist/lifecycle/index.d.ts +1 -0
  76. package/dist/lifecycle/index.d.ts.map +1 -1
  77. package/dist/lifecycle/observe.d.ts +8 -0
  78. package/dist/lifecycle/observe.d.ts.map +1 -1
  79. package/dist/lifecycle/replay.d.ts.map +1 -1
  80. package/dist/lifecycle/snapshot.d.ts.map +1 -1
  81. package/dist/lifecycle/teardown.d.ts +130 -0
  82. package/dist/lifecycle/teardown.d.ts.map +1 -0
  83. package/dist/lifecycle/types.d.ts +7 -0
  84. package/dist/lifecycle/types.d.ts.map +1 -1
  85. package/dist/lint/engine.d.ts +6 -2
  86. package/dist/lint/engine.d.ts.map +1 -1
  87. package/dist/lint/policy.d.ts.map +1 -1
  88. package/dist/lint/rule.d.ts +31 -0
  89. package/dist/lint/rule.d.ts.map +1 -1
  90. package/dist/lint/rules/cor021-env-literal-name.d.ts +3 -0
  91. package/dist/lint/rules/cor021-env-literal-name.d.ts.map +1 -0
  92. package/dist/lint/rules/index.d.ts +2 -1
  93. package/dist/lint/rules/index.d.ts.map +1 -1
  94. package/dist/managed-fields.d.ts +33 -21
  95. package/dist/managed-fields.d.ts.map +1 -1
  96. package/dist/observation.d.ts +23 -1
  97. package/dist/observation.d.ts.map +1 -1
  98. package/dist/op/builders.d.ts +36 -7
  99. package/dist/op/builders.d.ts.map +1 -1
  100. package/dist/op/index.d.ts +1 -1
  101. package/dist/op/index.d.ts.map +1 -1
  102. package/dist/testing.d.ts +136 -0
  103. package/dist/testing.d.ts.map +1 -0
  104. package/dist/yaml.d.ts +0 -8
  105. package/dist/yaml.d.ts.map +1 -1
  106. package/package.json +6 -1
  107. package/src/__snapshots__/okf.test.ts.snap +20 -0
  108. package/src/audit/discover.test.ts +55 -1
  109. package/src/audit/discover.ts +70 -2
  110. package/src/audit/edge-init-safety.test.ts +149 -18
  111. package/src/audit/report-model.ts +12 -1
  112. package/src/audit/rules-doc.ts +1 -0
  113. package/src/build-params.test.ts +52 -0
  114. package/src/cli/build-params-cli.test.ts +25 -8
  115. package/src/cli/build-params-cli.ts +37 -23
  116. package/src/cli/commands/__fixtures__/audit-coverage/.github/workflows/ci.yml +5 -0
  117. package/src/cli/commands/__fixtures__/audit-coverage/Dockerfile +2 -0
  118. package/src/cli/commands/__fixtures__/audit-coverage/infra/main.tf +3 -0
  119. package/src/cli/commands/__fixtures__/audit-coverage/infra/stack.json +6 -0
  120. package/src/cli/commands/__fixtures__/audit-coverage/k8s/deploy.yaml +12 -0
  121. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/pages/getting-started.mdx +16 -0
  122. package/src/cli/commands/__fixtures__/init-lexicon-output/docs/src/content.config.ts +10 -2
  123. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate-cli.ts +3 -1
  124. package/src/cli/commands/__fixtures__/init-lexicon-output/src/codegen/generate.ts +2 -1
  125. package/src/cli/commands/__snapshots__/init-lexicon.test.ts.snap +2 -1
  126. package/src/cli/commands/audit.test.ts +83 -1
  127. package/src/cli/commands/audit.ts +121 -28
  128. package/src/cli/commands/build.test.ts +275 -5
  129. package/src/cli/commands/build.ts +86 -14
  130. package/src/cli/commands/check-lexicon-docs.ts +30 -0
  131. package/src/cli/commands/check-lexicon-examples.test.ts +73 -7
  132. package/src/cli/commands/check-lexicon-examples.ts +66 -17
  133. package/src/cli/commands/check-lexicon.test.ts +46 -2
  134. package/src/cli/commands/check-lexicon.ts +64 -4
  135. package/src/cli/commands/init-lexicon/templates/codegen.ts +5 -2
  136. package/src/cli/commands/init-lexicon/templates/docs.ts +35 -2
  137. package/src/cli/commands/init-lexicon.test.ts +42 -1
  138. package/src/cli/commands/init-lexicon.ts +3 -1
  139. package/src/cli/commands/lexicon-rolling-upgrade.ts +1 -1
  140. package/src/cli/commands/lexicon-surface-diff.test.ts +1 -1
  141. package/src/cli/commands/lexicon-surface-diff.ts +10 -1
  142. package/src/cli/commands/lexicon-surface-diff.update.test.ts +112 -0
  143. package/src/cli/commands/lint.ts +19 -6
  144. package/src/cli/commands/onboard.test.ts +319 -225
  145. package/src/cli/commands/onboard.ts +144 -58
  146. package/src/cli/handlers/build.test.ts +3 -3
  147. package/src/cli/handlers/build.ts +2 -0
  148. package/src/cli/handlers/components.ts +2 -2
  149. package/src/cli/handlers/graph.test.ts +44 -0
  150. package/src/cli/handlers/graph.ts +24 -4
  151. package/src/cli/handlers/lifecycle.test.ts +348 -1
  152. package/src/cli/handlers/lifecycle.ts +235 -3
  153. package/src/cli/handlers/run.test.ts +5 -5
  154. package/src/cli/handlers/run.ts +1 -0
  155. package/src/cli/handlers/search-live.test.ts +217 -0
  156. package/src/cli/handlers/search.test.ts +41 -1
  157. package/src/cli/handlers/search.ts +60 -8
  158. package/src/cli/main.ts +17 -4
  159. package/src/cli/registry.ts +7 -0
  160. package/src/cli/security-boundary.test.ts +2 -1
  161. package/src/codegen/docs-pages.test.ts +194 -0
  162. package/src/codegen/docs-pages.ts +138 -0
  163. package/src/codegen/docs-sections.ts +0 -8
  164. package/src/codegen/docs-sidebar.ts +50 -37
  165. package/src/codegen/docs-types.ts +32 -4
  166. package/src/codegen/docs.ts +78 -40
  167. package/src/codegen/lexicon-regen.ts +22 -2
  168. package/src/codegen/rolling-upgrade.test.ts +1 -1
  169. package/src/codegen/rolling-upgrade.ts +1 -1
  170. package/src/codegen/surface-snapshot.test.ts +139 -0
  171. package/src/codegen/surface-snapshot.ts +297 -122
  172. package/src/codegen/validate.test.ts +49 -3
  173. package/src/codegen/validate.ts +36 -7
  174. package/src/components/auto-release.ts +1 -1
  175. package/src/components/capability-plugin.ts +23 -2
  176. package/src/components/driver.test.ts +45 -0
  177. package/src/components/driver.ts +64 -27
  178. package/src/components/index.ts +2 -0
  179. package/src/components/starter-plugin.ts +5 -2
  180. package/src/config.test.ts +40 -0
  181. package/src/config.ts +159 -8
  182. package/src/discovery/fold-import.test.ts +427 -2
  183. package/src/discovery/fold-import.ts +163 -7
  184. package/src/discovery/sandbox/driver.test.ts +11 -0
  185. package/src/env.test.ts +35 -1
  186. package/src/env.ts +17 -3
  187. package/src/fold/fold.test.ts +41 -0
  188. package/src/fold/fold.ts +372 -2
  189. package/src/graph-ir-live.test.ts +42 -0
  190. package/src/graph-ir.ts +42 -2
  191. package/src/graph-ops.test.ts +69 -0
  192. package/src/graph-ops.ts +42 -0
  193. package/src/lexicon.ts +182 -2
  194. package/src/lifecycle/change-set.test.ts +41 -0
  195. package/src/lifecycle/change-set.ts +30 -2
  196. package/src/lifecycle/deep-observe.ts +4 -0
  197. package/src/lifecycle/index.ts +1 -0
  198. package/src/lifecycle/observe.ts +29 -2
  199. package/src/lifecycle/replay.ts +3 -0
  200. package/src/lifecycle/snapshot.ts +3 -0
  201. package/src/lifecycle/teardown.test.ts +537 -0
  202. package/src/lifecycle/teardown.ts +357 -0
  203. package/src/lifecycle/types.ts +7 -0
  204. package/src/lint/engine.ts +7 -1
  205. package/src/lint/policy.ts +14 -3
  206. package/src/lint/rule.ts +23 -0
  207. package/src/lint/rules/cor021-env-literal-name.test.ts +128 -0
  208. package/src/lint/rules/cor021-env-literal-name.ts +114 -0
  209. package/src/lint/rules/index.ts +4 -1
  210. package/src/managed-fields.test.ts +15 -42
  211. package/src/managed-fields.ts +43 -26
  212. package/src/observation.test.ts +24 -8
  213. package/src/observation.ts +48 -4
  214. package/src/op/builders.ts +40 -7
  215. package/src/op/index.ts +1 -1
  216. package/src/ownership.test.ts +63 -1
  217. package/src/testing.test.ts +261 -0
  218. package/src/testing.ts +338 -0
  219. package/src/yaml.test.ts +15 -0
  220. package/src/yaml.ts +19 -2
@@ -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,262 @@ 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
+
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
+
186
443
  test("#1064 — a declared build-time parameter binds to params.<name> and folds to a literal", async () => {
187
444
  const thisDir = dirname(fileURLToPath(import.meta.url));
188
445
  const runtimePath = resolvePath(thisDir, "../../runtime");
@@ -235,10 +492,10 @@ export const testEntity = {
235
492
  expect(result.resourceCount).toBe(1);
236
493
  expect(result.buildParams).toEqual([{ name: "tier", value: "production", source: "cli" }]);
237
494
 
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);
495
+ // #1424 without --verbose the echo is a one-line count by source.
496
+ const logged = errorSpy.mock.calls.map((call) => String(call[0]));
497
+ expect(logged.some((line) => line.includes("1 build parameter resolved (1 from cli)"))).toBe(true);
498
+ expect(logged.some((line) => line.includes("[param] tier"))).toBe(false);
242
499
  } finally {
243
500
  errorSpy.mockRestore();
244
501
  }
@@ -346,6 +603,19 @@ export const testEntity = {
346
603
 
347
604
  expect(result.success).toBe(true);
348
605
  expect(result.resourceCount).toBe(1);
606
+ // #1424 — the default report is one summary line, not one line per file.
607
+ const logged = errorSpy.mock.calls.map((call) => String(call[0]));
608
+ expect(logged.some((line) => /^.*fold: \d+ files? folded, \d+ ran/.test(line))).toBe(true);
609
+ expect(logged.some((line) => line.includes("[fold:"))).toBe(false);
610
+
611
+ errorSpy.mockClear();
612
+ const verboseResult = await buildCommand({
613
+ path: testDir,
614
+ format: "json",
615
+ serializers: [mockSerializer],
616
+ verbose: true,
617
+ });
618
+ expect(verboseResult.success).toBe(true);
349
619
  const anyFoldLine = errorSpy.mock.calls.map((call) => String(call[0])).some((line) => line.includes("[fold:"));
350
620
  expect(anyFoldLine).toBe(true);
351
621
 
@@ -360,7 +630,7 @@ export const testEntity = {
360
630
  expect(runResult.resourceCount).toBe(1);
361
631
  const anyFoldLineOff = errorSpy.mock.calls
362
632
  .map((call) => String(call[0]))
363
- .some((line) => line.includes("[fold:"));
633
+ .some((line) => line.includes("[fold:") || line.includes("fold: "));
364
634
  expect(anyFoldLineOff).toBe(false);
365
635
  } finally {
366
636
  errorSpy.mockRestore();