rolldown-perf-kit 0.0.0-stage → 0.1.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 (43) hide show
  1. package/AGENTS.md +345 -0
  2. package/LICENSE +25 -0
  3. package/README.md +621 -2
  4. package/harness.mjs +4860 -0
  5. package/lib/build.mjs +57 -0
  6. package/lib/cdp.mjs +217 -0
  7. package/lib/chunk-discovery.mjs +226 -0
  8. package/lib/coverage.mjs +498 -0
  9. package/lib/field/analysis.mjs +225 -0
  10. package/lib/field/browser.mjs +232 -0
  11. package/lib/field/build-match.mjs +86 -0
  12. package/lib/field/capture.mjs +501 -0
  13. package/lib/field/config.mjs +84 -0
  14. package/lib/field/run.mjs +612 -0
  15. package/lib/gen-app.mjs +399 -0
  16. package/lib/graph-diff.mjs +136 -0
  17. package/lib/interrupt.mjs +25 -0
  18. package/lib/measure.mjs +540 -0
  19. package/lib/min-cut.mjs +218 -0
  20. package/lib/module-graph.mjs +318 -0
  21. package/lib/network-evidence.mjs +84 -0
  22. package/lib/node/launch.mjs +351 -0
  23. package/lib/node/measure.mjs +289 -0
  24. package/lib/paint-diagnostics.mjs +76 -0
  25. package/lib/parity/anchor.mjs +186 -0
  26. package/lib/parity/capture.mjs +527 -0
  27. package/lib/parity/compare.mjs +813 -0
  28. package/lib/parity/png.mjs +166 -0
  29. package/lib/parity/run.mjs +145 -0
  30. package/lib/profile.mjs +281 -0
  31. package/lib/replay/cert.mjs +45 -0
  32. package/lib/replay/model.mjs +656 -0
  33. package/lib/replay/run.mjs +1140 -0
  34. package/lib/replay/server.mjs +499 -0
  35. package/lib/replay/shaper.mjs +158 -0
  36. package/lib/rolldown-support.mjs +161 -0
  37. package/lib/serve.mjs +142 -0
  38. package/lib/worker/attrib.mjs +125 -0
  39. package/lib/worker/config.mjs +116 -0
  40. package/lib/worker/launch.mjs +337 -0
  41. package/lib/worker/measure.mjs +296 -0
  42. package/package.json +38 -4
  43. package/replay.mjs +79 -0
@@ -0,0 +1,218 @@
1
+ // Minimum edge cut over the static module graph: the smallest set of import statements
2
+ // to make dynamic so a target module leaves the initial load. Dominator retained-size
3
+ // can't answer this once a module is reachable by several independent paths — a cut can.
4
+ //
5
+ // Unit-capacity max-flow / min-cut (Menger): S --INF--> each entry, every static edge
6
+ // --1--> (so max flow = min number of import edges to cut), target --INF--> T. Protected
7
+ // edges get INF so the cut routes around them. We return the cut NEAREST the sink (the
8
+ // last-hop imports into the feature — the exact files to edit), computed from the sink
9
+ // side of the residual graph. Reimplemented fresh from unigraph's min_cut.rs semantics;
10
+ // fully iterative (explicit stacks/queues) — dependency graphs reach 50k modules and
11
+ // tens of thousands of levels deep, which would overflow a recursive DFS.
12
+
13
+ // Sentinel capacity for the artificial/protected edges. Real max flow is bounded by the
14
+ // number of unit-capacity edges, so this is never a bottleneck on a cuttable path.
15
+ const INF = 1e12;
16
+
17
+ /** Entry module indices (the flow sources). */
18
+ export function entryIdx(graph) {
19
+ const idOf = new Map(graph.modules.map((m, i) => [m.id, i]));
20
+ return (graph.entryModules ?? []).map((id) => idOf.get(id)).filter((i) => i != null);
21
+ }
22
+
23
+ /**
24
+ * Minimum edge cut separating `target` from the entries over static edges, never cutting
25
+ * an edge in `protectedEdges` (`{from,to}` pairs — e.g. every import into a `--keep`
26
+ * sentry). Returns `{ cutEdges:[{from,to}], flow, hasUncuttableSink, blockedByProtected }`.
27
+ * `flow === cutEdges.length` is asserted — the built-in min-cut sanity check.
28
+ */
29
+ export function minCut(graph, target, protectedEdges = [], sourcesArg = null) {
30
+ const mods = graph.modules;
31
+ const n = mods.length;
32
+ const sources = sourcesArg ?? entryIdx(graph);
33
+
34
+ // A target that is itself an entry can't be detached by cutting imports — you'd have
35
+ // to delete the module. Flag it, don't cut.
36
+ if (sources.includes(target)) {
37
+ return { cutEdges: [], flow: 0, hasUncuttableSink: true, blockedByProtected: false };
38
+ }
39
+
40
+ const source = n;
41
+ const sink = n + 1;
42
+ const total = n + 2;
43
+
44
+ // Flow network: forward/backward edges stored as consecutive pairs, so reverse(e) = e^1.
45
+ const edgeTo = [];
46
+ const edgeCap = [];
47
+ const adjacency = Array.from({ length: total }, () => []);
48
+ const realEdges = []; // cuttable graph edges we added, in insertion order
49
+ const addEdge = (from, to, cap) => {
50
+ adjacency[from].push(edgeTo.length);
51
+ edgeTo.push(to);
52
+ edgeCap.push(cap);
53
+ adjacency[to].push(edgeTo.length);
54
+ edgeTo.push(from);
55
+ edgeCap.push(0);
56
+ };
57
+
58
+ for (const s of sources) addEdge(source, s, INF);
59
+ const targetSinkFwd = edgeTo.length;
60
+ addEdge(target, sink, INF);
61
+
62
+ const protectedSet = new Set(protectedEdges.map((e) => e.from * n + e.to));
63
+ // Duplicate parallel records (the same from->to listed twice in a sidecar) collapse to
64
+ // ONE unit edge: the cut is counted in import statements at edge-pair granularity, and
65
+ // two parallel unit edges would push flow past the deduped cut list (invariant throw).
66
+ const seenEdge = new Set();
67
+ for (let from = 0; from < n; from++) {
68
+ if (!mods[from].staticReachable) continue; // unreachable nodes carry no flow
69
+ for (const [to, isDynamic] of mods[from].imports ?? []) {
70
+ if (isDynamic) continue;
71
+ const key = from * n + to;
72
+ if (seenEdge.has(key)) continue;
73
+ seenEdge.add(key);
74
+ if (protectedSet.has(key)) {
75
+ addEdge(from, to, INF); // uncuttable, not recorded as a real edge
76
+ } else {
77
+ realEdges.push({ from, to });
78
+ addEdge(from, to, 1);
79
+ }
80
+ }
81
+ }
82
+
83
+ // If the sink is reachable from the source through INF-capacity edges alone, every cut
84
+ // must include a protected edge — there is no valid cut. Check while caps are pristine.
85
+ const visited = Array.from({ length: total }, () => false);
86
+ {
87
+ const queue = [source];
88
+ let head = 0;
89
+ visited[source] = true;
90
+ while (head < queue.length) {
91
+ const node = queue[head++];
92
+ for (const e of adjacency[node]) {
93
+ const next = edgeTo[e];
94
+ if (edgeCap[e] >= INF && !visited[next]) {
95
+ visited[next] = true;
96
+ queue.push(next);
97
+ }
98
+ }
99
+ }
100
+ }
101
+ if (visited[sink]) {
102
+ return { cutEdges: [], flow: 0, hasUncuttableSink: false, blockedByProtected: true };
103
+ }
104
+
105
+ // --- Dinic max-flow ---
106
+ const level = Array.from({ length: total }, () => -1);
107
+ const iterPtr = Array.from({ length: total }, () => 0);
108
+
109
+ const buildLevels = () => {
110
+ level.fill(-1);
111
+ const queue = [source];
112
+ let head = 0;
113
+ level[source] = 0;
114
+ while (head < queue.length) {
115
+ const node = queue[head++];
116
+ for (const e of adjacency[node]) {
117
+ const next = edgeTo[e];
118
+ if (edgeCap[e] > 0 && level[next] < 0) {
119
+ level[next] = level[node] + 1;
120
+ queue.push(next);
121
+ }
122
+ }
123
+ }
124
+ return level[sink] >= 0;
125
+ };
126
+
127
+ const nextAdmissibleEdge = (node) => {
128
+ while (iterPtr[node] < adjacency[node].length) {
129
+ const e = adjacency[node][iterPtr[node]];
130
+ const next = edgeTo[e];
131
+ if (edgeCap[e] > 0 && level[next] === level[node] + 1) return e;
132
+ iterPtr[node] += 1;
133
+ }
134
+ return -1;
135
+ };
136
+
137
+ const pushBlockingFlow = () => {
138
+ const nodes = [source]; // current DFS path
139
+ const edges = []; // edges[i] = edge taken from nodes[i] to nodes[i+1]
140
+ while (nodes.length) {
141
+ const node = nodes[nodes.length - 1];
142
+ if (node === sink) {
143
+ // Augment by the path bottleneck, then retreat to just before the first edge
144
+ // that saturated (earlier edges keep residual and can carry more flow).
145
+ let bottleneck = Infinity;
146
+ for (const e of edges) if (edgeCap[e] < bottleneck) bottleneck = edgeCap[e];
147
+ let firstSaturated = edges.length;
148
+ for (let i = 0; i < edges.length; i++) {
149
+ const e = edges[i];
150
+ edgeCap[e] -= bottleneck;
151
+ edgeCap[e ^ 1] += bottleneck;
152
+ if (firstSaturated === edges.length && edgeCap[e] === 0) firstSaturated = i;
153
+ }
154
+ nodes.length = firstSaturated + 1;
155
+ edges.length = firstSaturated;
156
+ continue;
157
+ }
158
+ const e = nextAdmissibleEdge(node);
159
+ if (e !== -1) {
160
+ nodes.push(edgeTo[e]);
161
+ edges.push(e);
162
+ } else {
163
+ // Dead end: drop it from the level graph, back up, step the parent past this edge.
164
+ level[node] = -1;
165
+ nodes.pop();
166
+ if (edges.length) {
167
+ const back = edges.pop();
168
+ iterPtr[edgeTo[back ^ 1]] += 1;
169
+ }
170
+ }
171
+ }
172
+ };
173
+
174
+ while (buildLevels()) {
175
+ iterPtr.fill(0);
176
+ pushBlockingFlow();
177
+ }
178
+ const flow = edgeCap[targetSinkFwd ^ 1]; // backward residual on target->sink = flow pushed
179
+
180
+ // --- extract the cut nearest the sink ---
181
+ // T = nodes that can still reach the sink in the residual graph (reverse BFS). Real
182
+ // edges crossing from the source side (∉T) into T are the last-hop imports, and the
183
+ // min-cut theorem guarantees they are saturated.
184
+ const canReach = Array.from({ length: total }, () => false);
185
+ {
186
+ const queue = [sink];
187
+ let head = 0;
188
+ canReach[sink] = true;
189
+ while (head < queue.length) {
190
+ const node = queue[head++];
191
+ for (const e of adjacency[node]) {
192
+ const neighbor = edgeTo[e];
193
+ if (edgeCap[e ^ 1] > 0 && !canReach[neighbor]) {
194
+ canReach[neighbor] = true;
195
+ queue.push(neighbor);
196
+ }
197
+ }
198
+ }
199
+ }
200
+ const seen = new Set();
201
+ const cutEdges = [];
202
+ for (const { from, to } of realEdges) {
203
+ if (!canReach[from] && canReach[to]) {
204
+ const key = from * n + to;
205
+ if (!seen.has(key)) {
206
+ seen.add(key);
207
+ cutEdges.push({ from, to });
208
+ }
209
+ }
210
+ }
211
+ cutEdges.sort((a, b) => a.from - b.from || a.to - b.to);
212
+
213
+ // Built-in sanity check: the number of saturated last-hop edges must equal the flow.
214
+ if (cutEdges.length !== flow) {
215
+ throw new Error(`min-cut invariant violated: flow ${flow} != |cut| ${cutEdges.length}`);
216
+ }
217
+ return { cutEdges, flow, hasUncuttableSink: false, blockedByProtected: false };
218
+ }
@@ -0,0 +1,318 @@
1
+ // Reads rolldown's `module-graph.json` (emitted by devtools report mode) and answers
2
+ // deferral questions on it. The single engine is a read-only edge-override overlay
3
+ // (unigraph's EdgeOverrides): the initial load is forward reachability from the entries
4
+ // over static edges, and every "what leaves if ...?" recomputes it with the affected
5
+ // edges skipped. With no sentries a single module's closure equals its dominator
6
+ // subtree, so `removedBytes` matches the report's `retainedBytes`.
7
+
8
+ import fs from 'node:fs';
9
+ import path from 'node:path';
10
+
11
+ /**
12
+ * The report dir of one build output: `<cwd>/node_modules/.rolldown/report/<output dir>`.
13
+ * The same rule as `report_dir_for` in crates/rolldown_devtools_metrics/src/lib.rs: the
14
+ * output dir relative to cwd, each `..` written as `__`, every `:` removed.
15
+ */
16
+ export function reportDirFor(cwd, outDir) {
17
+ const segments = path
18
+ .relative(cwd, path.resolve(cwd, outDir))
19
+ .split(/[\\/]/)
20
+ .filter((segment) => segment !== '' && segment !== '.')
21
+ .map((segment) => (segment === '..' ? '__' : segment.replaceAll(':', '')));
22
+ return path.join(cwd, 'node_modules', '.rolldown', 'report', ...segments);
23
+ }
24
+
25
+ /** Candidate report dirs, most specific first. Each is checked for module-graph.json. */
26
+ export function moduleGraphCandidates({ reportDir, demoMetricsDir, dist }) {
27
+ const candidates = [];
28
+ if (reportDir) {
29
+ candidates.push(
30
+ reportDir.endsWith('.json') ? reportDir : path.join(reportDir, 'module-graph.json'),
31
+ );
32
+ }
33
+ if (demoMetricsDir) candidates.push(path.join(demoMetricsDir, 'module-graph.json'));
34
+ if (dist) {
35
+ // The report lands relative to the BUILD's cwd, which may sit one or more
36
+ // levels above the dist dir (nested outDirs like build/dist, monorepo app
37
+ // dirs) - walk up a few levels rather than assuming dist's direct parent.
38
+ let dir = path.dirname(dist);
39
+ for (let i = 0; i < 3; i++) {
40
+ candidates.push(path.join(reportDirFor(dir, dist), 'module-graph.json'));
41
+ const parent = path.dirname(dir);
42
+ if (parent === dir) break;
43
+ dir = parent;
44
+ }
45
+ }
46
+ return candidates;
47
+ }
48
+
49
+ // Static predecessor lists (only importers that are themselves part of the initial
50
+ // load can pin a module into it). Factored out so hand-built test graphs get the
51
+ // exact same predecessor construction the loader uses.
52
+ export function computeStaticPreds(modules) {
53
+ const staticPreds = modules.map(() => []);
54
+ modules.forEach((mod, from) => {
55
+ if (!mod.staticReachable) return;
56
+ for (const [to, isDynamic] of mod.imports ?? []) {
57
+ if (!isDynamic) staticPreds[to].push(from);
58
+ }
59
+ });
60
+ return staticPreds;
61
+ }
62
+
63
+ export function loadModuleGraph(candidates) {
64
+ const file = candidates.find((candidate) => fs.existsSync(candidate));
65
+ if (!file) return null;
66
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
67
+ return {
68
+ file,
69
+ ...data,
70
+ staticPreds: computeStaticPreds(data.modules),
71
+ buildReport: readBuildReport(file),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * The build report (`metrics.json`) next to a report-directory `module-graph.json`:
77
+ * the platform of the build and the output file of each entry. Null for a saved
78
+ * sidecar (a baseline snapshot has no sibling) or when the report is unreadable.
79
+ */
80
+ function readBuildReport(graphFile) {
81
+ if (path.basename(graphFile) !== 'module-graph.json') return null;
82
+ const file = path.join(path.dirname(graphFile), 'metrics.json');
83
+ if (!fs.existsSync(file)) return null;
84
+ let report;
85
+ try {
86
+ report = JSON.parse(fs.readFileSync(file, 'utf8'));
87
+ } catch {
88
+ return null;
89
+ }
90
+ return {
91
+ file,
92
+ platform: report.session?.platform ?? null,
93
+ format: report.session?.format ?? null,
94
+ entryChunks: (report.entries ?? [])
95
+ .map((entry) => entry?.chunk)
96
+ .filter((chunk) => typeof chunk === 'string' && chunk !== ''),
97
+ };
98
+ }
99
+
100
+ /**
101
+ * Why a report discovered for `dist` does not describe the build in `dist`, or null
102
+ * when it does (or when nothing can be checked). Report mode writes one directory per
103
+ * output dir, so the report is the last report-mode build into that dir: a rebuild
104
+ * without the report leaves the old report behind, and a build that shares the outDir
105
+ * replaces it. The entry chunks decide: when none of them exists in `dist`, the report
106
+ * is another build's.
107
+ */
108
+ export function reportDistMismatch(graph, dist) {
109
+ const report = graph?.buildReport;
110
+ if (!report || !dist || report.entryChunks.length === 0) return null;
111
+ if (report.entryChunks.some((chunk) => fs.existsSync(path.join(dist, chunk)))) return null;
112
+ const shown =
113
+ report.entryChunks.slice(0, 3).join(', ') + (report.entryChunks.length > 3 ? ', ...' : '');
114
+ let why = `the report at ${report.file} is not the build in ${dist}: none of its entry chunks (${shown}) exists there.`;
115
+ if (report.platform === 'node') {
116
+ // A client build (SSR frameworks write no index.html) is the usual victim, so the
117
+ // hint does not depend on the dist's layout.
118
+ why +=
119
+ '\nThe report comes from a node-platform build (a server), so the last report-mode build ' +
120
+ 'into this output dir was a server build.';
121
+ }
122
+ return why;
123
+ }
124
+
125
+ /**
126
+ * Find a module by query: exact id, then unique suffix, then unique substring
127
+ * (case-insensitive). Returns `{ index }`, `{ ambiguous: [...] }`, or `null`.
128
+ */
129
+ export function resolveModule(graph, query) {
130
+ const q = query.replaceAll('\\', '/');
131
+ const ids = graph.modules.map((m) => m.id);
132
+ const exact = ids.findIndex((id) => id === q);
133
+ if (exact >= 0) return { index: exact };
134
+ const lower = q.toLowerCase();
135
+ for (const match of [
136
+ (id) => id.toLowerCase().endsWith(lower),
137
+ (id) => id.toLowerCase().includes(lower),
138
+ ]) {
139
+ const hits = [];
140
+ ids.forEach((id, i) => {
141
+ if (match(id)) hits.push(i);
142
+ });
143
+ if (hits.length === 1) return { index: hits[0] };
144
+ if (hits.length > 1) {
145
+ const rows = hits
146
+ .map((i) => graph.modules[i])
147
+ .sort((a, b) => b.retainedBytes - a.retainedBytes || a.id.localeCompare(b.id))
148
+ .slice(0, 8)
149
+ .map((m) => m.id);
150
+ return { ambiguous: rows };
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+ // --- edge-override overlay engine ------------------------------------------------
157
+ // The initial load = everything reachable from the entries over STATIC edges. Every
158
+ // deferral question ("what leaves if these import edges become dynamic / vanish?") is
159
+ // answered by recomputing that forward reachability with the affected edges skipped —
160
+ // no graph mutation. This is the read-only overlay unigraph's EdgeOverrides provides,
161
+ // and the substrate `cut` and `graph-diff` price their answers with.
162
+
163
+ /** Indices of the entry modules (cached on the graph). Ids not present are skipped. */
164
+ function entryIndices(graph) {
165
+ if (graph.__entryIdx) return graph.__entryIdx;
166
+ const idOf = new Map(graph.modules.map((m, i) => [m.id, i]));
167
+ const idx = (graph.entryModules ?? []).map((id) => idOf.get(id)).filter((i) => i != null);
168
+ graph.__entryIdx = idx;
169
+ return idx;
170
+ }
171
+
172
+ /**
173
+ * The eager set: module indices reachable from the roots over static edges, with the
174
+ * `overrides` edges skipped. `overrides` is an array of `{ from, to, kind }` (both
175
+ * 'defer' and 'remove' stop the edge pinning `to` eager). `extraRoots` seeds extra
176
+ * always-eager roots (used to model `--keep` sentries: a kept module and everything
177
+ * only it needs stay eager even after the cut). The bare call (entries only, no
178
+ * overrides) is the base eager set and is cached on the graph object — do not mutate
179
+ * the returned Set.
180
+ */
181
+ export function eagerSet(graph, overrides = [], extraRoots = null) {
182
+ const n = graph.modules.length;
183
+ const bare = overrides.length === 0 && extraRoots == null;
184
+ if (bare && graph.__baseEager) return graph.__baseEager;
185
+ // Key edges as from*n + to in a Set — no string allocation on 50k-module graphs.
186
+ const blocked = overrides.length ? new Set(overrides.map((o) => o.from * n + o.to)) : null;
187
+ const roots = extraRoots == null ? entryIndices(graph) : [...entryIndices(graph), ...extraRoots];
188
+ const seen = new Set();
189
+ const stack = [];
190
+ for (const r of roots) {
191
+ if (r != null && !seen.has(r)) {
192
+ seen.add(r);
193
+ stack.push(r);
194
+ }
195
+ }
196
+ while (stack.length > 0) {
197
+ const v = stack.pop();
198
+ for (const [to, isDynamic] of graph.modules[v].imports ?? []) {
199
+ if (isDynamic) continue;
200
+ if (blocked && blocked.has(v * n + to)) continue;
201
+ if (!seen.has(to)) {
202
+ seen.add(to);
203
+ stack.push(to);
204
+ }
205
+ }
206
+ }
207
+ if (bare) graph.__baseEager = seen;
208
+ return seen;
209
+ }
210
+
211
+ /**
212
+ * The graph as the runtime loads it when `roots` (module indices) always load at
213
+ * startup: a Durable Object class the runtime loads by name, or a dynamic import the
214
+ * app runs on every boot (`import('./initReact')`). The roots join the entries, so the
215
+ * initial load becomes the static closure of entries + roots, and `staticReachable`,
216
+ * `dynamicOnly` and the static predecessor lists are recomputed for it. Every query
217
+ * (`whatIf`, `evalOverrides`, `minCut`) then answers for that startup load, also for a
218
+ * module that only dynamic imports reach in the build. Module objects are copied, never
219
+ * mutated; `retainedBytes` / `idom` keep the build's entry-only values. Returns the
220
+ * graph itself when no root adds anything.
221
+ */
222
+ export function withRoots(graph, roots) {
223
+ const entries = new Set(entryIndices(graph));
224
+ const extra = [...new Set(roots ?? [])].filter((r) => !entries.has(r));
225
+ if (!extra.length) return graph;
226
+ const eager = eagerSet(graph, [], extra);
227
+ const modules = graph.modules.map((mod, i) => ({
228
+ ...mod,
229
+ staticReachable: eager.has(i),
230
+ // Reachable at all = statically reachable or dynamic-only in the build; the roots
231
+ // are reachable themselves, so nothing new becomes reachable, only eager.
232
+ dynamicOnly: !eager.has(i) && (mod.staticReachable === true || mod.dynamicOnly === true),
233
+ }));
234
+ const { __entryIdx, __baseEager, ...rest } = graph;
235
+ return {
236
+ ...rest,
237
+ entryModules: [...(graph.entryModules ?? []), ...extra.map((r) => graph.modules[r].id)],
238
+ rootModules: extra.map((r) => graph.modules[r].id),
239
+ modules,
240
+ staticPreds: computeStaticPreds(modules),
241
+ };
242
+ }
243
+
244
+ /**
245
+ * Price a batch of edge overrides against the base (current) eager set. Returns the
246
+ * resulting eager set and its bytes, plus what LEFT the initial load: `removed` is the
247
+ * module objects sorted `bytes desc, id asc` (matching the display order), with total
248
+ * bytes and count. `extraRoots` models `--keep` sentries (see `eagerSet`).
249
+ */
250
+ export function evalOverrides(graph, overrides, extraRoots = null) {
251
+ const mods = graph.modules;
252
+ const base = eagerSet(graph);
253
+ const next = eagerSet(graph, overrides, extraRoots);
254
+ let eagerBytes = 0;
255
+ for (const v of next) eagerBytes += mods[v].bytes;
256
+ const removed = [];
257
+ for (const v of base) if (!next.has(v)) removed.push(mods[v]);
258
+ removed.sort((a, b) => b.bytes - a.bytes || a.id.localeCompare(b.id));
259
+ const removedBytes = removed.reduce((sum, m) => sum + m.bytes, 0);
260
+ return { eager: next, eagerBytes, removed, removedBytes, removedCount: removed.length };
261
+ }
262
+
263
+ /**
264
+ * Module-level shorthand: override every static import edge into `target` to 'defer'.
265
+ * These are exactly the edges the what-if module query cuts. (`keep` is accepted for a
266
+ * symmetric signature but is applied by the caller as `extraRoots`, not here — the cut
267
+ * itself is the same set of edges regardless of sentries.)
268
+ */
269
+ export function deferAllInto(graph, target) {
270
+ return graph.staticPreds[target].map((p) => ({ from: p, to: target, kind: 'defer' }));
271
+ }
272
+
273
+ /**
274
+ * The what-if-deferred closure: every module that would leave the initial load if all
275
+ * static import edges into `target` became dynamic. `keep` modules are sentries — they
276
+ * (and everything only they need) stay eager. Thin wrapper over the overlay: deferring
277
+ * `target`'s in-edges and recomputing forward reachability (with sentries re-seeded as
278
+ * roots) IS the unique-reachable set.
279
+ *
280
+ * An entry target returns `isEntry: true` with an empty result: an entry has no eager
281
+ * in-edge to cut — it IS the initial load. (Consolidated 2026-07-19: the pre-overlay
282
+ * grow/prune closure answered this case with the entry's whole forward subtree and
283
+ * zero cut edges, which read as "removable" but described deleting the entry, not
284
+ * deferring it. The old implementation survives only as the test oracle.)
285
+ */
286
+ export function whatIf(graph, target, keep = []) {
287
+ const mods = graph.modules;
288
+ const base = {
289
+ target: mods[target],
290
+ removed: [],
291
+ removedBytes: 0,
292
+ removedCount: 0,
293
+ cutEdges: [],
294
+ alreadyLazy: mods[target].dynamicOnly === true,
295
+ notStaticallyReachable: mods[target].staticReachable === false,
296
+ isEntry: entryIndices(graph).includes(target),
297
+ };
298
+ // Targets with no eager in-edge to cut: entries (they are roots), and modules that
299
+ // cost the initial load nothing (dynamic-only or unreachable). The caller reports the
300
+ // flag and never shows a removed set.
301
+ if (base.isEntry || base.notStaticallyReachable) return base;
302
+ // Sentries stay eager via extraRoots; the target itself is never its own sentry.
303
+ const extraRoots = keep.filter((k) => k !== target);
304
+ const {
305
+ eager: next,
306
+ removed,
307
+ removedBytes,
308
+ removedCount,
309
+ } = evalOverrides(graph, deferAllInto(graph, target), extraRoots.length ? extraRoots : null);
310
+ // Cut edges are the target's static importers that STAY eager (all static preds are
311
+ // themselves eager, so "survives" = "still in the next eager set"); an importer that
312
+ // is itself removed needs no edit — it is gone from the initial load anyway.
313
+ const cutEdges = graph.staticPreds[target]
314
+ .filter((p) => next.has(p))
315
+ .map((p) => mods[p].id)
316
+ .sort();
317
+ return { ...base, removed, removedBytes, removedCount, cutEdges };
318
+ }
@@ -0,0 +1,84 @@
1
+ // Request source locations and browser priorities only. Never retain headers,
2
+ // cookies, or request bodies from CDP events.
3
+ export function recordNetworkEvidence(page) {
4
+ const byUrl = new Map();
5
+ const byId = new Map();
6
+ const withoutFragment = (url) => url.split('#')[0];
7
+ const stopRequests = page.on('Network.requestWillBeSent', (event) => {
8
+ const initiator = event.initiator ?? {};
9
+ let stack = initiator.stack;
10
+ let frame;
11
+ while (stack && !frame) {
12
+ frame = stack.callFrames?.find((f) => f.url && f.url !== 'about:client');
13
+ stack = stack.parent;
14
+ }
15
+ const row = {
16
+ start: event.timestamp,
17
+ redirected: Boolean(event.redirectResponse),
18
+ initiator: {
19
+ type: initiator.type,
20
+ url: frame?.url ?? initiator.url ?? null,
21
+ lineNumber: frame?.lineNumber ?? initiator.lineNumber ?? null,
22
+ columnNumber: frame?.columnNumber ?? initiator.columnNumber ?? null,
23
+ },
24
+ requestPriority: {
25
+ initial: event.request.initialPriority ?? null,
26
+ last: event.request.initialPriority ?? null,
27
+ changes: [],
28
+ },
29
+ };
30
+ if (event.redirectResponse && byId.has(event.requestId)) {
31
+ byId.get(event.requestId).redirected = true;
32
+ }
33
+ byId.set(event.requestId, row);
34
+ const key = withoutFragment(event.request.url);
35
+ const rows = byUrl.get(key) ?? [];
36
+ rows.push(row);
37
+ byUrl.set(key, rows);
38
+ });
39
+ const stopPriorities = page.on('Network.resourceChangedPriority', (event) => {
40
+ const row = byId.get(event.requestId);
41
+ if (!row) return;
42
+ row.requestPriority.last = event.newPriority;
43
+ row.requestPriority.changes.push({
44
+ priority: event.newPriority,
45
+ afterStartMs: Math.round((event.timestamp - row.start) * 1000),
46
+ });
47
+ });
48
+ return {
49
+ attach(resources, origin) {
50
+ const keyOf = (resource) =>
51
+ withoutFragment(
52
+ resource.name.startsWith('/') ? `${origin}${resource.name}` : resource.name,
53
+ );
54
+ const counts = new Map();
55
+ for (const resource of resources) {
56
+ const key = keyOf(resource);
57
+ counts.set(key, (counts.get(key) ?? 0) + 1);
58
+ }
59
+ for (const resource of resources) {
60
+ const key = keyOf(resource);
61
+ const rows = byUrl.get(key) ?? [];
62
+ // URL alone cannot distinguish repeated requests or redirect hops.
63
+ // Omit ambiguous evidence instead of attaching the last request to all.
64
+ if (rows.length !== 1 || counts.get(key) !== 1 || rows[0].redirected) {
65
+ resource.networkEvidence = rows.length ? 'ambiguous' : 'missing';
66
+ continue;
67
+ }
68
+ const row = rows[0];
69
+ resource.networkEvidence = 'matched';
70
+ resource.initiator = {
71
+ ...row.initiator,
72
+ url: row.initiator.url?.startsWith(`${origin}/`)
73
+ ? row.initiator.url.slice(origin.length)
74
+ : row.initiator.url,
75
+ };
76
+ resource.requestPriority = row.requestPriority;
77
+ }
78
+ },
79
+ stop() {
80
+ stopRequests();
81
+ stopPriorities();
82
+ },
83
+ };
84
+ }