@intentius/chant 0.49.0 → 0.50.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 (247) hide show
  1. package/dist/audit/catalog.d.ts +13 -3
  2. package/dist/audit/catalog.d.ts.map +1 -1
  3. package/dist/audit/core.d.ts +9 -0
  4. package/dist/audit/core.d.ts.map +1 -1
  5. package/dist/audit/discover.d.ts +6 -0
  6. package/dist/audit/discover.d.ts.map +1 -1
  7. package/dist/audit/fetch.d.ts.map +1 -1
  8. package/dist/audit/report-html.d.ts.map +1 -1
  9. package/dist/audit/report-model.d.ts +6 -0
  10. package/dist/audit/report-model.d.ts.map +1 -1
  11. package/dist/audit/report.d.ts.map +1 -1
  12. package/dist/audit/rules-doc.d.ts.map +1 -1
  13. package/dist/audit/secrets.d.ts +95 -0
  14. package/dist/audit/secrets.d.ts.map +1 -0
  15. package/dist/audit/wrangler.d.ts +33 -0
  16. package/dist/audit/wrangler.d.ts.map +1 -0
  17. package/dist/build.d.ts.map +1 -1
  18. package/dist/cli/commands/audit.d.ts +7 -0
  19. package/dist/cli/commands/audit.d.ts.map +1 -1
  20. package/dist/cli/commands/build.d.ts +23 -0
  21. package/dist/cli/commands/build.d.ts.map +1 -1
  22. package/dist/cli/handlers/build.d.ts.map +1 -1
  23. package/dist/cli/handlers/components.d.ts +31 -0
  24. package/dist/cli/handlers/components.d.ts.map +1 -1
  25. package/dist/cli/handlers/lifecycle.d.ts +11 -0
  26. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  27. package/dist/cli/handlers/operator.d.ts +32 -0
  28. package/dist/cli/handlers/operator.d.ts.map +1 -0
  29. package/dist/cli/handlers/scenario.d.ts +39 -0
  30. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  31. package/dist/cli/main.d.ts.map +1 -1
  32. package/dist/cli/mcp/server.d.ts +35 -2
  33. package/dist/cli/mcp/server.d.ts.map +1 -1
  34. package/dist/cli/mcp/types.d.ts +29 -1
  35. package/dist/cli/mcp/types.d.ts.map +1 -1
  36. package/dist/cli/registry.d.ts +14 -2
  37. package/dist/cli/registry.d.ts.map +1 -1
  38. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  39. package/dist/components/capability.d.ts +17 -2
  40. package/dist/components/capability.d.ts.map +1 -1
  41. package/dist/components/cli-support.d.ts +7 -0
  42. package/dist/components/cli-support.d.ts.map +1 -1
  43. package/dist/components/component.d.ts +15 -0
  44. package/dist/components/component.d.ts.map +1 -1
  45. package/dist/components/driver.d.ts.map +1 -1
  46. package/dist/components/verbs/index.d.ts +6 -1
  47. package/dist/components/verbs/index.d.ts.map +1 -1
  48. package/dist/components/verbs/run-agent.d.ts +499 -0
  49. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  50. package/dist/components/verbs/sign.d.ts +30 -0
  51. package/dist/components/verbs/sign.d.ts.map +1 -1
  52. package/dist/composite.d.ts +6 -1
  53. package/dist/composite.d.ts.map +1 -1
  54. package/dist/discovery/collect.d.ts.map +1 -1
  55. package/dist/discovery/fold-import.d.ts +15 -1
  56. package/dist/discovery/fold-import.d.ts.map +1 -1
  57. package/dist/discovery/fold-rank.d.ts +66 -0
  58. package/dist/discovery/fold-rank.d.ts.map +1 -0
  59. package/dist/discovery/index.d.ts +15 -0
  60. package/dist/discovery/index.d.ts.map +1 -1
  61. package/dist/discovery/param-deps.d.ts +17 -0
  62. package/dist/discovery/param-deps.d.ts.map +1 -0
  63. package/dist/fold/fold.d.ts +55 -2
  64. package/dist/fold/fold.d.ts.map +1 -1
  65. package/dist/fold/subset.d.ts +21 -14
  66. package/dist/fold/subset.d.ts.map +1 -1
  67. package/dist/lexicon-schema.d.ts +2 -0
  68. package/dist/lexicon-schema.d.ts.map +1 -1
  69. package/dist/lexicon.d.ts +93 -0
  70. package/dist/lexicon.d.ts.map +1 -1
  71. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  72. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  73. package/dist/lifecycle/deep-diff.d.ts +18 -0
  74. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  75. package/dist/lifecycle/deep-observe.d.ts +9 -1
  76. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  77. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  78. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  79. package/dist/lifecycle/git.d.ts +145 -21
  80. package/dist/lifecycle/git.d.ts.map +1 -1
  81. package/dist/lifecycle/index.d.ts +4 -0
  82. package/dist/lifecycle/index.d.ts.map +1 -1
  83. package/dist/lifecycle/lease.d.ts +113 -0
  84. package/dist/lifecycle/lease.d.ts.map +1 -0
  85. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  86. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  87. package/dist/lifecycle/scenario.d.ts +163 -0
  88. package/dist/lifecycle/scenario.d.ts.map +1 -0
  89. package/dist/lifecycle/symptoms.d.ts +63 -0
  90. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  91. package/dist/lint/output-docs.d.ts +94 -0
  92. package/dist/lint/output-docs.d.ts.map +1 -0
  93. package/dist/lint/post-synth.d.ts +29 -0
  94. package/dist/lint/post-synth.d.ts.map +1 -1
  95. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  96. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  97. package/dist/lsp/lexicon-providers.d.ts +7 -0
  98. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  99. package/dist/op/activity-contract.d.ts +139 -0
  100. package/dist/op/activity-contract.d.ts.map +1 -0
  101. package/dist/op/builders.d.ts +42 -2
  102. package/dist/op/builders.d.ts.map +1 -1
  103. package/dist/op/converge-rule.d.ts +161 -0
  104. package/dist/op/converge-rule.d.ts.map +1 -0
  105. package/dist/op/generate-pipeline.d.ts +39 -0
  106. package/dist/op/generate-pipeline.d.ts.map +1 -0
  107. package/dist/op/index.d.ts +14 -0
  108. package/dist/op/index.d.ts.map +1 -1
  109. package/dist/op/local-executor.d.ts.map +1 -1
  110. package/dist/op/op-verb-class.d.ts +42 -0
  111. package/dist/op/op-verb-class.d.ts.map +1 -0
  112. package/dist/op/operator.d.ts +128 -0
  113. package/dist/op/operator.d.ts.map +1 -0
  114. package/dist/op/step-output-ref.d.ts +187 -0
  115. package/dist/op/step-output-ref.d.ts.map +1 -0
  116. package/dist/op/types.d.ts +18 -1
  117. package/dist/op/types.d.ts.map +1 -1
  118. package/dist/provenance.d.ts +73 -3
  119. package/dist/provenance.d.ts.map +1 -1
  120. package/dist/runtime-adapter.d.ts +7 -1
  121. package/dist/runtime-adapter.d.ts.map +1 -1
  122. package/dist/serializer.d.ts +18 -0
  123. package/dist/serializer.d.ts.map +1 -1
  124. package/dist/toml.d.ts +40 -5
  125. package/dist/toml.d.ts.map +1 -1
  126. package/package.json +1 -1
  127. package/src/audit/catalog.test.ts +1 -1
  128. package/src/audit/catalog.ts +75 -3
  129. package/src/audit/core.ts +9 -0
  130. package/src/audit/discover.ts +29 -2
  131. package/src/audit/fetch.test.ts +216 -3
  132. package/src/audit/fetch.ts +270 -59
  133. package/src/audit/report-html.ts +5 -2
  134. package/src/audit/report-model.ts +9 -0
  135. package/src/audit/report.test.ts +22 -0
  136. package/src/audit/report.ts +3 -2
  137. package/src/audit/rules-doc.ts +2 -0
  138. package/src/audit/secrets.test.ts +303 -0
  139. package/src/audit/secrets.ts +406 -0
  140. package/src/audit/wrangler.test.ts +230 -0
  141. package/src/audit/wrangler.ts +290 -0
  142. package/src/build.ts +8 -3
  143. package/src/cli/command-group.ts +1 -1
  144. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  145. package/src/cli/commands/audit.test.ts +215 -1
  146. package/src/cli/commands/audit.ts +86 -17
  147. package/src/cli/commands/build.test.ts +167 -2
  148. package/src/cli/commands/build.ts +114 -23
  149. package/src/cli/handlers/build.ts +2 -0
  150. package/src/cli/handlers/components.test.ts +199 -1
  151. package/src/cli/handlers/components.ts +160 -3
  152. package/src/cli/handlers/graph.test.ts +20 -0
  153. package/src/cli/handlers/graph.ts +10 -1
  154. package/src/cli/handlers/lifecycle.ts +12 -4
  155. package/src/cli/handlers/operator.test.ts +255 -0
  156. package/src/cli/handlers/operator.ts +240 -0
  157. package/src/cli/handlers/scenario.test.ts +456 -0
  158. package/src/cli/handlers/scenario.ts +330 -0
  159. package/src/cli/main.test.ts +23 -0
  160. package/src/cli/main.ts +72 -1
  161. package/src/cli/mcp/server.test.ts +265 -2
  162. package/src/cli/mcp/server.ts +84 -7
  163. package/src/cli/mcp/types.ts +27 -1
  164. package/src/cli/registry.ts +14 -2
  165. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  166. package/src/codegen/docs-rule-scanning.ts +25 -2
  167. package/src/components/README.md +7 -0
  168. package/src/components/capability.ts +17 -2
  169. package/src/components/cli-support.test.ts +17 -0
  170. package/src/components/cli-support.ts +13 -1
  171. package/src/components/component-schema.test.ts +32 -0
  172. package/src/components/component.schema.json +6 -0
  173. package/src/components/component.test.ts +21 -0
  174. package/src/components/component.ts +15 -0
  175. package/src/components/driver.ts +12 -4
  176. package/src/components/verbs/index.ts +6 -1
  177. package/src/components/verbs/run-agent.test.ts +683 -0
  178. package/src/components/verbs/run-agent.ts +786 -0
  179. package/src/components/verbs/sign.test.ts +19 -0
  180. package/src/components/verbs/sign.ts +34 -2
  181. package/src/composite.ts +31 -2
  182. package/src/discovery/collect.ts +11 -2
  183. package/src/discovery/fold-import.test.ts +54 -0
  184. package/src/discovery/fold-import.ts +178 -38
  185. package/src/discovery/fold-rank.test.ts +197 -0
  186. package/src/discovery/fold-rank.ts +346 -0
  187. package/src/discovery/index.ts +16 -1
  188. package/src/discovery/param-deps.test.ts +118 -0
  189. package/src/discovery/param-deps.ts +170 -0
  190. package/src/fold/fold.test.ts +6 -2
  191. package/src/fold/fold.ts +184 -3
  192. package/src/fold/subset.test.ts +82 -19
  193. package/src/fold/subset.ts +79 -41
  194. package/src/lexicon-schema.ts +3 -0
  195. package/src/lexicon.ts +103 -2
  196. package/src/lifecycle/converge-ledger.test.ts +199 -0
  197. package/src/lifecycle/converge-ledger.ts +179 -0
  198. package/src/lifecycle/deep-diff.test.ts +79 -1
  199. package/src/lifecycle/deep-diff.ts +23 -0
  200. package/src/lifecycle/deep-observe.ts +13 -2
  201. package/src/lifecycle/gate-ledger.test.ts +103 -0
  202. package/src/lifecycle/gate-ledger.ts +140 -0
  203. package/src/lifecycle/git.test.ts +430 -0
  204. package/src/lifecycle/git.ts +446 -84
  205. package/src/lifecycle/index.ts +4 -0
  206. package/src/lifecycle/lease.test.ts +343 -0
  207. package/src/lifecycle/lease.ts +270 -0
  208. package/src/lifecycle/scenario-eval.test.ts +199 -0
  209. package/src/lifecycle/scenario-eval.ts +158 -0
  210. package/src/lifecycle/scenario.test.ts +195 -0
  211. package/src/lifecycle/scenario.ts +321 -0
  212. package/src/lifecycle/symptoms.test.ts +116 -0
  213. package/src/lifecycle/symptoms.ts +126 -0
  214. package/src/lint/output-docs.test.ts +220 -0
  215. package/src/lint/output-docs.ts +204 -0
  216. package/src/lint/post-synth.test.ts +97 -0
  217. package/src/lint/post-synth.ts +45 -0
  218. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  219. package/src/lint/rules/comp/comp.test.ts +49 -1
  220. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  221. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  222. package/src/lsp/lexicon-providers.test.ts +44 -0
  223. package/src/lsp/lexicon-providers.ts +11 -1
  224. package/src/op/activity-contract.test.ts +180 -0
  225. package/src/op/activity-contract.ts +278 -0
  226. package/src/op/builders-exports.test.ts +17 -1
  227. package/src/op/builders.ts +59 -5
  228. package/src/op/converge-rule.test.ts +179 -0
  229. package/src/op/converge-rule.ts +311 -0
  230. package/src/op/generate-pipeline.test.ts +53 -0
  231. package/src/op/generate-pipeline.ts +99 -0
  232. package/src/op/index.ts +30 -0
  233. package/src/op/local-executor.test.ts +92 -0
  234. package/src/op/local-executor.ts +45 -9
  235. package/src/op/op-verb-class.test.ts +126 -0
  236. package/src/op/op-verb-class.ts +115 -0
  237. package/src/op/operator.test.ts +346 -0
  238. package/src/op/operator.ts +213 -0
  239. package/src/op/step-output-ref.test.ts +334 -0
  240. package/src/op/step-output-ref.ts +453 -0
  241. package/src/op/types.ts +18 -1
  242. package/src/provenance.test.ts +151 -4
  243. package/src/provenance.ts +118 -4
  244. package/src/runtime-adapter.ts +31 -10
  245. package/src/serializer.ts +18 -0
  246. package/src/toml.test.ts +157 -384
  247. package/src/toml.ts +371 -5
@@ -1,5 +1,6 @@
1
1
  import { describe, test, expect } from "vitest";
2
2
  import { fileURLToPath } from "url";
3
+ import Ajv from "ajv";
3
4
  import { auditCommand, tokenForHost, coverageNotes, installLine, NO_LEXICONS_EXIT_CODE } from "./audit";
4
5
  import { discoverByDetection, loadAuditPlugins } from "../../audit/discover";
5
6
  import { MissingLexiconError, type AuditInput, type AuditLexicon } from "../../audit/core";
@@ -8,12 +9,19 @@ import { MissingLexiconError, type AuditInput, type AuditLexicon } from "../../a
8
9
  async function discoverLexicon(repo: string, lexicon: AuditLexicon): Promise<AuditInput[]> {
9
10
  return discoverByDetection(repo, await loadAuditPlugins()).filter((i) => i.lexicon === lexicon);
10
11
  }
11
- import { readFileSync, existsSync, rmSync } from "fs";
12
+ import { readFileSync, existsSync, rmSync, mkdirSync, writeFileSync } from "fs";
12
13
  import { tmpdir } from "os";
13
14
  import { join } from "path";
15
+ import { fingerprintSecret } from "../../audit/secrets";
14
16
 
15
17
  const REPO = fileURLToPath(new URL("./__fixtures__/audit-repo", import.meta.url));
16
18
 
19
+ /** The real, vendored SARIF 2.1.0 JSON Schema — same structural-validation convention as component-bom.test.ts. */
20
+ function loadSarifSchema(): unknown {
21
+ const path = fileURLToPath(new URL("./__fixtures__/schemas/sarif-2.1.0.schema.json", import.meta.url));
22
+ return JSON.parse(readFileSync(path, "utf-8"));
23
+ }
24
+
17
25
  describe("auditCommand", () => {
18
26
  test("selects a host-specific token (no cross-host leakage)", () => {
19
27
  const env = { GITHUB_TOKEN: "gh", GITLAB_TOKEN: "gl", CODEBERG_TOKEN: "cb" } as unknown as NodeJS.ProcessEnv;
@@ -399,4 +407,210 @@ describe("auditCommand", () => {
399
407
  expect(sarif.version).toBe("2.1.0");
400
408
  expect(sarif.runs[0].results.length).toBeGreaterThan(0);
401
409
  });
410
+
411
+ test("sarif carries tier on results and dimension/category + remediation help on rules (#442)", async () => {
412
+ const result = await auditCommand({ path: REPO, format: "sarif" });
413
+ const sarif = JSON.parse(result.output) as {
414
+ runs: Array<{
415
+ tool: { driver: { rules: Array<{ id: string; helpUri?: string; help?: { text: string }; properties?: { category?: string; dimension?: string } }> } };
416
+ results: Array<{ ruleId: string; properties?: { tier?: string } }>;
417
+ }>;
418
+ };
419
+ const run = sarif.runs[0];
420
+ // Every result carries the finding's tier so a consumer can triage merge-worthy vs. report-only without a second lookup.
421
+ expect(run.results.length).toBeGreaterThan(0);
422
+ for (const r of run.results) {
423
+ expect(r.properties?.tier).toMatch(/^(merge-worthy|report-only)$/);
424
+ }
425
+ // Every rule carries its dimension/category and remediation text (mapped from the catalog, #351) as help.
426
+ expect(run.tool.driver.rules.length).toBeGreaterThan(0);
427
+ for (const rule of run.tool.driver.rules) {
428
+ expect(rule.properties?.category).toMatch(/^(security|correctness|best-practice)$/);
429
+ expect(rule.properties?.dimension).toBe(rule.properties?.category);
430
+ expect(rule.help?.text.length).toBeGreaterThan(0);
431
+ }
432
+ });
433
+
434
+ test("sarif output validates against the real, vendored SARIF 2.1.0 JSON Schema", async () => {
435
+ const result = await auditCommand({ path: REPO, format: "sarif" });
436
+ const doc = JSON.parse(result.output);
437
+ const ajv = new Ajv({ strict: false, allErrors: true });
438
+ const validate = ajv.compile(loadSarifSchema() as object);
439
+ const valid = validate(doc);
440
+ if (!valid) throw new Error(ajv.errorsText(validate.errors));
441
+ expect(valid).toBe(true);
442
+ });
443
+
444
+ test("sarif schema validation also holds for the empty-result case (no findings)", async () => {
445
+ const repo = fileURLToPath(new URL("./__fixtures__/audit-fountain-clean", import.meta.url));
446
+ const result = await auditCommand({ path: repo, format: "sarif" });
447
+ const doc = JSON.parse(result.output);
448
+ expect(doc.runs[0].results).toEqual([]);
449
+ const ajv = new Ajv({ strict: false, allErrors: true });
450
+ const validate = ajv.compile(loadSarifSchema() as object);
451
+ const valid = validate(doc);
452
+ if (!valid) throw new Error(ajv.errorsText(validate.errors));
453
+ expect(valid).toBe(true);
454
+ });
455
+ });
456
+
457
+ describe("secrets detection (#443)", () => {
458
+ // A deliberately fake, non-functional AWS-access-key-ID-shaped value, built
459
+ // via concatenation (never a contiguous literal in this file's raw bytes)
460
+ // so pushing this file doesn't trip GitHub's own secret-scanning push
461
+ // protection on what is just a test fixture.
462
+ const FAKE_AWS_KEY = "AKIA" + "ABCDEFGHIJKLMNOP";
463
+ const FAKE_STRIPE_LIVE = "sk_live_" + "4eC39HqLyjWDarjtT1zdp7dc";
464
+
465
+ function tmpRepo(): string {
466
+ const dir = join(tmpdir(), `chant-audit-secrets-${process.pid}-${Math.random().toString(36).slice(2)}`);
467
+ mkdirSync(dir, { recursive: true });
468
+ return dir;
469
+ }
470
+
471
+ test("flags a hardcoded credential even when no lexicon is installed", async () => {
472
+ const dir = tmpRepo();
473
+ writeFileSync(join(dir, ".env"), `AWS_ACCESS_KEY_ID=${FAKE_AWS_KEY}\n`);
474
+ const result = await auditCommand({ path: dir, plugins: [] });
475
+ expect(result.status).toBe("no-lexicons"); // no lexicon installed — still not a clean miss
476
+ expect(result.findings.map((f) => f.checkId)).toContain("SEC001");
477
+ expect(JSON.stringify(result.findings)).not.toContain(FAKE_AWS_KEY); // redaction
478
+ rmSync(dir, { recursive: true, force: true });
479
+ });
480
+
481
+ test("sarif output never carries the raw secret value for a planted fake credential (#442)", async () => {
482
+ // `plugins: []` takes the "no-lexicons" diagnostic branch regardless of
483
+ // format, so a real lexicon (github) has to be installed for `format:
484
+ // "sarif"` to actually render SARIF here — same setup as the JSON
485
+ // round-trip test below.
486
+ const dir = tmpRepo();
487
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
488
+ writeFileSync(
489
+ join(dir, ".github", "workflows", "ci.yml"),
490
+ "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
491
+ );
492
+ writeFileSync(join(dir, ".env"), `AWS_ACCESS_KEY_ID=${FAKE_AWS_KEY}\nSTRIPE_KEY=${FAKE_STRIPE_LIVE}\n`);
493
+ const all = await loadAuditPlugins();
494
+ const result = await auditCommand({ path: dir, plugins: all.filter((p) => p.name === "github"), format: "sarif" });
495
+ expect(result.status).toBe("ok");
496
+ expect(result.output).not.toContain(FAKE_AWS_KEY);
497
+ expect(result.output).not.toContain(FAKE_STRIPE_LIVE);
498
+ const sarif = JSON.parse(result.output) as { runs: Array<{ results: Array<{ ruleId: string; message: { text: string } }> }> };
499
+ const ruleIds = sarif.runs[0].results.map((r) => r.ruleId);
500
+ expect(ruleIds).toContain("SEC001");
501
+ expect(ruleIds).toContain("SEC006");
502
+ for (const r of sarif.runs[0].results) {
503
+ expect(r.message.text).not.toContain(FAKE_AWS_KEY);
504
+ expect(r.message.text).not.toContain(FAKE_STRIPE_LIVE);
505
+ }
506
+ rmSync(dir, { recursive: true, force: true });
507
+ });
508
+
509
+ test("a secret rides alongside lexicon findings, tiered and cataloged like any other finding", async () => {
510
+ const dir = tmpRepo();
511
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
512
+ writeFileSync(
513
+ join(dir, ".github", "workflows", "ci.yml"),
514
+ "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
515
+ );
516
+ writeFileSync(join(dir, ".env"), `STRIPE_KEY=${FAKE_STRIPE_LIVE}\n`);
517
+ const all = await loadAuditPlugins();
518
+ const result = await auditCommand({ path: dir, plugins: all.filter((p) => p.name === "github"), format: "json" });
519
+ expect(result.status).toBe("ok");
520
+ const json = JSON.parse(result.output);
521
+ const sec = json.findings.find((f: { checkId: string }) => f.checkId === "SEC006");
522
+ expect(sec).toBeDefined();
523
+ expect(sec.tier).toBe("merge-worthy");
524
+ expect(sec.category).toBe("security");
525
+ expect(sec.file).toBe(".env");
526
+ expect(JSON.stringify(json)).not.toContain(FAKE_STRIPE_LIVE); // redaction, incl. the JSON report
527
+ rmSync(dir, { recursive: true, force: true });
528
+ });
529
+
530
+ test("an inline `chant-audit-ignore` marker suppresses the finding", async () => {
531
+ const dir = tmpRepo();
532
+ writeFileSync(join(dir, ".env"), `AWS_ACCESS_KEY_ID=${FAKE_AWS_KEY} # chant-audit-ignore: SEC001\n`);
533
+ const result = await auditCommand({ path: dir, plugins: [] });
534
+ expect(result.findings.map((f) => f.checkId)).not.toContain("SEC001");
535
+ rmSync(dir, { recursive: true, force: true });
536
+ });
537
+
538
+ test(".chant-audit.json's allowlist suppresses a finding by fingerprint, without storing the secret", async () => {
539
+ const dir = tmpRepo();
540
+ writeFileSync(join(dir, ".env"), `AWS_ACCESS_KEY_ID=${FAKE_AWS_KEY}\n`);
541
+ const fingerprint = fingerprintSecret(FAKE_AWS_KEY);
542
+ writeFileSync(join(dir, ".chant-audit.json"), JSON.stringify({ secrets: { allow: [{ ruleId: "SEC001", fingerprint }] } }));
543
+ const result = await auditCommand({ path: dir, plugins: [] });
544
+ expect(result.findings.map((f) => f.checkId)).not.toContain("SEC001");
545
+ rmSync(dir, { recursive: true, force: true });
546
+ });
547
+
548
+ test("an explicit secretsScan option overrides (and wins over) the local config", async () => {
549
+ const dir = tmpRepo();
550
+ writeFileSync(join(dir, "config.yaml"), "token: kQ7mZ9pL2xR8vT4nW1sD6uJ3\n");
551
+ writeFileSync(join(dir, ".chant-audit.json"), JSON.stringify({ secrets: { entropyThreshold: 1 } }));
552
+ const result = await auditCommand({ path: dir, plugins: [], secretsScan: { entropyThreshold: 6.5 } });
553
+ expect(result.findings.map((f) => f.checkId)).not.toContain("SEC010");
554
+ rmSync(dir, { recursive: true, force: true });
555
+ });
556
+ });
557
+
558
+ describe("Wrangler config audit (#446)", () => {
559
+ function tmpRepo(): string {
560
+ const dir = join(tmpdir(), `chant-audit-wrangler-${process.pid}-${Math.random().toString(36).slice(2)}`);
561
+ mkdirSync(dir, { recursive: true });
562
+ return dir;
563
+ }
564
+
565
+ test("audits wrangler.toml end to end, even when no lexicon is installed", async () => {
566
+ const dir = tmpRepo();
567
+ writeFileSync(
568
+ join(dir, "wrangler.toml"),
569
+ [
570
+ 'name = "my-worker"',
571
+ "",
572
+ "[observability]",
573
+ "enabled = false",
574
+ "",
575
+ "[env.production]",
576
+ "workers_dev = true",
577
+ ].join("\n"),
578
+ );
579
+ const result = await auditCommand({ path: dir, plugins: [], format: "json" });
580
+ expect(result.status).toBe("no-lexicons"); // no lexicon installed — still not a clean miss
581
+ const ids = result.findings.map((f) => f.checkId);
582
+ expect(ids).toContain("WRG001");
583
+ expect(ids).toContain("WRG003");
584
+ rmSync(dir, { recursive: true, force: true });
585
+ });
586
+
587
+ test("a wrangler finding is tiered and cataloged like any other finding, alongside lexicon findings", async () => {
588
+ const dir = tmpRepo();
589
+ mkdirSync(join(dir, ".github", "workflows"), { recursive: true });
590
+ writeFileSync(
591
+ join(dir, ".github", "workflows", "ci.yml"),
592
+ "name: CI\non:\n push:\njobs:\n build:\n runs-on: ubuntu-latest\n steps:\n - run: echo hi\n",
593
+ );
594
+ writeFileSync(join(dir, "wrangler.toml"), 'routes = ["*/*"]\n');
595
+ const all = await loadAuditPlugins();
596
+ const result = await auditCommand({ path: dir, plugins: all.filter((p) => p.name === "github"), format: "json" });
597
+ expect(result.status).toBe("ok");
598
+ const json = JSON.parse(result.output);
599
+ const wrg = json.findings.find((f: { checkId: string }) => f.checkId === "WRG004");
600
+ expect(wrg).toBeDefined();
601
+ expect(wrg.tier).toBe("merge-worthy");
602
+ expect(wrg.category).toBe("security");
603
+ expect(wrg.file).toBe("wrangler.toml");
604
+ expect(wrg.docUrl).toBe("https://intentius.io/chant/lint-rules/audit-rules/#wrg004");
605
+ rmSync(dir, { recursive: true, force: true });
606
+ });
607
+
608
+ test("a malformed wrangler.toml contributes no findings, never fails the audit", async () => {
609
+ const dir = tmpRepo();
610
+ writeFileSync(join(dir, "wrangler.toml"), "not = valid = toml = [\n");
611
+ const result = await auditCommand({ path: dir, plugins: [] });
612
+ expect(result.success).toBe(true);
613
+ expect(result.findings.map((f) => f.checkId)).not.toEqual(expect.arrayContaining([expect.stringMatching(/^WRG/)]));
614
+ rmSync(dir, { recursive: true, force: true });
615
+ });
402
616
  });
@@ -5,10 +5,13 @@
5
5
  * directly and runs the real post-synth checks via the audit core.
6
6
  */
7
7
 
8
- import { existsSync, statSync, writeFileSync } from "fs";
8
+ import { existsSync, readFileSync, statSync, writeFileSync } from "fs";
9
+ import { join } from "path";
9
10
  import { auditFiles, type AuditInput, type AuditFinding, type ChecksProvider } from "../../audit/core";
10
11
  import { AUDIT_LEXICONS, classifyFiles, collectCandidates, loadAuditPlugins, unclaimedFiles, type DetectPlugin, type RepoFile, type UnclaimedFile } from "../../audit/discover";
11
12
  import { RULE_CATALOG, resolveAuditCatalog, type RuleMeta } from "../../audit/catalog";
13
+ import { scanForSecrets, parseSecretsConfig, type SecretsScanOptions } from "../../audit/secrets";
14
+ import { auditWranglerConfigs } from "../../audit/wrangler";
12
15
  import { renderMarkdown } from "../../audit/report";
13
16
  import { renderHtml, type ReportTheme } from "../../audit/report-html";
14
17
  import { buildReportJson, REPORT_SCHEMA_VERSION, type AuditSnapshot } from "../../audit/report-model";
@@ -47,6 +50,12 @@ export interface AuditCommandOptions {
47
50
  toolVersion?: string;
48
51
  /** Injectable detection plugins (testing); defaults to every installed audit lexicon. */
49
52
  plugins?: DetectPlugin[];
53
+ /**
54
+ * Secrets-detection options (#443) — entropy threshold/min-length and an
55
+ * allowlist. Merged over a `.chant-audit.json` at the local target root, if
56
+ * present (URL targets have no local config file to read).
57
+ */
58
+ secretsScan?: SecretsScanOptions;
50
59
  }
51
60
 
52
61
  export interface AuditCommandResult {
@@ -194,6 +203,26 @@ function sarifLevel(sev: Severity): string {
194
203
  return sev === "error" ? "error" : sev === "warning" ? "warning" : "note";
195
204
  }
196
205
 
206
+ /** `.chant-audit.json`'s `secrets` section at the local target root, if present (tolerant of a missing/malformed file). */
207
+ function readLocalSecretsConfig(root: string): SecretsScanOptions {
208
+ const file = join(root, ".chant-audit.json");
209
+ if (!existsSync(file)) return {};
210
+ try {
211
+ return parseSecretsConfig(readFileSync(file, "utf-8"));
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ /** Merge the local `.chant-audit.json` config under any explicitly-passed options (options win). */
218
+ function resolveSecretsOptions(fileConfig: SecretsScanOptions, explicit?: SecretsScanOptions): SecretsScanOptions {
219
+ return {
220
+ entropyThreshold: explicit?.entropyThreshold ?? fileConfig.entropyThreshold,
221
+ entropyMinLength: explicit?.entropyMinLength ?? fileConfig.entropyMinLength,
222
+ allow: [...(fileConfig.allow ?? []), ...(explicit?.allow ?? [])],
223
+ };
224
+ }
225
+
197
226
  /** Coverage caveats about what the audit could and couldn't see. */
198
227
  export function coverageNotes(inputs: AuditInput[]): string[] {
199
228
  const notes: string[] = [];
@@ -221,7 +250,8 @@ function renderStylish(findings: AuditFinding[], scanned: string[], notes: strin
221
250
  if (list.length === 0) return;
222
251
  lines.push("", title);
223
252
  for (const f of list) {
224
- const where = f.entity ? `${f.file} (${f.entity})` : f.file;
253
+ const loc = f.line ? `${f.file}:${f.line}` : f.file;
254
+ const where = f.entity ? `${loc} (${f.entity})` : loc;
225
255
  const title = catalog[f.checkId]?.title ?? f.checkId;
226
256
  lines.push(` [${f.checkId}] ${f.severity} ${where} — ${title}`);
227
257
  }
@@ -231,6 +261,14 @@ function renderStylish(findings: AuditFinding[], scanned: string[], notes: strin
231
261
  return lines.join("\n");
232
262
  }
233
263
 
264
+ /**
265
+ * SARIF (2.1.0) export (#442). Rule-level metadata — category (the finding
266
+ * "dimension": security/correctness/best-practice) and remediation — is
267
+ * carried in the property bag / `help` block since SARIF's core schema has no
268
+ * dedicated slot for either; `tier` is per-result (a `RuleMeta` fact looked up
269
+ * per finding) so consumers can triage merge-worthy vs. report-only without a
270
+ * second catalog lookup.
271
+ */
234
272
  function renderSarif(findings: AuditFinding[], catalog: Record<string, RuleMeta> = RULE_CATALOG): string {
235
273
  const ruleIds = [...new Set(findings.map((f) => f.checkId))].sort();
236
274
  const rules = ruleIds.map((id) => {
@@ -239,15 +277,28 @@ function renderSarif(findings: AuditFinding[], catalog: Record<string, RuleMeta>
239
277
  id,
240
278
  name: m?.title ?? id,
241
279
  shortDescription: { text: m?.title ?? id },
280
+ ...(m?.remediation ? { help: { text: m.remediation } } : {}),
242
281
  helpUri: m?.authority?.[0]?.url,
282
+ ...(m?.category ? { properties: { category: m.category, dimension: m.category } } : {}),
283
+ };
284
+ });
285
+ const results = findings.map((f) => {
286
+ const tier = catalog[f.checkId]?.tier;
287
+ return {
288
+ ruleId: f.checkId,
289
+ level: sarifLevel(f.severity),
290
+ message: { text: f.message },
291
+ locations: [
292
+ {
293
+ physicalLocation: {
294
+ artifactLocation: { uri: f.file },
295
+ ...(f.line ? { region: { startLine: f.line } } : {}),
296
+ },
297
+ },
298
+ ],
299
+ ...(tier ? { properties: { tier } } : {}),
243
300
  };
244
301
  });
245
- const results = findings.map((f) => ({
246
- ruleId: f.checkId,
247
- level: sarifLevel(f.severity),
248
- message: { text: f.message },
249
- locations: [{ physicalLocation: { artifactLocation: { uri: f.file } } }],
250
- }));
251
302
  return JSON.stringify(
252
303
  {
253
304
  $schema: "https://json.schemastore.org/sarif-2.1.0.json",
@@ -327,28 +378,46 @@ export async function auditCommand(options: AuditCommandOptions): Promise<AuditC
327
378
  const unclaimed = unclaimedFiles(candidates, inputs, plugins);
328
379
  const scanned = inputs.map((i) => i.path);
329
380
 
381
+ // Secrets detection (#443) is independent of lexicon: it scans every
382
+ // candidate file's raw text, not just the ones a lexicon claimed, and runs
383
+ // even when no lexicon is installed at all. `.chant-audit.json` at a local
384
+ // target's root supplies the entropy threshold / allowlist; a URL target
385
+ // has no local config file to read.
386
+ const secretsConfig = isUrl ? {} : readLocalSecretsConfig(options.path);
387
+ const secretsFindings = scanForSecrets(candidates, resolveSecretsOptions(secretsConfig, options.secretsScan));
388
+ // Wrangler config audit (#446) is likewise lexicon-independent: it scans
389
+ // every candidate file for `wrangler.toml` by its own detector, not through
390
+ // `classifyFiles`/an installed lexicon package (no such package exists —
391
+ // this format ships only a detector and checks, per the issue's scope).
392
+ const wranglerFindings = auditWranglerConfigs(candidates);
393
+
330
394
  if (plugins.length === 0) {
331
395
  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" };
396
+ return { success: true, status: "no-lexicons", output, findings: [...secretsFindings, ...wranglerFindings], scanned: [], unclaimed, exitCode: NO_LEXICONS_EXIT_CODE, stream: format === "json" ? "stdout" : "stderr" };
333
397
  }
334
398
 
335
399
  const missingLexiconNote = missingLexiconHint(unclaimed);
336
400
 
337
- if (inputs.length === 0) {
401
+ if (inputs.length === 0 && secretsFindings.length === 0 && wranglerFindings.length === 0) {
338
402
  const output = `No auditable files found under ${options.path}.${missingLexiconNote ? ` ${missingLexiconNote}` : ""}`;
339
403
  return { success: true, status: "ok", output, findings: [], scanned: [], unclaimed, exitCode: 0 };
340
404
  }
341
405
 
342
- let findings: AuditFinding[];
343
- try {
344
- findings = await auditFiles(inputs, { checksProvider: options.checksProvider });
345
- } catch (err) {
346
- const msg = err instanceof Error ? err.message : String(err);
347
- return { success: false, output: "", findings: [], scanned, exitCode: 1, error: msg };
406
+ let findings: AuditFinding[] = [];
407
+ if (inputs.length > 0) {
408
+ try {
409
+ findings = await auditFiles(inputs, { checksProvider: options.checksProvider });
410
+ } catch (err) {
411
+ const msg = err instanceof Error ? err.message : String(err);
412
+ return { success: false, output: "", findings: [], scanned, exitCode: 1, error: msg };
413
+ }
348
414
  }
415
+ findings = [...findings, ...secretsFindings, ...wranglerFindings];
349
416
  // Resolve the audit catalog once, aggregating the audited lexicons' own
350
417
  // metadata over core's static catalog (#687). The lexicons are already loaded
351
- // by `auditFiles` above, so this is cheap.
418
+ // by `auditFiles` above, so this is cheap. Core's static catalog (always
419
+ // included) carries the SEC* secrets rules, so they resolve even when
420
+ // `inputs` claimed nothing.
352
421
  const catalog = await resolveAuditCatalog([...new Set(inputs.map((i) => i.lexicon))]);
353
422
 
354
423
  if (tier === "merge-worthy") findings = findings.filter((f) => isMergeWorthy(f, catalog));
@@ -184,6 +184,71 @@ export const testEntity = {
184
184
  }
185
185
  });
186
186
 
187
+ test("#1083 — --fold-rank ranks blockers and, given a path, writes a collapsed-format export", async () => {
188
+ const thisDir = dirname(fileURLToPath(import.meta.url));
189
+ const runtimePath = resolvePath(thisDir, "../../runtime");
190
+
191
+ // A pair of files that fold fine (same shape as the #1022 test above),
192
+ // so the build produces output and the "no output" guard doesn't fire —
193
+ // unrelated to the ranking below.
194
+ await writeFile(
195
+ join(testDir, "resources.ts"),
196
+ `
197
+ import { createResource } from ${JSON.stringify(runtimePath)};
198
+ export const Bucket = createResource("Test::Bucket", "aws", { arn: "Arn" });
199
+ `,
200
+ );
201
+ await writeFile(
202
+ join(testDir, "main.ts"),
203
+ `
204
+ import { Bucket } from "./resources";
205
+ export const bucket = new Bucket({ name: "my-bucket" });
206
+ `,
207
+ );
208
+ // a.ts fails directly (process.env); b.ts fails because it imports a.ts.
209
+ await writeFile(join(testDir, "a.ts"), `export const a = process.env.SOMETHING;`);
210
+ await writeFile(
211
+ join(testDir, "b.ts"),
212
+ `
213
+ import { a } from "./a";
214
+ export const b = a;
215
+ `,
216
+ );
217
+
218
+ const awsSerializer: Serializer = {
219
+ name: "aws",
220
+ rulePrefix: "TEST",
221
+ serialize: (entities) => JSON.stringify([...entities.keys()]),
222
+ };
223
+
224
+ const collapsedPath = join(testDir, "out", "fold.collapsed");
225
+ const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
226
+ try {
227
+ const result = await buildCommand({
228
+ path: testDir,
229
+ format: "json",
230
+ serializers: [awsSerializer],
231
+ fold: true,
232
+ foldRank: true,
233
+ foldRankCollapsedFile: collapsedPath,
234
+ });
235
+
236
+ expect(result.success).toBe(true);
237
+
238
+ const lines = errorSpy.mock.calls.map((call) => String(call[0]));
239
+ const rankingLine = lines.find((l) => l.includes("fold-rank:") && l.includes("blocker(s)"));
240
+ expect(rankingLine).toBeDefined();
241
+ const aLine = lines.find((l) => l.includes("retained 2") && l.includes("a.ts"));
242
+ expect(aLine).toBeDefined();
243
+
244
+ expect(existsSync(collapsedPath)).toBe(true);
245
+ const collapsed = readFileSync(collapsedPath, "utf-8").trim().split("\n");
246
+ expect(collapsed.sort()).toEqual(["fold;a.ts 1", "fold;a.ts;b.ts 1"].sort());
247
+ } finally {
248
+ errorSpy.mockRestore();
249
+ }
250
+ });
251
+
187
252
  describe("#1396 — ownership.env resolves from the env build parameter", () => {
188
253
  const thisDir = dirname(fileURLToPath(import.meta.url));
189
254
  const runtimePath = resolvePath(thisDir, "../../runtime");
@@ -712,7 +777,11 @@ export const testEntity = {
712
777
  const sidecarSerializer: Serializer = {
713
778
  name: "multi",
714
779
  rulePrefix: "MULTI",
715
- serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": CIPHERTEXT } }),
780
+ serialize: () => ({
781
+ primary: "kind: Deployment\n",
782
+ files: { "db.sops.yaml": CIPHERTEXT },
783
+ verbatimFiles: ["db.sops.yaml"],
784
+ }),
716
785
  };
717
786
 
718
787
  async function writeProject(fileValue: string): Promise<void> {
@@ -768,7 +837,11 @@ export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexi
768
837
  const jsonSidecar: Serializer = {
769
838
  name: "multi",
770
839
  rulePrefix: "MULTI",
771
- serialize: () => ({ primary: "kind: Deployment\n", files: { "db.sops.yaml": jsonish } }),
840
+ serialize: () => ({
841
+ primary: "kind: Deployment\n",
842
+ files: { "db.sops.yaml": jsonish },
843
+ verbatimFiles: ["db.sops.yaml"],
844
+ }),
772
845
  };
773
846
  await writeProject("secrets/db.sops.yaml");
774
847
  const outputPath = join(testDir, "dist", "manifests.yaml");
@@ -785,6 +858,98 @@ export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexi
785
858
  });
786
859
  });
787
860
 
861
+ describe("SerializerResult.verbatimFiles — general channel (#1937)", () => {
862
+ test("a verbatim file whose content happens to be valid JSON survives byte-identical", async () => {
863
+ // Deliberately not key-sorted or 2-space indented — content the JSON
864
+ // round trip below would reformat. The old workaround (skip the round
865
+ // trip only because a file happens to fail JSON.parse, true for the
866
+ // committed-encrypted `.sops.yaml` case but never a real guarantee)
867
+ // could not protect this case. `verbatimFiles` can, structurally.
868
+ const raw = '{"b":1,"a":2, "nested":{"z":true}}';
869
+ const verbatimSerializer: Serializer = {
870
+ name: "multi",
871
+ rulePrefix: "MULTI",
872
+ serialize: () => ({
873
+ primary: "{}",
874
+ files: { "raw.json": raw },
875
+ verbatimFiles: ["raw.json"],
876
+ }),
877
+ };
878
+ await writeFile(
879
+ join(testDir, "infra.ts"),
880
+ `export const x = { [Symbol.for("chant.declarable")]: true, entityType: "X", lexicon: "multi", kind: "resource", props: {}, attributes: {} };`,
881
+ );
882
+ const outputPath = join(testDir, "dist", "manifest.json");
883
+
884
+ const result = await buildCommand({
885
+ path: testDir,
886
+ output: outputPath,
887
+ format: "json",
888
+ serializers: [verbatimSerializer],
889
+ });
890
+
891
+ expect(result.errors).toEqual([]);
892
+ expect(readFileSync(join(testDir, "dist", "raw.json"), "utf-8")).toBe(raw);
893
+ });
894
+ });
895
+
896
+ describe("additional-file basename collisions across lexicons (#1937)", () => {
897
+ const lexASerializer = (content: string): Serializer => ({
898
+ name: "lexa",
899
+ rulePrefix: "LEXA",
900
+ serialize: () => ({ primary: "{}", files: { "shared.yaml": content } }),
901
+ });
902
+ const lexBSerializer = (content: string): Serializer => ({
903
+ name: "lexb",
904
+ rulePrefix: "LEXB",
905
+ serialize: () => ({ primary: "{}", files: { "shared.yaml": content } }),
906
+ });
907
+
908
+ async function writeTwoLexiconProject(): Promise<void> {
909
+ await writeFile(
910
+ join(testDir, "infra.ts"),
911
+ [
912
+ `export const a = { [Symbol.for("chant.declarable")]: true, entityType: "A", lexicon: "lexa", kind: "resource", props: {}, attributes: {} };`,
913
+ `export const b = { [Symbol.for("chant.declarable")]: true, entityType: "B", lexicon: "lexb", kind: "resource", props: {}, attributes: {} };`,
914
+ ].join("\n"),
915
+ );
916
+ }
917
+
918
+ test("differing content at the same basename fails the build, naming the basename and both sources", async () => {
919
+ await writeTwoLexiconProject();
920
+ const outputPath = join(testDir, "dist", "manifest.json");
921
+
922
+ const result = await buildCommand({
923
+ path: testDir,
924
+ output: outputPath,
925
+ format: "json",
926
+ serializers: [lexASerializer("from lexa\n"), lexBSerializer("from lexb\n")],
927
+ });
928
+
929
+ expect(result.success).toBe(false);
930
+ const message = result.errors.join("\n");
931
+ expect(message).toContain("shared.yaml");
932
+ expect(message).toContain("lexa");
933
+ expect(message).toContain("lexb");
934
+ });
935
+
936
+ test("identical content at the same basename passes", async () => {
937
+ await writeTwoLexiconProject();
938
+ const outputPath = join(testDir, "dist", "manifest.json");
939
+ const shared = "same content for both\n";
940
+
941
+ const result = await buildCommand({
942
+ path: testDir,
943
+ output: outputPath,
944
+ format: "json",
945
+ serializers: [lexASerializer(shared), lexBSerializer(shared)],
946
+ });
947
+
948
+ expect(result.errors).toEqual([]);
949
+ expect(readFileSync(join(testDir, "dist", "shared.yaml"), "utf-8")).toBe(shared);
950
+ });
951
+ });
952
+
788
953
  test("op worker files go to <project>/dist/ops even with no --output (#878)", async () => {
789
954
  // The generated Op worker must land where `chant run <op> --temporal` reads it
790
955
  // (`<project>/dist/ops/<name>/worker.ts`) even when the build has no --output