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.
- package/AGENTS.md +345 -0
- package/LICENSE +25 -0
- package/README.md +621 -2
- package/harness.mjs +4860 -0
- package/lib/build.mjs +57 -0
- package/lib/cdp.mjs +217 -0
- package/lib/chunk-discovery.mjs +226 -0
- package/lib/coverage.mjs +498 -0
- package/lib/field/analysis.mjs +225 -0
- package/lib/field/browser.mjs +232 -0
- package/lib/field/build-match.mjs +86 -0
- package/lib/field/capture.mjs +501 -0
- package/lib/field/config.mjs +84 -0
- package/lib/field/run.mjs +612 -0
- package/lib/gen-app.mjs +399 -0
- package/lib/graph-diff.mjs +136 -0
- package/lib/interrupt.mjs +25 -0
- package/lib/measure.mjs +540 -0
- package/lib/min-cut.mjs +218 -0
- package/lib/module-graph.mjs +318 -0
- package/lib/network-evidence.mjs +84 -0
- package/lib/node/launch.mjs +351 -0
- package/lib/node/measure.mjs +289 -0
- package/lib/paint-diagnostics.mjs +76 -0
- package/lib/parity/anchor.mjs +186 -0
- package/lib/parity/capture.mjs +527 -0
- package/lib/parity/compare.mjs +813 -0
- package/lib/parity/png.mjs +166 -0
- package/lib/parity/run.mjs +145 -0
- package/lib/profile.mjs +281 -0
- package/lib/replay/cert.mjs +45 -0
- package/lib/replay/model.mjs +656 -0
- package/lib/replay/run.mjs +1140 -0
- package/lib/replay/server.mjs +499 -0
- package/lib/replay/shaper.mjs +158 -0
- package/lib/rolldown-support.mjs +161 -0
- package/lib/serve.mjs +142 -0
- package/lib/worker/attrib.mjs +125 -0
- package/lib/worker/config.mjs +116 -0
- package/lib/worker/launch.mjs +337 -0
- package/lib/worker/measure.mjs +296 -0
- package/package.json +38 -4
- package/replay.mjs +79 -0
package/lib/gen-app.mjs
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Generates the demo app the harness measures and mutates, plus the marker-based
|
|
2
|
+
// defer/undefer codemod that implements the loop's "lazy-load this module" step.
|
|
3
|
+
//
|
|
4
|
+
// Design constraints that keep coverage-driven candidate detection honest:
|
|
5
|
+
// - All heavy weight sits INSIDE function bodies, never in top-level literals. V8
|
|
6
|
+
// precise coverage marks a module's top level as executed the moment it evaluates,
|
|
7
|
+
// so top-level data would look "used before paint" even when nothing reads it;
|
|
8
|
+
// function-body weight cleanly separates "parsed" from "ran before paint".
|
|
9
|
+
// - The page is client-rendered: LCP is the hero <h1> painted by main.ts, so entry
|
|
10
|
+
// chunk download + parse directly gates LCP.
|
|
11
|
+
// - Every feature reports readiness on window.__ready, so a measurement can assert
|
|
12
|
+
// that a defer did not break the page (the guard) before accepting it.
|
|
13
|
+
// - hero_data is the trap: structurally deferrable (it has a marker block) but its
|
|
14
|
+
// bytes execute before first paint — coverage must exclude it, not the harness.
|
|
15
|
+
|
|
16
|
+
import fs from 'node:fs';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
|
|
19
|
+
// Feature registry: marker-block templates for baseline (static import) and
|
|
20
|
+
// deferred (dynamic import after first paint) forms. `kb` ~= generated source KB.
|
|
21
|
+
export const FEATURES = {
|
|
22
|
+
hero_data: {
|
|
23
|
+
kb: 25,
|
|
24
|
+
seed: 505,
|
|
25
|
+
baseline: [
|
|
26
|
+
'import { heroSubtitle } from "./features/hero_data";',
|
|
27
|
+
'setHeroSubtitle(heroSubtitle());',
|
|
28
|
+
'readyFlags.hero_data = true;',
|
|
29
|
+
],
|
|
30
|
+
deferred: [
|
|
31
|
+
'void import("./features/hero_data").then((m) => {',
|
|
32
|
+
' setHeroSubtitle(m.heroSubtitle());',
|
|
33
|
+
' readyFlags.hero_data = true;',
|
|
34
|
+
'});',
|
|
35
|
+
],
|
|
36
|
+
},
|
|
37
|
+
charts: {
|
|
38
|
+
kb: 150,
|
|
39
|
+
seed: 101,
|
|
40
|
+
baseline: [
|
|
41
|
+
'import { initCharts } from "./features/charts";',
|
|
42
|
+
'scheduleInit("charts", initCharts);',
|
|
43
|
+
],
|
|
44
|
+
deferred: [
|
|
45
|
+
'scheduleInit("charts", () => import("./features/charts").then((m) => m.initCharts()));',
|
|
46
|
+
],
|
|
47
|
+
},
|
|
48
|
+
markdown: {
|
|
49
|
+
kb: 110,
|
|
50
|
+
seed: 202,
|
|
51
|
+
baseline: [
|
|
52
|
+
'import { initMarkdown } from "./features/markdown";',
|
|
53
|
+
'scheduleInit("markdown", initMarkdown);',
|
|
54
|
+
],
|
|
55
|
+
deferred: [
|
|
56
|
+
'scheduleInit("markdown", () => import("./features/markdown").then((m) => m.initMarkdown()));',
|
|
57
|
+
],
|
|
58
|
+
},
|
|
59
|
+
analytics: {
|
|
60
|
+
kb: 60,
|
|
61
|
+
seed: 303,
|
|
62
|
+
baseline: [
|
|
63
|
+
'import { initAnalytics } from "./features/analytics";',
|
|
64
|
+
'scheduleInit("analytics", initAnalytics);',
|
|
65
|
+
],
|
|
66
|
+
deferred: [
|
|
67
|
+
'scheduleInit("analytics", () => import("./features/analytics").then((m) => m.initAnalytics()));',
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
badges: {
|
|
71
|
+
kb: 4,
|
|
72
|
+
seed: 404,
|
|
73
|
+
baseline: [
|
|
74
|
+
'import { initBadges } from "./features/badges";',
|
|
75
|
+
'scheduleInit("badges", initBadges);',
|
|
76
|
+
],
|
|
77
|
+
deferred: [
|
|
78
|
+
'scheduleInit("badges", () => import("./features/badges").then((m) => m.initBadges()));',
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
export const FEATURE_NAMES = Object.keys(FEATURES);
|
|
84
|
+
|
|
85
|
+
// Deterministic PRNG so regenerated apps are byte-identical.
|
|
86
|
+
function mulberry32(seed) {
|
|
87
|
+
return function next() {
|
|
88
|
+
seed |= 0;
|
|
89
|
+
seed = (seed + 0x6d2b79f5) | 0;
|
|
90
|
+
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
|
|
91
|
+
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
|
|
92
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
|
97
|
+
|
|
98
|
+
// ~1KB per block function: a string table plus a mixing loop. ASCII only, so V8
|
|
99
|
+
// coverage offsets (UTF-16 units) equal byte offsets in the emitted chunk.
|
|
100
|
+
function heavyFunctions(name, kb, seed) {
|
|
101
|
+
const rand = mulberry32(seed);
|
|
102
|
+
const parts = [];
|
|
103
|
+
for (let i = 0; i < kb; i++) {
|
|
104
|
+
const words = [];
|
|
105
|
+
for (let w = 0; w < 24; w++) {
|
|
106
|
+
let s = '';
|
|
107
|
+
for (let c = 0; c < 24; c++) s += ALPHABET[Math.floor(rand() * ALPHABET.length)];
|
|
108
|
+
words.push('"' + s + '"');
|
|
109
|
+
}
|
|
110
|
+
parts.push(
|
|
111
|
+
`export function ${name}_block_${i}(x: number): number {\n` +
|
|
112
|
+
` const table = [${words.join(', ')}];\n` +
|
|
113
|
+
' let acc = x | 0;\n' +
|
|
114
|
+
' for (let i = 0; i < table.length; i++) {\n' +
|
|
115
|
+
' const s = table[i];\n' +
|
|
116
|
+
' for (let j = 0; j < s.length; j++) {\n' +
|
|
117
|
+
` acc = (acc * 31 + s.charCodeAt(j) + ${i}) | 0;\n` +
|
|
118
|
+
' }\n' +
|
|
119
|
+
' }\n' +
|
|
120
|
+
' return acc;\n' +
|
|
121
|
+
'}\n',
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
const calls = [];
|
|
125
|
+
for (let i = 0; i < kb; i++) calls.push(` acc = (acc + ${name}_block_${i}(acc)) | 0;`);
|
|
126
|
+
parts.push(
|
|
127
|
+
`export function ${name}_run_all(seed: number): number {\n` +
|
|
128
|
+
' let acc = seed | 0;\n' +
|
|
129
|
+
`${calls.join('\n')}\n` +
|
|
130
|
+
' return acc;\n' +
|
|
131
|
+
'}\n',
|
|
132
|
+
);
|
|
133
|
+
return parts.join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const INDEX_HTML = `<!doctype html>
|
|
137
|
+
<html lang="en">
|
|
138
|
+
<head>
|
|
139
|
+
<meta charset="utf-8" />
|
|
140
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
141
|
+
<title>Quokka Analytics - demo storefront</title>
|
|
142
|
+
<style>
|
|
143
|
+
body { margin: 0; font-family: system-ui, sans-serif; color: #1a2233; }
|
|
144
|
+
nav { display: flex; gap: 24px; padding: 18px 32px; border-bottom: 1px solid #e3e8f0; }
|
|
145
|
+
nav a { color: #45526b; text-decoration: none; }
|
|
146
|
+
#hero { padding: 72px 32px 48px; max-width: 900px; }
|
|
147
|
+
#hero-title { font-size: 52px; line-height: 1.1; margin: 0 0 16px; min-height: 114px; }
|
|
148
|
+
#hero-subtitle { font-size: 20px; color: #45526b; margin: 0 0 24px; min-height: 26px; }
|
|
149
|
+
#hero-cta { font-size: 18px; padding: 12px 28px; }
|
|
150
|
+
.below { margin: 900px 32px 40px; }
|
|
151
|
+
#chart-slot { display: flex; align-items: flex-end; gap: 3px; height: 120px; }
|
|
152
|
+
#chart-slot .bar { width: 10px; background: #4a6cf7; }
|
|
153
|
+
#md-slot { min-height: 40px; color: #45526b; }
|
|
154
|
+
.badge { background: #e8ecff; padding: 2px 8px; border-radius: 10px; font-size: 12px; }
|
|
155
|
+
</style>
|
|
156
|
+
</head>
|
|
157
|
+
<body>
|
|
158
|
+
<div id="app"></div>
|
|
159
|
+
<script type="module" src="./main.js"></script>
|
|
160
|
+
</body>
|
|
161
|
+
</html>
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
const BOOT_TS = `// Shared boot utilities: post-paint scheduling and per-feature readiness flags.
|
|
165
|
+
export const readyFlags: Record<string, boolean> =
|
|
166
|
+
((window as unknown as { __ready: Record<string, boolean> }).__ready = {});
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Run a side-band feature init strictly after first paint: window load, two
|
|
170
|
+
* animation frames (paint has happened), then a generous tick. Both the
|
|
171
|
+
* static-import baseline and the dynamic-import deferred form go through this
|
|
172
|
+
* same path, so a measurement compares only download/parse cost, never init
|
|
173
|
+
* scheduling. The 250ms tick also gives a coverage snapshot polling for FCP a
|
|
174
|
+
* wide margin to land before any feature code runs.
|
|
175
|
+
*/
|
|
176
|
+
export function scheduleInit(name: string, fn: () => unknown): void {
|
|
177
|
+
readyFlags[name] = false;
|
|
178
|
+
const run = () => {
|
|
179
|
+
requestAnimationFrame(() => {
|
|
180
|
+
requestAnimationFrame(() => {
|
|
181
|
+
setTimeout(() => {
|
|
182
|
+
void Promise.resolve()
|
|
183
|
+
.then(fn)
|
|
184
|
+
.catch((err) => console.error("[init:" + name + "]", err))
|
|
185
|
+
.finally(() => {
|
|
186
|
+
readyFlags[name] = true;
|
|
187
|
+
});
|
|
188
|
+
}, 250);
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
if (document.readyState === "complete") run();
|
|
193
|
+
else window.addEventListener("load", run, { once: true });
|
|
194
|
+
}
|
|
195
|
+
`;
|
|
196
|
+
|
|
197
|
+
function i18nTs() {
|
|
198
|
+
return `// Locale layer. The string table is built INSIDE a function, but that function
|
|
199
|
+
// runs at startup (before first paint) because the hero needs translated copy.
|
|
200
|
+
// Coverage therefore reports this module as used-before-paint: not a defer candidate.
|
|
201
|
+
${heavyFunctions('i18n', 40, 606)}
|
|
202
|
+
const strings = new Map<string, string>();
|
|
203
|
+
|
|
204
|
+
export function initI18n(): void {
|
|
205
|
+
const checksum = i18n_run_all(11);
|
|
206
|
+
strings.set("hero.title", "Ship faster with Quokka Analytics");
|
|
207
|
+
strings.set("hero.cta", "Start free trial");
|
|
208
|
+
strings.set("footer.note", "chk:" + (checksum >>> 0).toString(16));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
export function t(key: string): string {
|
|
212
|
+
return strings.get(key) ?? key;
|
|
213
|
+
}
|
|
214
|
+
`;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
const RENDER_TS = `// Critical-path rendering: builds the shell and paints the hero (the LCP element).
|
|
218
|
+
export type Translate = (key: string) => string;
|
|
219
|
+
|
|
220
|
+
export function renderShell(): void {
|
|
221
|
+
const app = document.getElementById("app");
|
|
222
|
+
if (!app) return;
|
|
223
|
+
app.textContent = "";
|
|
224
|
+
|
|
225
|
+
const nav = document.createElement("nav");
|
|
226
|
+
for (const label of ["Quokka", "Docs", "Pricing", "Blog"]) {
|
|
227
|
+
const a = document.createElement("a");
|
|
228
|
+
a.href = "#";
|
|
229
|
+
a.textContent = label;
|
|
230
|
+
nav.appendChild(a);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const hero = document.createElement("section");
|
|
234
|
+
hero.id = "hero";
|
|
235
|
+
const title = document.createElement("h1");
|
|
236
|
+
title.id = "hero-title";
|
|
237
|
+
const subtitle = document.createElement("p");
|
|
238
|
+
subtitle.id = "hero-subtitle";
|
|
239
|
+
const cta = document.createElement("button");
|
|
240
|
+
cta.id = "hero-cta";
|
|
241
|
+
hero.append(title, subtitle, cta);
|
|
242
|
+
|
|
243
|
+
const below = document.createElement("section");
|
|
244
|
+
below.className = "below";
|
|
245
|
+
const chartSlot = document.createElement("div");
|
|
246
|
+
chartSlot.id = "chart-slot";
|
|
247
|
+
const mdBtn = document.createElement("button");
|
|
248
|
+
mdBtn.id = "md-btn";
|
|
249
|
+
mdBtn.textContent = "Render changelog";
|
|
250
|
+
const mdSlot = document.createElement("div");
|
|
251
|
+
mdSlot.id = "md-slot";
|
|
252
|
+
const badgeSlot = document.createElement("div");
|
|
253
|
+
badgeSlot.id = "badge-slot";
|
|
254
|
+
below.append(chartSlot, mdBtn, mdSlot, badgeSlot);
|
|
255
|
+
|
|
256
|
+
app.append(nav, hero, below);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function renderHero(t: Translate): void {
|
|
260
|
+
const title = document.getElementById("hero-title");
|
|
261
|
+
if (title) title.textContent = t("hero.title");
|
|
262
|
+
const cta = document.getElementById("hero-cta");
|
|
263
|
+
if (cta) cta.textContent = t("hero.cta");
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function setHeroSubtitle(text: string): void {
|
|
267
|
+
const subtitle = document.getElementById("hero-subtitle");
|
|
268
|
+
if (subtitle) subtitle.textContent = text;
|
|
269
|
+
}
|
|
270
|
+
`;
|
|
271
|
+
|
|
272
|
+
function featureTs(name) {
|
|
273
|
+
const heavy = heavyFunctions(name, FEATURES[name].kb, FEATURES[name].seed);
|
|
274
|
+
const inits = {
|
|
275
|
+
hero_data: `export function heroSubtitle(): string {
|
|
276
|
+
const n = hero_data_run_all(3);
|
|
277
|
+
return "Handcrafted insights, checksum " + (n >>> 0).toString(16) + ", refreshed daily.";
|
|
278
|
+
}
|
|
279
|
+
`,
|
|
280
|
+
charts: `export function initCharts(): void {
|
|
281
|
+
const slot = document.getElementById("chart-slot");
|
|
282
|
+
if (!slot) return;
|
|
283
|
+
let acc = 7;
|
|
284
|
+
for (let i = 0; i < 40; i++) {
|
|
285
|
+
acc = Math.abs(charts_run_all(acc + i)) % 96;
|
|
286
|
+
const bar = document.createElement("div");
|
|
287
|
+
bar.className = "bar";
|
|
288
|
+
bar.style.height = (4 + acc) + "px";
|
|
289
|
+
slot.appendChild(bar);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
`,
|
|
293
|
+
markdown: `export function initMarkdown(): void {
|
|
294
|
+
const btn = document.getElementById("md-btn");
|
|
295
|
+
const slot = document.getElementById("md-slot");
|
|
296
|
+
if (!btn || !slot) return;
|
|
297
|
+
btn.addEventListener("click", () => {
|
|
298
|
+
// The heavy path runs only on user interaction, never during load.
|
|
299
|
+
const n = markdown_run_all(42);
|
|
300
|
+
slot.textContent = "rendered:" + (n >>> 0).toString(16);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
`,
|
|
304
|
+
analytics: `export function initAnalytics(): void {
|
|
305
|
+
const n = analytics_run_all(1234);
|
|
306
|
+
document.documentElement.dataset.analytics = (n >>> 0).toString(16);
|
|
307
|
+
}
|
|
308
|
+
`,
|
|
309
|
+
badges: `export function initBadges(): void {
|
|
310
|
+
const slot = document.getElementById("badge-slot");
|
|
311
|
+
if (!slot) return;
|
|
312
|
+
const n = badges_run_all(9);
|
|
313
|
+
const badge = document.createElement("span");
|
|
314
|
+
badge.className = "badge";
|
|
315
|
+
badge.textContent = "v" + (Math.abs(n) % 10) + "." + (Math.abs(n >> 4) % 10);
|
|
316
|
+
slot.appendChild(badge);
|
|
317
|
+
}
|
|
318
|
+
`,
|
|
319
|
+
};
|
|
320
|
+
return heavy + '\n' + inits[name];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function featureBlock(name, mode) {
|
|
324
|
+
return [`// <feature:${name}>`, ...FEATURES[name][mode], `// </feature:${name}>`].join('\n');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function mainTs() {
|
|
328
|
+
const blocks = FEATURE_NAMES.map((name) => featureBlock(name, 'baseline')).join('\n\n');
|
|
329
|
+
return `import { readyFlags, scheduleInit } from "./boot";
|
|
330
|
+
import { initI18n, t } from "./i18n";
|
|
331
|
+
import { renderHero, renderShell, setHeroSubtitle } from "./render";
|
|
332
|
+
|
|
333
|
+
initI18n();
|
|
334
|
+
renderShell();
|
|
335
|
+
renderHero(t);
|
|
336
|
+
|
|
337
|
+
${blocks}
|
|
338
|
+
|
|
339
|
+
console.log("[app] booted");
|
|
340
|
+
`;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
export function generateApp(appDir, { force = false } = {}) {
|
|
344
|
+
const mainPath = path.join(appDir, 'src', 'main.ts');
|
|
345
|
+
if (fs.existsSync(mainPath) && !force) {
|
|
346
|
+
return { written: false, reason: 'app already exists (use --force to regenerate)' };
|
|
347
|
+
}
|
|
348
|
+
fs.rmSync(appDir, { recursive: true, force: true });
|
|
349
|
+
fs.mkdirSync(path.join(appDir, 'src', 'features'), { recursive: true });
|
|
350
|
+
fs.writeFileSync(path.join(appDir, 'index.html'), INDEX_HTML);
|
|
351
|
+
fs.writeFileSync(path.join(appDir, 'src', 'boot.ts'), BOOT_TS);
|
|
352
|
+
fs.writeFileSync(path.join(appDir, 'src', 'i18n.ts'), i18nTs());
|
|
353
|
+
fs.writeFileSync(path.join(appDir, 'src', 'render.ts'), RENDER_TS);
|
|
354
|
+
fs.writeFileSync(mainPath, mainTs());
|
|
355
|
+
for (const name of FEATURE_NAMES) {
|
|
356
|
+
fs.writeFileSync(path.join(appDir, 'src', 'features', `${name}.ts`), featureTs(name));
|
|
357
|
+
}
|
|
358
|
+
return { written: true };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/** Rewrite one feature's marker block in main.ts to its baseline or deferred form. */
|
|
362
|
+
export function setFeatureMode(appDir, feature, mode) {
|
|
363
|
+
if (!FEATURES[feature]) {
|
|
364
|
+
throw new Error(`unknown feature "${feature}" (known: ${FEATURE_NAMES.join(', ')})`);
|
|
365
|
+
}
|
|
366
|
+
if (mode !== 'baseline' && mode !== 'deferred') throw new Error(`bad mode "${mode}"`);
|
|
367
|
+
const mainPath = path.join(appDir, 'src', 'main.ts');
|
|
368
|
+
const src = fs.readFileSync(mainPath, 'utf8');
|
|
369
|
+
const open = `// <feature:${feature}>`;
|
|
370
|
+
const close = `// </feature:${feature}>`;
|
|
371
|
+
const start = src.indexOf(open);
|
|
372
|
+
const end = src.indexOf(close);
|
|
373
|
+
if (start < 0 || end < 0)
|
|
374
|
+
throw new Error(`feature block markers for "${feature}" not found in main.ts`);
|
|
375
|
+
const next = src.slice(0, start) + featureBlock(feature, mode) + src.slice(end + close.length);
|
|
376
|
+
if (next !== src) fs.writeFileSync(mainPath, next);
|
|
377
|
+
return { changed: next !== src };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** Parse main.ts marker blocks -> { feature: 'baseline' | 'deferred' }. */
|
|
381
|
+
export function featureModes(appDir) {
|
|
382
|
+
const mainPath = path.join(appDir, 'src', 'main.ts');
|
|
383
|
+
if (!fs.existsSync(mainPath)) return null;
|
|
384
|
+
const src = fs.readFileSync(mainPath, 'utf8');
|
|
385
|
+
const modes = {};
|
|
386
|
+
for (const name of FEATURE_NAMES) {
|
|
387
|
+
const open = `// <feature:${name}>`;
|
|
388
|
+
const close = `// </feature:${name}>`;
|
|
389
|
+
const start = src.indexOf(open);
|
|
390
|
+
const end = src.indexOf(close);
|
|
391
|
+
if (start < 0 || end < 0) {
|
|
392
|
+
modes[name] = 'missing';
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
const block = src.slice(start + open.length, end);
|
|
396
|
+
modes[name] = block.includes(`import("./features/${name}")`) ? 'deferred' : 'baseline';
|
|
397
|
+
}
|
|
398
|
+
return modes;
|
|
399
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
// Deterministic diff of two module-graph sidecars: what a change did to the INITIAL
|
|
2
|
+
// LOAD (the eager set), before paying a throttled measure. Alignment is by module id
|
|
3
|
+
// (indices shift between builds), ids slash-normalized. The headline is the eager-tier
|
|
4
|
+
// diff — modules that ENTERED or LEFT the initial load — computed with the same overlay
|
|
5
|
+
// `eagerSet` the rest of the kit uses, so it agrees with what-if's predictions.
|
|
6
|
+
//
|
|
7
|
+
// Attribution is exclusion-aware (unigraph's TwinGraph "+1 not +8" rule): a new module
|
|
8
|
+
// that reuses 7 pre-existing deps attributes 1 changed module, not 8 — because the set
|
|
9
|
+
// difference only counts nodes that themselves entered/left. Grouping folds changed
|
|
10
|
+
// modules under their nearest changed idom-ancestor and reports untouched intermediates
|
|
11
|
+
// as a skip count instead of listing them (token-cheap for agents).
|
|
12
|
+
|
|
13
|
+
import { eagerSet } from './module-graph.mjs';
|
|
14
|
+
|
|
15
|
+
const norm = (id) => id.replaceAll('\\', '/');
|
|
16
|
+
const byBytesDesc = (a, b) => b.bytes - a.bytes || a.id.localeCompare(b.id);
|
|
17
|
+
|
|
18
|
+
/** Eager module ids (slash-normalized) for a loaded graph. */
|
|
19
|
+
function eagerIds(graph) {
|
|
20
|
+
const ids = new Set();
|
|
21
|
+
for (const i of eagerSet(graph)) ids.add(norm(graph.modules[i].id));
|
|
22
|
+
return ids;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Static import target ids of a module (dynamic edges don't shape the eager structure).
|
|
26
|
+
function staticImportIds(graph, mod) {
|
|
27
|
+
const ids = new Set();
|
|
28
|
+
for (const [to, isDynamic] of mod.imports ?? []) {
|
|
29
|
+
if (!isDynamic) ids.add(norm(graph.modules[to].id));
|
|
30
|
+
}
|
|
31
|
+
return ids;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Fold changed modules under their nearest changed idom-ancestor. Each returned group is
|
|
35
|
+
// keyed by the TOPMOST changed ancestor on the chain; `skipped` counts the unchanged
|
|
36
|
+
// intermediate modules between members and that root (the ones we don't list).
|
|
37
|
+
function groupByChangedAncestor(graph, changedList, changedIdSet) {
|
|
38
|
+
const idToIndex = new Map(graph.modules.map((m, i) => [norm(m.id), i]));
|
|
39
|
+
const resolve = (startId) => {
|
|
40
|
+
const idx = idToIndex.get(startId);
|
|
41
|
+
let cur = idx == null ? null : graph.modules[idx].idom;
|
|
42
|
+
let rootId = startId; // own root until a changed ancestor is found
|
|
43
|
+
const pending = [];
|
|
44
|
+
const skipped = new Set();
|
|
45
|
+
while (cur != null) {
|
|
46
|
+
const id = norm(graph.modules[cur].id);
|
|
47
|
+
if (changedIdSet.has(id)) {
|
|
48
|
+
rootId = id;
|
|
49
|
+
for (const p of pending) skipped.add(p);
|
|
50
|
+
pending.length = 0;
|
|
51
|
+
} else {
|
|
52
|
+
pending.push(id);
|
|
53
|
+
}
|
|
54
|
+
cur = graph.modules[cur].idom;
|
|
55
|
+
}
|
|
56
|
+
return { rootId, skipped };
|
|
57
|
+
};
|
|
58
|
+
const groups = new Map();
|
|
59
|
+
for (const item of changedList) {
|
|
60
|
+
const { rootId, skipped } = resolve(item.id);
|
|
61
|
+
let g = groups.get(rootId);
|
|
62
|
+
if (!g) {
|
|
63
|
+
g = { rootId, bytes: 0, count: 0, skipped: new Set() };
|
|
64
|
+
groups.set(rootId, g);
|
|
65
|
+
}
|
|
66
|
+
g.bytes += item.bytes;
|
|
67
|
+
g.count += 1;
|
|
68
|
+
for (const s of skipped) g.skipped.add(s);
|
|
69
|
+
}
|
|
70
|
+
return [...groups.values()]
|
|
71
|
+
.map((g) => ({ rootId: g.rootId, bytes: g.bytes, count: g.count, skipped: g.skipped.size }))
|
|
72
|
+
.sort((a, b) => b.bytes - a.bytes || a.rootId.localeCompare(b.rootId));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Diff `before` (e.g. the pinned baseline sidecar) against `after` (the current build).
|
|
77
|
+
* Returns node classification (added / removed / bytesChanged / edgesChanged) and the
|
|
78
|
+
* eager-tier diff (entered / left with byte totals) plus exclusion-aware groupings.
|
|
79
|
+
* Ids are identity — a rename reads as one add + one remove (no rename detection).
|
|
80
|
+
*/
|
|
81
|
+
export function diffGraphs(before, after) {
|
|
82
|
+
const beforeById = new Map(before.modules.map((m) => [norm(m.id), m]));
|
|
83
|
+
const afterById = new Map(after.modules.map((m) => [norm(m.id), m]));
|
|
84
|
+
|
|
85
|
+
const added = [];
|
|
86
|
+
const removed = [];
|
|
87
|
+
const bytesChanged = [];
|
|
88
|
+
const edgesChanged = [];
|
|
89
|
+
for (const [id, m] of afterById) {
|
|
90
|
+
if (!beforeById.has(id)) added.push({ id, bytes: m.bytes });
|
|
91
|
+
}
|
|
92
|
+
for (const [id, m] of beforeById) {
|
|
93
|
+
const am = afterById.get(id);
|
|
94
|
+
if (!am) {
|
|
95
|
+
removed.push({ id, bytes: m.bytes });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (am.bytes !== m.bytes) {
|
|
99
|
+
bytesChanged.push({ id, before: m.bytes, after: am.bytes, delta: am.bytes - m.bytes });
|
|
100
|
+
}
|
|
101
|
+
const bImp = staticImportIds(before, m);
|
|
102
|
+
const aImp = staticImportIds(after, am);
|
|
103
|
+
if (bImp.size !== aImp.size || [...aImp].some((t) => !bImp.has(t))) {
|
|
104
|
+
edgesChanged.push({ id });
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const beforeEager = eagerIds(before);
|
|
109
|
+
const afterEager = eagerIds(after);
|
|
110
|
+
const entered = [];
|
|
111
|
+
const left = [];
|
|
112
|
+
for (const id of afterEager)
|
|
113
|
+
if (!beforeEager.has(id)) entered.push({ id, bytes: afterById.get(id).bytes });
|
|
114
|
+
for (const id of beforeEager)
|
|
115
|
+
if (!afterEager.has(id)) left.push({ id, bytes: beforeById.get(id).bytes });
|
|
116
|
+
entered.sort(byBytesDesc);
|
|
117
|
+
left.sort(byBytesDesc);
|
|
118
|
+
const enteredBytes = entered.reduce((s, m) => s + m.bytes, 0);
|
|
119
|
+
const leftBytes = left.reduce((s, m) => s + m.bytes, 0);
|
|
120
|
+
|
|
121
|
+
return {
|
|
122
|
+
added: added.sort(byBytesDesc),
|
|
123
|
+
removed: removed.sort(byBytesDesc),
|
|
124
|
+
bytesChanged: bytesChanged.sort(
|
|
125
|
+
(a, b) => Math.abs(b.delta) - Math.abs(a.delta) || a.id.localeCompare(b.id),
|
|
126
|
+
),
|
|
127
|
+
edgesChanged: edgesChanged.sort((a, b) => a.id.localeCompare(b.id)),
|
|
128
|
+
entered,
|
|
129
|
+
left,
|
|
130
|
+
enteredBytes,
|
|
131
|
+
leftBytes,
|
|
132
|
+
enteredGroups: groupByChangedAncestor(after, entered, new Set(entered.map((m) => m.id))),
|
|
133
|
+
leftGroups: groupByChangedAncestor(before, left, new Set(left.map((m) => m.id))),
|
|
134
|
+
changed: entered.length + left.length + added.length + removed.length !== 0,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One SIGINT handler for the whole process. Each command registers its cleanup
|
|
2
|
+
// here instead of adding its own listener: Node calls listeners in order, and a
|
|
3
|
+
// listener that exits first would skip the cleanups registered after it (a field
|
|
4
|
+
// run would then leave its variant cookies in the user's profile).
|
|
5
|
+
|
|
6
|
+
const cleanups = new Set();
|
|
7
|
+
let installed = false;
|
|
8
|
+
|
|
9
|
+
/** Run `fn` on Ctrl-C (newest first), then exit 130. Returns a function that unregisters it. */
|
|
10
|
+
export function onInterrupt(fn) {
|
|
11
|
+
if (!installed) {
|
|
12
|
+
installed = true;
|
|
13
|
+
process.on('SIGINT', async () => {
|
|
14
|
+
for (const cleanup of [...cleanups].reverse()) {
|
|
15
|
+
cleanups.delete(cleanup);
|
|
16
|
+
await Promise.resolve()
|
|
17
|
+
.then(cleanup)
|
|
18
|
+
.catch(() => {});
|
|
19
|
+
}
|
|
20
|
+
process.exit(130);
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
cleanups.add(fn);
|
|
24
|
+
return () => cleanups.delete(fn);
|
|
25
|
+
}
|