@intentius/chant 0.49.0 → 0.51.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 (294) 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/op-progress.d.ts +57 -0
  28. package/dist/cli/handlers/op-progress.d.ts.map +1 -0
  29. package/dist/cli/handlers/operator.d.ts +32 -0
  30. package/dist/cli/handlers/operator.d.ts.map +1 -0
  31. package/dist/cli/handlers/run-client.d.ts +21 -1
  32. package/dist/cli/handlers/run-client.d.ts.map +1 -1
  33. package/dist/cli/handlers/run-report.d.ts.map +1 -1
  34. package/dist/cli/handlers/run.d.ts.map +1 -1
  35. package/dist/cli/handlers/scenario.d.ts +39 -0
  36. package/dist/cli/handlers/scenario.d.ts.map +1 -0
  37. package/dist/cli/handlers/search.d.ts +22 -0
  38. package/dist/cli/handlers/search.d.ts.map +1 -1
  39. package/dist/cli/main.d.ts.map +1 -1
  40. package/dist/cli/mcp/op-tools.d.ts.map +1 -1
  41. package/dist/cli/mcp/resource-handlers.d.ts.map +1 -1
  42. package/dist/cli/mcp/server.d.ts +35 -2
  43. package/dist/cli/mcp/server.d.ts.map +1 -1
  44. package/dist/cli/mcp/types.d.ts +29 -1
  45. package/dist/cli/mcp/types.d.ts.map +1 -1
  46. package/dist/cli/registry.d.ts +47 -3
  47. package/dist/cli/registry.d.ts.map +1 -1
  48. package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
  49. package/dist/components/capability.d.ts +17 -2
  50. package/dist/components/capability.d.ts.map +1 -1
  51. package/dist/components/cli-support.d.ts +7 -0
  52. package/dist/components/cli-support.d.ts.map +1 -1
  53. package/dist/components/component.d.ts +15 -0
  54. package/dist/components/component.d.ts.map +1 -1
  55. package/dist/components/driver.d.ts.map +1 -1
  56. package/dist/components/run-progress.d.ts +7 -5
  57. package/dist/components/run-progress.d.ts.map +1 -1
  58. package/dist/components/verbs/index.d.ts +6 -1
  59. package/dist/components/verbs/index.d.ts.map +1 -1
  60. package/dist/components/verbs/run-agent.d.ts +499 -0
  61. package/dist/components/verbs/run-agent.d.ts.map +1 -0
  62. package/dist/components/verbs/sign.d.ts +30 -0
  63. package/dist/components/verbs/sign.d.ts.map +1 -1
  64. package/dist/composite.d.ts +6 -1
  65. package/dist/composite.d.ts.map +1 -1
  66. package/dist/discovery/collect.d.ts.map +1 -1
  67. package/dist/discovery/fold-import.d.ts +15 -1
  68. package/dist/discovery/fold-import.d.ts.map +1 -1
  69. package/dist/discovery/fold-rank.d.ts +66 -0
  70. package/dist/discovery/fold-rank.d.ts.map +1 -0
  71. package/dist/discovery/index.d.ts +15 -0
  72. package/dist/discovery/index.d.ts.map +1 -1
  73. package/dist/discovery/param-deps.d.ts +17 -0
  74. package/dist/discovery/param-deps.d.ts.map +1 -0
  75. package/dist/fold/fold.d.ts +55 -2
  76. package/dist/fold/fold.d.ts.map +1 -1
  77. package/dist/fold/subset.d.ts +21 -14
  78. package/dist/fold/subset.d.ts.map +1 -1
  79. package/dist/lexicon-schema.d.ts +2 -0
  80. package/dist/lexicon-schema.d.ts.map +1 -1
  81. package/dist/lexicon.d.ts +134 -0
  82. package/dist/lexicon.d.ts.map +1 -1
  83. package/dist/lifecycle/assert-live.d.ts +77 -0
  84. package/dist/lifecycle/assert-live.d.ts.map +1 -0
  85. package/dist/lifecycle/change-set.d.ts +17 -0
  86. package/dist/lifecycle/change-set.d.ts.map +1 -1
  87. package/dist/lifecycle/converge-ledger.d.ts +90 -0
  88. package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
  89. package/dist/lifecycle/deep-diff.d.ts +18 -0
  90. package/dist/lifecycle/deep-diff.d.ts.map +1 -1
  91. package/dist/lifecycle/deep-observe.d.ts +9 -1
  92. package/dist/lifecycle/deep-observe.d.ts.map +1 -1
  93. package/dist/lifecycle/disruption.d.ts +96 -0
  94. package/dist/lifecycle/disruption.d.ts.map +1 -0
  95. package/dist/lifecycle/gate-ledger.d.ts +33 -0
  96. package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
  97. package/dist/lifecycle/git.d.ts +145 -21
  98. package/dist/lifecycle/git.d.ts.map +1 -1
  99. package/dist/lifecycle/index.d.ts +6 -0
  100. package/dist/lifecycle/index.d.ts.map +1 -1
  101. package/dist/lifecycle/lease.d.ts +113 -0
  102. package/dist/lifecycle/lease.d.ts.map +1 -0
  103. package/dist/lifecycle/replay.d.ts +2 -0
  104. package/dist/lifecycle/replay.d.ts.map +1 -1
  105. package/dist/lifecycle/scenario-eval.d.ts +42 -0
  106. package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
  107. package/dist/lifecycle/scenario.d.ts +163 -0
  108. package/dist/lifecycle/scenario.d.ts.map +1 -0
  109. package/dist/lifecycle/symptoms.d.ts +63 -0
  110. package/dist/lifecycle/symptoms.d.ts.map +1 -0
  111. package/dist/lint/output-docs.d.ts +94 -0
  112. package/dist/lint/output-docs.d.ts.map +1 -0
  113. package/dist/lint/post-synth.d.ts +29 -0
  114. package/dist/lint/post-synth.d.ts.map +1 -1
  115. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
  116. package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
  117. package/dist/lsp/lexicon-providers.d.ts +7 -0
  118. package/dist/lsp/lexicon-providers.d.ts.map +1 -1
  119. package/dist/op/activity-contract.d.ts +139 -0
  120. package/dist/op/activity-contract.d.ts.map +1 -0
  121. package/dist/op/builders.d.ts +42 -2
  122. package/dist/op/builders.d.ts.map +1 -1
  123. package/dist/op/converge-rule.d.ts +161 -0
  124. package/dist/op/converge-rule.d.ts.map +1 -0
  125. package/dist/op/generate-pipeline.d.ts +39 -0
  126. package/dist/op/generate-pipeline.d.ts.map +1 -0
  127. package/dist/op/index.d.ts +14 -0
  128. package/dist/op/index.d.ts.map +1 -1
  129. package/dist/op/local-executor.d.ts +7 -1
  130. package/dist/op/local-executor.d.ts.map +1 -1
  131. package/dist/op/op-verb-class.d.ts +42 -0
  132. package/dist/op/op-verb-class.d.ts.map +1 -0
  133. package/dist/op/operator.d.ts +128 -0
  134. package/dist/op/operator.d.ts.map +1 -0
  135. package/dist/op/step-output-ref.d.ts +187 -0
  136. package/dist/op/step-output-ref.d.ts.map +1 -0
  137. package/dist/op/types.d.ts +18 -1
  138. package/dist/op/types.d.ts.map +1 -1
  139. package/dist/provenance.d.ts +73 -3
  140. package/dist/provenance.d.ts.map +1 -1
  141. package/dist/runtime-adapter.d.ts +7 -1
  142. package/dist/runtime-adapter.d.ts.map +1 -1
  143. package/dist/serializer.d.ts +18 -0
  144. package/dist/serializer.d.ts.map +1 -1
  145. package/dist/testing.d.ts +23 -2
  146. package/dist/testing.d.ts.map +1 -1
  147. package/dist/toml.d.ts +40 -5
  148. package/dist/toml.d.ts.map +1 -1
  149. package/package.json +1 -1
  150. package/src/audit/catalog.test.ts +1 -1
  151. package/src/audit/catalog.ts +75 -3
  152. package/src/audit/core.ts +9 -0
  153. package/src/audit/discover.ts +29 -2
  154. package/src/audit/fetch.test.ts +216 -3
  155. package/src/audit/fetch.ts +270 -59
  156. package/src/audit/report-html.ts +5 -2
  157. package/src/audit/report-model.ts +9 -0
  158. package/src/audit/report.test.ts +22 -0
  159. package/src/audit/report.ts +3 -2
  160. package/src/audit/rules-doc.ts +2 -0
  161. package/src/audit/secrets.test.ts +303 -0
  162. package/src/audit/secrets.ts +406 -0
  163. package/src/audit/wrangler.test.ts +230 -0
  164. package/src/audit/wrangler.ts +290 -0
  165. package/src/build.ts +8 -3
  166. package/src/cli/command-group.ts +1 -1
  167. package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
  168. package/src/cli/commands/audit.test.ts +215 -1
  169. package/src/cli/commands/audit.ts +86 -17
  170. package/src/cli/commands/build.test.ts +167 -2
  171. package/src/cli/commands/build.ts +114 -23
  172. package/src/cli/handlers/build.ts +2 -0
  173. package/src/cli/handlers/components.test.ts +199 -1
  174. package/src/cli/handlers/components.ts +160 -3
  175. package/src/cli/handlers/graph.test.ts +20 -0
  176. package/src/cli/handlers/graph.ts +10 -1
  177. package/src/cli/handlers/lifecycle.test.ts +90 -0
  178. package/src/cli/handlers/lifecycle.ts +30 -5
  179. package/src/cli/handlers/op-progress.test.ts +202 -0
  180. package/src/cli/handlers/op-progress.ts +192 -0
  181. package/src/cli/handlers/operator.test.ts +255 -0
  182. package/src/cli/handlers/operator.ts +240 -0
  183. package/src/cli/handlers/run-client.test.ts +82 -0
  184. package/src/cli/handlers/run-client.ts +85 -2
  185. package/src/cli/handlers/run-report.test.ts +62 -0
  186. package/src/cli/handlers/run-report.ts +20 -58
  187. package/src/cli/handlers/run.test.ts +144 -0
  188. package/src/cli/handlers/run.ts +40 -18
  189. package/src/cli/handlers/scenario.test.ts +456 -0
  190. package/src/cli/handlers/scenario.ts +330 -0
  191. package/src/cli/handlers/search-drift.test.ts +263 -0
  192. package/src/cli/handlers/search.ts +150 -1
  193. package/src/cli/main.test.ts +23 -0
  194. package/src/cli/main.ts +81 -1
  195. package/src/cli/mcp/op-tools.ts +17 -6
  196. package/src/cli/mcp/resource-handlers.ts +13 -5
  197. package/src/cli/mcp/server.test.ts +265 -2
  198. package/src/cli/mcp/server.ts +84 -7
  199. package/src/cli/mcp/types.ts +27 -1
  200. package/src/cli/registry.ts +47 -3
  201. package/src/codegen/docs-rule-scanning.test.ts +42 -0
  202. package/src/codegen/docs-rule-scanning.ts +25 -2
  203. package/src/components/README.md +7 -0
  204. package/src/components/capability.ts +17 -2
  205. package/src/components/cli-support.test.ts +17 -0
  206. package/src/components/cli-support.ts +13 -1
  207. package/src/components/component-schema.test.ts +32 -0
  208. package/src/components/component.schema.json +6 -0
  209. package/src/components/component.test.ts +21 -0
  210. package/src/components/component.ts +15 -0
  211. package/src/components/driver.ts +12 -4
  212. package/src/components/run-progress.ts +9 -5
  213. package/src/components/verbs/index.ts +6 -1
  214. package/src/components/verbs/run-agent.test.ts +683 -0
  215. package/src/components/verbs/run-agent.ts +786 -0
  216. package/src/components/verbs/sign.test.ts +19 -0
  217. package/src/components/verbs/sign.ts +34 -2
  218. package/src/composite.ts +31 -2
  219. package/src/discovery/collect.ts +11 -2
  220. package/src/discovery/fold-import.test.ts +54 -0
  221. package/src/discovery/fold-import.ts +178 -38
  222. package/src/discovery/fold-rank.test.ts +197 -0
  223. package/src/discovery/fold-rank.ts +346 -0
  224. package/src/discovery/index.ts +16 -1
  225. package/src/discovery/param-deps.test.ts +118 -0
  226. package/src/discovery/param-deps.ts +170 -0
  227. package/src/fold/fold.test.ts +6 -2
  228. package/src/fold/fold.ts +184 -3
  229. package/src/fold/subset.test.ts +82 -19
  230. package/src/fold/subset.ts +79 -41
  231. package/src/lexicon-schema.ts +3 -0
  232. package/src/lexicon.ts +154 -2
  233. package/src/lifecycle/assert-live.test.ts +125 -0
  234. package/src/lifecycle/assert-live.ts +154 -0
  235. package/src/lifecycle/change-set.ts +35 -3
  236. package/src/lifecycle/converge-ledger.test.ts +199 -0
  237. package/src/lifecycle/converge-ledger.ts +179 -0
  238. package/src/lifecycle/deep-diff.test.ts +79 -1
  239. package/src/lifecycle/deep-diff.ts +23 -0
  240. package/src/lifecycle/deep-observe.ts +13 -2
  241. package/src/lifecycle/disruption.test.ts +186 -0
  242. package/src/lifecycle/disruption.ts +224 -0
  243. package/src/lifecycle/gate-ledger.test.ts +103 -0
  244. package/src/lifecycle/gate-ledger.ts +140 -0
  245. package/src/lifecycle/git.test.ts +430 -0
  246. package/src/lifecycle/git.ts +446 -84
  247. package/src/lifecycle/index.ts +6 -0
  248. package/src/lifecycle/lease.test.ts +343 -0
  249. package/src/lifecycle/lease.ts +270 -0
  250. package/src/lifecycle/replay.test.ts +25 -0
  251. package/src/lifecycle/replay.ts +11 -3
  252. package/src/lifecycle/scenario-eval.test.ts +199 -0
  253. package/src/lifecycle/scenario-eval.ts +158 -0
  254. package/src/lifecycle/scenario.test.ts +195 -0
  255. package/src/lifecycle/scenario.ts +321 -0
  256. package/src/lifecycle/symptoms.test.ts +116 -0
  257. package/src/lifecycle/symptoms.ts +126 -0
  258. package/src/lint/output-docs.test.ts +220 -0
  259. package/src/lint/output-docs.ts +204 -0
  260. package/src/lint/post-synth.test.ts +97 -0
  261. package/src/lint/post-synth.ts +45 -0
  262. package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
  263. package/src/lint/rules/comp/comp.test.ts +49 -1
  264. package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
  265. package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
  266. package/src/lsp/lexicon-providers.test.ts +44 -0
  267. package/src/lsp/lexicon-providers.ts +11 -1
  268. package/src/op/activity-contract.test.ts +180 -0
  269. package/src/op/activity-contract.ts +278 -0
  270. package/src/op/builders-exports.test.ts +17 -1
  271. package/src/op/builders.ts +59 -5
  272. package/src/op/converge-rule.test.ts +179 -0
  273. package/src/op/converge-rule.ts +311 -0
  274. package/src/op/generate-pipeline.test.ts +53 -0
  275. package/src/op/generate-pipeline.ts +99 -0
  276. package/src/op/index.ts +30 -0
  277. package/src/op/local-executor.test.ts +92 -0
  278. package/src/op/local-executor.ts +52 -10
  279. package/src/op/local-output.ts +1 -1
  280. package/src/op/op-verb-class.test.ts +126 -0
  281. package/src/op/op-verb-class.ts +115 -0
  282. package/src/op/operator.test.ts +346 -0
  283. package/src/op/operator.ts +213 -0
  284. package/src/op/step-output-ref.test.ts +334 -0
  285. package/src/op/step-output-ref.ts +453 -0
  286. package/src/op/types.ts +18 -1
  287. package/src/provenance.test.ts +151 -4
  288. package/src/provenance.ts +118 -4
  289. package/src/runtime-adapter.ts +31 -10
  290. package/src/serializer.ts +18 -0
  291. package/src/testing.test.ts +89 -2
  292. package/src/testing.ts +63 -3
  293. package/src/toml.test.ts +157 -384
  294. package/src/toml.ts +371 -5
@@ -17,7 +17,6 @@ import type { Serializer, SerializerResult } from "../../serializer";
17
17
  import type { LexiconPlugin } from "../../lexicon";
18
18
  import { resolveLexiconVersions, collectBuildRootContributors } from "../plugins";
19
19
  import { runPostSynthChecks } from "../../lint/post-synth";
20
- import { collectSecretDeclarations } from "../../secret-provenance";
21
20
  import { coreReceiptChecks } from "../../lint/receipt-checks";
22
21
  import { coreOutputChecks } from "../../lint/output-checks";
23
22
  import { coreKnowledgeChecks } from "../../lint/knowledge-checks";
@@ -25,6 +24,7 @@ import { applyConfiguredSeverity } from "../../lint/config";
25
24
  import { loadPolicyChecks } from "../../lint/policy";
26
25
  import { armSandboxPolicyExecution, runProjectPolicies } from "../../lint/policy-sandbox";
27
26
  import { sortedJsonReplacer } from "../../utils";
27
+ import { rankFoldBlockers, toCollapsedFormat, type FoldRankResult } from "../../discovery/fold-rank";
28
28
  import { formatError, formatWarning, formatSuccess, formatBold, formatInfo } from "../format";
29
29
  import { writeFileSync, mkdirSync } from "fs";
30
30
  import { resolve, dirname, join, relative } from "path";
@@ -85,6 +85,23 @@ export interface BuildOptions {
85
85
  * after {@link params}.
86
86
  */
87
87
  paramsFile?: string;
88
+
89
+ /**
90
+ * chant #1083 — `chant build --fold --fold-rank`: after the build, rank
91
+ * `"run"`-mode files by dominator retained-count over the forward
92
+ * import-failure graph (see `../../discovery/fold-rank.ts`), and report
93
+ * the reverse-taint bucket (#1044) separately. Printed to stderr,
94
+ * independent of `--verbose`'s per-file listing. No-op without `fold`.
95
+ */
96
+ foldRank?: boolean;
97
+
98
+ /**
99
+ * chant #1083 — `chant build --fold --fold-rank <path>`: as {@link
100
+ * foldRank}, and ALSO writes the ranking as a Brendan Gregg collapsed
101
+ * stack export (weighted by retained count) to this path, so it opens in
102
+ * any flame/icicle viewer with no chant-specific tooling.
103
+ */
104
+ foldRankCollapsedFile?: string;
88
105
  }
89
106
 
90
107
  /**
@@ -347,6 +364,23 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
347
364
  }
348
365
  }
349
366
 
367
+ // chant #1083 — rank fold blockers by dominator retained-count over the
368
+ // forward import-failure graph, and report the reverse-taint bucket
369
+ // (#1044) separately. Opt-in (`--fold-rank`) and independent of
370
+ // `--verbose`: the per-file listing above says WHAT ran; this says WHICH
371
+ // of those files is worth fixing first.
372
+ if (fold && (options.foldRank || options.foldRankCollapsedFile) && result.foldDecisions.length > 0) {
373
+ const ranking = await rankFoldBlockers(result.foldDecisions);
374
+ console.error(formatInfo(formatFoldRanking(ranking, infraPath)));
375
+ if (options.foldRankCollapsedFile) {
376
+ const collapsedPath = resolve(options.foldRankCollapsedFile);
377
+ mkdirSync(dirname(collapsedPath), { recursive: true });
378
+ const lines = toCollapsedFormat(ranking, { relativeTo: infraPath });
379
+ writeFileSync(collapsedPath, lines.length > 0 ? `${lines.join("\n")}\n` : "");
380
+ console.error(formatInfo(`[fold-rank] collapsed-format export: ${collapsedPath} (${lines.length} line(s))`));
381
+ }
382
+ }
383
+
350
384
  // Format errors — grouped by message, because one refusal can surface once
351
385
  // per resource file. A project whose params module throws (a guard like
352
386
  // "this seam cannot back that tier") throws in EVERY file that imports it,
@@ -545,28 +579,55 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
545
579
 
546
580
  // Handle output
547
581
  if (result.errors.length === 0 && errors.length === 0) {
548
- // Extract primary content and collect additional files from SerializerResult
549
- const additionalFiles = new Map<string, string>();
582
+ // Extract primary content and collect additional files from SerializerResult.
583
+ // Each entry remembers which lexicon produced it, so a same-basename
584
+ // collision between two sources (below) can name both.
585
+ const additionalFiles = new Map<string, { content: string; source: string }>();
550
586
 
551
- // Sidecar files that must reach disk BYTE-FOR-BYTE: committed ciphertext
587
+ // Basenames (from some SerializerResult.files) that must reach disk
588
+ // BYTE-FOR-BYTE — e.g. committed ciphertext
552
589
  // (`declareSecret({ provenance: "committed-encrypted", file })`). The
553
590
  // additional-file writer below round-trips anything JSON.parse accepts,
554
- // key-sorting it and possibly re-emitting it as YAML — which would break
555
- // the `sops` MAC. `file` is restricted to .yaml/.yml, so that parse fails
556
- // in practice, but naming the files here makes byte identity structural
557
- // rather than a lucky accident.
591
+ // key-sorting it and possibly re-emitting it as YAML, which would break
592
+ // e.g. the `sops` MAC on committed ciphertext. A serializer opts a file
593
+ // out of that round trip structurally, via `SerializerResult.verbatimFiles`
594
+ // (chant#1937) — "the parse happens to fail" is not a safety guarantee.
558
595
  const verbatimFiles = new Set<string>();
559
- for (const decl of collectSecretDeclarations(result.entities).values()) {
560
- if (decl.provenance === "committed-encrypted") {
561
- verbatimFiles.add(decl.file.split(/[\\/]/).pop()!);
596
+
597
+ // A same basename emitted by two sources with different content would
598
+ // otherwise silently overwrite (last-writer-wins, since `additionalFiles`
599
+ // is keyed by basename alone). Identical content from two sources is
600
+ // fine — dedup — but differing content is refused, naming both sources,
601
+ // the same way the committed-encrypted resolution stage refuses a
602
+ // basename collision among its own declarations.
603
+ function addAdditionalFile(filename: string, content: string, source: string): void {
604
+ const existing = additionalFiles.get(filename);
605
+ if (existing === undefined) {
606
+ additionalFiles.set(filename, { content, source });
607
+ return;
562
608
  }
609
+ if (existing.content === content) return;
610
+ errors.push(
611
+ formatError({
612
+ message:
613
+ `Additional file "${filename}" collides: "${existing.source}" and "${source}" ` +
614
+ `both emit it with different content — additional files share one flat ` +
615
+ `namespace, so two sources whose filenames match would otherwise silently ` +
616
+ `overwrite each other.`,
617
+ }),
618
+ );
563
619
  }
564
620
 
565
- function getPrimaryContent(raw: string | SerializerResult): string {
621
+ function getPrimaryContent(raw: string | SerializerResult, source: string): string {
566
622
  if (typeof raw === "string") return raw;
567
623
  if (raw.files) {
568
624
  for (const [filename, content] of Object.entries(raw.files)) {
569
- additionalFiles.set(filename, content);
625
+ addAdditionalFile(filename, content, source);
626
+ }
627
+ }
628
+ if (raw.verbatimFiles) {
629
+ for (const filename of raw.verbatimFiles) {
630
+ verbatimFiles.add(filename);
570
631
  }
571
632
  }
572
633
  return raw.primary;
@@ -585,8 +646,8 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
585
646
  // Multiple lexicons: wrap in lexicon keys
586
647
  let output: string = "{}";
587
648
  if (result.outputs.size === 1) {
588
- const [, raw] = [...result.outputs.entries()][0];
589
- const content = getPrimaryContent(raw);
649
+ const [lexiconName, raw] = [...result.outputs.entries()][0];
650
+ const content = getPrimaryContent(raw, lexiconName);
590
651
  const parsed = tryParseJson(content);
591
652
  if ("json" in parsed) {
592
653
  output = JSON.stringify(parsed.json, sortedJsonReplacer, 2);
@@ -603,7 +664,7 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
603
664
  const nonJsonSections: string[] = [];
604
665
  const sortedLexiconNames = [...result.outputs.keys()].sort();
605
666
  for (const lexiconName of sortedLexiconNames) {
606
- const content = getPrimaryContent(result.outputs.get(lexiconName)!);
667
+ const content = getPrimaryContent(result.outputs.get(lexiconName)!, lexiconName);
607
668
  const parsed = tryParseJson(content);
608
669
  if ("json" in parsed) {
609
670
  combined[lexiconName] = parsed.json;
@@ -634,13 +695,13 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
634
695
  // `foo.yaml`, so the durable-run worker was never where `run --temporal` looks.
635
696
  const projectDist = resolve(options.path ?? ".", "dist");
636
697
  let opsWritten = 0;
637
- for (const [filename, content] of [...additionalFiles]) {
698
+ for (const [filename, entry] of [...additionalFiles]) {
638
699
  if (!filename.startsWith("ops/")) continue;
639
700
  additionalFiles.delete(filename);
640
701
  try {
641
702
  const targetPath = join(projectDist, filename);
642
703
  mkdirSync(dirname(targetPath), { recursive: true });
643
- writeFileSync(targetPath, content);
704
+ writeFileSync(targetPath, entry.content);
644
705
  opsWritten += 1;
645
706
  } catch (err) {
646
707
  errors.push(
@@ -665,13 +726,13 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
665
726
  // Write additional files (e.g. nested stack templates) alongside the primary output
666
727
  if (additionalFiles.size > 0) {
667
728
  const outputDir = dirname(outputPath);
668
- for (const [filename, content] of additionalFiles) {
669
- let fileContent = content;
729
+ for (const [filename, entry] of additionalFiles) {
730
+ let fileContent = entry.content;
670
731
  // Format additional files consistently — except the ones declared
671
732
  // verbatim, which are copied exactly as they were committed.
672
733
  if (!verbatimFiles.has(filename)) {
673
734
  try {
674
- const fileParsed = JSON.parse(content);
735
+ const fileParsed = JSON.parse(entry.content);
675
736
  fileContent = JSON.stringify(fileParsed, sortedJsonReplacer, 2);
676
737
  if (options.format === "yaml") {
677
738
  fileContent = jsonToYaml(JSON.parse(fileContent));
@@ -711,9 +772,9 @@ export async function buildCommand(options: BuildOptions): Promise<BuildResult>
711
772
  // Print to stdout
712
773
  console.log(output);
713
774
  // Log additional files to stderr if any
714
- for (const [filename, content] of additionalFiles) {
775
+ for (const [filename, entry] of additionalFiles) {
715
776
  console.error(`\n--- ${filename} ---`);
716
- console.error(content);
777
+ console.error(entry.content);
717
778
  }
718
779
  }
719
780
  }
@@ -757,6 +818,36 @@ export function summarizeFoldDecisions(decisions: readonly { mode: string }[]):
757
818
  return `fold: ${files(folded)} folded, ${ran} ran${hint}`;
758
819
  }
759
820
 
821
+ /**
822
+ * `chant build --fold --fold-rank`'s text report (chant #1083): blockers
823
+ * ranked by dominator retained-count, highest first, then the reverse-taint
824
+ * bucket (#1044) called out separately since the tree can't express that
825
+ * edge (see `../../discovery/fold-rank.ts`'s module doc).
826
+ */
827
+ export function formatFoldRanking(ranking: FoldRankResult, infraPath: string): string {
828
+ const rel = (file: string): string => relative(infraPath, file) || file;
829
+ const lines: string[] = [];
830
+ if (ranking.blockers.length === 0) {
831
+ lines.push("fold-rank: no run-mode files to rank");
832
+ } else {
833
+ lines.push(`fold-rank: ${ranking.blockers.length} blocker(s), ${ranking.totalBlocked} file(s) blocked`);
834
+ for (const b of ranking.blockers) {
835
+ const via = b.topLevel ? "" : ` (behind ${rel(b.dominatedBy!)})`;
836
+ const reason = b.reason ? ` — ${b.reason}` : "";
837
+ lines.push(` retained ${b.retained}: ${rel(b.file)}${via}${reason}`);
838
+ }
839
+ }
840
+ if (ranking.reverseTainted.length > 0) {
841
+ lines.push(
842
+ `fold-rank: ${ranking.reverseTainted.length} file(s) held back only by the reverse rule (#1044) — would fold in isolation, blocked by a run-mode importer, not modeled in the tree above:`,
843
+ );
844
+ for (const t of ranking.reverseTainted) {
845
+ lines.push(` ${rel(t.file)}`);
846
+ }
847
+ }
848
+ return lines.join("\n");
849
+ }
850
+
760
851
  /**
761
852
  * Simple JSON to YAML converter
762
853
  */
@@ -148,6 +148,8 @@ export async function runBuild(ctx: CommandContext): Promise<number> {
148
148
  env: args.env,
149
149
  fold: args.fold,
150
150
  sandbox: args.sandbox,
151
+ foldRank: args.foldRank,
152
+ foldRankCollapsedFile: args.foldRankCollapsedFile,
151
153
  params,
152
154
  paramsFile: args.paramsFile,
153
155
  });
@@ -1,7 +1,11 @@
1
1
  import { describe, test, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
2
5
  import { createMockPlugin, staticDescribeResources } from "@intentius/chant-test-utils";
3
6
  import type { LexiconPlugin, ResourceMetadata } from "../../lexicon";
4
7
  import type { BuildResult } from "../../build";
8
+ import type { BuildArchiveManifest } from "../../components/verbs/build-archive";
5
9
  import type { ParsedArgs } from "../registry";
6
10
 
7
11
  /**
@@ -28,6 +32,7 @@ const loadChantConfigMock = vi.fn();
28
32
  const buildMock = vi.fn();
29
33
  const discoverComponentsMock = vi.fn();
30
34
  const findBuildManifestByArtifactDigestMock = vi.fn();
35
+ const readBuildManifestMock = vi.fn();
31
36
 
32
37
  vi.mock("../../lifecycle/git", () => ({
33
38
  getHeadCommit: (...args: unknown[]) => getHeadCommitMock(...args),
@@ -38,6 +43,7 @@ vi.mock("../../lifecycle/git", () => ({
38
43
 
39
44
  vi.mock("../../lifecycle/build-ledger-store", () => ({
40
45
  findBuildManifestByArtifactDigest: (...args: unknown[]) => findBuildManifestByArtifactDigestMock(...args),
46
+ readBuildManifest: (...args: unknown[]) => readBuildManifestMock(...args),
41
47
  }));
42
48
 
43
49
  vi.mock("../../lifecycle/release-ledger", async () => {
@@ -66,7 +72,7 @@ vi.mock("../../components/discover", () => ({
66
72
  discoverComponents: (...args: unknown[]) => discoverComponentsMock(...args),
67
73
  }));
68
74
 
69
- const { runComponentsReleaseRecord, runComponentsStatus, runComponentsUnknown } = await import("./components");
75
+ const { runComponentsReleaseRecord, runComponentsStatus, runComponentsExport, runComponentsUnknown } = await import("./components");
70
76
 
71
77
  function makeArgs(overrides: Partial<ParsedArgs>): ParsedArgs {
72
78
  return {
@@ -126,6 +132,7 @@ describe("components handlers", () => {
126
132
  buildMock.mockReset().mockResolvedValue(makeBuildResult({}));
127
133
  discoverComponentsMock.mockReset().mockResolvedValue({ components: new Map(), sourceFiles: [], errors: [] });
128
134
  findBuildManifestByArtifactDigestMock.mockReset().mockResolvedValue(undefined);
135
+ readBuildManifestMock.mockReset().mockResolvedValue(null);
129
136
 
130
137
  delete process.env.GITHUB_RUN_ID;
131
138
  delete process.env.CI_PIPELINE_ID;
@@ -569,6 +576,197 @@ describe("components handlers", () => {
569
576
  });
570
577
  });
571
578
 
579
+ describe("runComponentsExport", () => {
580
+ const originalCwd = process.cwd();
581
+ let srcDir: string;
582
+ let outDir: string;
583
+
584
+ function makeManifest(overrides: Partial<BuildArchiveManifest> = {}): BuildArchiveManifest {
585
+ return {
586
+ version: 1,
587
+ component: "search-service",
588
+ createdAt: "2026-01-01T00:00:00.000Z",
589
+ manifestDigest: "sha256:manifest1",
590
+ contents: [
591
+ { kind: "template", path: "search.template.json", digest: "sha256:tmpl1", mediaType: "application/json" },
592
+ { kind: "asset", path: "dist/search.zip", digest: "sha256:asset1", mediaType: "application/octet-stream" },
593
+ ],
594
+ ...overrides,
595
+ };
596
+ }
597
+
598
+ beforeEach(async () => {
599
+ srcDir = await mkdtemp(join(tmpdir(), "chant-export-src-"));
600
+ outDir = await mkdtemp(join(tmpdir(), "chant-export-out-"));
601
+ process.chdir(srcDir);
602
+ });
603
+
604
+ afterEach(async () => {
605
+ process.chdir(originalCwd);
606
+ await rm(srcDir, { recursive: true, force: true });
607
+ await rm(outDir, { recursive: true, force: true });
608
+ });
609
+
610
+ test("-o/--output is required", async () => {
611
+ const ctx = { args: makeArgs({ path: "export", digest: "sha256:manifest1" }), plugins: [], serializers: [] };
612
+ const exit = await runComponentsExport(ctx);
613
+ expect(exit).toBe(1);
614
+ expect(stderrBuf.join("\n")).toContain("-o/--output <dir> is required");
615
+ });
616
+
617
+ test("without --digest, requires environment and --component", async () => {
618
+ const ctx = { args: makeArgs({ path: "export", output: outDir }), plugins: [], serializers: [] };
619
+ const exit = await runComponentsExport(ctx);
620
+ expect(exit).toBe(1);
621
+ expect(stderrBuf.join("\n")).toContain("Environment and --component are required");
622
+ });
623
+
624
+ test("--digest resolves the manifest directly and materializes every entry plus manifest.json", async () => {
625
+ await writeFile(join(srcDir, "search.template.json"), '{"Resources":{}}');
626
+ await mkdir(join(srcDir, "dist"), { recursive: true });
627
+ await writeFile(join(srcDir, "dist", "search.zip"), "zip-bytes");
628
+
629
+ readBuildManifestMock.mockResolvedValue(makeManifest());
630
+
631
+ const ctx = {
632
+ args: makeArgs({ path: "export", component: "search-service", digest: "sha256:manifest1", output: outDir }),
633
+ plugins: [],
634
+ serializers: [],
635
+ };
636
+ const exit = await runComponentsExport(ctx);
637
+
638
+ expect(exit).toBe(0);
639
+ expect(readBuildManifestMock).toHaveBeenCalledWith("sha256:manifest1");
640
+ expect(await readFile(join(outDir, "search.template.json"), "utf-8")).toBe('{"Resources":{}}');
641
+ expect(await readFile(join(outDir, "dist", "search.zip"), "utf-8")).toBe("zip-bytes");
642
+ const manifestOut = JSON.parse(await readFile(join(outDir, "manifest.json"), "utf-8"));
643
+ expect(manifestOut.manifestDigest).toBe("sha256:manifest1");
644
+ expect(manifestOut.contents).toHaveLength(2);
645
+ });
646
+
647
+ test("resolves via the release ledger's latest record when --digest is omitted (manifestDigest present)", async () => {
648
+ readReleaseLedgerMock.mockResolvedValue({
649
+ records: [
650
+ {
651
+ version: 1, component: "search-service", env: "prod", digest: "sha256:artifact1",
652
+ gitSha: "abc", runId: "1", timestamp: "2026-01-01T00:00:00.000Z", actor: "alice",
653
+ manifestDigest: "sha256:manifest1",
654
+ },
655
+ ],
656
+ malformed: 0,
657
+ });
658
+ readBuildManifestMock.mockResolvedValue(makeManifest({ contents: [] }));
659
+
660
+ const ctx = {
661
+ args: makeArgs({ path: "export", extraPositional: "prod", component: "search-service", output: outDir }),
662
+ plugins: [],
663
+ serializers: [],
664
+ };
665
+ const exit = await runComponentsExport(ctx);
666
+
667
+ expect(exit).toBe(0);
668
+ expect(readBuildManifestMock).toHaveBeenCalledWith("sha256:manifest1");
669
+ expect(findBuildManifestByArtifactDigestMock).not.toHaveBeenCalled();
670
+ });
671
+
672
+ test("falls back to findBuildManifestByArtifactDigest when the release record carries no manifestDigest", async () => {
673
+ readReleaseLedgerMock.mockResolvedValue({
674
+ records: [
675
+ {
676
+ version: 1, component: "search-service", env: "prod", digest: "sha256:artifact1",
677
+ gitSha: "abc", runId: "1", timestamp: "2026-01-01T00:00:00.000Z", actor: "alice",
678
+ },
679
+ ],
680
+ malformed: 0,
681
+ });
682
+ findBuildManifestByArtifactDigestMock.mockResolvedValue(makeManifest({ contents: [] }));
683
+
684
+ const ctx = {
685
+ args: makeArgs({ path: "export", extraPositional: "prod", component: "search-service", output: outDir }),
686
+ plugins: [],
687
+ serializers: [],
688
+ };
689
+ const exit = await runComponentsExport(ctx);
690
+
691
+ expect(exit).toBe(0);
692
+ expect(findBuildManifestByArtifactDigestMock).toHaveBeenCalledWith("sha256:artifact1");
693
+ });
694
+
695
+ test("no release record for the component/env -> error", async () => {
696
+ readReleaseLedgerMock.mockResolvedValue({ records: [], malformed: 0 });
697
+ const ctx = {
698
+ args: makeArgs({ path: "export", extraPositional: "prod", component: "search-service", output: outDir }),
699
+ plugins: [],
700
+ serializers: [],
701
+ };
702
+ const exit = await runComponentsExport(ctx);
703
+ expect(exit).toBe(1);
704
+ expect(stderrBuf.join("\n")).toContain("No release record for component");
705
+ });
706
+
707
+ test("--digest with no persisted manifest -> error", async () => {
708
+ readBuildManifestMock.mockResolvedValue(null);
709
+ const ctx = {
710
+ args: makeArgs({ path: "export", digest: "sha256:missing", output: outDir }),
711
+ plugins: [],
712
+ serializers: [],
713
+ };
714
+ const exit = await runComponentsExport(ctx);
715
+ expect(exit).toBe(1);
716
+ expect(stderrBuf.join("\n")).toContain("No persisted build manifest found");
717
+ });
718
+
719
+ test("a source file missing on disk is reported per entry and fails the command", async () => {
720
+ readBuildManifestMock.mockResolvedValue(makeManifest());
721
+ // Neither entry's source file exists in srcDir.
722
+ const ctx = {
723
+ args: makeArgs({ path: "export", digest: "sha256:manifest1", output: outDir }),
724
+ plugins: [],
725
+ serializers: [],
726
+ };
727
+ const exit = await runComponentsExport(ctx);
728
+ expect(exit).toBe(1);
729
+ expect(stderrBuf.join("\n")).toContain("not found on disk");
730
+ });
731
+
732
+ test("--json emits a structured per-entry summary", async () => {
733
+ await writeFile(join(srcDir, "search.template.json"), "{}");
734
+ await mkdir(join(srcDir, "dist"), { recursive: true });
735
+ await writeFile(join(srcDir, "dist", "search.zip"), "zip-bytes");
736
+ readBuildManifestMock.mockResolvedValue(makeManifest());
737
+
738
+ const ctx = {
739
+ args: makeArgs({ path: "export", digest: "sha256:manifest1", output: outDir, json: true }),
740
+ plugins: [],
741
+ serializers: [],
742
+ };
743
+ const exit = await runComponentsExport(ctx);
744
+ expect(exit).toBe(0);
745
+
746
+ const parsed = JSON.parse(stdoutBuf.join(""));
747
+ expect(parsed.component).toBe("search-service");
748
+ expect(parsed.manifestDigest).toBe("sha256:manifest1");
749
+ expect(parsed.entries).toHaveLength(2);
750
+ expect(parsed.entries.every((e: { status: string }) => e.status === "materialized")).toBe(true);
751
+ });
752
+
753
+ test("a --component that mismatches the resolved --digest manifest warns but still exports", async () => {
754
+ await writeFile(join(srcDir, "search.template.json"), "{}");
755
+ await mkdir(join(srcDir, "dist"), { recursive: true });
756
+ await writeFile(join(srcDir, "dist", "search.zip"), "zip-bytes");
757
+ readBuildManifestMock.mockResolvedValue(makeManifest());
758
+
759
+ const ctx = {
760
+ args: makeArgs({ path: "export", component: "other-service", digest: "sha256:manifest1", output: outDir }),
761
+ plugins: [],
762
+ serializers: [],
763
+ };
764
+ const exit = await runComponentsExport(ctx);
765
+ expect(exit).toBe(0);
766
+ expect(stderrBuf.join("\n")).toContain("does not match the resolved manifest's component");
767
+ });
768
+ });
769
+
572
770
  describe("runComponentsUnknown", () => {
573
771
  test("reports the unknown subcommand", async () => {
574
772
  const ctx = { args: makeArgs({ path: "bogus" }), plugins: [], serializers: [] };