@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.
- package/dist/audit/catalog.d.ts +13 -3
- package/dist/audit/catalog.d.ts.map +1 -1
- package/dist/audit/core.d.ts +9 -0
- package/dist/audit/core.d.ts.map +1 -1
- package/dist/audit/discover.d.ts +6 -0
- package/dist/audit/discover.d.ts.map +1 -1
- package/dist/audit/fetch.d.ts.map +1 -1
- package/dist/audit/report-html.d.ts.map +1 -1
- package/dist/audit/report-model.d.ts +6 -0
- package/dist/audit/report-model.d.ts.map +1 -1
- package/dist/audit/report.d.ts.map +1 -1
- package/dist/audit/rules-doc.d.ts.map +1 -1
- package/dist/audit/secrets.d.ts +95 -0
- package/dist/audit/secrets.d.ts.map +1 -0
- package/dist/audit/wrangler.d.ts +33 -0
- package/dist/audit/wrangler.d.ts.map +1 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/audit.d.ts +7 -0
- package/dist/cli/commands/audit.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts +23 -0
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/handlers/build.d.ts.map +1 -1
- package/dist/cli/handlers/components.d.ts +31 -0
- package/dist/cli/handlers/components.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts +11 -0
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/operator.d.ts +32 -0
- package/dist/cli/handlers/operator.d.ts.map +1 -0
- package/dist/cli/handlers/scenario.d.ts +39 -0
- package/dist/cli/handlers/scenario.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/mcp/server.d.ts +35 -2
- package/dist/cli/mcp/server.d.ts.map +1 -1
- package/dist/cli/mcp/types.d.ts +29 -1
- package/dist/cli/mcp/types.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +14 -2
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/codegen/docs-rule-scanning.d.ts.map +1 -1
- package/dist/components/capability.d.ts +17 -2
- package/dist/components/capability.d.ts.map +1 -1
- package/dist/components/cli-support.d.ts +7 -0
- package/dist/components/cli-support.d.ts.map +1 -1
- package/dist/components/component.d.ts +15 -0
- package/dist/components/component.d.ts.map +1 -1
- package/dist/components/driver.d.ts.map +1 -1
- package/dist/components/verbs/index.d.ts +6 -1
- package/dist/components/verbs/index.d.ts.map +1 -1
- package/dist/components/verbs/run-agent.d.ts +499 -0
- package/dist/components/verbs/run-agent.d.ts.map +1 -0
- package/dist/components/verbs/sign.d.ts +30 -0
- package/dist/components/verbs/sign.d.ts.map +1 -1
- package/dist/composite.d.ts +6 -1
- package/dist/composite.d.ts.map +1 -1
- package/dist/discovery/collect.d.ts.map +1 -1
- package/dist/discovery/fold-import.d.ts +15 -1
- package/dist/discovery/fold-import.d.ts.map +1 -1
- package/dist/discovery/fold-rank.d.ts +66 -0
- package/dist/discovery/fold-rank.d.ts.map +1 -0
- package/dist/discovery/index.d.ts +15 -0
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/param-deps.d.ts +17 -0
- package/dist/discovery/param-deps.d.ts.map +1 -0
- package/dist/fold/fold.d.ts +55 -2
- package/dist/fold/fold.d.ts.map +1 -1
- package/dist/fold/subset.d.ts +21 -14
- package/dist/fold/subset.d.ts.map +1 -1
- package/dist/lexicon-schema.d.ts +2 -0
- package/dist/lexicon-schema.d.ts.map +1 -1
- package/dist/lexicon.d.ts +93 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/converge-ledger.d.ts +90 -0
- package/dist/lifecycle/converge-ledger.d.ts.map +1 -0
- package/dist/lifecycle/deep-diff.d.ts +18 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +9 -1
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/gate-ledger.d.ts +33 -0
- package/dist/lifecycle/gate-ledger.d.ts.map +1 -0
- package/dist/lifecycle/git.d.ts +145 -21
- package/dist/lifecycle/git.d.ts.map +1 -1
- package/dist/lifecycle/index.d.ts +4 -0
- package/dist/lifecycle/index.d.ts.map +1 -1
- package/dist/lifecycle/lease.d.ts +113 -0
- package/dist/lifecycle/lease.d.ts.map +1 -0
- package/dist/lifecycle/scenario-eval.d.ts +42 -0
- package/dist/lifecycle/scenario-eval.d.ts.map +1 -0
- package/dist/lifecycle/scenario.d.ts +163 -0
- package/dist/lifecycle/scenario.d.ts.map +1 -0
- package/dist/lifecycle/symptoms.d.ts +63 -0
- package/dist/lifecycle/symptoms.d.ts.map +1 -0
- package/dist/lint/output-docs.d.ts +94 -0
- package/dist/lint/output-docs.d.ts.map +1 -0
- package/dist/lint/post-synth.d.ts +29 -0
- package/dist/lint/post-synth.d.ts.map +1 -1
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts +11 -0
- package/dist/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.d.ts.map +1 -0
- package/dist/lsp/lexicon-providers.d.ts +7 -0
- package/dist/lsp/lexicon-providers.d.ts.map +1 -1
- package/dist/op/activity-contract.d.ts +139 -0
- package/dist/op/activity-contract.d.ts.map +1 -0
- package/dist/op/builders.d.ts +42 -2
- package/dist/op/builders.d.ts.map +1 -1
- package/dist/op/converge-rule.d.ts +161 -0
- package/dist/op/converge-rule.d.ts.map +1 -0
- package/dist/op/generate-pipeline.d.ts +39 -0
- package/dist/op/generate-pipeline.d.ts.map +1 -0
- package/dist/op/index.d.ts +14 -0
- package/dist/op/index.d.ts.map +1 -1
- package/dist/op/local-executor.d.ts.map +1 -1
- package/dist/op/op-verb-class.d.ts +42 -0
- package/dist/op/op-verb-class.d.ts.map +1 -0
- package/dist/op/operator.d.ts +128 -0
- package/dist/op/operator.d.ts.map +1 -0
- package/dist/op/step-output-ref.d.ts +187 -0
- package/dist/op/step-output-ref.d.ts.map +1 -0
- package/dist/op/types.d.ts +18 -1
- package/dist/op/types.d.ts.map +1 -1
- package/dist/provenance.d.ts +73 -3
- package/dist/provenance.d.ts.map +1 -1
- package/dist/runtime-adapter.d.ts +7 -1
- package/dist/runtime-adapter.d.ts.map +1 -1
- package/dist/serializer.d.ts +18 -0
- package/dist/serializer.d.ts.map +1 -1
- package/dist/toml.d.ts +40 -5
- package/dist/toml.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +1 -1
- package/src/audit/catalog.ts +75 -3
- package/src/audit/core.ts +9 -0
- package/src/audit/discover.ts +29 -2
- package/src/audit/fetch.test.ts +216 -3
- package/src/audit/fetch.ts +270 -59
- package/src/audit/report-html.ts +5 -2
- package/src/audit/report-model.ts +9 -0
- package/src/audit/report.test.ts +22 -0
- package/src/audit/report.ts +3 -2
- package/src/audit/rules-doc.ts +2 -0
- package/src/audit/secrets.test.ts +303 -0
- package/src/audit/secrets.ts +406 -0
- package/src/audit/wrangler.test.ts +230 -0
- package/src/audit/wrangler.ts +290 -0
- package/src/build.ts +8 -3
- package/src/cli/command-group.ts +1 -1
- package/src/cli/commands/__fixtures__/schemas/sarif-2.1.0.schema.json +2882 -0
- package/src/cli/commands/audit.test.ts +215 -1
- package/src/cli/commands/audit.ts +86 -17
- package/src/cli/commands/build.test.ts +167 -2
- package/src/cli/commands/build.ts +114 -23
- package/src/cli/handlers/build.ts +2 -0
- package/src/cli/handlers/components.test.ts +199 -1
- package/src/cli/handlers/components.ts +160 -3
- package/src/cli/handlers/graph.test.ts +20 -0
- package/src/cli/handlers/graph.ts +10 -1
- package/src/cli/handlers/lifecycle.ts +12 -4
- package/src/cli/handlers/operator.test.ts +255 -0
- package/src/cli/handlers/operator.ts +240 -0
- package/src/cli/handlers/scenario.test.ts +456 -0
- package/src/cli/handlers/scenario.ts +330 -0
- package/src/cli/main.test.ts +23 -0
- package/src/cli/main.ts +72 -1
- package/src/cli/mcp/server.test.ts +265 -2
- package/src/cli/mcp/server.ts +84 -7
- package/src/cli/mcp/types.ts +27 -1
- package/src/cli/registry.ts +14 -2
- package/src/codegen/docs-rule-scanning.test.ts +42 -0
- package/src/codegen/docs-rule-scanning.ts +25 -2
- package/src/components/README.md +7 -0
- package/src/components/capability.ts +17 -2
- package/src/components/cli-support.test.ts +17 -0
- package/src/components/cli-support.ts +13 -1
- package/src/components/component-schema.test.ts +32 -0
- package/src/components/component.schema.json +6 -0
- package/src/components/component.test.ts +21 -0
- package/src/components/component.ts +15 -0
- package/src/components/driver.ts +12 -4
- package/src/components/verbs/index.ts +6 -1
- package/src/components/verbs/run-agent.test.ts +683 -0
- package/src/components/verbs/run-agent.ts +786 -0
- package/src/components/verbs/sign.test.ts +19 -0
- package/src/components/verbs/sign.ts +34 -2
- package/src/composite.ts +31 -2
- package/src/discovery/collect.ts +11 -2
- package/src/discovery/fold-import.test.ts +54 -0
- package/src/discovery/fold-import.ts +178 -38
- package/src/discovery/fold-rank.test.ts +197 -0
- package/src/discovery/fold-rank.ts +346 -0
- package/src/discovery/index.ts +16 -1
- package/src/discovery/param-deps.test.ts +118 -0
- package/src/discovery/param-deps.ts +170 -0
- package/src/fold/fold.test.ts +6 -2
- package/src/fold/fold.ts +184 -3
- package/src/fold/subset.test.ts +82 -19
- package/src/fold/subset.ts +79 -41
- package/src/lexicon-schema.ts +3 -0
- package/src/lexicon.ts +103 -2
- package/src/lifecycle/converge-ledger.test.ts +199 -0
- package/src/lifecycle/converge-ledger.ts +179 -0
- package/src/lifecycle/deep-diff.test.ts +79 -1
- package/src/lifecycle/deep-diff.ts +23 -0
- package/src/lifecycle/deep-observe.ts +13 -2
- package/src/lifecycle/gate-ledger.test.ts +103 -0
- package/src/lifecycle/gate-ledger.ts +140 -0
- package/src/lifecycle/git.test.ts +430 -0
- package/src/lifecycle/git.ts +446 -84
- package/src/lifecycle/index.ts +4 -0
- package/src/lifecycle/lease.test.ts +343 -0
- package/src/lifecycle/lease.ts +270 -0
- package/src/lifecycle/scenario-eval.test.ts +199 -0
- package/src/lifecycle/scenario-eval.ts +158 -0
- package/src/lifecycle/scenario.test.ts +195 -0
- package/src/lifecycle/scenario.ts +321 -0
- package/src/lifecycle/symptoms.test.ts +116 -0
- package/src/lifecycle/symptoms.ts +126 -0
- package/src/lint/output-docs.test.ts +220 -0
- package/src/lint/output-docs.ts +204 -0
- package/src/lint/post-synth.test.ts +97 -0
- package/src/lint/post-synth.ts +45 -0
- package/src/lint/rules/__fixtures__/comp/comp003/pass/agent-turn.component.ts +26 -0
- package/src/lint/rules/comp/comp.test.ts +49 -1
- package/src/lint/rules/evl001-non-literal-expression.test.ts +8 -3
- package/src/lint/rules/evl001-non-literal-expression.ts +6 -6
- package/src/lsp/lexicon-providers.test.ts +44 -0
- package/src/lsp/lexicon-providers.ts +11 -1
- package/src/op/activity-contract.test.ts +180 -0
- package/src/op/activity-contract.ts +278 -0
- package/src/op/builders-exports.test.ts +17 -1
- package/src/op/builders.ts +59 -5
- package/src/op/converge-rule.test.ts +179 -0
- package/src/op/converge-rule.ts +311 -0
- package/src/op/generate-pipeline.test.ts +53 -0
- package/src/op/generate-pipeline.ts +99 -0
- package/src/op/index.ts +30 -0
- package/src/op/local-executor.test.ts +92 -0
- package/src/op/local-executor.ts +45 -9
- package/src/op/op-verb-class.test.ts +126 -0
- package/src/op/op-verb-class.ts +115 -0
- package/src/op/operator.test.ts +346 -0
- package/src/op/operator.ts +213 -0
- package/src/op/step-output-ref.test.ts +334 -0
- package/src/op/step-output-ref.ts +453 -0
- package/src/op/types.ts +18 -1
- package/src/provenance.test.ts +151 -4
- package/src/provenance.ts +118 -4
- package/src/runtime-adapter.ts +31 -10
- package/src/serializer.ts +18 -0
- package/src/toml.test.ts +157 -384
- 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
|
+
}
|
package/src/discovery/index.ts
CHANGED
|
@@ -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) {
|