@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
@@ -0,0 +1,197 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from "vitest";
2
+ import { mkdir, writeFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { rankFoldBlockers, toCollapsedFormat } from "./fold-rank";
6
+ import type { FoldDecision } from "./index";
7
+
8
+ describe("rankFoldBlockers", () => {
9
+ let testDir: string;
10
+
11
+ beforeEach(async () => {
12
+ testDir = join(tmpdir(), `chant-fold-rank-test-${Date.now()}-${Math.random()}`);
13
+ await mkdir(testDir, { recursive: true });
14
+ });
15
+
16
+ afterEach(async () => {
17
+ await rm(testDir, { recursive: true, force: true });
18
+ });
19
+
20
+ function run(file: string, reason = "unresolved identifier"): FoldDecision {
21
+ return { file, mode: "run", reason };
22
+ }
23
+ function folded(file: string): FoldDecision {
24
+ return { file, mode: "fold", resourceCount: 1 };
25
+ }
26
+ function reverseTaintedDecision(file: string, reason: string): FoldDecision {
27
+ return { file, mode: "run", reason, reverseTainted: true };
28
+ }
29
+
30
+ test("a chain — one root-cause blocker retains every file behind it", async () => {
31
+ // a.ts <- b.ts <- c.ts (b imports a, c imports b); all three fall back to run.
32
+ const a = join(testDir, "a.ts");
33
+ const b = join(testDir, "b.ts");
34
+ const c = join(testDir, "c.ts");
35
+ await writeFile(a, `export const a = process.env.X;`);
36
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
37
+ await writeFile(c, `import { b } from "./b";\nexport const c = b;`);
38
+
39
+ const result = await rankFoldBlockers([run(a), run(b), run(c)]);
40
+
41
+ expect(result.totalBlocked).toBe(3);
42
+ const byFile = new Map(result.blockers.map((x) => [x.file, x]));
43
+ expect(byFile.get(a)).toMatchObject({ retained: 3, topLevel: true });
44
+ expect(byFile.get(b)).toMatchObject({ retained: 2, topLevel: false, dominatedBy: a });
45
+ expect(byFile.get(c)).toMatchObject({ retained: 1, topLevel: false, dominatedBy: b });
46
+
47
+ // Ranked descending by retained count — the root cause comes first.
48
+ expect(result.blockers.map((x) => x.file)).toEqual([a, b, c]);
49
+ });
50
+
51
+ test("a diamond — a single root cause reached two ways is not double-counted", async () => {
52
+ // root.ts <- {left.ts, right.ts} <- both.ts (both.ts imports BOTH left and right).
53
+ const root = join(testDir, "root.ts");
54
+ const left = join(testDir, "left.ts");
55
+ const right = join(testDir, "right.ts");
56
+ const both = join(testDir, "both.ts");
57
+ await writeFile(root, `export const root = process.env.X;`);
58
+ await writeFile(left, `import { root } from "./root";\nexport const left = root;`);
59
+ await writeFile(right, `import { root } from "./root";\nexport const right = root;`);
60
+ await writeFile(
61
+ both,
62
+ `import { left } from "./left";\nimport { right } from "./right";\nexport const both = left + right;`,
63
+ );
64
+
65
+ const result = await rankFoldBlockers([run(root), run(left), run(right), run(both)]);
66
+
67
+ expect(result.totalBlocked).toBe(4);
68
+ const rootBlocker = result.blockers.find((x) => x.file === root)!;
69
+ // root retains ALL FOUR files — left, right, and "both" reached via
70
+ // either path — not eight (double-counted through the diamond).
71
+ expect(rootBlocker.retained).toBe(4);
72
+ expect(rootBlocker.topLevel).toBe(true);
73
+
74
+ const bothBlocker = result.blockers.find((x) => x.file === both)!;
75
+ expect(bothBlocker.retained).toBe(1);
76
+
77
+ // Conservation: top-level blockers' retained counts sum to totalBlocked.
78
+ const topLevelSum = result.blockers.filter((x) => x.topLevel).reduce((s, x) => s + x.retained, 0);
79
+ expect(topLevelSum).toBe(result.totalBlocked);
80
+ });
81
+
82
+ test("two independent blockers — a shared dependent is not credited to either", async () => {
83
+ // x.ts and y.ts fail independently (no relation to each other).
84
+ // both.ts imports both, and needs BOTH fixed to fold — so it is its
85
+ // own top-level entry, inflating neither x's nor y's retained count.
86
+ const x = join(testDir, "x.ts");
87
+ const y = join(testDir, "y.ts");
88
+ const both = join(testDir, "both.ts");
89
+ await writeFile(x, `export const x = process.env.X;`);
90
+ await writeFile(y, `export const y = process.env.Y;`);
91
+ await writeFile(both, `import { x } from "./x";\nimport { y } from "./y";\nexport const both = x + y;`);
92
+
93
+ const result = await rankFoldBlockers([run(x), run(y), run(both)]);
94
+
95
+ expect(result.totalBlocked).toBe(3);
96
+ const byFile = new Map(result.blockers.map((b) => [b.file, b]));
97
+ expect(byFile.get(x)).toMatchObject({ retained: 1, topLevel: true });
98
+ expect(byFile.get(y)).toMatchObject({ retained: 1, topLevel: true });
99
+ // "both" is dominated by neither x nor y alone — it becomes its own
100
+ // top-level entry rather than inflating either blocker.
101
+ expect(byFile.get(both)).toMatchObject({ retained: 1, topLevel: true });
102
+
103
+ const topLevelSum = result.blockers.filter((b) => b.topLevel).reduce((s, b) => s + b.retained, 0);
104
+ expect(topLevelSum).toBe(3);
105
+ });
106
+
107
+ test("a reverse-tainted file is reported separately and never enters the tree", async () => {
108
+ const blocker = join(testDir, "blocker.ts");
109
+ const wouldFold = join(testDir, "would-fold.ts");
110
+ await writeFile(blocker, `import { p } from "./would-fold";\nexport const b = p + process.env.X;`);
111
+ await writeFile(wouldFold, `export const p = "fine";`);
112
+
113
+ const decisions: FoldDecision[] = [
114
+ run(blocker),
115
+ reverseTaintedDecision(
116
+ wouldFold,
117
+ "would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance",
118
+ ),
119
+ ];
120
+
121
+ const result = await rankFoldBlockers(decisions);
122
+
123
+ expect(result.totalBlocked).toBe(1);
124
+ expect(result.blockers.map((b) => b.file)).toEqual([blocker]);
125
+ expect(result.reverseTainted).toEqual([
126
+ {
127
+ file: wouldFold,
128
+ reason:
129
+ "would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance",
130
+ },
131
+ ]);
132
+ });
133
+
134
+ test("fold-mode files are excluded from the tree entirely", async () => {
135
+ const okFile = join(testDir, "ok.ts");
136
+ const badFile = join(testDir, "bad.ts");
137
+ await writeFile(okFile, `export const ok = 1;`);
138
+ await writeFile(badFile, `export const bad = process.env.X;`);
139
+
140
+ const result = await rankFoldBlockers([folded(okFile), run(badFile)]);
141
+
142
+ expect(result.totalBlocked).toBe(1);
143
+ expect(result.blockers.map((b) => b.file)).toEqual([badFile]);
144
+ });
145
+
146
+ test("retained counts are conserved across a mixed graph (chain + independent leaf)", async () => {
147
+ const a = join(testDir, "a.ts");
148
+ const b = join(testDir, "b.ts");
149
+ const leaf = join(testDir, "leaf.ts");
150
+ await writeFile(a, `export const a = process.env.X;`);
151
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
152
+ await writeFile(leaf, `export const leaf = process.env.Y;`); // unrelated second root cause
153
+
154
+ const result = await rankFoldBlockers([run(a), run(b), run(leaf)]);
155
+
156
+ expect(result.totalBlocked).toBe(3);
157
+ const topLevelSum = result.blockers.filter((x) => x.topLevel).reduce((s, x) => s + x.retained, 0);
158
+ expect(topLevelSum).toBe(3);
159
+ // a.ts ranks ahead of leaf.ts (retains 2 vs 1) — sorted descending.
160
+ expect(result.blockers[0].file).toBe(a);
161
+ expect(result.blockers[0].retained).toBe(2);
162
+ });
163
+ });
164
+
165
+ describe("toCollapsedFormat", () => {
166
+ let testDir: string;
167
+
168
+ beforeEach(async () => {
169
+ testDir = join(tmpdir(), `chant-fold-rank-collapsed-test-${Date.now()}-${Math.random()}`);
170
+ await mkdir(testDir, { recursive: true });
171
+ });
172
+
173
+ afterEach(async () => {
174
+ await rm(testDir, { recursive: true, force: true });
175
+ });
176
+
177
+ test("emits one weighted line per file, folding to retained counts under a shared root frame", async () => {
178
+ const a = join(testDir, "a.ts");
179
+ const b = join(testDir, "b.ts");
180
+ await writeFile(a, `export const a = process.env.X;`);
181
+ await writeFile(b, `import { a } from "./a";\nexport const b = a;`);
182
+
183
+ const result = await rankFoldBlockers([
184
+ { file: a, mode: "run", reason: "r" },
185
+ { file: b, mode: "run", reason: "r" },
186
+ ]);
187
+ const lines = toCollapsedFormat(result, { relativeTo: testDir });
188
+
189
+ expect(lines.sort()).toEqual(["fold;a.ts 1", "fold;a.ts;b.ts 1"].sort());
190
+
191
+ // Every line for a's own subtree (its own line, plus every line whose
192
+ // stack is prefixed by it) sums to a's retained count.
193
+ const aPrefixCount = lines.filter((l) => l.startsWith("fold;a.ts")).length;
194
+ const aBlocker = result.blockers.find((x) => x.file === a)!;
195
+ expect(aPrefixCount).toBe(aBlocker.retained);
196
+ });
197
+ });
@@ -0,0 +1,346 @@
1
+ import { relative } from "node:path";
2
+ import { buildProjectImportEdges } from "./fold-import";
3
+ import type { FoldDecision } from "./index";
4
+
5
+ /**
6
+ * chant #1083 — rank fold blockers by dominator retained-count over the
7
+ * import graph discovery already builds.
8
+ *
9
+ * ## Why dominators, not a flat "files downstream of X" count
10
+ *
11
+ * The import graph is a DAG (occasionally cyclic, when project source
12
+ * itself has an import cycle), not a tree: a file can be reachable through
13
+ * several paths, so naively summing "files that import X, transitively"
14
+ * double-counts every diamond and the totals stop meaning anything. The
15
+ * fix is the same one heap snapshot tools use for object retained sizes
16
+ * (Cooper–Harvey–Kennedy immediate dominators, prior art: spicypath's
17
+ * `src/heap-dominators.js`, cited on chant #1083): file `d` dominates file
18
+ * `n` when every causal path to `n`'s failure passes through `d`. A file's
19
+ * retained count is then the size of its dominator subtree — the number of
20
+ * files that would fold if `d` folded — and every node lands in exactly one
21
+ * dominator subtree, so retained counts are conserved: they sum to the
22
+ * total across the tree's top-level blockers, with nothing counted twice
23
+ * through a diamond, and a file with two INDEPENDENT blockers (no common
24
+ * ancestor besides the synthetic root) inflates neither.
25
+ *
26
+ * ## One direction only
27
+ *
28
+ * This models FORWARD contagion only: file `n` fails to fold because one of
29
+ * its own imports, `d`, also fails — an unresolved cross-file reference.
30
+ * The edge direction for dominance purposes is therefore `d -> n` (the
31
+ * blocker points at what it blocks), which is the REVERSE of the literal
32
+ * import edge `n -> d` (n imports d).
33
+ *
34
+ * Fold has a second, opposite-direction rule (chant #1044, implemented in
35
+ * {@link import("./fold-import").planFoldTaint}): a file that WOULD fold
36
+ * fine in isolation is forced back to run anyway because some file that
37
+ * imports it (directly or transitively) itself runs — folding it
38
+ * independently would create a second, non-identical instance. That is
39
+ * contagion from importER to importEE, the opposite of the direction this
40
+ * tree models, and a dominator tree over import edges cannot express it: a
41
+ * fix to the "blocker" here wouldn't actually unblock the reverse-tainted
42
+ * file (it was never broken on its own terms), so crediting it would
43
+ * overstate the fix's blast radius. Every file discovery marked
44
+ * {@link FoldDecision.reverseTainted} is therefore excluded from the tree
45
+ * entirely and reported in {@link FoldRankResult.reverseTainted} instead —
46
+ * see chant #1083's re-scope comment.
47
+ */
48
+
49
+ /** Sentinel "file path" for the synthetic dominator-tree root. Never a real file — file paths never contain NUL. */
50
+ const ROOT = "\0chant-fold-rank-root\0";
51
+
52
+ /** One node in the forward-contagion dominator tree — a `"run"`, non-reverse-tainted file. */
53
+ export interface FoldBlocker {
54
+ /** Absolute source file path (matches {@link FoldDecision.file}). */
55
+ file: string;
56
+ /** Why THIS file itself falls back to run (its own {@link FoldDecision.reason}). */
57
+ reason?: string;
58
+ /**
59
+ * Number of files — including this one — that would fold if this file's
60
+ * own fold problem were fixed: this node's dominator-subtree size.
61
+ * Conserved: summing this field over every blocker with no closer
62
+ * dominator ({@link topLevel}) equals {@link FoldRankResult.totalBlocked}.
63
+ */
64
+ retained: number;
65
+ /** True when nothing else in the tree dominates this file more closely than the synthetic root — i.e. this file's own fold failure isn't explained by importing another blocker already in the tree. */
66
+ topLevel: boolean;
67
+ /** This file's immediate dominator's file path, or `undefined` for a {@link topLevel} blocker. */
68
+ dominatedBy?: string;
69
+ }
70
+
71
+ /** A `"run"` file held back only by the chant #1044 reverse rule — never part of the forward dominator tree (see this module's doc). */
72
+ export interface FoldReverseTaintedFile {
73
+ file: string;
74
+ reason?: string;
75
+ }
76
+
77
+ export interface FoldRankResult {
78
+ /**
79
+ * Every forward-contagion blocker (a `"run"`, non-reverse-tainted file),
80
+ * sorted by {@link FoldBlocker.retained} descending, ties broken by file
81
+ * path for determinism. Includes every node in the tree, not just
82
+ * top-level ones — an intermediate hub file (e.g. a `params.ts` many
83
+ * others funnel through) is itself a meaningful blocker to rank.
84
+ */
85
+ blockers: FoldBlocker[];
86
+ /** `"run"` files excluded from the tree by the reverse rule (chant #1044) — see this module's doc. */
87
+ reverseTainted: FoldReverseTaintedFile[];
88
+ /** Total forward-blocked files considered ({@link blockers}.length). Top-level blockers' retained counts sum to exactly this. */
89
+ totalBlocked: number;
90
+ }
91
+
92
+ /**
93
+ * Compute immediate dominators for `nodes` over `edges` (blocker -> blocked,
94
+ * i.e. flowing in the CAUSAL direction, not the import direction), rooted at
95
+ * a synthetic {@link ROOT}. Handles a cyclic/irreducible graph correctly —
96
+ * Cooper–Harvey–Kennedy's iterative algorithm converges on any graph, not
97
+ * just reducible ones (this is exactly why it's the right tool here: an
98
+ * import graph can have cycles).
99
+ *
100
+ * `rootChildren` must already guarantee every node in `nodes` is reachable
101
+ * from {@link ROOT} via `rootChildren` + `edges` — see `rankFoldBlockers`'s
102
+ * two-phase root-selection below.
103
+ */
104
+ function computeImmediateDominators(
105
+ nodes: readonly string[],
106
+ edges: ReadonlyMap<string, ReadonlySet<string>>,
107
+ rootChildren: ReadonlySet<string>,
108
+ ): Map<string, string> {
109
+ const succ = (n: string): ReadonlySet<string> => (n === ROOT ? rootChildren : edges.get(n) ?? new Set());
110
+
111
+ // Predecessors, including the synthetic ROOT edge into every root child.
112
+ const preds = new Map<string, Set<string>>();
113
+ for (const n of nodes) preds.set(n, new Set());
114
+ for (const [from, targets] of edges) {
115
+ for (const to of targets) {
116
+ if (!preds.has(to)) preds.set(to, new Set());
117
+ preds.get(to)!.add(from);
118
+ }
119
+ }
120
+ for (const rc of rootChildren) preds.get(rc)?.add(ROOT);
121
+
122
+ // Reverse-postorder numbering via one DFS from ROOT — every node in
123
+ // `nodes` must be reached (guaranteed by the caller's root selection).
124
+ const postorderNumber = new Map<string, number>();
125
+ const visited = new Set<string>([ROOT]);
126
+ let counter = 0;
127
+ const visitStack: Array<{ node: string; iter: Iterator<string> }> = [
128
+ { node: ROOT, iter: succ(ROOT).values() },
129
+ ];
130
+ while (visitStack.length > 0) {
131
+ const top = visitStack[visitStack.length - 1];
132
+ const next = top.iter.next();
133
+ if (next.done) {
134
+ postorderNumber.set(top.node, counter++);
135
+ visitStack.pop();
136
+ continue;
137
+ }
138
+ const child = next.value;
139
+ if (!visited.has(child)) {
140
+ visited.add(child);
141
+ visitStack.push({ node: child, iter: succ(child).values() });
142
+ }
143
+ }
144
+
145
+ const rpo = [...nodes, ROOT].filter((n) => postorderNumber.has(n));
146
+ rpo.sort((a, b) => postorderNumber.get(b)! - postorderNumber.get(a)!); // reverse postorder: highest number first
147
+
148
+ const idom = new Map<string, string>();
149
+ idom.set(ROOT, ROOT);
150
+
151
+ const intersect = (a: string, b: string): string => {
152
+ let finger1 = a;
153
+ let finger2 = b;
154
+ while (finger1 !== finger2) {
155
+ while (postorderNumber.get(finger1)! < postorderNumber.get(finger2)!) finger1 = idom.get(finger1)!;
156
+ while (postorderNumber.get(finger2)! < postorderNumber.get(finger1)!) finger2 = idom.get(finger2)!;
157
+ }
158
+ return finger1;
159
+ };
160
+
161
+ let changed = true;
162
+ while (changed) {
163
+ changed = false;
164
+ for (const node of rpo) {
165
+ if (node === ROOT) continue;
166
+ let newIdom: string | undefined;
167
+ for (const p of preds.get(node) ?? []) {
168
+ if (!idom.has(p)) continue; // not yet processed this pass
169
+ newIdom = newIdom === undefined ? p : intersect(newIdom, p);
170
+ }
171
+ if (newIdom !== undefined && idom.get(node) !== newIdom) {
172
+ idom.set(node, newIdom);
173
+ changed = true;
174
+ }
175
+ }
176
+ }
177
+
178
+ idom.delete(ROOT);
179
+ return idom;
180
+ }
181
+
182
+ /**
183
+ * Rank fold blockers by dominator retained-count over the (forward-only)
184
+ * import graph among this build's `"run"`-mode files. See this module's doc
185
+ * for the model. `decisions` is expected to be a `--fold` build's complete
186
+ * {@link FoldDecision}[] — every discovered file, fold or run.
187
+ */
188
+ export async function rankFoldBlockers(decisions: readonly FoldDecision[]): Promise<FoldRankResult> {
189
+ const allFiles = decisions.map((d) => d.file);
190
+ const reverseTainted = decisions
191
+ .filter((d) => d.mode === "run" && d.reverseTainted === true)
192
+ .map((d) => ({ file: d.file, reason: d.reason }))
193
+ .sort((a, b) => a.file.localeCompare(b.file));
194
+
195
+ const nodeDecisions = decisions.filter((d) => d.mode === "run" && d.reverseTainted !== true);
196
+ const reasonByFile = new Map(nodeDecisions.map((d) => [d.file, d.reason] as const));
197
+ const nodes = nodeDecisions.map((d) => d.file).sort((a, b) => a.localeCompare(b));
198
+ const nodeSet = new Set(nodes);
199
+
200
+ // file -> the OTHER discovered files it imports (real import direction).
201
+ const importEdges = await buildProjectImportEdges(allFiles);
202
+
203
+ // Cause graph, blocker -> blocked: importee -> importer, restricted to
204
+ // both endpoints being forward-tree nodes (a fold-mode or reverse-tainted
205
+ // file is neither a blocker nor blocked in this tree).
206
+ const causeEdges = new Map<string, Set<string>>();
207
+ for (const n of nodes) causeEdges.set(n, new Set());
208
+ for (const [importer, targets] of importEdges) {
209
+ if (!nodeSet.has(importer)) continue;
210
+ for (const target of targets) {
211
+ if (!nodeSet.has(target)) continue;
212
+ causeEdges.get(target)!.add(importer);
213
+ }
214
+ }
215
+
216
+ // Predecessor count, to find true root causes (a blocked file that is
217
+ // itself never blocked by another tree node).
218
+ const predCount = new Map<string, number>();
219
+ for (const n of nodes) predCount.set(n, 0);
220
+ for (const targets of causeEdges.values()) {
221
+ for (const t of targets) predCount.set(t, (predCount.get(t) ?? 0) + 1);
222
+ }
223
+
224
+ // Two-phase root selection, so every node is reachable from ROOT even
225
+ // across an import cycle with no external entry point (chant #1083's doc
226
+ // cites spicypath's dominators working over a graph that is "cyclic and
227
+ // irreducible" as the precedent this needs to match).
228
+ const rootChildren = new Set<string>();
229
+ const reached = new Set<string>();
230
+ const expandFrom = (start: string): void => {
231
+ if (reached.has(start)) return;
232
+ const stack = [start];
233
+ reached.add(start);
234
+ while (stack.length > 0) {
235
+ const cur = stack.pop()!;
236
+ for (const next of causeEdges.get(cur) ?? []) {
237
+ if (!reached.has(next)) {
238
+ reached.add(next);
239
+ stack.push(next);
240
+ }
241
+ }
242
+ }
243
+ };
244
+ for (const n of nodes) {
245
+ if (predCount.get(n) === 0) {
246
+ rootChildren.add(n);
247
+ expandFrom(n);
248
+ }
249
+ }
250
+ for (const n of nodes) {
251
+ if (!reached.has(n)) {
252
+ rootChildren.add(n);
253
+ expandFrom(n);
254
+ }
255
+ }
256
+
257
+ const idom = computeImmediateDominators(nodes, causeEdges, rootChildren);
258
+
259
+ // Dominator-tree children, to compute retained (subtree size) bottom-up.
260
+ const domChildren = new Map<string, string[]>();
261
+ for (const n of nodes) domChildren.set(n, []);
262
+ for (const [n, parent] of idom) {
263
+ if (parent !== ROOT) domChildren.get(parent)!.push(n);
264
+ }
265
+
266
+ const retained = new Map<string, number>();
267
+ const computeRetained = (n: string): number => {
268
+ const cached = retained.get(n);
269
+ if (cached !== undefined) return cached;
270
+ let size = 1;
271
+ for (const child of domChildren.get(n) ?? []) size += computeRetained(child);
272
+ retained.set(n, size);
273
+ return size;
274
+ };
275
+ for (const n of nodes) computeRetained(n);
276
+
277
+ const blockers: FoldBlocker[] = nodes.map((file) => {
278
+ const parent = idom.get(file);
279
+ const topLevel = parent === undefined || parent === ROOT;
280
+ return {
281
+ file,
282
+ reason: reasonByFile.get(file),
283
+ retained: retained.get(file)!,
284
+ topLevel,
285
+ dominatedBy: topLevel ? undefined : parent,
286
+ };
287
+ });
288
+ blockers.sort((a, b) => b.retained - a.retained || a.file.localeCompare(b.file));
289
+
290
+ return { blockers, reverseTainted, totalBlocked: nodes.length };
291
+ }
292
+
293
+ /** `file -> dominatedBy` lookup built from a {@link FoldRankResult}, for reconstructing a blocker's dominator-chain (used by {@link toCollapsedFormat}). */
294
+ function dominatorChain(result: FoldRankResult, file: string): string[] {
295
+ const dominatedByFile = new Map(result.blockers.map((b) => [b.file, b.dominatedBy] as const));
296
+ const chain: string[] = [file];
297
+ let current: string | undefined = file;
298
+ const guard = new Set<string>([file]);
299
+ for (;;) {
300
+ const parent = dominatedByFile.get(current!);
301
+ if (parent === undefined) break;
302
+ if (guard.has(parent)) break; // defensive: never trust a cycle in the tree itself
303
+ chain.push(parent);
304
+ guard.add(parent);
305
+ current = parent;
306
+ }
307
+ return chain.reverse(); // top-level ancestor first, `file` last
308
+ }
309
+
310
+ /** A collapsed-format frame may not contain `;` (the frame separator) or a newline; sanitize rather than silently mis-render. */
311
+ function sanitizeFrame(label: string): string {
312
+ return label.replace(/[\n\r]/g, " ").replace(/;/g, ",");
313
+ }
314
+
315
+ /**
316
+ * Export the dominator tree in Brendan Gregg collapsed stack format
317
+ * (`frame;frame;...;frame count`), weighted by retained count, so it opens
318
+ * in any flame or icicle graph viewer with no chant-specific tooling (the
319
+ * seam is the file format, per chant #1083's original scope).
320
+ *
321
+ * One line per blocker, `count` always `1`: each file's stack is the chain
322
+ * of dominators from the top-level blocker down to itself. Folding
323
+ * identical prefixes — what every collapsed-format consumer does — then
324
+ * reproduces each blocker's retained count as that prefix's total weight,
325
+ * with nothing double-counted through a diamond. `rootLabel` is a single
326
+ * synthetic top frame (default `"fold"`) so every top-level blocker renders
327
+ * under one shared root instead of the viewer showing several disconnected
328
+ * trees; pass `relativeTo` (typically the build's infra path) to shorten
329
+ * frame labels to project-relative paths.
330
+ */
331
+ export function toCollapsedFormat(
332
+ result: FoldRankResult,
333
+ options?: { rootLabel?: string; relativeTo?: string },
334
+ ): string[] {
335
+ const rootLabel = options?.rootLabel ?? "fold";
336
+ const toLabel = (file: string): string =>
337
+ sanitizeFrame(options?.relativeTo ? relative(options.relativeTo, file) || file : file);
338
+
339
+ return result.blockers
340
+ .slice()
341
+ .sort((a, b) => a.file.localeCompare(b.file))
342
+ .map((blocker) => {
343
+ const chain = dominatorChain(result, blocker.file).map(toLabel);
344
+ return `${sanitizeFrame(rootLabel)};${chain.join(";")} 1`;
345
+ });
346
+ }
@@ -85,6 +85,21 @@ export interface FoldDecision {
85
85
  reason?: string;
86
86
  /** Number of entities the fold produced. Present only when `mode === "fold"`. */
87
87
  resourceCount?: number;
88
+ /**
89
+ * chant #1083 — true when this file's OWN fold attempt succeeded (it would
90
+ * fold cleanly in isolation) but it was forced to `"run"` anyway by
91
+ * {@link planFoldTaint}'s REVERSE rule (chant #1044): some other file that
92
+ * imports it, directly or transitively, itself falls back to run, so
93
+ * folding this one independently would create a duplicate, non-identical
94
+ * instance. Present only when `mode === "run"`.
95
+ *
96
+ * The fold-blocker dominator ranking (`./fold-rank.ts`) keys on this: a
97
+ * reverse-tainted file is not a forward-contagion blocker (fixing it
98
+ * unblocks nothing — it was never the cause), and the tree can't express
99
+ * the edge that DOES hold it back (an importer, not something it imports).
100
+ * It's reported in a separate bucket instead of a dominator subtree.
101
+ */
102
+ reverseTainted?: boolean;
88
103
  }
89
104
 
90
105
  /**
@@ -295,7 +310,7 @@ export async function discover(path: string, options?: DiscoveryOptions): Promis
295
310
  const reason = !folded.ok
296
311
  ? folded.reason
297
312
  : `would fold in isolation, but a file that imports it (directly or transitively) falls back to run — folding independently would create a duplicate, non-identical instance`;
298
- foldDecisions.push({ file, mode: "run", reason });
313
+ foldDecisions.push({ file, mode: "run", reason, reverseTainted: folded.ok });
299
314
  }
300
315
 
301
316
  if (options?.sandbox) {