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
package/harness.mjs ADDED
@@ -0,0 +1,4860 @@
1
+ #!/usr/bin/env node
2
+ // Browser-loading perf harness — the measurement + diagnosis primitives an agent
3
+ // drives to run the optimize loop (see README.md / AGENTS.md).
4
+ // Prototype of metrics-plan Phase 3b (lab runner) + 3c (coverage).
5
+ //
6
+ // The short version:
7
+ // node harness.mjs scan --app <appDir> everything in one go: N timed runs +
8
+ // coverage + boot profile + verdict.
9
+ // The target is remembered; afterwards
10
+ // plain `node harness.mjs scan` works.
11
+ // node harness.mjs verdict the only "done" that counts.
12
+ //
13
+ // All commands work from any working directory.
14
+
15
+ import fs from 'node:fs';
16
+ import os from 'node:os';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { parseArgs } from 'node:util';
20
+ import { spawnSync } from 'node:child_process';
21
+
22
+ import { launchBrowser } from './lib/cdp.mjs';
23
+ import { startServer } from './lib/serve.mjs';
24
+ import { buildApp } from './lib/build.mjs';
25
+ import { installCheckoutRolldown, reportSupportNote } from './lib/rolldown-support.mjs';
26
+ import {
27
+ FEATURES,
28
+ FEATURE_NAMES,
29
+ featureModes,
30
+ generateApp,
31
+ setFeatureMode,
32
+ } from './lib/gen-app.mjs';
33
+ import {
34
+ DEFAULT_THROTTLE,
35
+ deltaSection,
36
+ lcpElementOf,
37
+ parseNetworkFlags,
38
+ scaleThrottle,
39
+ summarize,
40
+ throttleFromProfile,
41
+ throttleId,
42
+ timedRun,
43
+ } from './lib/measure.mjs';
44
+ import {
45
+ COLD_OPEN_MIN_BYTES,
46
+ attributeChunks,
47
+ coldAtPaintModules,
48
+ coverageRun,
49
+ largeAtPaintModules,
50
+ siblingVariantGroups,
51
+ } from './lib/coverage.mjs';
52
+ import { aggregateProfile, profileEvidence, profileRun } from './lib/profile.mjs';
53
+ import { analyzeChunkDiscovery, loadChunkGraph } from './lib/chunk-discovery.mjs';
54
+ import {
55
+ chunkDiscoveryAdvice,
56
+ comparePaints,
57
+ renderGapEvidence,
58
+ } from './lib/paint-diagnostics.mjs';
59
+ import {
60
+ deferAllInto,
61
+ evalOverrides,
62
+ loadModuleGraph,
63
+ moduleGraphCandidates,
64
+ reportDirFor,
65
+ reportDistMismatch,
66
+ resolveModule,
67
+ whatIf,
68
+ withRoots,
69
+ } from './lib/module-graph.mjs';
70
+ import { minCut } from './lib/min-cut.mjs';
71
+ import { diffGraphs } from './lib/graph-diff.mjs';
72
+ import {
73
+ POLL_MS,
74
+ assertReadyPortFree,
75
+ describeReady,
76
+ freshCacheDir,
77
+ parseReadySpec,
78
+ runEnv,
79
+ } from './lib/node/launch.mjs';
80
+ import {
81
+ NOISE_FLOOR_MS as NODE_NOISE_FLOOR_MS,
82
+ NOISE_FLOOR_PCT as NODE_NOISE_FLOOR_PCT,
83
+ coldModules,
84
+ gatherStartupSamples,
85
+ graphIdCandidates,
86
+ measureBootFloor,
87
+ profileStartup,
88
+ startupCoverage,
89
+ startupProfile,
90
+ summarizeStartup,
91
+ } from './lib/node/measure.mjs';
92
+ import {
93
+ COVERAGE_MIN_WRANGLER,
94
+ EMPTY_NAME,
95
+ classifyModules,
96
+ createStderrCollector,
97
+ loadWorkerOptions,
98
+ resolveWorkerTools,
99
+ } from './lib/worker/launch.mjs';
100
+ import {
101
+ NOISE_FLOOR_MS as WORKER_NOISE_FLOOR_MS,
102
+ NOISE_FLOOR_PCT as WORKER_NOISE_FLOOR_PCT,
103
+ attributeCoverage,
104
+ attributeProfile,
105
+ coverageRun as workerCoverageRun,
106
+ cpuProfileRun,
107
+ firstRequests,
108
+ gatherSeries,
109
+ summarizeWorkerStartup,
110
+ } from './lib/worker/measure.mjs';
111
+ import { evaluatingModules, whyModule } from './lib/worker/attrib.mjs';
112
+ import { ATTRIB_DIR, SHIM_FILE, writeViteConfigShim } from './lib/worker/config.mjs';
113
+ import {
114
+ discoverPages,
115
+ gitState,
116
+ hashDist,
117
+ normalizePagePath,
118
+ parityPaths,
119
+ saveAnchor,
120
+ } from './lib/parity/anchor.mjs';
121
+ import { compareLcp, describeLcp, lcpReason } from './lib/parity/compare.mjs';
122
+ import { runParity, pageSlug } from './lib/parity/run.mjs';
123
+ import { cmdField } from './lib/field/run.mjs';
124
+ import { onInterrupt } from './lib/interrupt.mjs';
125
+ import { cmdRecord, cmdReplay } from './lib/replay/run.mjs';
126
+
127
+ const ROOT = path.dirname(fileURLToPath(import.meta.url));
128
+ const APP_DIR = path.join(ROOT, 'app');
129
+ // State placement: inside the package while developing it from the repo, but for
130
+ // an installed copy (anywhere under node_modules) state belongs to the USER'S
131
+ // project — node_modules is wiped on reinstall and should never hold results.
132
+ const STATE_DIR =
133
+ process.env.ROLLDOWN_PERF_KIT_STATE ??
134
+ (/[\\/]node_modules[\\/]/.test(ROOT)
135
+ ? path.join(process.cwd(), '.perf-kit')
136
+ : path.join(ROOT, 'state'));
137
+ const TARGET_FILE = path.join(STATE_DIR, 'target.json');
138
+ // The demo build's report: report mode writes one dir per output dir, under the build cwd.
139
+ const BUILD_METRICS_DIR = reportDirFor(APP_DIR, 'dist');
140
+ const CHROME_PROFILE_DIR = path.join(STATE_DIR, 'chrome-profile');
141
+
142
+ // Decision thresholds the runbook references. The harness only REPORTS against
143
+ // them; accepting or reverting a change is the loop driver's (agent's) call.
144
+ const NOISE_FLOOR_MS = 30;
145
+ const NOISE_FLOOR_PCT = 2;
146
+ // A large first-run LCP change can end a probe early. A keep/revert decision
147
+ // still needs a full scan of both paint metrics with --no-early-stop.
148
+ // Keep the throttle fixed: changing it can invert a comparison.
149
+ const EARLY_STOP_FACTOR = 5;
150
+ // Per-target throttle calibration: the ONE throttle dimension that may vary is
151
+ // network bandwidth, in named discrete steps, chosen once per target and PINNED
152
+ // (it is part of the measurement's identity - agents, re-scans, and the eval
153
+ // referee must all inherit the same value; delete throttle-profile.json or pass
154
+ // --net-scale to change it, which invalidates the pinned baseline). First scan
155
+ // walks the ladder until the baseline LCP fits under the ceiling: apps in the
156
+ // normal band stay at x1 Lighthouse realism; only pathologically heavy ones
157
+ // (drawdb: 20s = mostly transfer WAIT) scale up so every navigation stops
158
+ // costing half a minute. RTT and CPU pressure never change.
159
+ const CALIBRATE_LADDER = [1, 2, 4, 8];
160
+ const CALIBRATE_CEILING_MS = 8000;
161
+ const CANDIDATE_MIN_BYTES = 3 * 1024;
162
+ const CANDIDATE_MAX_PAINT_RATIO = 0.02;
163
+
164
+ // How to spell an invocation in hints: a launcher can dictate it via ROLLDOWN_PERF_KIT_CLI;
165
+ // installed, `npx <package name>` when a bin carries the package's bare name (npx runs that
166
+ // bin, with or without a local install), else the first bin; from the repo, the script.
167
+ const CLI =
168
+ process.env.ROLLDOWN_PERF_KIT_CLI ??
169
+ (/[\\/]node_modules[\\/]/.test(ROOT) ? installedCli() : 'node harness.mjs');
170
+
171
+ function installedCli() {
172
+ const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, 'package.json'), 'utf8'));
173
+ const bins = Object.keys(pkg.bin ?? {});
174
+ const bare = pkg.name.split('/').pop();
175
+ return `npx ${bins.includes(bare) ? pkg.name : (bins[0] ?? bare)}`;
176
+ }
177
+
178
+ const kb = (n) => `${(n / 1024).toFixed(1)}KB`;
179
+ const kib = (n) => `${(n / 1024).toFixed(1)}KiB`;
180
+ const ms = (v) => (v == null ? 'n/a' : `${Math.round(v)}ms`);
181
+ const readJson = (file) => (fs.existsSync(file) ? JSON.parse(fs.readFileSync(file, 'utf8')) : null);
182
+ // The state dir self-ignores: agents running `git add -A` in a consumer repo must
183
+ // never commit tool state (a haiku run committed the whole Chrome profile).
184
+ const guardStateDir = () => {
185
+ const gitignore = path.join(STATE_DIR, '.gitignore');
186
+ if (!fs.existsSync(gitignore)) {
187
+ fs.mkdirSync(STATE_DIR, { recursive: true });
188
+ fs.writeFileSync(gitignore, '*\n');
189
+ }
190
+ };
191
+ const writeJson = (file, value) => {
192
+ fs.mkdirSync(path.dirname(file), { recursive: true });
193
+ if (file.startsWith(STATE_DIR)) guardStateDir();
194
+ fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`);
195
+ };
196
+
197
+ const cleanups = [];
198
+ onInterrupt(async () => {
199
+ for (const fn of cleanups.splice(0)) await fn().catch(() => {});
200
+ });
201
+
202
+ function parse(argv, options) {
203
+ return parseArgs({ args: argv, options, allowPositionals: true }).values;
204
+ }
205
+
206
+ // --- target resolution ---------------------------------------------------------
207
+ // Measurement commands operate on a built app ("target"). The first --app/--dist
208
+ // is remembered, so every later command can be invoked bare. Each target keeps
209
+ // its own state directory: baselines and history never mix across apps.
210
+
211
+ const TARGET_OPTS = {
212
+ app: { type: 'string' },
213
+ dist: { type: 'string' },
214
+ };
215
+
216
+ // `--bandwidth <Mbit/s> [--rtt <ms>] [--cpu <rate>]`: pin a network in real units - the
217
+ // network a deployed environment measured (a record's model) - instead of a net-scale
218
+ // step. Pinned like --net-scale; changing it makes the pinned baseline incomparable.
219
+ const NETWORK_OPTS = {
220
+ bandwidth: { type: 'string' },
221
+ rtt: { type: 'string' },
222
+ cpu: { type: 'string' },
223
+ };
224
+
225
+ // The throttle identity a measurement will run under: the flag's, else the pinned
226
+ // profile's; null before the first calibration (nothing to compare against yet).
227
+ function expectedThrottleId(target, opts, networkFlag) {
228
+ if (networkFlag) return throttleId({ network: networkFlag });
229
+ if (opts['net-scale'] != null) return throttleId({ netScale: Number(opts['net-scale']) });
230
+ const profile = readJson(target.paths.throttleProfile);
231
+ return profile ? throttleId(profile) : null;
232
+ }
233
+
234
+ function targetSlug(dist) {
235
+ return dist
236
+ .toLowerCase()
237
+ .replace(/[^a-z0-9]+/g, '-')
238
+ .replace(/^-+|-+$/g, '')
239
+ .slice(-60);
240
+ }
241
+
242
+ function isDemoDist(dist) {
243
+ return path.normalize(dist) === path.normalize(path.join(APP_DIR, 'dist'));
244
+ }
245
+
246
+ function targetPaths(dist) {
247
+ const dir = isDemoDist(dist) ? STATE_DIR : path.join(STATE_DIR, 'targets', targetSlug(dist));
248
+ return {
249
+ dir,
250
+ runtimeMetrics: path.join(dir, 'runtime-metrics.json'),
251
+ runtimeState: path.join(dir, '.state.json'),
252
+ runtimeBaseline: path.join(dir, 'baseline.json'),
253
+ coverage: path.join(dir, 'coverage.json'),
254
+ profile: path.join(dir, 'profile.json'),
255
+ history: path.join(dir, 'history.jsonl'),
256
+ throttleProfile: path.join(dir, 'throttle-profile.json'),
257
+ baselineModuleGraph: path.join(dir, 'baseline-module-graph.json'),
258
+ parity: parityPaths(dir),
259
+ };
260
+ }
261
+
262
+ // Resolve --app/<appDir> to an EXISTING build dir, or throw with the fix. A guessed
263
+ // path must never be remembered: a haiku agent once obeyed the fabricated path in
264
+ // our error message and rewired the app's outDir (then "fixed" the functional check).
265
+ function resolveAppDist(appArg) {
266
+ const app = path.resolve(appArg);
267
+ const candidates = ['dist', 'build', 'out'].map((dir) => path.join(app, dir));
268
+ const dist =
269
+ candidates.find((dir) => fs.existsSync(path.join(dir, 'index.html'))) ??
270
+ // --app aimed at the built dir itself is a common miss - accept it.
271
+ (fs.existsSync(path.join(app, 'index.html')) ? app : null);
272
+ if (!dist) {
273
+ throw new Error(
274
+ `no build found under ${app} - tried dist/, build/, out/, and the dir itself (none has an index.html).\n` +
275
+ 'Build the app first. --app takes the APP ROOT (the dir you build from); if the build output\n' +
276
+ "lives somewhere else, pass --dist <builtDir> directly. Never change the app's outDir to fit\n" +
277
+ 'this tool - aim the tool at the build instead.',
278
+ );
279
+ }
280
+ return dist;
281
+ }
282
+
283
+ function resolveTarget(opts) {
284
+ let dist;
285
+ if (opts.dist) dist = path.resolve(opts.dist);
286
+ else if (opts.app) dist = resolveAppDist(opts.app);
287
+ else {
288
+ const sticky = readJson(TARGET_FILE);
289
+ dist = sticky?.dist ?? path.join(APP_DIR, 'dist');
290
+ }
291
+ if ((opts.dist || opts.app) && readJson(TARGET_FILE)?.dist !== dist) {
292
+ writeJson(TARGET_FILE, { dist, setAtMs: Date.now() });
293
+ }
294
+ console.log(`target: ${dist}${opts.dist || opts.app ? '' : ' (remembered)'}`);
295
+ return { dist, paths: targetPaths(dist), isDemo: isDemoDist(dist) };
296
+ }
297
+
298
+ function expectedFeaturesFor(target, opts) {
299
+ if (opts.features !== undefined) return opts.features.split(',').filter(Boolean);
300
+ return target.isDemo ? FEATURE_NAMES : [];
301
+ }
302
+
303
+ // A command that needs the entry must fail with the REAL problem: "no build here"
304
+ // or "could not detect the entry" - never a fabricated default path. The old
305
+ // `?? 'main.js'` fallback produced "no sourcemap at <dist>/main.js.map", which an
306
+ // agent read as a layout instruction and obeyed.
307
+ function requireEntry(target, opts = {}) {
308
+ if (!fs.existsSync(path.join(target.dist, 'index.html'))) {
309
+ throw new Error(
310
+ `no build at ${target.dist} - build the app first.\n` +
311
+ '(Wrong target? --app <appRoot> resolves dist/build/out; --dist <builtDir> aims directly.)',
312
+ );
313
+ }
314
+ const entry = opts.entry ?? detectEntry(target.dist);
315
+ if (!entry) {
316
+ throw new Error(
317
+ `could not detect the entry script in ${path.join(target.dist, 'index.html')} - ` +
318
+ 'pass --entry <file> (path relative to the build dir).',
319
+ );
320
+ }
321
+ return entry;
322
+ }
323
+
324
+ /** The entry chunk of a built app, read from its index.html module script. */
325
+ function detectEntry(distDir) {
326
+ const indexFile = path.join(distDir, 'index.html');
327
+ if (!fs.existsSync(indexFile)) return null;
328
+ const html = fs.readFileSync(indexFile, 'utf8');
329
+ const locals = [];
330
+ for (const tag of html.match(/<script\b[^>]*>/g) ?? []) {
331
+ const src = tag.match(/\bsrc="([^"]+)"/)?.[1];
332
+ if (!src || src.startsWith('http')) continue;
333
+ // webpack emits cache-busting queries (main.bundle.js?abc123) - strip them
334
+ // or every existsSync/readFile on the entry fails.
335
+ locals.push({
336
+ src: src.split('?')[0].replace(/^\.?\//, ''),
337
+ module: tag.includes('type="module"'),
338
+ });
339
+ }
340
+ // Astro islands have no script tag: each <astro-island> names its chunks in
341
+ // component-url / renderer-url attributes. They load as modules.
342
+ if (!locals.length) {
343
+ for (const [, src] of html.matchAll(/\b(?:component|renderer)-url="([^"]+)"/g)) {
344
+ if (!src.startsWith('http')) {
345
+ locals.push({ src: src.split('?')[0].replace(/^\.?\//, ''), module: false });
346
+ }
347
+ }
348
+ }
349
+ const moduleScript = locals.find((s) => s.module);
350
+ if (moduleScript) return moduleScript.src;
351
+ // webpack-style multi-script pages have no type="module": prefer the
352
+ // main-looking bundle, else the biggest local script on disk (runtime/shim
353
+ // scripts come first in the HTML and are tiny).
354
+ const named = locals.find((s) => /(^|[./])(main|index|app)[^/]*\.m?js$/i.test(s.src));
355
+ if (named) return named.src;
356
+ let best = null;
357
+ for (const s of locals) {
358
+ const file = path.join(distDir, s.src);
359
+ const size = fs.existsSync(file) ? fs.statSync(file).size : -1;
360
+ if (size >= 0 && (!best || size > best.size)) best = { ...s, size };
361
+ }
362
+ return best?.src ?? locals[0]?.src ?? null;
363
+ }
364
+
365
+ /**
366
+ * Resolve the target's PINNED throttle profile, calibrating on first use: walk
367
+ * the net-scale ladder (one probe navigation per step) until the baseline LCP
368
+ * fits under the ceiling, then pin the winning scale. Explicit --net-scale
369
+ * pins without probing; so does --bandwidth/--rtt/--cpu, which pins a network in
370
+ * real units (a deployed environment's measured network) instead of a scale step.
371
+ * Pinned means pinned: every later scan/measure/coverage/
372
+ * profile - and the eval referee - inherits this value until the profile file
373
+ * is deleted or explicitly overridden.
374
+ */
375
+ async function ensureThrottleProfile(
376
+ target,
377
+ { origin, cdp, netScaleFlag = null, networkFlag = null },
378
+ ) {
379
+ const existing = readJson(target.paths.throttleProfile);
380
+ if (networkFlag) {
381
+ const id = throttleId({ network: networkFlag });
382
+ if (!existing || throttleId(existing) !== id) {
383
+ writeJson(target.paths.throttleProfile, {
384
+ schemaVersion: 1,
385
+ network: networkFlag,
386
+ source: 'flag',
387
+ pinnedAtMs: Date.now(),
388
+ });
389
+ console.log(
390
+ `throttle profile pinned by flag: ${id}${existing ? ` (was ${throttleId(existing)} - the pinned baseline is now incomparable, re-pin with scan --pin)` : ''}`,
391
+ );
392
+ }
393
+ return readJson(target.paths.throttleProfile);
394
+ }
395
+ if (netScaleFlag != null) {
396
+ const netScale = Number(netScaleFlag);
397
+ if (!CALIBRATE_LADDER.includes(netScale)) {
398
+ throw new Error(
399
+ `--net-scale must be one of ${CALIBRATE_LADDER.join('/')} (named, reproducible steps only)`,
400
+ );
401
+ }
402
+ if (!existing || throttleId(existing) !== throttleId({ netScale })) {
403
+ writeJson(target.paths.throttleProfile, {
404
+ schemaVersion: 1,
405
+ netScale,
406
+ source: 'flag',
407
+ pinnedAtMs: Date.now(),
408
+ });
409
+ console.log(
410
+ `throttle profile pinned by flag: net x${netScale}${existing ? ` (was ${throttleId(existing)} - the pinned baseline is now incomparable, re-pin with scan --pin)` : ''}`,
411
+ );
412
+ }
413
+ return readJson(target.paths.throttleProfile);
414
+ }
415
+ if (existing) return existing;
416
+ const probes = [];
417
+ let chosen = 1;
418
+ for (const netScale of CALIBRATE_LADDER) {
419
+ const sample = await timedRun(cdp, {
420
+ url: `${origin}/`,
421
+ throttle: scaleThrottle(DEFAULT_THROTTLE, netScale),
422
+ settleMs: 1000,
423
+ });
424
+ const lcpMs = typeof sample.lcp === 'number' ? Math.round(sample.lcp) : null;
425
+ probes.push({ netScale, lcpMs });
426
+ process.stderr.write(`calibrate net x${netScale}: LCP ${ms(sample.lcp)}\n`);
427
+ if (lcpMs == null) break; // measurement failed - stay at the safest default
428
+ chosen = netScale;
429
+ if (lcpMs <= CALIBRATE_CEILING_MS) break;
430
+ }
431
+ const profile = {
432
+ schemaVersion: 1,
433
+ netScale: probes.some((p) => p.lcpMs != null) ? chosen : 1,
434
+ source: 'calibration',
435
+ ceilingMs: CALIBRATE_CEILING_MS,
436
+ probes,
437
+ pinnedAtMs: Date.now(),
438
+ };
439
+ writeJson(target.paths.throttleProfile, profile);
440
+ console.log(
441
+ `throttle profile pinned: net x${profile.netScale} (${probes.map((p) => `x${p.netScale}=${p.lcpMs == null ? 'n/a' : `${p.lcpMs}ms`}`).join(', ')}; ceiling ${CALIBRATE_CEILING_MS}ms)`,
442
+ );
443
+ console.log(
444
+ 'this scale is part of the measurement identity - every later scan and the scoring legs use it.',
445
+ );
446
+ return profile;
447
+ }
448
+
449
+ async function withServerAndBrowser(
450
+ target,
451
+ throttleOff,
452
+ fn,
453
+ { netScaleFlag = null, networkFlag = null } = {},
454
+ ) {
455
+ const distDir = target.dist;
456
+ if (!fs.existsSync(path.join(distDir, 'index.html'))) {
457
+ throw new Error(`no build at ${distDir} - build the app first`);
458
+ }
459
+ const server = await startServer(distDir);
460
+ const browser = await launchBrowser({ profileDir: CHROME_PROFILE_DIR });
461
+ // Called through their owners: these are the SIGINT cleanups, so a `this` that
462
+ // went missing would only surface as a leaked browser/port on interrupt.
463
+ cleanups.push(
464
+ () => server.close(),
465
+ () => browser.close(),
466
+ );
467
+ try {
468
+ let throttle = null;
469
+ if (!throttleOff) {
470
+ const profile = await ensureThrottleProfile(target, {
471
+ origin: server.origin,
472
+ cdp: browser.cdp,
473
+ netScaleFlag,
474
+ networkFlag,
475
+ });
476
+ throttle = throttleFromProfile(profile);
477
+ }
478
+ return await fn({ origin: server.origin, cdp: browser.cdp, throttle });
479
+ } finally {
480
+ await browser.close().catch(() => {});
481
+ await server.close().catch(() => {});
482
+ }
483
+ }
484
+
485
+ // --- measure core ----------------------------------------------------------------
486
+
487
+ async function gatherSamples(
488
+ cdp,
489
+ origin,
490
+ { throttle, expectedFeatures, runs, warmup, settleMs, earlyStop = null },
491
+ ) {
492
+ // Navigate '/' (not '/index.html'): SPA routers treat the literal
493
+ // /index.html path as an unknown route and render their 404 page - drawDB
494
+ // showed us a "looking for something?" screen instead of its landing.
495
+ const url = `${origin}/`;
496
+ // The warmup stays even when early stop is possible: it absorbs the
497
+ // launch-cold outlier (first navigation after a browser start), which would
498
+ // otherwise be the single sample the decision rests on.
499
+ for (let i = 0; i < warmup; i++) {
500
+ process.stderr.write(`warmup ${i + 1}/${warmup}...\n`);
501
+ await timedRun(cdp, { url, throttle, expectedFeatures, settleMs });
502
+ }
503
+ const samples = [];
504
+ for (let i = 0; i < runs; i++) {
505
+ const sample = await timedRun(cdp, { url, throttle, expectedFeatures, settleMs });
506
+ samples.push(sample);
507
+ process.stderr.write(`run ${i + 1}/${runs}: LCP ${ms(sample.lcp)}, load ${ms(sample.load)}\n`);
508
+ // Decide on run 1 ONLY: if it lands ambiguous (within the early-stop band),
509
+ // later runs disagreeing with it is exactly the near-threshold case that
510
+ // needs the full median, so no late stopping.
511
+ if (earlyStop && i === 0 && runs > 1 && typeof sample.lcp === 'number') {
512
+ const deltaMs = sample.lcp - earlyStop.baselineLcp;
513
+ if (Math.abs(deltaMs) >= earlyStop.factor * earlyStop.thresholdMs) {
514
+ process.stderr.write(
515
+ `early stop after run 1: |dLCP| ${Math.round(Math.abs(deltaMs))}ms >= ${earlyStop.factor}x noise threshold ${Math.round(earlyStop.thresholdMs)}ms\n`,
516
+ );
517
+ return {
518
+ samples,
519
+ earlyStopped: {
520
+ afterRuns: 1,
521
+ plannedRuns: runs,
522
+ deltaMs: Math.round(deltaMs),
523
+ thresholdMs: Math.round(earlyStop.thresholdMs),
524
+ factor: earlyStop.factor,
525
+ },
526
+ };
527
+ }
528
+ }
529
+ }
530
+ return { samples, earlyStopped: null };
531
+ }
532
+
533
+ function writeMeasureReport(
534
+ target,
535
+ samples,
536
+ { expectedFeatures, label, throttle, earlyStopped = null },
537
+ ) {
538
+ const summary = summarize(samples, expectedFeatures);
539
+ const prev = readJson(target.paths.runtimeState);
540
+ const baseline = readJson(target.paths.runtimeBaseline);
541
+ // Deltas are only meaningful between measurements taken under the SAME pinned
542
+ // throttle (net scale or network) - it is part of the measurement's identity. A
543
+ // mismatch suppresses the comparison and says so, instead of printing a wrong number.
544
+ const currId = throttle ? throttleId(throttle) : null;
545
+ const sameScale = (state) => state && currId != null && throttleId(state) === currId;
546
+ const chunkGraph = loadChunkGraph(
547
+ target.dist,
548
+ moduleGraphCandidates({
549
+ demoMetricsDir: target.isDemo ? BUILD_METRICS_DIR : undefined,
550
+ dist: target.dist,
551
+ }).map((file) => path.join(path.dirname(file), 'chunk-graph.json')),
552
+ );
553
+ const baselineDelta = sameScale(baseline)
554
+ ? deltaSection(baseline.metrics, summary.metrics)
555
+ : null;
556
+ const report = {
557
+ schemaVersion: 1,
558
+ generatedAtMs: Date.now(),
559
+ label: label || null,
560
+ dist: target.dist,
561
+ entry: detectEntry(target.dist),
562
+ throttle,
563
+ deferred: target.isDemo ? deferredList() : null,
564
+ runs: summary.runs,
565
+ earlyStopped,
566
+ metrics: summary.metrics,
567
+ guard: summary.guard,
568
+ gatingFetches: summary.gatingFetches,
569
+ resourceWeight: summary.resourceWeight,
570
+ renderBlockingGate: summary.renderBlockingGate,
571
+ postLoadScripts: summary.postLoadScripts,
572
+ resourceDetailsComplete: summary.resourceDetailsComplete,
573
+ chunkDiscovery: analyzeChunkDiscovery(chunkGraph, samples, throttle?.latencyMs),
574
+ lcpElement: summary.lcpElement,
575
+ delta: sameScale(prev) ? deltaSection(prev.metrics, summary.metrics) : null,
576
+ baselineDelta,
577
+ paintComparison: comparePaints(baselineDelta, {
578
+ noiseFloorMs: NOISE_FLOOR_MS,
579
+ noiseFloorPct: NOISE_FLOOR_PCT,
580
+ }),
581
+ baselineScaleMismatch:
582
+ baseline && currId != null && !sameScale(baseline)
583
+ ? { pinned: throttleId(baseline), current: currId }
584
+ : null,
585
+ samples: summary.samples,
586
+ };
587
+ writeJson(target.paths.runtimeMetrics, report);
588
+ // runs travels with the state so pinBaseline can refuse single-run pins
589
+ // (quick probes and early-stopped scans - a 1-run baseline poisons deltas);
590
+ // netScale / network travel so future measurements refuse cross-throttle comparisons.
591
+ writeJson(target.paths.runtimeState, {
592
+ schemaVersion: 1,
593
+ tsMs: report.generatedAtMs,
594
+ label: report.label,
595
+ runs: report.runs,
596
+ netScale: throttle && !throttle.network ? (throttle.netScale ?? 1) : undefined,
597
+ network: throttle?.network,
598
+ metrics: report.metrics,
599
+ });
600
+ fs.appendFileSync(
601
+ target.paths.history,
602
+ `${JSON.stringify({
603
+ tsMs: report.generatedAtMs,
604
+ label: report.label,
605
+ deferred: report.deferred,
606
+ metrics: report.metrics,
607
+ guard: report.guard,
608
+ })}\n`,
609
+ );
610
+ return { report, hadBaseline: Boolean(baseline) };
611
+ }
612
+
613
+ function deferredList() {
614
+ const modes = featureModes(APP_DIR);
615
+ return modes ? FEATURE_NAMES.filter((n) => modes[n] === 'deferred') : [];
616
+ }
617
+
618
+ // advice:false drops the "next:" coaching paragraphs (scan prints the verdict right
619
+ // after this summary, and every one of them reappears there as the matching lead's
620
+ // next: line - printed twice, they sit in the agent's context for the whole session
621
+ // and get re-read on every later turn). The signal lines and numbers always print.
622
+ function printMeasureSummary(
623
+ report,
624
+ hadBaseline,
625
+ { advice = true, parity = null, lcpCheck = null } = {},
626
+ ) {
627
+ const m = report.metrics;
628
+ console.log(
629
+ `\n${report.label ? `[${report.label}] ` : ''}${report.runs} runs` +
630
+ `${report.deferred?.length ? `, deferred: ${report.deferred.join(', ')}` : ''}`,
631
+ );
632
+ if (report.earlyStopped) {
633
+ const e = report.earlyStopped;
634
+ console.log(
635
+ `early stop after run 1 of ${e.plannedRuns}: |dLCP vs pinned baseline| ${Math.abs(e.deltaMs)}ms >= ${e.factor}x noise threshold ${e.thresholdMs}ms` +
636
+ ' - this is a probe. A full scan with --no-early-stop must check both FCP and LCP before a decision.',
637
+ );
638
+ }
639
+ console.log(
640
+ `LCP ${ms(m['runtime.lcp_ms'])} (p75 ${ms(m['runtime.lcp_p75_ms'])}) | ` +
641
+ `FCP ${ms(m['runtime.fcp_ms'])} | load ${ms(m['runtime.load_ms'])} | ` +
642
+ `CLS ${m['runtime.cls']} | transfer ${kb(m['runtime.transfer_bytes'])} | ` +
643
+ `${m['runtime.js_request_count']} js requests` +
644
+ `${report.throttle && (report.throttle.network || (report.throttle.netScale ?? 1) !== 1) ? ` | ${throttleId(report.throttle)} (pinned)` : ''}`,
645
+ );
646
+ if (report.baselineScaleMismatch) {
647
+ const mm = report.baselineScaleMismatch;
648
+ console.log(
649
+ `vs pinned baseline: INCOMPARABLE - the baseline was pinned under ${mm.pinned}, this measurement ran under ${mm.current}.`,
650
+ );
651
+ console.log('next: re-pin under the current profile (scan --pin) before judging any change.');
652
+ }
653
+ const guardOk =
654
+ report.guard.allFeaturesReady &&
655
+ report.guard.heroRendered !== false && // null = no hero probe on this app
656
+ report.guard.fcpObservedInAllRuns &&
657
+ report.guard.lcpObservedInAllRuns;
658
+ console.log(`guard: ${guardOk ? 'PASS' : `FAIL ${JSON.stringify(report.guard)}`}`);
659
+
660
+ // Deep signals: things LCP alone doesn't say, each with the move it suggests.
661
+ const gate = report.renderBlockingGate;
662
+ if (gate?.gating) {
663
+ console.log(
664
+ `render-blocking CSS gate: ${gate.count} blocking stylesheet(s), ${gate.kb}KB held ${Math.round(gate.shareOfFcp * 100)}% of the FCP timeline (last finished ${gate.lastEndMs}ms, FCP ${gate.fcpMs}ms).`,
665
+ );
666
+ for (const w of (gate.worst ?? []).slice(0, 3)) {
667
+ console.log(` blocking until ${w.end}ms: ${w.name} (${kb(w.bytes)})`);
668
+ }
669
+ if (advice) {
670
+ console.log(
671
+ ' next: nothing paints until these finish - inline the small critical CSS, load the rest',
672
+ );
673
+ console.log(
674
+ ' non-blocking (preload + media swap), and split styles only later routes need. Fix this',
675
+ );
676
+ console.log(' and any render-gating fetch before judging CPU/JS deferrals.');
677
+ }
678
+ }
679
+ const renderGap = m['runtime.render_gap_ms'];
680
+ if (typeof renderGap === 'number' && renderGap > 150) {
681
+ console.log(
682
+ `render gap: LCP landed ${Math.round(renderGap)}ms AFTER the load event. Downloads can continue after load.`,
683
+ );
684
+ for (const evidence of renderGapEvidence(report)) {
685
+ console.log(` candidate: ${evidence.detail}`);
686
+ if (advice) console.log(` next: ${evidence.next}`);
687
+ }
688
+ if (advice) {
689
+ console.log(
690
+ ` next: check these candidates against the source and the CPU profile (${CLI} profile). Check whether an animation hides the LCP element. Timing alone does not show what blocked paint.`,
691
+ );
692
+ }
693
+ }
694
+ for (const paint of ['fcp', 'lcp']) {
695
+ const discovery = report.chunkDiscovery?.[paint];
696
+ if (!discovery?.groups.length) continue;
697
+ console.log(
698
+ `late chunk candidates before ${paint.toUpperCase()}: ${discovery.groups.length} static import group(s). Paint dependencies are unverified.`,
699
+ );
700
+ for (const group of discovery.groups.slice(0, 4)) {
701
+ console.log(
702
+ ` ${group.parent}: ${group.count} chunk(s), ${kib(group.bytes)}, up to ${ms(group.maxDelayMs)} delay (${group.runsAffected}/${group.measuredRuns} runs).`,
703
+ );
704
+ const example = group.example.chunks[0];
705
+ console.log(` import chain: ${example.importChain.join(' -> ')}`);
706
+ if (example.facadeModuleId) console.log(` source: ${example.facadeModuleId}`);
707
+ }
708
+ console.log(' Groups and paint windows overlap. Do not add their bytes or delays.');
709
+ if (advice) console.log(` next: ${chunkDiscoveryAdvice(paint)}`);
710
+ console.log(
711
+ ` evidence: ${discovery.graphFile}; runtime-metrics.json -> chunkDiscovery.${paint}.groups`,
712
+ );
713
+ }
714
+ const prepaintCpu = m['runtime.prepaint_longtask_ms'];
715
+ if (typeof prepaintCpu === 'number' && prepaintCpu > 150) {
716
+ console.log(`pre-paint CPU: ${Math.round(prepaintCpu)}ms of long tasks before first paint.`);
717
+ if (advice) {
718
+ console.log(
719
+ ` next: run ${CLI} profile to find the modules that use this CPU time. Check their timing against required downloads. Test deferring work that the first paint does not need. Compare both FCP and LCP.`,
720
+ );
721
+ }
722
+ }
723
+
724
+ if (report.baselineDelta) {
725
+ const comparison = report.paintComparison;
726
+ for (const [paint, metric] of Object.entries(comparison.metrics)) {
727
+ if (metric.state === 'unknown') {
728
+ console.log(`vs pinned baseline: ${paint.toUpperCase()} missing`);
729
+ continue;
730
+ }
731
+ console.log(
732
+ `vs pinned baseline: ${paint.toUpperCase()} ${metric.delta > 0 ? '+' : ''}${Math.round(metric.delta)}ms ` +
733
+ `(${metric.pct > 0 ? '+' : ''}${metric.pct}%) -> ${metric.state} (noise threshold ${Math.round(metric.thresholdMs)}ms)`,
734
+ );
735
+ }
736
+ if (parity?.state === 'open' || !guardOk) {
737
+ console.log(
738
+ 'next: resolve the failed guard or rendered output check before judging paint gains. Both paints must appear in every run.',
739
+ );
740
+ } else if (report.runs < 3) {
741
+ console.log(
742
+ `next: this is a probe. Run ${CLI} scan --no-early-stop with at least 3 runs before a keep/revert decision.`,
743
+ );
744
+ } else if (comparison.state === 'improved') {
745
+ console.log(
746
+ `next: one or both paint metrics improved beyond noise, and neither regressed beyond noise. Keep the change if the functional check passes. Re-pin with ${CLI} baseline. Run scan --parity when the work is complete.`,
747
+ );
748
+ } else if (comparison.state === 'regressed') {
749
+ console.log(
750
+ 'next: FCP or LCP regressed beyond noise. Revert the change and test a different experiment.',
751
+ );
752
+ } else if (comparison.state === 'unknown') {
753
+ console.log('next: collect both FCP and LCP before judging this change.');
754
+ } else {
755
+ console.log(
756
+ 'next: neither FCP nor LCP improved beyond noise. Revert the change and test a different experiment.',
757
+ );
758
+ }
759
+ } else if (hadBaseline) {
760
+ console.log('vs pinned baseline: n/a (metrics missing or throttle changed)');
761
+ }
762
+ // Free on every measurement (the timed runs already observe it), so it is shown
763
+ // as it happens - information, not a gate; the final parity check gates on it.
764
+ if (lcpCheck?.state === 'same') {
765
+ console.log(`LCP element: same as the original - ${describeLcp(lcpCheck.current)}`);
766
+ } else if (lcpCheck?.state === 'unstable') {
767
+ console.log(
768
+ `LCP element: varies between loads (${describeLcp(lcpCheck.anchor)} / ${describeLcp(lcpCheck.current)})`,
769
+ );
770
+ } else if (lcpCheck && lcpCheck.state !== 'unknown') {
771
+ console.log(
772
+ `LCP element: ${lcpReason(lcpCheck)} - LCP now times a different paint than the original did, so this gain is likely not real (the final rendered-output check opens on it).`,
773
+ );
774
+ }
775
+ if (report.delta?.['runtime.lcp_ms']) {
776
+ const d = report.delta['runtime.lcp_ms'];
777
+ console.log(
778
+ `vs previous measure: LCP ${d.delta > 0 ? '+' : ''}${Math.round(d.delta)}ms (${d.pct > 0 ? '+' : ''}${d.pct}%)`,
779
+ );
780
+ }
781
+ }
782
+
783
+ function pinBaseline(target) {
784
+ const state = readJson(target.paths.runtimeState);
785
+ if (!state) throw new Error('nothing to pin - measure (or scan) first');
786
+ // A 1-run median (quick probe or early-stopped scan) poisons every later
787
+ // delta. scan --pin always runs full sampling - use it.
788
+ if (state.runs === 1) {
789
+ throw new Error(
790
+ 'refusing to pin a single-run measurement - run a full scan first (scan --pin re-measures with full sampling and then pins)',
791
+ );
792
+ }
793
+ // A build already KNOWN to render differently from the original must never become
794
+ // the reference later gains count from. An unchecked build may be pinned: the
795
+ // comparison runs once, when the optimizing is done.
796
+ const parity = parityStatus(target);
797
+ if (parity.state === 'open') {
798
+ throw new Error(
799
+ 'refusing to pin: this build renders differently from the original (see the verdict). Fix or revert the change that caused it.\n' +
800
+ `If a HUMAN decides the difference is intended, they approve it: ${CLI} parity approve <page> --reason "<why>"`,
801
+ );
802
+ }
803
+ if (parity.report && !parity.report.integrity) {
804
+ throw new Error(
805
+ 'refusing to pin: the saved original build was modified after it was saved - parity cannot vouch for this build.',
806
+ );
807
+ }
808
+ writeJson(target.paths.runtimeBaseline, state);
809
+ console.log(
810
+ `baseline pinned (label: ${state.label ?? 'none'}, LCP ${ms(state.metrics['runtime.lcp_ms'])})`,
811
+ );
812
+ if (target.isDemo) {
813
+ const buildState = path.join(BUILD_METRICS_DIR, '.state.json');
814
+ if (fs.existsSync(buildState)) {
815
+ fs.copyFileSync(buildState, path.join(BUILD_METRICS_DIR, 'baseline.json'));
816
+ console.log(`build baseline pinned (${path.join(BUILD_METRICS_DIR, 'baseline.json')})`);
817
+ }
818
+ }
819
+ snapshotBaselineGraph(target);
820
+ }
821
+
822
+ // Alongside the runtime baseline, snapshot the resolved module-graph.json so `graph-diff`
823
+ // can attribute later changes to the eager set. The WHOLE sidecar is copied (no field
824
+ // cherry-picking - the v4.5 lesson); a stale/absent graph just skips it with one line.
825
+ // This runs from the single pin helper, so every pin path (baseline / scan --pin /
826
+ // measure --pin / first-scan auto-pin) gets the snapshot.
827
+ function snapshotBaselineGraph(target) {
828
+ const entry = detectEntry(target.dist);
829
+ const builtAtMs =
830
+ entry && fs.existsSync(path.join(target.dist, entry))
831
+ ? fs.statSync(path.join(target.dist, entry)).mtimeMs
832
+ : null;
833
+ const mg = moduleGraphStatus(target, builtAtMs);
834
+ if (mg.state === 'present') {
835
+ fs.mkdirSync(path.dirname(target.paths.baselineModuleGraph), { recursive: true });
836
+ guardStateDir();
837
+ fs.copyFileSync(mg.graph.file, target.paths.baselineModuleGraph);
838
+ console.log(`baseline module graph snapshotted (graph-diff will compare against it)`);
839
+ } else if (mg.state === 'stale') {
840
+ console.log(
841
+ 'note: module graph predates this build - no graph snapshot pinned (graph-diff needs a fresh one).',
842
+ );
843
+ } else if (mg.state === 'absent-rolldown') {
844
+ console.log(
845
+ 'note: no module graph collected - enable devtools metrics to pin one for graph-diff.',
846
+ );
847
+ }
848
+ }
849
+
850
+ // --- coverage core ----------------------------------------------------------------
851
+
852
+ function buildCoverageReport(target, cov, entry) {
853
+ // Fetch-timing map (decoded pathname -> bytes) for scripts whose download
854
+ // began before first paint - see attributeChunks' static-prepaint-transfer.
855
+ const prePaintFetches = new Map();
856
+ for (const fetch of cov.scriptFetches ?? []) {
857
+ if (!fetch.prePaint) continue;
858
+ let file = fetch.pathname;
859
+ try {
860
+ file = decodeURIComponent(file);
861
+ } catch {
862
+ /* keep raw */
863
+ }
864
+ prePaintFetches.set(file.replace(/^\/+/, ''), fetch.bytes ?? 0);
865
+ }
866
+ const { chunks, modules, skipped } = attributeChunks({
867
+ scripts: cov.scripts,
868
+ entryName: `/${entry.replaceAll('\\', '/')}`,
869
+ prePaintFetches,
870
+ readChunk: (file) => {
871
+ const chunkFile = path.join(target.dist, file);
872
+ if (!fs.existsSync(chunkFile) || !fs.existsSync(`${chunkFile}.map`)) return null;
873
+ return {
874
+ code: fs.readFileSync(chunkFile, 'utf8'),
875
+ map: JSON.parse(fs.readFileSync(`${chunkFile}.map`, 'utf8')),
876
+ };
877
+ },
878
+ });
879
+
880
+ // Defer candidates. For the demo app these map to feature marker blocks (the
881
+ // seams `defer <name>` can rewrite); otherwise they are advisory per-module.
882
+ const modes = target.isDemo ? featureModes(APP_DIR) : null;
883
+ const candidates = [];
884
+ for (const mod of modules) {
885
+ if (mod.totalBytes < CANDIDATE_MIN_BYTES || mod.paintRatio >= CANDIDATE_MAX_PAINT_RATIO)
886
+ continue;
887
+ const feature = FEATURE_NAMES.find((n) => mod.source.endsWith(`features/${n}.ts`));
888
+ if (modes) {
889
+ if (!feature || modes[feature] !== 'baseline') continue;
890
+ candidates.push({ feature, ...mod });
891
+ } else {
892
+ candidates.push({ feature: feature ?? null, ...mod });
893
+ }
894
+ }
895
+
896
+ const report = {
897
+ schemaVersion: 1,
898
+ generatedAtMs: Date.now(),
899
+ entry,
900
+ chunks,
901
+ skippedChunks: skipped,
902
+ deferred: target.isDemo ? deferredList() : null,
903
+ thresholds: {
904
+ candidateMinBytes: CANDIDATE_MIN_BYTES,
905
+ candidateMaxPaintRatio: CANDIDATE_MAX_PAINT_RATIO,
906
+ },
907
+ modules,
908
+ candidates,
909
+ coldAtPaint: coldAtPaintModules(modules).slice(0, 20),
910
+ };
911
+ writeJson(target.paths.coverage, report);
912
+ return report;
913
+ }
914
+
915
+ // compact:true is the re-scan view: the actionable sections (chunks, candidates, cold
916
+ // bytes, large-at-paint, sibling groups) stay complete, only the informational module
917
+ // table shrinks - it is the biggest block of a scan and re-printing 60 rows on every
918
+ // iteration multiplies what an agent re-reads each turn for the rest of its session.
919
+ // advice:false drops the "next:" paragraphs for the same reason as printMeasureSummary:
920
+ // scan prints the verdict right after, where every one of them reappears as a lead's
921
+ // next: line. Standalone `coverage` has no verdict following it and keeps them.
922
+ function printCoverageReport(target, report, { compact = false, advice = true } = {}) {
923
+ const { modules, candidates, entry } = report;
924
+ const chunks = report.chunks ?? [];
925
+ const extraChunks = chunks.filter((c) => !c.entry);
926
+ const chunkTag = (mod) =>
927
+ extraChunks.some((c) => c.file === mod.chunk) ? ` [${mod.chunk}]` : '';
928
+ console.log(
929
+ `initial-load coverage (${entry}${extraChunks.length ? ` + ${extraChunks.length} pre-paint chunk(s)` : ''}, first paint vs settled):\n`,
930
+ );
931
+ if (extraChunks.length) {
932
+ console.log(
933
+ ' pre-paint chunks - fetched AND executed before first paint, so critical-path transfer:',
934
+ );
935
+ for (const c of extraChunks) {
936
+ console.log(` ${c.file} ${kb(c.totalBytes)} paint ${(c.paintRatio * 100).toFixed(0)}%`);
937
+ }
938
+ }
939
+ for (const s of report.skippedChunks ?? []) {
940
+ if (s.reason === 'no-sourcemap') {
941
+ console.log(
942
+ ` NOTE: chunk ${s.file} executed before paint but has no sourcemap - its bytes are NOT attributed below`,
943
+ );
944
+ }
945
+ }
946
+ const staticPrepaint = (report.skippedChunks ?? [])
947
+ .filter((s) => s.reason === 'static-prepaint-transfer')
948
+ .sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
949
+ if (staticPrepaint.length) {
950
+ const totalBytes = staticPrepaint.reduce((sum, s) => sum + (s.bytes ?? 0), 0);
951
+ console.log(
952
+ ` STATIC PRE-PAINT TRANSFER: ${staticPrepaint.length} chunk(s), ${kb(totalBytes)} - fetched BEFORE first paint (static tags/preloads)`,
953
+ );
954
+ console.log(
955
+ ' but executed only after it. Their download competes with the paint for bandwidth. Largest:',
956
+ );
957
+ for (const s of staticPrepaint.slice(0, 10)) {
958
+ console.log(
959
+ ` ${s.file} ${kb(s.bytes ?? 0)}${s.neverExecuted ? ' (never executed by settle at all)' : ''}`,
960
+ );
961
+ }
962
+ if (staticPrepaint.length > 10)
963
+ console.log(` ... +${staticPrepaint.length - 10} more in coverage.json`);
964
+ if (advice) {
965
+ console.log(
966
+ ' next: load these on demand (dynamic import / drop them from the initial script tags or preloads)',
967
+ );
968
+ console.log(' so the first paint stops paying for their transfer.');
969
+ }
970
+ }
971
+ const lazyChunks = (report.skippedChunks ?? []).filter((s) => s.reason === 'post-paint');
972
+ if (lazyChunks.length) {
973
+ console.log(
974
+ ` (${lazyChunks.length} chunk(s) fetched AND first executed after paint - already deferred, not analyzed: ${lazyChunks.map((s) => s.file).join(', ')})`,
975
+ );
976
+ }
977
+ if (extraChunks.length || lazyChunks.length || staticPrepaint.length) console.log('');
978
+ const pct = (r) => `${(r * 100).toFixed(1)}%`;
979
+ const shown = modules.slice(0, compact ? 15 : 60);
980
+ for (const mod of shown) {
981
+ const verdict =
982
+ mod.paintRatio >= CANDIDATE_MAX_PAINT_RATIO
983
+ ? 'used-before-paint'
984
+ : mod.settleRatio >= CANDIDATE_MAX_PAINT_RATIO
985
+ ? 'post-paint-only'
986
+ : 'not-executed-by-settle';
987
+ console.log(
988
+ ` ${mod.source.padEnd(34)} ${kb(mod.totalBytes).padStart(9)} ` +
989
+ `paint ${pct(mod.paintRatio).padStart(6)} settle ${pct(mod.settleRatio).padStart(6)} ${verdict}${chunkTag(mod)}`,
990
+ );
991
+ }
992
+ if (modules.length > shown.length) {
993
+ console.log(
994
+ ` ... +${modules.length - shown.length} more modules (largest shown) - full list in coverage.json` +
995
+ (compact ? ', full table with scan --full' : ''),
996
+ );
997
+ }
998
+ if (candidates.length) {
999
+ const shownCandidates = candidates.slice(0, 12);
1000
+ console.log(
1001
+ `\ndefer candidates (>=${kb(CANDIDATE_MIN_BYTES)}, <${CANDIDATE_MAX_PAINT_RATIO * 100}% executed at paint), largest first:`,
1002
+ );
1003
+ for (const c of shownCandidates) {
1004
+ console.log(
1005
+ ` ${c.feature ?? c.source} (${kb(c.totalBytes)}${chunkTag(c) ? `, in ${c.chunk}` : ''})` +
1006
+ `${c.feature ? ` -> node harness.mjs defer ${c.feature}` : ''}`,
1007
+ );
1008
+ }
1009
+ if (candidates.length > shownCandidates.length) {
1010
+ console.log(` ... +${candidates.length - shownCandidates.length} more in coverage.json`);
1011
+ }
1012
+ if (advice) {
1013
+ console.log(
1014
+ target.isDemo
1015
+ ? '\nnext: defer the top candidate, rebuild, then measure.'
1016
+ : '\nnext: change the app so the landing page stops loading these before first paint\n' +
1017
+ '(follow their import chains from the entry), rebuild, run its functional check, then measure.',
1018
+ );
1019
+ }
1020
+ } else {
1021
+ console.log(
1022
+ '\nno defer candidates at current thresholds - nothing sizeable is parsed-but-unexecuted.',
1023
+ );
1024
+ }
1025
+
1026
+ // The unified byte view: candidates catch the never-ran extreme, but a module
1027
+ // that PARTIALLY executes at boot (vendor SDK init) matches no other bucket
1028
+ // while holding the most recoverable weight. Rank by cold bytes so it can't hide.
1029
+ const cold = report.coldAtPaint ?? coldAtPaintModules(modules);
1030
+ if (cold.length) {
1031
+ const shownCold = cold.slice(0, 12);
1032
+ console.log(
1033
+ '\ncold bytes at paint - fetched+parsed before first paint but mostly unread by it, coldest first:',
1034
+ );
1035
+ for (const mod of shownCold) {
1036
+ console.log(
1037
+ ` ${kb(mod.coldBytes).padStart(9)} cold (${kb(mod.totalBytes)} @ ${(mod.paintRatio * 100).toFixed(0)}% at paint) ${mod.source}` +
1038
+ `${chunkTag(mod) ? ` [${mod.chunk}]` : ''}${mod.framework ? ' (framework runtime - import edge rarely movable)' : ''}`,
1039
+ );
1040
+ }
1041
+ if (cold.length > shownCold.length)
1042
+ console.log(` ... +${cold.length - shownCold.length} more in coverage.json`);
1043
+ if (advice) {
1044
+ console.log(
1045
+ 'next: for each non-framework module, find the import edge that pulls it in before paint and move',
1046
+ );
1047
+ console.log(
1048
+ 'that edge behind interaction/idle (dynamic import). A middling percentage on a vendor SDK usually',
1049
+ );
1050
+ console.log(
1051
+ 'means one boot-time init call drags the whole package - defer the call, not just the import.',
1052
+ );
1053
+ }
1054
+ }
1055
+
1056
+ // Executed-at-paint is NOT the same as needed-at-paint: top-level data counts
1057
+ // as "executed" the moment its module is imported. Surface the places where
1058
+ // that inversion typically hides real weight.
1059
+ const largeHot = largeAtPaintModules(modules);
1060
+ if (largeHot.length) {
1061
+ console.log(
1062
+ '\nlarge modules fully executed at paint - "executed" does NOT prove the first paint needs their contents (top-level data evaluates on import):',
1063
+ );
1064
+ for (const mod of largeHot) {
1065
+ console.log(
1066
+ ` ${mod.source} (${kb(mod.totalBytes)}, ${(mod.paintRatio * 100).toFixed(0)}% at paint${chunkTag(mod) ? `, in ${mod.chunk}` : ''})`,
1067
+ );
1068
+ }
1069
+ if (advice) {
1070
+ console.log(
1071
+ 'next: for each, check how much of it the first render actually reads; split rarely-read parts (full records, long bodies, alternate variants) into a module reached only by dynamic import.',
1072
+ );
1073
+ }
1074
+ }
1075
+
1076
+ for (const group of siblingVariantGroups(modules)) {
1077
+ console.log(
1078
+ `\nsibling group ${group.dir}: ${group.files} modules, ${kb(group.bytes)}, ~${Math.round((group.paintBytes / group.bytes) * 100)}% executed at paint.`,
1079
+ );
1080
+ if (advice) {
1081
+ console.log(
1082
+ 'next: families of same-shaped modules (locales, themes, per-tenant configs) usually need only ONE variant per session - keep the default in the entry and load the active variant with a dynamic import.',
1083
+ );
1084
+ }
1085
+ }
1086
+ }
1087
+
1088
+ // --- profile core ----------------------------------------------------------------
1089
+
1090
+ function buildProfileReport(target, profile, entry) {
1091
+ const summary = aggregateProfile(profile, {
1092
+ readChunk: (file) => {
1093
+ const chunkFile = path.join(target.dist, file);
1094
+ if (!fs.existsSync(chunkFile) || !fs.existsSync(`${chunkFile}.map`)) return null;
1095
+ return {
1096
+ code: fs.readFileSync(chunkFile, 'utf8'),
1097
+ map: JSON.parse(fs.readFileSync(`${chunkFile}.map`, 'utf8')),
1098
+ };
1099
+ },
1100
+ });
1101
+ const report = { schemaVersion: 2, generatedAtMs: Date.now(), entry, ...summary };
1102
+ writeJson(target.paths.profile, report);
1103
+ return report;
1104
+ }
1105
+
1106
+ function printProfileReport(report, { advice = true } = {}) {
1107
+ console.log(`boot CPU by module, navigation -> FCP observation (${report.totalMs}ms sampled):\n`);
1108
+ for (const row of report.rows.slice(0, 20)) {
1109
+ const pctStr =
1110
+ report.totalMs > 0 ? `${((row.ms / report.totalMs) * 100).toFixed(0).padStart(4)}%` : '';
1111
+ console.log(
1112
+ ` ${row.bucket.padEnd(40)} ${String(row.ms).padStart(7)}ms ${pctStr} [${row.kind}]`,
1113
+ );
1114
+ }
1115
+ console.log(
1116
+ `\nsource ${report.attribution.sourceMs}ms | engine ${report.attribution.engineMs}ms | unattributed ${report.attribution.unattributedMs}ms`,
1117
+ );
1118
+ console.log(
1119
+ 'The profile can include work between FCP and its observation. Sampled time is not a predicted paint gain.',
1120
+ );
1121
+ if (advice) {
1122
+ console.log(
1123
+ '\nnext: check whether each source module contributes to the first render. Test deferring only optional work. Compare both FCP and LCP.',
1124
+ );
1125
+ if (profileEvidence(report).unattributed.length) {
1126
+ console.log(
1127
+ 'Inspect unattributed rows and supply missing sourcemaps before treating CPU attribution as complete.',
1128
+ );
1129
+ }
1130
+ }
1131
+ }
1132
+
1133
+ // --- verdict core ----------------------------------------------------------------
1134
+ // The lesson behind this command: a diagnostic tool's completeness claim is
1135
+ // load-bearing — an agent that trusts a premature "converged" stops in front of
1136
+ // real wins. `verdict` therefore fuses EVERY signal class, refuses to conclude
1137
+ // while any lead is open or any signal is missing/stale, and states the tools'
1138
+ // blind-spot boundary even when everything is clear.
1139
+
1140
+ function printVerdict(target, opts = {}) {
1141
+ const entry = requireEntry(target, opts);
1142
+ const entryFile = path.join(target.dist, entry);
1143
+ const builtAtMs = fs.statSync(entryFile).mtimeMs;
1144
+ // A report vouches only for the build it ran against: entry filename must match
1145
+ // (hashed names change with content) and it must postdate the current build.
1146
+ const fresh = (report) =>
1147
+ Boolean(report) &&
1148
+ (!report.entry || report.entry === entry) &&
1149
+ report.generatedAtMs >= builtAtMs;
1150
+
1151
+ const runtime = readJson(target.paths.runtimeMetrics);
1152
+ const coverage = readJson(target.paths.coverage);
1153
+ const profile = readJson(target.paths.profile);
1154
+
1155
+ const lines = [];
1156
+ let openCount = 0;
1157
+ let unknownCount = 0;
1158
+ let pendingCount = 0;
1159
+ // pending = a check that belongs at the END of the loop (rendered-output parity):
1160
+ // it blocks "done" like UNKNOWN does, but says it is not wanted per change.
1161
+ const lead = (state, title, detail, next) => {
1162
+ if (state === 'open') openCount++;
1163
+ if (state === 'unknown') unknownCount++;
1164
+ if (state === 'pending') pendingCount++;
1165
+ const tag = {
1166
+ open: '[OPEN] ',
1167
+ unknown: '[UNKNOWN]',
1168
+ pending: '[PENDING]',
1169
+ clear: '[clear] ',
1170
+ }[state];
1171
+ lines.push(` ${tag} ${title}${detail ? ` - ${detail}` : ''}`);
1172
+ if (next && state !== 'clear') lines.push(` next: ${next}`);
1173
+ };
1174
+
1175
+ // Rendered output first: it decides whether any number below counts. It is
1176
+ // compared ONCE, when the optimizing is done; a difference starts recover mode.
1177
+ const parity = parityStatus(target);
1178
+ const recover = readJson(target.paths.parity.recover);
1179
+ const parityTitle = 'rendered output vs the original build';
1180
+ const recheck =
1181
+ recover?.strategy === 'each-tweak'
1182
+ ? `every scan re-checks (strategy each-tweak)`
1183
+ : `\`${CLI} parity\` (all pages; --only <page> re-checks just the broken ones)`;
1184
+ if (parity.state === 'none') {
1185
+ lead(
1186
+ 'unknown',
1187
+ parityTitle,
1188
+ 'no original build saved for this target',
1189
+ `${CLI} scan - the first scan saves the CURRENT build as the original, so run it before changing anything`,
1190
+ );
1191
+ } else if (parity.report && !parity.report.integrity) {
1192
+ lead(
1193
+ 'unknown',
1194
+ parityTitle,
1195
+ 'the saved original build was modified after it was saved',
1196
+ `a human builds the original commit and saves it again: ${CLI} parity anchor --replace`,
1197
+ );
1198
+ } else if (parity.state === 'open') {
1199
+ const open = parity.report.pages.filter((p) => p.state === 'open');
1200
+ lead(
1201
+ 'open',
1202
+ `rendered output differs from the original on ${open.length}/${parity.manifest.pages.length} page(s)`,
1203
+ open.map((p) => `${p.page}: ${p.reasons.join('; ')}`).join(' | '),
1204
+ `RECOVER MODE - this build's LCP numbers do not count. Fix or revert the change that caused it (diff images: ${target.paths.parity.pages}); re-check with ${recheck}. Options: \`${CLI} parity recover\``,
1205
+ );
1206
+ } else if (parity.state === 'stale' || parity.state === 'partial') {
1207
+ const checked = parity.state === 'partial' ? parity.report.pages.length : 0;
1208
+ if (recover) {
1209
+ lead(
1210
+ 'unknown',
1211
+ parityTitle,
1212
+ `RECOVER MODE - ${checked ? `only ${checked}/${parity.manifest.pages.length} page(s) re-checked for this build` : 'this build is not re-checked yet'}`,
1213
+ `re-check with ${recheck}; recover mode ends when every page matches`,
1214
+ );
1215
+ } else if (checked) {
1216
+ lead(
1217
+ 'unknown',
1218
+ parityTitle,
1219
+ `only ${checked} of ${parity.manifest.pages.length} page(s) compared for this build`,
1220
+ `${CLI} parity`,
1221
+ );
1222
+ } else {
1223
+ lead(
1224
+ 'pending',
1225
+ parityTitle,
1226
+ 'compared once, when you have finished optimizing - not needed per change',
1227
+ `when you are done: ${CLI} scan --parity (the final measurement + the comparison on every pinned page)`,
1228
+ );
1229
+ }
1230
+ } else {
1231
+ const pages = parity.report?.pages ?? [];
1232
+ const unstable = pages.filter((p) => p.state === 'unknown');
1233
+ // A check made without a fresh timed measurement could not compare the LCP
1234
+ // element; a measurement taken since (same build) can, with no capture.
1235
+ const lcpLate =
1236
+ parity.state !== 'identical' && !parity.report.lcpCompared && fresh(runtime)
1237
+ ? lateLcpCheck(parity.manifest, runtime)
1238
+ : null;
1239
+ if (unstable.length) {
1240
+ lead(
1241
+ 'unknown',
1242
+ parityTitle,
1243
+ unstable.map((p) => `${p.page}: ${p.notes[0] ?? 'not judged'}`).join(' | '),
1244
+ `a human declares masks for the parts that move: ${CLI} parity --mask <selector>`,
1245
+ );
1246
+ } else if (lcpLate && ['changed', 'shrank', 'placeholder'].includes(lcpLate.state)) {
1247
+ lead(
1248
+ 'open',
1249
+ 'rendered output: the LCP element is not the original one',
1250
+ lcpReason(lcpLate),
1251
+ `RECOVER MODE - LCP now times a cheaper paint, so the gain does not count. Fix or revert the change that caused it; re-check with ${CLI} scan --parity`,
1252
+ );
1253
+ } else if (parity.state !== 'identical' && !parity.report.lcpCompared && !lcpLate) {
1254
+ lead(
1255
+ 'unknown',
1256
+ parityTitle,
1257
+ `same on ${pages.length} page(s), but the LCP element was not compared (no fresh timed measurement of this build)`,
1258
+ `${CLI} scan`,
1259
+ );
1260
+ } else {
1261
+ const same = pages.filter((p) => p.state === 'clear').length;
1262
+ lead(
1263
+ 'clear',
1264
+ parityTitle,
1265
+ `${parity.state === 'identical' ? 'this build is byte-identical to the original' : `same on ${same} of ${pages.length} page(s)`}${parityExtras(parity.report)}`,
1266
+ );
1267
+ }
1268
+ }
1269
+
1270
+ if (!fresh(runtime)) {
1271
+ lead(
1272
+ 'unknown',
1273
+ 'render gap / pre-paint CPU',
1274
+ runtime ? 'measurement is stale (dist was rebuilt after it)' : 'no measurement yet',
1275
+ `${CLI} measure --runs 5 --label <name> (or scan)`,
1276
+ );
1277
+ } else {
1278
+ const gate = runtime.renderBlockingGate;
1279
+ if (gate?.gating) {
1280
+ lead(
1281
+ 'open',
1282
+ `render-blocking CSS gates first paint (${gate.count} stylesheet(s), ${gate.kb}KB)`,
1283
+ `held ${Math.round(gate.shareOfFcp * 100)}% of the FCP timeline (finished ${gate.lastEndMs}ms, FCP ${gate.fcpMs}ms) - nothing painted until it arrived: ${(
1284
+ gate.worst ?? []
1285
+ )
1286
+ .slice(0, 2)
1287
+ .map((w) => `${w.name} ${kb(w.bytes)}`)
1288
+ .join(', ')}`,
1289
+ 'inline critical CSS, load the rest non-blocking (preload + media swap), split route-only styles - fix this before judging CPU/JS deferrals',
1290
+ );
1291
+ } else {
1292
+ lead(
1293
+ 'clear',
1294
+ 'render-blocking CSS',
1295
+ gate
1296
+ ? `blocking CSS held only ${Math.round((gate.shareOfFcp ?? 0) * 100)}% of the FCP timeline - not the gate`
1297
+ : 'none observed',
1298
+ );
1299
+ }
1300
+
1301
+ const gap = runtime.metrics['runtime.render_gap_ms'];
1302
+ if (typeof gap === 'number' && gap > 150) {
1303
+ lead(
1304
+ 'open',
1305
+ `LCP after load: ${Math.round(gap)}ms`,
1306
+ 'downloads, CPU work, or visibility changes can delay paint',
1307
+ `Inspect all candidates below. Check the source and the CPU profile (${CLI} profile). Check whether an animation hides the LCP element.`,
1308
+ );
1309
+ for (const evidence of renderGapEvidence(runtime)) {
1310
+ lines.push(` candidate: ${evidence.detail}`);
1311
+ lines.push(` next: ${evidence.next}`);
1312
+ }
1313
+ } else {
1314
+ lead(
1315
+ gap == null ? 'unknown' : 'clear',
1316
+ 'LCP after load',
1317
+ gap == null ? 'not measurable' : `LCP lands ${Math.round(gap)}ms after load`,
1318
+ `${CLI} scan`,
1319
+ );
1320
+ }
1321
+
1322
+ for (const paint of ['fcp', 'lcp']) {
1323
+ const discovery = runtime.chunkDiscovery?.[paint];
1324
+ lead(
1325
+ discovery?.state ?? 'unknown',
1326
+ `late chunk candidates before ${paint.toUpperCase()}`,
1327
+ !discovery
1328
+ ? 'runtime report has no discovery data for this paint'
1329
+ : (discovery.reason ??
1330
+ (discovery.groups.length
1331
+ ? `${discovery.groups.length} static group(s); paint dependencies are unverified. Largest: ${discovery.groups[0].parent}, ${kib(discovery.groups[0].bytes)}`
1332
+ : `${discovery.matchedScripts} scripts matched; no static descendant met the delay threshold before this paint. Dynamic imports are excluded.`)),
1333
+ discovery?.state === 'open'
1334
+ ? `Inspect runtime-metrics.json -> chunkDiscovery.${paint}.groups. ${chunkDiscoveryAdvice(paint)} Groups overlap. Delay is not a predicted paint gain.`
1335
+ : 'run scan with full request details and a current chunk-graph.json or Vite manifest',
1336
+ );
1337
+ }
1338
+
1339
+ const cpu = runtime.metrics['runtime.prepaint_longtask_ms'];
1340
+ if (typeof cpu !== 'number' || cpu <= 150) {
1341
+ lead(
1342
+ 'clear',
1343
+ 'pre-paint CPU',
1344
+ cpu == null
1345
+ ? 'no long tasks observed'
1346
+ : `${Math.round(cpu)}ms of long tasks before paint (baseline territory)`,
1347
+ );
1348
+ } else if (!fresh(profile)) {
1349
+ lead(
1350
+ 'unknown',
1351
+ `pre-paint CPU ${Math.round(cpu)}ms`,
1352
+ profile ? 'profile is stale (dist was rebuilt after it)' : 'not yet attributed to modules',
1353
+ `${CLI} profile (or scan)`,
1354
+ );
1355
+ } else {
1356
+ const evidence = profileEvidence(profile);
1357
+ lead(
1358
+ evidence.state,
1359
+ `pre-paint CPU ${Math.round(cpu)}ms`,
1360
+ evidence.reason ??
1361
+ (evidence.candidates.length
1362
+ ? `sampled source work near FCP: ${evidence.candidates
1363
+ .slice(0, 3)
1364
+ .map((row) => `${row.bucket} ${row.ms}ms`)
1365
+ .join(', ')}`
1366
+ : evidence.unattributed.length
1367
+ ? 'sampled work lacks complete source attribution'
1368
+ : 'no source module reaches the 15ms sample threshold'),
1369
+ 'Check which work the first render needs. Test deferring optional work and compare both FCP and LCP. Run profile again for old reports.',
1370
+ );
1371
+ if (evidence.unattributed.length) {
1372
+ lead(
1373
+ 'unknown',
1374
+ 'CPU source attribution',
1375
+ `${profile.attribution.unattributedMs}ms unattributed: ${evidence.unattributed
1376
+ .slice(0, 3)
1377
+ .map((row) => `${row.bucket} [${row.kind}]`)
1378
+ .join(', ')}`,
1379
+ 'Inspect profile.json. Supply missing sourcemaps or inspect generated/inline code; do not classify these rows as engine work.',
1380
+ );
1381
+ }
1382
+ }
1383
+ }
1384
+
1385
+ if (!fresh(coverage)) {
1386
+ lead(
1387
+ 'unknown',
1388
+ 'coverage (cold-at-paint / candidates / large-at-paint / sibling groups)',
1389
+ coverage ? 'coverage report is stale (dist was rebuilt after it)' : 'no coverage run yet',
1390
+ `${CLI} coverage (or scan)`,
1391
+ );
1392
+ } else {
1393
+ const candidates = coverage.candidates ?? [];
1394
+ const inChunk = (c) =>
1395
+ coverage.chunks?.some((ch) => !ch.entry && ch.file === c.chunk) ? ` [${c.chunk}]` : '';
1396
+ if (candidates.length) {
1397
+ lead(
1398
+ 'open',
1399
+ `defer candidates (${candidates.length})`,
1400
+ candidates
1401
+ .slice(0, 3)
1402
+ .map((c) => `${c.feature ?? c.source} ${kb(c.totalBytes)}${inChunk(c)}`)
1403
+ .join(', '),
1404
+ 'lazy-load them, rebuild, re-measure',
1405
+ );
1406
+ } else {
1407
+ lead('clear', 'defer candidates', 'nothing sizeable is parsed-but-unexecuted');
1408
+ }
1409
+
1410
+ const cold = (coverage.coldAtPaint ?? coldAtPaintModules(coverage.modules ?? [])).filter(
1411
+ (m) => !m.framework,
1412
+ );
1413
+ const coldOpen = cold.filter((m) => m.coldBytes >= COLD_OPEN_MIN_BYTES);
1414
+ if (coldOpen.length) {
1415
+ lead(
1416
+ 'open',
1417
+ `cold bytes at paint (${coldOpen.length} module(s) >=${kb(COLD_OPEN_MIN_BYTES)} cold)`,
1418
+ coldOpen
1419
+ .slice(0, 4)
1420
+ .map(
1421
+ (m) =>
1422
+ `${m.source} ${kb(m.coldBytes)} cold of ${kb(m.totalBytes)} @ ${(m.paintRatio * 100).toFixed(0)}%${inChunk(m)}`,
1423
+ )
1424
+ .join(', '),
1425
+ 'move their import edges behind interaction/idle; a partially-executed vendor SDK usually hides one boot-time init call - defer the call, not just the import',
1426
+ );
1427
+ } else {
1428
+ lead(
1429
+ 'clear',
1430
+ 'cold bytes at paint',
1431
+ `no non-framework module holds >=${kb(COLD_OPEN_MIN_BYTES)} unread at paint`,
1432
+ );
1433
+ }
1434
+ const unattributed = (coverage.skippedChunks ?? []).filter((s) => s.reason === 'no-sourcemap');
1435
+ if (unattributed.length) {
1436
+ lead(
1437
+ 'unknown',
1438
+ `unattributed pre-paint chunk(s): ${unattributed.map((s) => s.file).join(', ')}`,
1439
+ 'executed before first paint but built without a sourcemap - their bytes are invisible to coverage',
1440
+ 'rebuild with sourcemaps for these chunks, then re-run coverage',
1441
+ );
1442
+ }
1443
+
1444
+ const staticPre = (coverage.skippedChunks ?? [])
1445
+ .filter((s) => s.reason === 'static-prepaint-transfer')
1446
+ .sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
1447
+ const staticPreBytes = staticPre.reduce((sum, s) => sum + (s.bytes ?? 0), 0);
1448
+ if (staticPreBytes >= 100 * 1024) {
1449
+ lead(
1450
+ 'open',
1451
+ `static pre-paint transfer (${staticPre.length} chunk(s), ${kb(staticPreBytes)})`,
1452
+ `fetched before paint, executed after it - the paint paid for the download: ${staticPre
1453
+ .slice(0, 3)
1454
+ .map((s) => `${s.file} ${kb(s.bytes ?? 0)}`)
1455
+ .join(', ')}`,
1456
+ 'make these load on demand (dynamic import / drop from initial script tags or preloads) - biggest lever when transfer dominates LCP',
1457
+ );
1458
+ } else {
1459
+ lead(
1460
+ 'clear',
1461
+ 'static pre-paint transfer',
1462
+ staticPre.length
1463
+ ? `only ${kb(staticPreBytes)} fetched-but-unused before paint`
1464
+ : 'nothing fetched before paint that the paint does not use',
1465
+ );
1466
+ }
1467
+
1468
+ const largeHot = largeAtPaintModules(coverage.modules ?? []);
1469
+ if (largeHot.length) {
1470
+ lead(
1471
+ 'open',
1472
+ `large modules executed at paint (${largeHot.length})`,
1473
+ largeHot
1474
+ .slice(0, 3)
1475
+ .map((m) => `${m.source} ${kb(m.totalBytes)}`)
1476
+ .join(', '),
1477
+ 'executed does not mean needed - verify how much the first render reads; split rarely-read data behind dynamic import',
1478
+ );
1479
+ } else {
1480
+ lead('clear', 'large modules executed at paint', 'none at current thresholds');
1481
+ }
1482
+
1483
+ const groups = siblingVariantGroups(coverage.modules ?? []);
1484
+ if (groups.length) {
1485
+ lead(
1486
+ 'open',
1487
+ `sibling variant groups (${groups.length})`,
1488
+ groups.map((g) => `${g.dir} ${g.files} modules ${kb(g.bytes)}`).join(', '),
1489
+ 'keep the default variant in the entry, load the active one dynamically',
1490
+ );
1491
+ } else {
1492
+ lead('clear', 'sibling variant groups', 'none detected');
1493
+ }
1494
+ }
1495
+
1496
+ // Static retained imports: only exists on rolldown builds - omitted entirely
1497
+ // elsewhere, UNKNOWN (with the one config line that enables it) when the app
1498
+ // detectably builds with rolldown but the graph was never collected.
1499
+ const mg = moduleGraphStatus(target, builtAtMs);
1500
+ if (mg.state === 'present') {
1501
+ const rows = retainedLeadRows(mg.graph);
1502
+ if (rows.length) {
1503
+ const mods = mg.graph.modules;
1504
+ lead(
1505
+ 'open',
1506
+ `statically retained imports from build entries (${rows.length} module(s) >=${kb(GRAPH_RETAINED_OPEN_BYTES)})`,
1507
+ rows
1508
+ .slice(0, 3)
1509
+ .map(
1510
+ (m) =>
1511
+ `${m.id} retains ${kb(m.retainedBytes)}/${m.retainedModuleCount} module(s)${m.idom != null ? ` via ${mods[m.idom].id}` : ''}`,
1512
+ )
1513
+ .join(', '),
1514
+ `${CLI} what-if <module> prices the deferral (exact modules+bytes freed; --keep a,b holds needed parts eager); ${CLI} cut <module> lists the fewest import edits when several paths reach it; make the importer use dynamic import(). Retained is potential, not proof - if the first render genuinely needs it, justify with a measurement or constraint`,
1515
+ );
1516
+ } else {
1517
+ lead(
1518
+ 'clear',
1519
+ 'statically retained imports (build entries only)',
1520
+ `no non-framework module retains >=${kb(GRAPH_RETAINED_OPEN_BYTES)} behind a cuttable static edge`,
1521
+ );
1522
+ }
1523
+ } else if (mg.state === 'stale') {
1524
+ lead(
1525
+ 'unknown',
1526
+ 'static module graph',
1527
+ 'module-graph.json predates the current build - a build without the devtools flag leaves it stale',
1528
+ 'keep build.rolldownOptions.devtools = { mode: "report" } in the vite config, rebuild, re-scan',
1529
+ );
1530
+ } else if (mg.state === 'absent-rolldown') {
1531
+ lead(
1532
+ 'unknown',
1533
+ 'static module graph',
1534
+ 'not collected - this app builds with rolldown, so one config line enables static split-candidate ranking',
1535
+ `vite >= 8: add build.rolldownOptions.devtools = { mode: "report" } to the vite config, rebuild, re-scan - \`${CLI} graph\` then ranks every candidate by the bytes a deferral frees, \`${CLI} what-if\` prices one cut, no browser run needed`,
1536
+ );
1537
+ }
1538
+
1539
+ console.log(`verdict for ${target.dist} (entry ${entry})\n`);
1540
+ for (const line of lines) console.log(line);
1541
+ console.log('');
1542
+ if (fresh(runtime) && runtime.earlyStopped) {
1543
+ console.log(
1544
+ `NOTE: the latest measurement EARLY-STOPPED after run 1 - its LCP delta was >=${runtime.earlyStopped.factor}x the`,
1545
+ );
1546
+ console.log(
1547
+ 'noise threshold. This is a probe. Use scan --no-early-stop to check both FCP and LCP before a decision.\n',
1548
+ );
1549
+ } else if (fresh(runtime) && runtime.runs < 3) {
1550
+ console.log(
1551
+ 'NOTE: the latest measurement used fewer than 3 runs. Its deltas are indicative only.',
1552
+ );
1553
+ console.log(
1554
+ 'Check both FCP and LCP with scan --no-early-stop (at least 3 runs) before a keep/revert decision.\n',
1555
+ );
1556
+ }
1557
+ if (openCount + unknownCount + pendingCount === 0) {
1558
+ console.log(
1559
+ 'VERDICT: every signal class is clear and fresh. Nothing further is indicated by these tools.',
1560
+ );
1561
+ console.log(
1562
+ 'Boundary: coverage attributes the entry + same-origin pre-paint chunks with sourcemaps;',
1563
+ );
1564
+ console.log(
1565
+ 'cross-origin scripts are unattributed, and non-JS weight (fonts/images/CSS) is counted',
1566
+ );
1567
+ console.log(
1568
+ 'by type but not analyzed further. Server latency, cache policy, and interaction-time',
1569
+ );
1570
+ console.log(
1571
+ 'cost are out of scope. Remaining LCP is baseline network + parse + render for what the',
1572
+ );
1573
+ console.log('page genuinely needs at first paint.');
1574
+ console.log(
1575
+ 'Rendered output: compared with the original on every pinned page (first screen + full page',
1576
+ );
1577
+ console.log(
1578
+ 'pixels, text, accessible names, runtime errors, LCP element) as served statically - states',
1579
+ );
1580
+ console.log('behind interactions or live API data are not covered.');
1581
+ } else {
1582
+ console.log(
1583
+ `VERDICT: not done - ${openCount} lead(s) OPEN, ${unknownCount} signal(s) UNKNOWN or stale` +
1584
+ `${pendingCount ? `, ${pendingCount} check(s) PENDING until you finish` : ''}.`,
1585
+ );
1586
+ if (openCount === 0 && unknownCount === 0) {
1587
+ console.log(
1588
+ `Only the final rendered-output comparison is left: \`${CLI} scan --parity\` once you have`,
1589
+ );
1590
+ console.log('finished optimizing; report only after it is clear.');
1591
+ }
1592
+ if (parity.state === 'open') {
1593
+ console.log(
1594
+ 'RECOVER MODE: this build renders differently from the original, so none of its LCP numbers',
1595
+ );
1596
+ console.log(
1597
+ `count until it matches again. Fix or revert the change that broke it FIRST (\`${CLI} parity recover\`).`,
1598
+ );
1599
+ }
1600
+ console.log(
1601
+ 'Work the OPEN items (render gap first), gather the UNKNOWN signals, rebuild, re-measure.',
1602
+ );
1603
+ console.log('');
1604
+ console.log(
1605
+ 'Do NOT report this work as finished or "confirmed by the harness" while leads are OPEN -',
1606
+ );
1607
+ console.log('a re-pinned baseline records your gain; it does not close the checklist above.');
1608
+ console.log(
1609
+ 'Copy the checklist verbatim into your final summary. If you stop now, your summary must',
1610
+ );
1611
+ console.log(
1612
+ `say "stopping with ${openCount} lead(s) OPEN" and justify each one with a measurement`,
1613
+ );
1614
+ console.log(
1615
+ '(you tried it and the delta was sub-noise) or a concrete constraint (framework dep,',
1616
+ );
1617
+ console.log('the first paint genuinely needs it, outside the declared scope).');
1618
+ }
1619
+ }
1620
+
1621
+ // --- commands ---------------------------------------------------------------
1622
+
1623
+ async function cmdGen(argv) {
1624
+ const opts = parse(argv, { force: { type: 'boolean', default: false } });
1625
+ const result = generateApp(APP_DIR, { force: opts.force });
1626
+ if (!result.written) {
1627
+ console.log(`app unchanged: ${result.reason}`);
1628
+ return;
1629
+ }
1630
+ console.log(`demo app generated at ${APP_DIR}`);
1631
+ console.log(`features: ${FEATURE_NAMES.map((n) => `${n} (~${FEATURES[n].kb}KB)`).join(', ')}`);
1632
+ }
1633
+
1634
+ async function cmdBuild() {
1635
+ if (!fs.existsSync(path.join(APP_DIR, 'src', 'main.ts'))) {
1636
+ throw new Error('no app yet - run `node harness.mjs gen` first');
1637
+ }
1638
+ guardStateDir();
1639
+ const result = await buildApp({ appDir: APP_DIR });
1640
+ console.log(`built in ${result.wallMs}ms`);
1641
+ console.log(`entry main.js: ${kb(result.entryBytes)}`);
1642
+ for (const chunk of result.chunks) console.log(`async ${chunk.file}: ${kb(chunk.bytes)}`);
1643
+ const m = result.buildMetrics?.metrics;
1644
+ if (m) {
1645
+ console.log(
1646
+ `build metrics: total ${m['build.total_ms']}ms, output ${kb(m['output.total_bytes'] ?? 0)}, ` +
1647
+ `max initial load ${kb(m['output.max_initial_load_bytes'] ?? 0)}`,
1648
+ );
1649
+ console.log(`build report: ${path.join(BUILD_METRICS_DIR, 'metrics.json')}`);
1650
+ } else {
1651
+ console.log('build metrics report missing (devtools report mode did not run?)');
1652
+ }
1653
+ }
1654
+
1655
+ async function cmdMeasure(argv) {
1656
+ const opts = parse(argv, {
1657
+ ...TARGET_OPTS,
1658
+ runs: { type: 'string', default: '5' },
1659
+ warmup: { type: 'string', default: '1' },
1660
+ label: { type: 'string', default: '' },
1661
+ settle: { type: 'string', default: '1500' },
1662
+ 'no-throttle': { type: 'boolean', default: false },
1663
+ features: { type: 'string' },
1664
+ pin: { type: 'boolean', default: false },
1665
+ 'no-early-stop': { type: 'boolean', default: false },
1666
+ 'net-scale': { type: 'string' },
1667
+ ...NETWORK_OPTS,
1668
+ });
1669
+ const networkFlag = parseNetworkFlags(opts);
1670
+ const target = resolveTarget(opts);
1671
+ const expectedFeatures = expectedFeaturesFor(target, opts);
1672
+ ensureAnchor(target);
1673
+ const baselineState = readJson(target.paths.runtimeBaseline);
1674
+ const pinnedLcp = baselineState?.metrics?.['runtime.lcp_ms'];
1675
+ // The early stop compares run 1 against the pinned baseline - only valid when
1676
+ // both were measured under the SAME pinned throttle.
1677
+ const expectedId = expectedThrottleId(target, opts, networkFlag);
1678
+ const scaleMatches =
1679
+ expectedId != null && baselineState != null && throttleId(baselineState) === expectedId;
1680
+ const earlyStop =
1681
+ !opts.pin && !opts['no-early-stop'] && typeof pinnedLcp === 'number' && scaleMatches
1682
+ ? {
1683
+ baselineLcp: pinnedLcp,
1684
+ thresholdMs: Math.max(NOISE_FLOOR_MS, (NOISE_FLOOR_PCT / 100) * pinnedLcp),
1685
+ factor: EARLY_STOP_FACTOR,
1686
+ }
1687
+ : null;
1688
+ const {
1689
+ samples,
1690
+ earlyStopped,
1691
+ throttle: usedThrottle,
1692
+ } = await withServerAndBrowser(
1693
+ target,
1694
+ opts['no-throttle'],
1695
+ async ({ origin, cdp, throttle }) => ({
1696
+ ...(await gatherSamples(cdp, origin, {
1697
+ throttle,
1698
+ expectedFeatures,
1699
+ runs: Number(opts.runs),
1700
+ warmup: Number(opts.warmup),
1701
+ settleMs: Number(opts.settle),
1702
+ earlyStop,
1703
+ })),
1704
+ throttle,
1705
+ }),
1706
+ { netScaleFlag: opts['net-scale'] ?? null, networkFlag },
1707
+ );
1708
+ const { report, hadBaseline } = writeMeasureReport(target, samples, {
1709
+ expectedFeatures,
1710
+ label: opts.label,
1711
+ throttle: usedThrottle,
1712
+ earlyStopped,
1713
+ });
1714
+ printMeasureSummary(report, hadBaseline, {
1715
+ parity: parityStatus(target),
1716
+ lcpCheck: noteLcpElement(target, report),
1717
+ });
1718
+ if (opts.pin) pinBaseline(target);
1719
+ else if (!hadBaseline)
1720
+ console.log(
1721
+ `no pinned baseline yet - run \`${CLI} baseline\` (or pass --pin) to pin this measurement`,
1722
+ );
1723
+ console.log(`\nfull report: ${target.paths.runtimeMetrics}`);
1724
+ }
1725
+
1726
+ async function cmdCoverage(argv) {
1727
+ const opts = parse(argv, {
1728
+ ...TARGET_OPTS,
1729
+ settle: { type: 'string', default: '2000' },
1730
+ 'no-throttle': { type: 'boolean', default: false },
1731
+ features: { type: 'string' },
1732
+ entry: { type: 'string' },
1733
+ });
1734
+ const target = resolveTarget(opts);
1735
+ const expectedFeatures = expectedFeaturesFor(target, opts);
1736
+ const entry = requireEntry(target, opts);
1737
+ if (!opts.entry) console.log(`entry: ${entry} (auto-detected from dist/index.html)`);
1738
+ if (!fs.existsSync(path.join(target.dist, `${entry}.map`))) {
1739
+ throw new Error(
1740
+ `entry ${entry} has no sourcemap (${path.join(target.dist, entry)}.map) - build with sourcemap: true`,
1741
+ );
1742
+ }
1743
+ const cov = await withServerAndBrowser(target, opts['no-throttle'], ({ origin, cdp, throttle }) =>
1744
+ coverageRun(cdp, {
1745
+ origin,
1746
+ throttle,
1747
+ expectedFeatures,
1748
+ entryName: `/${entry.replaceAll('\\', '/')}`,
1749
+ settleMs: Number(opts.settle),
1750
+ }),
1751
+ );
1752
+ const report = buildCoverageReport(target, cov, entry);
1753
+ printCoverageReport(target, report);
1754
+ console.log(`\nfull report: ${target.paths.coverage}`);
1755
+ }
1756
+
1757
+ async function cmdProfile(argv) {
1758
+ const opts = parse(argv, {
1759
+ ...TARGET_OPTS,
1760
+ 'no-throttle': { type: 'boolean', default: false },
1761
+ entry: { type: 'string' },
1762
+ });
1763
+ const target = resolveTarget(opts);
1764
+ const entry = requireEntry(target, opts);
1765
+ if (!opts.entry) console.log(`entry: ${entry} (auto-detected from dist/index.html)`);
1766
+ const profile = await withServerAndBrowser(
1767
+ target,
1768
+ opts['no-throttle'],
1769
+ ({ origin, cdp, throttle }) => profileRun(cdp, { origin, throttle }),
1770
+ );
1771
+ const report = buildProfileReport(target, profile, entry);
1772
+ printProfileReport(report);
1773
+ console.log(`\nfull report: ${target.paths.profile}`);
1774
+ }
1775
+
1776
+ // Everything in one browser session: timed runs, coverage, boot profile — then
1777
+ // the fused verdict. One command per iteration instead of four.
1778
+ async function cmdScan(argv) {
1779
+ const opts = parse(argv, {
1780
+ ...TARGET_OPTS,
1781
+ // 3 runs: the median is stable at 3 and each throttled run costs the app's
1782
+ // full LCP (minutes on slow pages). --runs 5 remains available for noisy
1783
+ // pages; the effect side is regression-gated by the eval ledger.
1784
+ runs: { type: 'string', default: '3' },
1785
+ warmup: { type: 'string', default: '1' },
1786
+ label: { type: 'string', default: '' },
1787
+ settle: { type: 'string', default: '1500' },
1788
+ 'no-throttle': { type: 'boolean', default: false },
1789
+ features: { type: 'string' },
1790
+ entry: { type: 'string' },
1791
+ pin: { type: 'boolean', default: false },
1792
+ // One measure run, no profile: a cheap mid-iteration "did my change move
1793
+ // LCP" probe on slow apps (a full throttled scan costs minutes when LCP
1794
+ // is >10s). Never a basis for accept/revert/pin - verdict says so too.
1795
+ quick: { type: 'boolean', default: false },
1796
+ // Force the boot-CPU profile leg even when pre-paint CPU is in baseline
1797
+ // territory (it is skipped then - the verdict only ever demands a profile
1798
+ // for pre-paint CPU >150ms, so skipping never leaves an UNKNOWN).
1799
+ profile: { type: 'boolean', default: false },
1800
+ // Re-scans print the compact coverage view (top 15 module rows instead of
1801
+ // 60 - the actionable sections are never trimmed); --full restores the
1802
+ // whole table. First scan of a target is always full.
1803
+ full: { type: 'boolean', default: false },
1804
+ // Disable the probe's early stop (run 1 stops when its LCP delta vs
1805
+ // the pinned baseline is >=5x the noise threshold; see EARLY_STOP_FACTOR).
1806
+ 'no-early-stop': { type: 'boolean', default: false },
1807
+ // Pin the target's net-scale explicitly (one of 1/2/4/8) instead of the
1808
+ // calibrated value. Changing it makes the pinned baseline incomparable.
1809
+ 'net-scale': { type: 'string' },
1810
+ // Or pin a network in real units (--bandwidth/--rtt/--cpu), same rules.
1811
+ ...NETWORK_OPTS,
1812
+ // Rendered-output parity: pages beyond the build's discoverable entries (SPA
1813
+ // routes) and masks for regions the page renders differently on every load.
1814
+ pages: { type: 'string' },
1815
+ mask: { type: 'string', multiple: true },
1816
+ // Compare the rendered output with the original in this scan too - the final
1817
+ // check once the optimizing is done (recover mode's each-tweak strategy turns
1818
+ // it on for every scan).
1819
+ parity: { type: 'boolean', default: false },
1820
+ });
1821
+ if (opts.quick && opts.pin) {
1822
+ throw new Error(
1823
+ 'scan --quick cannot --pin: a 1-run baseline poisons every later delta. Run a full scan to pin.',
1824
+ );
1825
+ }
1826
+ if (opts.quick) {
1827
+ opts.runs = '1';
1828
+ }
1829
+ const networkFlag = parseNetworkFlags(opts);
1830
+ const target = resolveTarget(opts);
1831
+ const expectedFeatures = expectedFeaturesFor(target, opts);
1832
+ const entry = requireEntry(target, opts);
1833
+ if (!fs.existsSync(path.join(target.dist, `${entry}.map`))) {
1834
+ throw new Error(
1835
+ `entry ${entry} has no sourcemap (${path.join(target.dist, entry)}.map) - build with sourcemap: true`,
1836
+ );
1837
+ }
1838
+ // The first scan of a target saves the build as the original - before any change.
1839
+ ensureAnchor(target, { pages: parsePages(opts.pages), masks: opts.mask ?? [] });
1840
+ // Early stop needs a decision context (a pinned baseline measured under the
1841
+ // SAME pinned throttle) and full-median exemptions: --pin measurements
1842
+ // BECOME the baseline, so they always sample fully; quick mode is already a
1843
+ // single run.
1844
+ const baselineState = readJson(target.paths.runtimeBaseline);
1845
+ const pinnedLcp = baselineState?.metrics?.['runtime.lcp_ms'];
1846
+ const expectedId = expectedThrottleId(target, opts, networkFlag);
1847
+ const scaleMatches =
1848
+ expectedId != null && baselineState != null && throttleId(baselineState) === expectedId;
1849
+ const earlyStop =
1850
+ !opts.quick &&
1851
+ !opts.pin &&
1852
+ !opts['no-early-stop'] &&
1853
+ typeof pinnedLcp === 'number' &&
1854
+ scaleMatches
1855
+ ? {
1856
+ baselineLcp: pinnedLcp,
1857
+ thresholdMs: Math.max(NOISE_FLOOR_MS, (NOISE_FLOOR_PCT / 100) * pinnedLcp),
1858
+ factor: EARLY_STOP_FACTOR,
1859
+ }
1860
+ : null;
1861
+
1862
+ const gathered = await withServerAndBrowser(
1863
+ target,
1864
+ opts['no-throttle'],
1865
+ async ({ origin, cdp, throttle }) => {
1866
+ const { samples, earlyStopped } = await gatherSamples(cdp, origin, {
1867
+ throttle,
1868
+ expectedFeatures,
1869
+ runs: Number(opts.runs),
1870
+ warmup: Number(opts.warmup),
1871
+ settleMs: Number(opts.settle),
1872
+ earlyStop,
1873
+ });
1874
+ process.stderr.write('coverage run...\n');
1875
+ const cov = await coverageRun(cdp, {
1876
+ origin,
1877
+ throttle,
1878
+ expectedFeatures,
1879
+ entryName: `/${entry.replaceAll('\\', '/')}`,
1880
+ settleMs: 2000,
1881
+ });
1882
+ let profile = null;
1883
+ if (!opts.quick) {
1884
+ // The profile only ever matters when pre-paint CPU is above the verdict's
1885
+ // 150ms threshold - skip its navigation otherwise (a scan-time minute on
1886
+ // slow apps). --profile forces it.
1887
+ const prepaintMs = summarize(samples, expectedFeatures).metrics[
1888
+ 'runtime.prepaint_longtask_ms'
1889
+ ];
1890
+ if (opts.profile || (typeof prepaintMs === 'number' && prepaintMs > 150)) {
1891
+ process.stderr.write('profile run...\n');
1892
+ profile = await profileRun(cdp, { origin, throttle });
1893
+ } else {
1894
+ process.stderr.write(
1895
+ `profile skipped (pre-paint CPU ${prepaintMs == null ? 'n/a' : `${Math.round(prepaintMs)}ms`} - baseline territory; force with --profile)\n`,
1896
+ );
1897
+ }
1898
+ }
1899
+ // The rendered output is compared once, at the end (scan --parity), not per
1900
+ // change - except in recover mode under the each-tweak strategy, which
1901
+ // re-checks (and records a snapshot) with every scan.
1902
+ const checkParity =
1903
+ opts.parity || readJson(target.paths.parity.recover)?.strategy === 'each-tweak';
1904
+ const parity = checkParity
1905
+ ? await parityLeg(target, {
1906
+ cdp,
1907
+ throttle,
1908
+ currentLcp: summarize(samples, expectedFeatures).lcpElement,
1909
+ label: opts.label,
1910
+ })
1911
+ : null;
1912
+ return { samples, earlyStopped, cov, profile, throttle, parity };
1913
+ },
1914
+ { netScaleFlag: opts['net-scale'] ?? null, networkFlag },
1915
+ );
1916
+
1917
+ const { report, hadBaseline } = writeMeasureReport(target, gathered.samples, {
1918
+ expectedFeatures,
1919
+ label: opts.label,
1920
+ throttle: gathered.throttle,
1921
+ earlyStopped: gathered.earlyStopped,
1922
+ });
1923
+ // Read before buildCoverageReport overwrites it: an existing coverage report
1924
+ // means this is a re-scan, so the module table prints compact (--full restores it).
1925
+ const isRescan = fs.existsSync(target.paths.coverage);
1926
+ const coverageReport = buildCoverageReport(target, gathered.cov, entry);
1927
+ const profileReport = gathered.profile
1928
+ ? buildProfileReport(target, gathered.profile, entry)
1929
+ : null;
1930
+
1931
+ // advice:false throughout - the verdict printed below carries every next: line,
1932
+ // so the sections above it report numbers without a second copy of the coaching.
1933
+ printMeasureSummary(report, hadBaseline, {
1934
+ advice: false,
1935
+ parity: parityStatus(target),
1936
+ lcpCheck: noteLcpElement(target, report),
1937
+ });
1938
+ console.log('');
1939
+ printCoverageReport(target, coverageReport, { compact: isRescan && !opts.full, advice: false });
1940
+ console.log('');
1941
+ if (profileReport) {
1942
+ printProfileReport(profileReport, { advice: false });
1943
+ console.log('');
1944
+ }
1945
+ const graphBuiltAtMs = fs.statSync(path.join(target.dist, entry)).mtimeMs;
1946
+ printGraphSection(target, graphBuiltAtMs);
1947
+ printEagerDiffSummary(target, graphBuiltAtMs);
1948
+ if (gathered.parity) {
1949
+ printParityReport(gathered.parity, target);
1950
+ console.log('');
1951
+ }
1952
+ printVerdict(target, { entry });
1953
+
1954
+ if (opts.pin) {
1955
+ pinBaseline(target);
1956
+ } else if (!hadBaseline) {
1957
+ if (opts.quick) {
1958
+ console.log(
1959
+ '(no pinned baseline yet, and quick scans are never pinned - run a full scan to establish it)',
1960
+ );
1961
+ } else {
1962
+ // First scan of a target IS the baseline: pin it so every later scan
1963
+ // reports a baselineDelta without extra ceremony.
1964
+ pinBaseline(target);
1965
+ console.log('(first scan of this target - pinned as the baseline automatically)');
1966
+ }
1967
+ }
1968
+ console.log(`\nreports: ${target.paths.dir}`);
1969
+ }
1970
+
1971
+ async function cmdVerdict(argv) {
1972
+ const opts = parse(argv, { ...TARGET_OPTS, entry: { type: 'string' } });
1973
+ printVerdict(resolveTarget(opts), opts);
1974
+ }
1975
+
1976
+ // --- rendered-output parity -------------------------------------------------------
1977
+ // The numbers above say how much faster the page got; parity says whether it is
1978
+ // still the same page. A build that renders differently from the original - a style
1979
+ // that stopped applying, a web font that never loads, a hero swapped for a skeleton -
1980
+ // can measure faster precisely BECAUSE it broke. So parity gates everything that
1981
+ // accepts a change: the keep/revert line, pinning, and the verdict's "done".
1982
+ // See README.md "Rendered-output parity" for the design.
1983
+
1984
+ // The lab's own state can sit inside the build dir (--dist . with ./.perf-kit):
1985
+ // never copy, hash or crawl it as part of the app.
1986
+ const PARITY_EXCLUDE = [STATE_DIR];
1987
+
1988
+ const readApprovals = (target) => readJson(target.paths.parity.approvals) ?? { approvals: [] };
1989
+
1990
+ /**
1991
+ * A human approval turns an OPEN page "approved" - for its exact signature only.
1992
+ * A build is only 'clear' once EVERY pinned page has a result; fewer is 'partial'
1993
+ * (a recover-mode `parity --only` re-check).
1994
+ */
1995
+ function effectiveParity(report, approvals, pinnedPages) {
1996
+ if (!report) return null;
1997
+ const pages = report.pages.map((page) => {
1998
+ if (page.state !== 'open') return page;
1999
+ const approval = approvals.approvals.find(
2000
+ (a) => a.page === page.page && a.signature === page.signature,
2001
+ );
2002
+ return approval ? { ...page, state: 'approved', approval } : page;
2003
+ });
2004
+ const covered = pinnedPages.every((p) => pages.some((r) => r.page === p));
2005
+ const state = pages.some((p) => p.state === 'open')
2006
+ ? 'open'
2007
+ : !report.integrity || pages.some((p) => p.state === 'unknown')
2008
+ ? 'unknown'
2009
+ : covered
2010
+ ? 'clear'
2011
+ : 'partial';
2012
+ return { ...report, pages, state };
2013
+ }
2014
+
2015
+ /**
2016
+ * Where parity stands for the CURRENT build: 'none' (no original saved), 'identical'
2017
+ * (same files as the original), 'stale' (not compared since the last rebuild),
2018
+ * 'partial' (some pages compared), or the latest report's state with approvals
2019
+ * applied.
2020
+ */
2021
+ function parityStatus(target) {
2022
+ const paths = target.paths.parity;
2023
+ const manifest = readJson(paths.manifest);
2024
+ if (!manifest || !fs.existsSync(path.join(target.dist, 'index.html'))) {
2025
+ return { state: 'none', manifest, report: null };
2026
+ }
2027
+ const currentHash = hashDist(target.dist, { exclude: PARITY_EXCLUDE });
2028
+ const stored = readJson(paths.report);
2029
+ const report =
2030
+ stored?.distHash === currentHash && stored.anchor?.distHash === manifest.distHash
2031
+ ? effectiveParity(
2032
+ stored,
2033
+ readApprovals(target),
2034
+ manifest.pages.map((p) => p.path),
2035
+ )
2036
+ : null;
2037
+ if (currentHash === manifest.distHash) return { state: 'identical', manifest, report };
2038
+ if (!report) return { state: 'stale', manifest, report: null };
2039
+ return { state: report.state, manifest, report };
2040
+ }
2041
+
2042
+ const throttleKey = (throttle) => throttleId(throttle);
2043
+
2044
+ /**
2045
+ * LCP-element bookkeeping on every measurement - free, the timed runs observed it.
2046
+ * On the original build itself it records the original's LCP element; on any other
2047
+ * build it compares with it. Shown as information; only the final check gates on it.
2048
+ */
2049
+ function noteLcpElement(target, report) {
2050
+ const paths = target.paths.parity;
2051
+ const manifest = readJson(paths.manifest);
2052
+ if (!manifest || !report.lcpElement) return null;
2053
+ const key = throttleKey(report.throttle);
2054
+ if (hashDist(target.dist, { exclude: PARITY_EXCLUDE }) === manifest.distHash) {
2055
+ // a single run is no majority - quick probes never define the original's
2056
+ if (manifest.lcp?.key !== key && report.runs > 1) {
2057
+ manifest.lcp = { key, element: report.lcpElement, measuredAtMs: Date.now() };
2058
+ writeJson(paths.manifest, manifest);
2059
+ }
2060
+ return null;
2061
+ }
2062
+ return manifest.lcp?.key === key ? compareLcp(manifest.lcp.element, report.lcpElement) : null;
2063
+ }
2064
+
2065
+ /** The LCP-element comparison a later measurement of an already-compared build allows. */
2066
+ function lateLcpCheck(manifest, runtime) {
2067
+ if (!runtime.lcpElement || manifest.lcp?.key !== throttleKey(runtime.throttle)) return null;
2068
+ return compareLcp(manifest.lcp.element, runtime.lcpElement);
2069
+ }
2070
+
2071
+ /** What a reader of a "clear" must also know: approvals, masks added late, a replaced original. */
2072
+ function parityExtras(report) {
2073
+ if (!report) return '';
2074
+ const parts = [];
2075
+ const approved = report.pages.filter((p) => p.state === 'approved');
2076
+ if (approved.length) {
2077
+ parts.push(
2078
+ `${approved.length} difference(s) approved by a human: ${approved
2079
+ .map((p) => `${p.page} "${p.approval.reason}"`)
2080
+ .join(', ')}`,
2081
+ );
2082
+ }
2083
+ if (report.anchor.lateMasks.length) {
2084
+ parts.push(`masks added after the original was saved: ${report.anchor.lateMasks.join(', ')}`);
2085
+ }
2086
+ if (report.anchor.replaced) parts.push(`the original was replaced ${report.anchor.replaced}x`);
2087
+ if (report.anchor.late) {
2088
+ parts.push('the original was saved after a perf baseline existed - it may include changes');
2089
+ }
2090
+ return parts.length ? ` (${parts.join('; ')})` : '';
2091
+ }
2092
+
2093
+ function parsePages(value) {
2094
+ return value
2095
+ ? value
2096
+ .split(',')
2097
+ .map((p) => p.trim())
2098
+ .filter(Boolean)
2099
+ .map((p) => normalizePagePath(p))
2100
+ : [];
2101
+ }
2102
+
2103
+ /**
2104
+ * Save the original build on a target's first scan / measure / parity; afterwards
2105
+ * only ADD pages and masks (pages widen coverage; a mask narrows it, so every mask
2106
+ * added after the save is listed in each verdict).
2107
+ */
2108
+ function ensureAnchor(target, { pages = [], masks = [] } = {}) {
2109
+ const paths = target.paths.parity;
2110
+ const manifest = readJson(paths.manifest);
2111
+ if (!fs.existsSync(path.join(target.dist, 'index.html'))) return manifest;
2112
+ if (!manifest) {
2113
+ guardStateDir();
2114
+ const discovered = discoverPages(target.dist, { exclude: PARITY_EXCLUDE });
2115
+ const late = fs.existsSync(target.paths.runtimeBaseline);
2116
+ const created = saveAnchor({
2117
+ dist: target.dist,
2118
+ paths,
2119
+ pages: Array.from(new Set(['/', ...discovered.pages, ...pages])),
2120
+ masks,
2121
+ git: gitState(path.dirname(target.dist), target.dist),
2122
+ late,
2123
+ exclude: PARITY_EXCLUDE,
2124
+ });
2125
+ writeJson(paths.manifest, created);
2126
+ console.log(
2127
+ `original build saved for rendered-output checks - page(s): ${created.pages.map((p) => p.path).join(' ')}`,
2128
+ );
2129
+ console.log(
2130
+ ' every later build is compared against it; scan --pin / baseline never replace it.',
2131
+ );
2132
+ if (discovered.more.length) {
2133
+ console.log(
2134
+ ` ${discovered.more.length} more entry page(s) not pinned (add with --pages): ${discovered.more.slice(0, 5).join(' ')}`,
2135
+ );
2136
+ }
2137
+ if (discovered.skipped.length) {
2138
+ console.log(
2139
+ ` skipped self-contained HTML (no bundled script/stylesheet): ${discovered.skipped.slice(0, 5).join(' ')}`,
2140
+ );
2141
+ }
2142
+ if (created.pages.length === 1) {
2143
+ console.log(
2144
+ ' client-side routes are not discoverable from the build: pin the ones that matter with --pages /route,/other',
2145
+ );
2146
+ }
2147
+ if (late) {
2148
+ console.log(
2149
+ ' note: a perf baseline was pinned before this - this build may already include changes. If it does,',
2150
+ );
2151
+ console.log(
2152
+ ` a human rebuilds the original commit and runs \`${CLI} parity anchor --replace\` on that build.`,
2153
+ );
2154
+ }
2155
+ if (created.git?.dirty) {
2156
+ console.log(
2157
+ ' note: the app has uncommitted changes - the saved build may already include some of them.',
2158
+ );
2159
+ }
2160
+ return created;
2161
+ }
2162
+ let changed = false;
2163
+ for (const page of pages) {
2164
+ if (manifest.pages.some((p) => p.path === page)) continue;
2165
+ manifest.pages.push({ path: page, addedAtMs: Date.now() });
2166
+ changed = true;
2167
+ console.log(`parity: page ${page} added`);
2168
+ }
2169
+ for (const selector of masks) {
2170
+ if (manifest.masks.some((m) => m.selector === selector)) continue;
2171
+ manifest.masks.push({ selector, addedAtMs: Date.now() });
2172
+ changed = true;
2173
+ console.log(
2174
+ `parity: mask ${selector} added - masks added after the original was saved are listed in every verdict`,
2175
+ );
2176
+ }
2177
+ if (changed) writeJson(paths.manifest, manifest);
2178
+ return manifest;
2179
+ }
2180
+
2181
+ /**
2182
+ * The original's LCP element under the given throttle. A full scan of the original
2183
+ * itself records it for free (noteLcpElement); otherwise it is measured once here
2184
+ * (3 throttled runs against the saved build) and kept - the original never changes.
2185
+ */
2186
+ async function anchorLcpElement(target, manifest, { cdp, throttle }) {
2187
+ const key = throttleKey(throttle);
2188
+ if (manifest.lcp?.key === key) return manifest.lcp.element;
2189
+ const server = await startServer(target.paths.parity.anchorDist);
2190
+ let element;
2191
+ try {
2192
+ const samples = [];
2193
+ for (let i = 1; i <= 3; i++) {
2194
+ process.stderr.write(`parity: LCP element of the original build, run ${i}/3 (once)...\n`);
2195
+ samples.push(await timedRun(cdp, { url: `${server.origin}/`, throttle, settleMs: 1500 }));
2196
+ }
2197
+ element = lcpElementOf(samples);
2198
+ } finally {
2199
+ await server.close();
2200
+ }
2201
+ manifest.lcp = { key, element, measuredAtMs: Date.now() };
2202
+ writeJson(target.paths.parity.manifest, manifest);
2203
+ return element;
2204
+ }
2205
+
2206
+ /**
2207
+ * Compare the current build with the original and write the report - every pinned
2208
+ * page, or just `only` (a recover-mode re-check; results for the same build merge,
2209
+ * so a build is clear once every page has been compared). `currentLcp` is this
2210
+ * build's LCP element from a timed measurement under `throttle`; undefined means
2211
+ * there is none to compare (the report says so).
2212
+ */
2213
+ async function parityLeg(target, { cdp, throttle, currentLcp, label = null, only = [] }) {
2214
+ const paths = target.paths.parity;
2215
+ const manifest = ensureAnchor(target);
2216
+ if (!manifest) return null;
2217
+ const pinned = manifest.pages.map((p) => p.path);
2218
+ const checking = only.length ? only : pinned;
2219
+ for (const page of checking) {
2220
+ if (!pinned.includes(page)) {
2221
+ throw new Error(`${page} is not a pinned page (${pinned.join(' ')}) - add it with --pages`);
2222
+ }
2223
+ }
2224
+ const distHash = hashDist(target.dist, { exclude: PARITY_EXCLUDE });
2225
+ const identical = distHash === manifest.distHash;
2226
+ const integrity = hashDist(paths.anchorDist) === manifest.distHash;
2227
+ let lcp = null;
2228
+ if (integrity && !identical && currentLcp !== undefined && checking.includes('/')) {
2229
+ lcp = compareLcp(await anchorLcpElement(target, manifest, { cdp, throttle }), currentLcp);
2230
+ }
2231
+ // Same files as the original renders the same by construction - nothing to capture.
2232
+ const results =
2233
+ integrity && !identical
2234
+ ? await runParity({
2235
+ cdp,
2236
+ anchorDist: paths.anchorDist,
2237
+ candidateDist: target.dist,
2238
+ pages: checking,
2239
+ masks: manifest.masks.map((m) => m.selector),
2240
+ outDir: paths.pages,
2241
+ lcpByPage: lcp ? { '/': lcp } : {},
2242
+ log: (line) => process.stderr.write(`${line}\n`),
2243
+ })
2244
+ : [];
2245
+ const previous = readJson(paths.report);
2246
+ const carried =
2247
+ previous?.distHash === distHash && previous.anchor?.distHash === manifest.distHash
2248
+ ? previous.pages.filter((p) => !checking.includes(p.page))
2249
+ : [];
2250
+ const pages = identical
2251
+ ? pinned.map((page) => ({ page, state: 'clear', reasons: [], notes: [] }))
2252
+ : [...carried, ...results].sort((a, b) => pinned.indexOf(a.page) - pinned.indexOf(b.page));
2253
+ const report = {
2254
+ schemaVersion: 1,
2255
+ generatedAtMs: Date.now(),
2256
+ dist: target.dist,
2257
+ distHash,
2258
+ identical,
2259
+ integrity,
2260
+ lcpCompared: identical || Boolean(pages.find((p) => p.page === '/')?.lcp),
2261
+ anchor: {
2262
+ createdAtMs: manifest.createdAtMs,
2263
+ distHash: manifest.distHash,
2264
+ git: manifest.git,
2265
+ late: manifest.late,
2266
+ replaced: (manifest.replaced ?? []).length,
2267
+ lateMasks: manifest.masks
2268
+ .filter((m) => m.addedAtMs > manifest.createdAtMs)
2269
+ .map((m) => m.selector),
2270
+ lcp: manifest.lcp?.element ?? null,
2271
+ },
2272
+ pages,
2273
+ };
2274
+ writeJson(paths.report, report);
2275
+ const effective = effectiveParity(report, readApprovals(target), pinned);
2276
+ recordCheck(target, effective, {
2277
+ label,
2278
+ captured: results.map((r) => r.page),
2279
+ });
2280
+ return effective;
2281
+ }
2282
+
2283
+ /**
2284
+ * Every check is recorded - history.jsonl plus a copy of the pages it captured
2285
+ * under snapshots/<n>/ - and moves recover mode: a difference enters it; a build
2286
+ * compared on every page without one (or identical to the original) leaves it.
2287
+ */
2288
+ function recordCheck(target, report, { label, captured }) {
2289
+ const paths = target.paths.parity;
2290
+ const n = fs.existsSync(paths.history)
2291
+ ? fs.readFileSync(paths.history, 'utf8').split('\n').filter(Boolean).length + 1
2292
+ : 1;
2293
+ const recover = readJson(paths.recover);
2294
+ let event = null;
2295
+ if (report.state === 'open' && !recover) {
2296
+ writeJson(paths.recover, { enteredAtMs: Date.now(), atCheck: n, strategy: 'on-demand' });
2297
+ event = 'entered recover mode';
2298
+ } else if (
2299
+ recover &&
2300
+ report.integrity &&
2301
+ (report.identical || report.state === 'clear' || report.state === 'unknown')
2302
+ ) {
2303
+ fs.rmSync(paths.recover, { force: true });
2304
+ event = 'left recover mode';
2305
+ }
2306
+ for (const page of captured) {
2307
+ const from = path.join(paths.pages, pageSlug(page));
2308
+ if (fs.existsSync(from)) {
2309
+ fs.cpSync(from, path.join(paths.snapshots, String(n), pageSlug(page)), { recursive: true });
2310
+ }
2311
+ }
2312
+ guardStateDir();
2313
+ fs.appendFileSync(
2314
+ paths.history,
2315
+ `${JSON.stringify({
2316
+ n,
2317
+ label,
2318
+ atMs: Date.now(),
2319
+ distHash: report.distHash,
2320
+ identical: report.identical,
2321
+ state: report.state,
2322
+ event,
2323
+ pages: report.pages
2324
+ .filter((p) => captured.includes(p.page))
2325
+ .map((p) => ({ page: p.page, state: p.state, reasons: p.reasons })),
2326
+ })}\n`,
2327
+ );
2328
+ }
2329
+
2330
+ /** What to do once a check found a difference - printed with it, and by `parity recover`. */
2331
+ function printRecoverGuide(target) {
2332
+ const strategy = readJson(target.paths.parity.recover)?.strategy ?? 'on-demand';
2333
+ // bisect runs from the app's repo: spell the lab by absolute path
2334
+ const lab = CLI === 'node harness.mjs' ? `node ${path.join(ROOT, 'harness.mjs')}` : CLI;
2335
+ const say = (line = '') => console.log(line);
2336
+ say(
2337
+ 'RECOVER MODE - this build renders differently from the original, so its LCP numbers do not count',
2338
+ );
2339
+ say('until it matches again.');
2340
+ say(
2341
+ ' 1. Diagnose: each OPEN page names what changed (element + property, rendered font, missing',
2342
+ );
2343
+ say(
2344
+ ' text, new runtime error, LCP element) and points at its diff image (red = changed, blue =',
2345
+ );
2346
+ say(' varies between loads of the original, not judged).');
2347
+ say(` 2. Choose how to re-check your fixes (now: ${strategy}):`);
2348
+ say(
2349
+ ` ${CLI} parity strategy on-demand re-check when you decide: \`parity\` (every page), \`parity --only /page\` (the broken ones), --label <tweak> names the snapshot`,
2350
+ );
2351
+ say(` ${CLI} parity strategy each-tweak every scan re-checks and records a snapshot`);
2352
+ say(' 3. Not sure which kept change broke it? Bisect your commits - the check exits 1 while a');
2353
+ say(' page differs:');
2354
+ say(` git bisect run sh -c '<build command> && ${lab} parity --only <page> --exit-code'`);
2355
+ say(` 4. ${CLI} parity history every check of this recovery, with its snapshot images`);
2356
+ say(
2357
+ ' A fix may give back part of the gain: the number you report must come from a build that matches',
2358
+ );
2359
+ say(
2360
+ ' the original. Recover mode ends when a check covers every page and finds no difference - never by',
2361
+ );
2362
+ say(' approving, replacing the original or adding masks (human decisions).');
2363
+ }
2364
+
2365
+ function describeRegionElement(e) {
2366
+ const name = e.text ? `${e.label} "${e.text.slice(0, 30)}"` : e.label;
2367
+ if (e.gone) return `${name} is gone`;
2368
+ if (e.added) return `${name} is new`;
2369
+ if (e.movedIn) return `${name} moved here`;
2370
+ const bits = [];
2371
+ if (e.font) bits.push(`rendered in ${e.font.to} (was ${e.font.from})`);
2372
+ for (const c of e.changes ?? []) bits.push(`${c.prop} ${c.from} -> ${c.to}`);
2373
+ if (e.moved) {
2374
+ const r = (b) => `${b.x},${b.y} ${b.w}x${b.h}`;
2375
+ bits.push(`moved ${r(e.moved.from)} -> ${r(e.moved.to)}`);
2376
+ }
2377
+ return bits.length ? `${name}: ${bits.join(', ')}` : name;
2378
+ }
2379
+
2380
+ function printParityDetails(page) {
2381
+ const out = (line) => console.log(` ${line}`);
2382
+ const list = (label, items, n = 5) => {
2383
+ if (!items.length) return;
2384
+ const more = items.length > n ? ` (+${items.length - n} more)` : '';
2385
+ out(
2386
+ `${label}: ${items
2387
+ .slice(0, n)
2388
+ .map((s) => JSON.stringify(s))
2389
+ .join(', ')}${more}`,
2390
+ );
2391
+ };
2392
+ for (const [name, view] of Object.entries(page.views)) {
2393
+ for (const region of view.regions.slice(0, 3)) {
2394
+ const where = name === 'viewport' ? 'first screen' : 'full page';
2395
+ const what = region.elements?.length
2396
+ ? `: ${region.elements.map((e) => describeRegionElement(e)).join('; ')}`
2397
+ : '';
2398
+ out(`${where} region ${region.w}x${region.h} at ${region.x},${region.y}${what}`);
2399
+ }
2400
+ }
2401
+ list('text missing', page.text.missing);
2402
+ list('text new', page.text.added);
2403
+ list('accessible names missing', page.a11y.missing);
2404
+ list('accessible names new', page.a11y.added);
2405
+ for (const error of page.runtime.added.slice(0, 5)) out(`new runtime error: ${error}`);
2406
+ for (const f of page.fonts.slice(0, 3)) {
2407
+ out(
2408
+ `font: ${f.label}${f.text ? ` "${f.text.slice(0, 30)}"` : ''} rendered in ${f.to} (was ${f.from})`,
2409
+ );
2410
+ }
2411
+ list('web fonts no longer loaded', page.faces.missing, 4);
2412
+ list('images missing', page.images.missing, 3);
2413
+ list('images new', page.images.added, 3);
2414
+ const styles = page.styles;
2415
+ if (styles?.changedCount) {
2416
+ const examples = styles.changed
2417
+ .slice(0, 2)
2418
+ .map(
2419
+ (d) => `${d.label} ${d.changes.map((c) => `${c.prop} ${c.from} -> ${c.to}`).join(', ')}`,
2420
+ );
2421
+ out(`computed style changed on ${styles.changedCount} element(s), e.g. ${examples.join('; ')}`);
2422
+ }
2423
+ if (styles?.missingCount) {
2424
+ out(`elements gone: ${styles.missingCount} (${styles.missing.slice(0, 3).join(', ')})`);
2425
+ }
2426
+ if (styles?.addedCount) {
2427
+ out(`elements new: ${styles.addedCount} (${styles.added.slice(0, 3).join(', ')})`);
2428
+ }
2429
+ if (styles?.movedCount) out(`${styles.movedCount} element(s) moved or resized`);
2430
+ }
2431
+
2432
+ function printParityReport(report, target) {
2433
+ if (!report) return;
2434
+ const a = report.anchor;
2435
+ const saved = new Date(a.createdAtMs).toISOString().slice(0, 16).replace('T', ' ');
2436
+ const commit = a.git ? `, commit ${a.git.sha}${a.git.dirty ? ' + uncommitted changes' : ''}` : '';
2437
+ console.log(`rendered output vs the original build (saved ${saved} UTC${commit}):`);
2438
+ if (!report.integrity) {
2439
+ console.log(
2440
+ ' the saved original build was modified after it was saved - nothing can be compared against it',
2441
+ );
2442
+ return;
2443
+ }
2444
+ if (report.identical) {
2445
+ console.log(' this build is byte-identical to the original - same rendering by construction');
2446
+ return;
2447
+ }
2448
+ for (const page of report.pages) {
2449
+ const tag = { open: 'OPEN', unknown: 'UNKNOWN', approved: 'approved', clear: 'clear' }[
2450
+ page.state
2451
+ ];
2452
+ console.log(
2453
+ ` ${page.page} ${tag}${page.reasons.length ? ` - ${page.reasons.join('; ')}` : ''}`,
2454
+ );
2455
+ if (page.approval) {
2456
+ console.log(
2457
+ ` approved by ${page.approval.by} ${new Date(page.approval.approvedAtMs).toISOString().slice(0, 10)}: "${page.approval.reason}"`,
2458
+ );
2459
+ }
2460
+ if (page.state === 'open') printParityDetails(page);
2461
+ for (const note of page.notes) console.log(` note: ${note}`);
2462
+ const evidence = Object.entries(page.files ?? {}).filter(([name]) => name.startsWith('diff'));
2463
+ if (page.state !== 'clear' && evidence.length) {
2464
+ console.log(` evidence: ${evidence.map(([, file]) => file).join(' ')}`);
2465
+ }
2466
+ }
2467
+ const root = report.pages.find((p) => p.page === '/');
2468
+ if (!report.lcpCompared) {
2469
+ console.log(
2470
+ ' LCP element: not compared - no fresh timed measurement of this build (scan --parity compares it)',
2471
+ );
2472
+ } else if (root?.lcp?.state === 'same') {
2473
+ console.log(` LCP element: same as the original - ${describeLcp(root.lcp.current)}`);
2474
+ }
2475
+ if (report.state === 'partial') {
2476
+ const pinned = readJson(target.paths.parity.manifest)?.pages.length ?? report.pages.length;
2477
+ console.log(
2478
+ ` ${report.pages.length} of ${pinned} page(s) compared for this build so far - it is clear once every pinned page is`,
2479
+ );
2480
+ }
2481
+ if (a.lateMasks.length) {
2482
+ console.log(` masks added after the original was saved: ${a.lateMasks.join(', ')}`);
2483
+ }
2484
+ if (a.replaced) {
2485
+ console.log(` the original was replaced ${a.replaced}x since it was first saved`);
2486
+ }
2487
+ if (report.state === 'open') {
2488
+ console.log('');
2489
+ printRecoverGuide(target);
2490
+ }
2491
+ }
2492
+
2493
+ async function cmdParity(argv) {
2494
+ const sub = {
2495
+ approve: cmdParityApprove,
2496
+ anchor: cmdParityAnchor,
2497
+ strategy: cmdParityStrategy,
2498
+ history: cmdParityHistory,
2499
+ recover: cmdParityRecover,
2500
+ }[argv[0]];
2501
+ if (sub) return sub(argv.slice(1));
2502
+ const opts = parse(argv, {
2503
+ ...TARGET_OPTS,
2504
+ pages: { type: 'string' },
2505
+ mask: { type: 'string', multiple: true },
2506
+ only: { type: 'string' },
2507
+ label: { type: 'string' },
2508
+ 'exit-code': { type: 'boolean', default: false },
2509
+ });
2510
+ const target = resolveTarget(opts);
2511
+ const manifest = ensureAnchor(target, { pages: parsePages(opts.pages), masks: opts.mask ?? [] });
2512
+ if (!manifest) {
2513
+ throw new Error(
2514
+ `no build at ${target.dist} - build the app first.\n` +
2515
+ '(Wrong target? --app <appRoot> resolves dist/build/out; --dist <builtDir> aims directly.)',
2516
+ );
2517
+ }
2518
+ // The LCP element is only compared against a fresh timed measurement of THIS
2519
+ // build (a scan's); without one, no throttled run is spent here.
2520
+ const entry = detectEntry(target.dist);
2521
+ const builtAtMs = entry ? fs.statSync(path.join(target.dist, entry)).mtimeMs : Infinity;
2522
+ const runtime = readJson(target.paths.runtimeMetrics);
2523
+ const fresh = runtime && runtime.entry === entry && runtime.generatedAtMs >= builtAtMs;
2524
+ const report = await withServerAndBrowser(target, true, ({ cdp }) =>
2525
+ parityLeg(target, {
2526
+ cdp,
2527
+ throttle: fresh ? runtime.throttle : null,
2528
+ currentLcp: fresh ? (runtime.lcpElement ?? null) : undefined,
2529
+ label: opts.label ?? null,
2530
+ only: parsePages(opts.only),
2531
+ }),
2532
+ );
2533
+ console.log('');
2534
+ printParityReport(report, target);
2535
+ const tag = { open: 'OPEN', unknown: 'UNKNOWN', partial: 'partial', clear: 'clear' }[
2536
+ report.state
2537
+ ];
2538
+ console.log(`\nparity: ${tag} - full report ${target.paths.parity.report}`);
2539
+ // lets `git bisect run` find the change that broke the page
2540
+ if (opts['exit-code'] && report.state === 'open') process.exitCode = 1;
2541
+ }
2542
+
2543
+ async function cmdParityStrategy(argv) {
2544
+ const { values: opts, positionals } = parseArgs({
2545
+ args: argv,
2546
+ options: TARGET_OPTS,
2547
+ allowPositionals: true,
2548
+ });
2549
+ const target = resolveTarget(opts);
2550
+ const recover = readJson(target.paths.parity.recover);
2551
+ if (!recover) {
2552
+ console.log(
2553
+ `not in recover mode - the rendered output is compared once, when you finish (${CLI} scan --parity); a strategy applies after that finds a difference`,
2554
+ );
2555
+ return;
2556
+ }
2557
+ const choice = positionals[0];
2558
+ if (!choice) {
2559
+ console.log(`recover strategy: ${recover.strategy}`);
2560
+ return;
2561
+ }
2562
+ if (choice !== 'each-tweak' && choice !== 'on-demand') {
2563
+ throw new Error('usage: parity strategy each-tweak|on-demand');
2564
+ }
2565
+ recover.strategy = choice;
2566
+ writeJson(target.paths.parity.recover, recover);
2567
+ console.log(
2568
+ choice === 'each-tweak'
2569
+ ? 'each-tweak: every scan now re-checks the rendered output and records a snapshot, until recover mode ends'
2570
+ : `on-demand: re-check when you decide - \`${CLI} parity\` (every page) or \`${CLI} parity --only <page>\``,
2571
+ );
2572
+ }
2573
+
2574
+ async function cmdParityRecover(argv) {
2575
+ const target = resolveTarget(parse(argv, { ...TARGET_OPTS }));
2576
+ if (!readJson(target.paths.parity.recover)) {
2577
+ console.log(
2578
+ `not in recover mode - nothing found to differ. The comparison runs once, when you finish: ${CLI} scan --parity`,
2579
+ );
2580
+ return;
2581
+ }
2582
+ printRecoverGuide(target);
2583
+ }
2584
+
2585
+ async function cmdParityHistory(argv) {
2586
+ const target = resolveTarget(parse(argv, { ...TARGET_OPTS }));
2587
+ const paths = target.paths.parity;
2588
+ const checks = fs.existsSync(paths.history)
2589
+ ? fs
2590
+ .readFileSync(paths.history, 'utf8')
2591
+ .split('\n')
2592
+ .filter(Boolean)
2593
+ .map((line) => JSON.parse(line))
2594
+ : [];
2595
+ if (!checks.length) {
2596
+ console.log('no rendered-output checks recorded for this target yet');
2597
+ return;
2598
+ }
2599
+ for (const check of checks) {
2600
+ const when = new Date(check.atMs).toISOString().slice(0, 16).replace('T', ' ');
2601
+ const what = check.identical ? 'identical to the original' : check.state;
2602
+ console.log(
2603
+ `#${check.n}${check.label ? ` "${check.label}"` : ''} ${when} UTC build ${check.distHash.slice(0, 8)} ${what}${check.event ? ` (${check.event})` : ''}`,
2604
+ );
2605
+ for (const page of check.pages) {
2606
+ console.log(
2607
+ ` ${page.page} ${page.state}${page.reasons.length ? ` - ${page.reasons.join('; ')}` : ''}`,
2608
+ );
2609
+ }
2610
+ if (check.pages.length) {
2611
+ console.log(` snapshot: ${path.join(paths.snapshots, String(check.n))}`);
2612
+ }
2613
+ }
2614
+ const recover = readJson(paths.recover);
2615
+ console.log(
2616
+ recover
2617
+ ? `\nin recover mode since check #${recover.atCheck} (strategy: ${recover.strategy})`
2618
+ : '\nnot in recover mode',
2619
+ );
2620
+ }
2621
+
2622
+ async function cmdParityApprove(argv) {
2623
+ const { values: opts, positionals } = parseArgs({
2624
+ args: argv,
2625
+ options: { ...TARGET_OPTS, reason: { type: 'string' } },
2626
+ allowPositionals: true,
2627
+ });
2628
+ const pagePath = positionals[0];
2629
+ if (!pagePath || !opts.reason?.trim()) {
2630
+ throw new Error(
2631
+ 'usage: parity approve <page> --reason "<why this difference is intended>"\n' +
2632
+ 'Approving is a HUMAN decision about a difference they looked at - an agent must never run it.',
2633
+ );
2634
+ }
2635
+ const target = resolveTarget(opts);
2636
+ const status = parityStatus(target);
2637
+ if (!status.report) {
2638
+ throw new Error(
2639
+ `no current parity check for this build - run \`${CLI} parity\`, look at the evidence, then approve`,
2640
+ );
2641
+ }
2642
+ const page = status.report.pages.find((p) => p.page === pagePath);
2643
+ if (!page) {
2644
+ throw new Error(
2645
+ `no page ${pagePath} in the last check (pages: ${status.report.pages.map((p) => p.page).join(' ')})`,
2646
+ );
2647
+ }
2648
+ if (page.state !== 'open') throw new Error(`${pagePath} is ${page.state} - nothing to approve`);
2649
+ const approvals = readApprovals(target);
2650
+ approvals.approvals.push({
2651
+ page: pagePath,
2652
+ signature: page.signature,
2653
+ reasons: page.reasons,
2654
+ reason: opts.reason.trim(),
2655
+ by: os.userInfo().username,
2656
+ approvedAtMs: Date.now(),
2657
+ });
2658
+ writeJson(target.paths.parity.approvals, approvals);
2659
+ console.log(`approved ${pagePath}: ${page.reasons.join('; ')}`);
2660
+ console.log(
2661
+ 'it covers this exact difference only - any further change to the page reopens it. Every verdict lists it.',
2662
+ );
2663
+ }
2664
+
2665
+ async function cmdParityAnchor(argv) {
2666
+ const opts = parse(argv, { ...TARGET_OPTS, replace: { type: 'boolean', default: false } });
2667
+ const target = resolveTarget(opts);
2668
+ const paths = target.paths.parity;
2669
+ const manifest = readJson(paths.manifest);
2670
+ if (!manifest) {
2671
+ console.log(
2672
+ `no original build saved for ${target.dist} - \`${CLI} parity\` (or a scan) saves the current one`,
2673
+ );
2674
+ return;
2675
+ }
2676
+ if (opts.replace) {
2677
+ if (!fs.existsSync(path.join(target.dist, 'index.html'))) {
2678
+ throw new Error(
2679
+ `no build at ${target.dist} - build the commit that should become the original`,
2680
+ );
2681
+ }
2682
+ const next = saveAnchor({
2683
+ dist: target.dist,
2684
+ paths,
2685
+ pages: [],
2686
+ git: gitState(path.dirname(target.dist), target.dist),
2687
+ late: false,
2688
+ exclude: PARITY_EXCLUDE,
2689
+ });
2690
+ // Pages, masks (with their dates) and the first-save time carry over: lateness
2691
+ // is measured from the FIRST save, and a replacement is recorded, never hidden.
2692
+ next.createdAtMs = manifest.createdAtMs;
2693
+ next.pages = manifest.pages;
2694
+ next.masks = manifest.masks;
2695
+ next.replaced = [
2696
+ ...(manifest.replaced ?? []),
2697
+ { atMs: Date.now(), previousHash: manifest.distHash, previousGit: manifest.git },
2698
+ ];
2699
+ writeJson(paths.manifest, next);
2700
+ fs.rmSync(paths.report, { force: true });
2701
+ fs.rmSync(paths.approvals, { force: true });
2702
+ console.log(
2703
+ `the saved original build was REPLACED by ${target.dist}${next.git ? ` (commit ${next.git.sha})` : ''}.`,
2704
+ );
2705
+ console.log(
2706
+ 'Parity now compares against it, and every verdict says the original was replaced. This is a human decision -',
2707
+ );
2708
+ console.log('an agent must never replace the original to make a difference go away.');
2709
+ return;
2710
+ }
2711
+ const date = (msValue) => new Date(msValue).toISOString().slice(0, 16).replace('T', ' ');
2712
+ console.log(`original build for ${target.dist}:`);
2713
+ console.log(` saved ${date(manifest.createdAtMs)} UTC from ${manifest.dist}`);
2714
+ if (manifest.git) {
2715
+ console.log(
2716
+ ` commit ${manifest.git.sha}${manifest.git.dirty ? ' + uncommitted changes' : ''}`,
2717
+ );
2718
+ }
2719
+ console.log(` files hash ${manifest.distHash.slice(0, 12)} (copy at ${paths.anchorDist})`);
2720
+ for (const page of manifest.pages) {
2721
+ const later = page.addedAtMs > manifest.createdAtMs ? ` (added ${date(page.addedAtMs)})` : '';
2722
+ console.log(` page ${page.path}${later}`);
2723
+ }
2724
+ for (const mask of manifest.masks) {
2725
+ const later = mask.addedAtMs > manifest.createdAtMs ? ` (added ${date(mask.addedAtMs)})` : '';
2726
+ console.log(` mask ${mask.selector}${later}`);
2727
+ }
2728
+ if (manifest.lcp?.element) {
2729
+ console.log(` LCP element (${manifest.lcp.key}): ${describeLcp(manifest.lcp.element)}`);
2730
+ }
2731
+ for (const r of manifest.replaced ?? []) {
2732
+ console.log(` replaced ${date(r.atMs)} (previous hash ${r.previousHash.slice(0, 12)})`);
2733
+ }
2734
+ if (manifest.late) {
2735
+ console.log(' saved after a perf baseline existed - it may already include changes');
2736
+ }
2737
+ }
2738
+
2739
+ // --- module-graph analysis (rolldown devtools metrics builds) ---------------------
2740
+
2741
+ // Scan/verdict integration thresholds: a non-framework module retaining >=100KB on
2742
+ // the initial load is always worth pricing with what-if; the 30s slack absorbs the
2743
+ // moments between the graph write (generate stage) and the dist flush of one build.
2744
+ const GRAPH_RETAINED_OPEN_BYTES = 100 * 1024;
2745
+ const GRAPH_FRESH_SLACK_MS = 30 * 1000;
2746
+ const GRAPH_FRAMEWORK_RE = /^(react-dom|react|scheduler|vue|@vue|svelte|preact)(\/|$)/;
2747
+
2748
+ function moduleGraphStatus(target, builtAtMs = null) {
2749
+ const graph = loadModuleGraph(
2750
+ moduleGraphCandidates({
2751
+ demoMetricsDir: target.isDemo ? BUILD_METRICS_DIR : null,
2752
+ dist: target.dist,
2753
+ }),
2754
+ );
2755
+ if (graph) {
2756
+ const stale =
2757
+ typeof builtAtMs === 'number' &&
2758
+ fs.statSync(graph.file).mtimeMs < builtAtMs - GRAPH_FRESH_SLACK_MS;
2759
+ return { state: stale ? 'stale' : 'present', graph };
2760
+ }
2761
+ const rolldownBuilt = target.isDemo || rolldownBuildDetected(path.dirname(target.dist));
2762
+ return { state: rolldownBuilt ? 'absent-rolldown' : 'absent' };
2763
+ }
2764
+
2765
+ // Evidence that rolldown actually bundles this app - a planted node_modules/rolldown
2766
+ // (the link: launcher vehicle) proves nothing. Real evidence: the app declares
2767
+ // rolldown as its own dependency, or the vite it resolves is rolldown-powered
2768
+ // (vite >= 8, or the rolldown-vite alias). Walks up for hoisted monorepo installs.
2769
+ function rolldownBuildDetected(appRoot) {
2770
+ let viteSpec = null;
2771
+ let dir = appRoot;
2772
+ for (let i = 0; i < 4; i++) {
2773
+ const pkg = readJson(path.join(dir, 'package.json'));
2774
+ const deps = { ...pkg?.dependencies, ...pkg?.devDependencies };
2775
+ if (deps.rolldown) return true;
2776
+ if (deps.vite && !viteSpec) viteSpec = deps.vite;
2777
+ const vitePkg = readJson(path.join(dir, 'node_modules', 'vite', 'package.json'));
2778
+ if (vitePkg) {
2779
+ return (
2780
+ vitePkg.name === 'rolldown-vite' || Number(String(vitePkg.version ?? '').split('.')[0]) >= 8
2781
+ );
2782
+ }
2783
+ const parent = path.dirname(dir);
2784
+ if (parent === dir) break;
2785
+ dir = parent;
2786
+ }
2787
+ if (viteSpec) {
2788
+ return /rolldown-vite/.test(viteSpec) || Number((viteSpec.match(/\d+/) ?? [0])[0]) >= 8;
2789
+ }
2790
+ return false;
2791
+ }
2792
+
2793
+ // Boot roots (a script dominated directly by an HTML entry) are excluded: deferring
2794
+ // the module the HTML loads is a blank page, and their children surface in the
2795
+ // ranking anyway. Children of a JS entry are ordinary candidates.
2796
+ function retainedLeadRows(graph) {
2797
+ const entrySet = new Set(graph.entryModules);
2798
+ const mods = graph.modules;
2799
+ const isHtmlBootChild = (m) =>
2800
+ m.idom != null && entrySet.has(mods[m.idom].id) && /\.html?$/i.test(mods[m.idom].id);
2801
+ return mods
2802
+ .filter(
2803
+ (m) =>
2804
+ m.staticReachable &&
2805
+ !entrySet.has(m.id) &&
2806
+ m.retainedBytes >= GRAPH_RETAINED_OPEN_BYTES &&
2807
+ !isHtmlBootChild(m) &&
2808
+ !GRAPH_FRAMEWORK_RE.test(m.id),
2809
+ )
2810
+ .sort((a, b) => b.retainedBytes - a.retainedBytes || a.id.localeCompare(b.id));
2811
+ }
2812
+
2813
+ // Group note (P1a adoption): when >=2 printed rows come from the same package (a
2814
+ // node_modules package, or the same top-level source subdir), a combined deferral can
2815
+ // free shared internals no single row's retained size counts. Ids are slash-normalized
2816
+ // first (Windows). `packageRoot` returns the grouping key for one id.
2817
+ function packageRoot(id) {
2818
+ const norm = id.replaceAll('\\', '/');
2819
+ const nm = norm.lastIndexOf('node_modules/');
2820
+ if (nm >= 0) {
2821
+ const rest = norm.slice(nm + 'node_modules/'.length).split('/');
2822
+ return rest[0]?.startsWith('@') && rest[1] ? `${rest[0]}/${rest[1]}` : rest[0];
2823
+ }
2824
+ const segs = norm.split('/');
2825
+ return segs.length >= 2 ? `${segs[0]}/${segs[1]}` : segs[0];
2826
+ }
2827
+
2828
+ // The single line printed when one package root repeats across the display rows (the
2829
+ // most-repeated one wins), or null. Teaches what-if's group pricing from a printed
2830
+ // surface (adoption rule 3) without opening a verdict lead (rule 2).
2831
+ function packageRootNote(rows) {
2832
+ const counts = new Map();
2833
+ for (const m of rows) {
2834
+ const root = packageRoot(m.id);
2835
+ counts.set(root, (counts.get(root) ?? 0) + 1);
2836
+ }
2837
+ let best = null;
2838
+ for (const [root, n] of counts) {
2839
+ if (n >= 2 && (!best || n > best.n)) best = { root, n };
2840
+ }
2841
+ if (!best) return null;
2842
+ return `note: ${best.root} appears in ${best.n} rows - \`${CLI} what-if <a> <b>\` prices the combined deferral; shared internals can make it exceed the sum.`;
2843
+ }
2844
+
2845
+ function printGraphSection(target, builtAtMs = null) {
2846
+ const mg = moduleGraphStatus(target, builtAtMs);
2847
+ if (mg.state === 'absent') return;
2848
+ if (mg.state === 'stale') {
2849
+ console.log(
2850
+ 'static module graph: STALE (predates the current build) - rebuild with build.rolldownOptions.devtools = { mode: "report" } in the vite config\n',
2851
+ );
2852
+ return;
2853
+ }
2854
+ if (mg.state === 'absent-rolldown') {
2855
+ console.log(
2856
+ `static module graph: not collected - this app builds with rolldown. vite >= 8: add build.rolldownOptions.devtools = { mode: "report" } to the vite config, rebuild, re-scan (unlocks \`${CLI} graph\` / \`${CLI} what-if\`)\n`,
2857
+ );
2858
+ return;
2859
+ }
2860
+ const mods = mg.graph.modules;
2861
+ const entrySet = new Set(mg.graph.entryModules);
2862
+ const rows = mods
2863
+ .filter((m) => m.staticReachable && m.retainedBytes > 0 && !entrySet.has(m.id))
2864
+ .sort((a, b) => b.retainedBytes - a.retainedBytes || a.id.localeCompare(b.id))
2865
+ .slice(0, 8);
2866
+ if (!rows.length) return;
2867
+ console.log(
2868
+ 'statically retained imports (rolldown module graph - what one deferral would free):',
2869
+ );
2870
+ for (const mod of rows) {
2871
+ console.log(
2872
+ ` ${kb(mod.retainedBytes).padStart(10)} ${mod.id}${mod.idom != null ? ` via ${mods[mod.idom].id}` : ''}`,
2873
+ );
2874
+ }
2875
+ const note = packageRootNote(rows);
2876
+ if (note) console.log(` ${note}`);
2877
+ console.log(
2878
+ ` full ranking: \`${CLI} graph\`; exact modules+bytes for one cut: \`${CLI} what-if <module>\`\n`,
2879
+ );
2880
+ }
2881
+
2882
+ // The full graph-diff render (the `graph-diff` command). Eager-tier diff is the headline;
2883
+ // node classification (added/removed/bytes/edges) follows. Sections are sorted |Δ| desc.
2884
+ function printGraphDiff(diff) {
2885
+ const line = (sign, m) => ` ${`${sign}${kb(m.bytes)}`.padStart(11)} ${m.id}`;
2886
+ console.log(
2887
+ `eager set: +${diff.entered.length} module(s)/+${kb(diff.enteredBytes)} entered, ` +
2888
+ `-${diff.left.length}/-${kb(diff.leftBytes)} left the initial load\n`,
2889
+ );
2890
+ if (diff.entered.length) {
2891
+ console.log('entered the initial load (weight the change ADDED to first paint):');
2892
+ for (const m of diff.entered.slice(0, 15)) console.log(line('+', m));
2893
+ if (diff.entered.length > 15) console.log(` ... +${diff.entered.length - 15} more`);
2894
+ printDiffGroups(diff.enteredGroups, '+');
2895
+ console.log('');
2896
+ }
2897
+ if (diff.left.length) {
2898
+ console.log('left the initial load (weight the change DEFERRED off first paint):');
2899
+ for (const m of diff.left.slice(0, 15)) console.log(line('-', m));
2900
+ if (diff.left.length > 15) console.log(` ... +${diff.left.length - 15} more`);
2901
+ printDiffGroups(diff.leftGroups, '-');
2902
+ console.log('');
2903
+ }
2904
+ if (!diff.entered.length && !diff.left.length) {
2905
+ console.log('eager set unchanged - the initial load holds exactly the same modules.');
2906
+ console.log('(an unchanged eager set does not promise an unchanged measurement: CSS, assets,');
2907
+ console.log(' ordering and cache behavior still move it.)\n');
2908
+ }
2909
+ const nodes = [];
2910
+ if (diff.added.length) nodes.push(`+${diff.added.length} added`);
2911
+ if (diff.removed.length) nodes.push(`-${diff.removed.length} removed`);
2912
+ if (diff.bytesChanged.length) nodes.push(`${diff.bytesChanged.length} bytes-changed`);
2913
+ if (diff.edgesChanged.length) nodes.push(`${diff.edgesChanged.length} edges-changed`);
2914
+ console.log(
2915
+ `nodes: ${nodes.length ? nodes.join(', ') : 'no id-level changes'} (renames read as add+remove - ids are identity).`,
2916
+ );
2917
+ }
2918
+
2919
+ // Exclusion-aware rollup: bytes attributed to a changed subtree root count only nodes
2920
+ // that themselves changed; untouched intermediates are a skip count, not a listing.
2921
+ function printDiffGroups(groups, sign) {
2922
+ const multi = groups.filter((g) => g.count > 1 || g.skipped > 0);
2923
+ if (!multi.length) return;
2924
+ console.log(' grouped under nearest changed ancestor:');
2925
+ for (const g of multi.slice(0, 8)) {
2926
+ console.log(
2927
+ ` ${sign}${kb(g.bytes)} across ${g.count} changed module(s) under ${g.rootId}` +
2928
+ `${g.skipped ? ` (+${g.skipped} unchanged dep(s) skipped)` : ''}`,
2929
+ );
2930
+ }
2931
+ }
2932
+
2933
+ // Scan integration (rule 3, fact-only): when a pinned baseline graph exists and the
2934
+ // current sidecar is fresh, print <=3 lines. Never tells the agent to skip a measure.
2935
+ function printEagerDiffSummary(target, builtAtMs) {
2936
+ const baseline = loadModuleGraph([target.paths.baselineModuleGraph]);
2937
+ if (!baseline) return;
2938
+ const mg = moduleGraphStatus(target, builtAtMs);
2939
+ if (mg.state !== 'present') return;
2940
+ const diff = diffGraphs(baseline, mg.graph);
2941
+ if (!diff.entered.length && !diff.left.length) {
2942
+ console.log(
2943
+ 'eager set vs pinned baseline: unchanged (CSS/assets/order can still move the measurement).\n',
2944
+ );
2945
+ return;
2946
+ }
2947
+ const topStr = diff.entered.length
2948
+ ? `top entered ${diff.entered[0].id} (+${kb(diff.entered[0].bytes)})`
2949
+ : `top left ${diff.left[0].id} (-${kb(diff.left[0].bytes)})`;
2950
+ console.log(
2951
+ `eager set vs pinned baseline: +${diff.entered.length} module(s)/+${kb(diff.enteredBytes)} entered, ` +
2952
+ `-${diff.left.length}/-${kb(diff.leftBytes)} left; ${topStr}.`,
2953
+ );
2954
+ console.log(
2955
+ `(deterministic eager-set attribution; \`${CLI} graph-diff\` shows the full account.)\n`,
2956
+ );
2957
+ }
2958
+
2959
+ function requireModuleGraph(opts) {
2960
+ // A remembered Worker target answers the static queries too (its report sits in a
2961
+ // per-environment directory); a browser target is only resolved when none applies.
2962
+ const workerCandidates = opts.report ? null : workerReportCandidates(opts);
2963
+ let candidates;
2964
+ let target = null;
2965
+ let appHint = null; // where the app's rolldown resolves from, for the missing-report cause
2966
+ if (workerCandidates) {
2967
+ candidates = workerCandidates;
2968
+ appHint = readJson(WORKER_TARGET_FILE)?.app ?? null;
2969
+ } else {
2970
+ target = resolveTarget(opts);
2971
+ candidates = moduleGraphCandidates({
2972
+ reportDir: opts.report,
2973
+ demoMetricsDir: target.isDemo ? BUILD_METRICS_DIR : null,
2974
+ dist: target.dist,
2975
+ });
2976
+ appHint = target.isDemo ? null : path.dirname(target.dist);
2977
+ }
2978
+ const graph = loadModuleGraph(candidates);
2979
+ if (!graph) {
2980
+ const cause = appHint && !opts.report ? reportSupportNote(appHint, CLI) : null;
2981
+ throw new Error(
2982
+ (cause ? `${cause}\n\n` : '') +
2983
+ 'no module-graph.json found - it is written by rolldown devtools metrics builds.\n' +
2984
+ 'vite >= 8: add `build.rolldownOptions.devtools = { mode: "report" }` to the vite config\n' +
2985
+ '(report lands in node_modules/.rolldown/report/<output dir>), rebuild, then re-run.\n' +
2986
+ `For a Cloudflare Worker: \`${CLI} worker-config --app <appDir>\` writes a config that puts the report\n` +
2987
+ 'on every environment. Or point at an existing report with --report <dir>.',
2988
+ );
2989
+ }
2990
+ if (target && !opts.report) assertReportMatchesDist(graph, target.dist);
2991
+ return graph;
2992
+ }
2993
+
2994
+ // A report the lab discovered for the dist must describe that dist. Seen in the field:
2995
+ // an agent ranked a server build's graph against the client dist, because the SSR build
2996
+ // ran last and overwrote the one shared report directory. Report mode now writes one
2997
+ // directory per output dir, but a rebuild without the report still leaves a stale one.
2998
+ // An explicit --report is the user's choice and is not checked.
2999
+ function assertReportMatchesDist(graph, dist) {
3000
+ const why = reportDistMismatch(graph, dist);
3001
+ if (!why) return;
3002
+ throw new Error(
3003
+ `${why}\n` +
3004
+ 'Rebuild with the report on, so the report and the dist come from the same build.\n' +
3005
+ 'Report mode writes one report per output dir: builds that share an outDir share a report.\n' +
3006
+ 'Or point at the client report with --report <dir>.',
3007
+ );
3008
+ }
3009
+
3010
+ async function cmdGraph(argv) {
3011
+ const opts = parse(argv, {
3012
+ ...TARGET_OPTS,
3013
+ report: { type: 'string' },
3014
+ top: { type: 'string', default: '15' },
3015
+ });
3016
+ const graph = requireModuleGraph(opts);
3017
+ const mods = graph.modules;
3018
+ console.log(`module graph: ${graph.file}`);
3019
+ console.log(`entries: ${graph.entryModules.join(', ')}`);
3020
+ const staticMods = mods.filter((m) => m.staticReachable);
3021
+ const staticBytes = staticMods.reduce((sum, m) => sum + m.bytes, 0);
3022
+ const lazyCount = mods.filter((m) => m.dynamicOnly).length;
3023
+ console.log(
3024
+ `initial-load view: ${staticMods.length} modules / ${kb(staticBytes)} statically reachable; ${lazyCount} already lazy (dynamic-import only)\n`,
3025
+ );
3026
+ const entrySet = new Set(graph.entryModules);
3027
+ const rows = staticMods
3028
+ .filter((m) => m.retainedBytes > 0 && !entrySet.has(m.id))
3029
+ .sort((a, b) => b.retainedBytes - a.retainedBytes || a.id.localeCompare(b.id))
3030
+ .slice(0, Number(opts.top));
3031
+ console.log(
3032
+ "retained size - what deferring each module's import edge would remove from the initial load:",
3033
+ );
3034
+ for (const mod of rows) {
3035
+ const via = mod.idom != null ? ` via ${mods[mod.idom].id}` : ' (directly under the entries)';
3036
+ console.log(
3037
+ ` ${kb(mod.retainedBytes).padStart(10)} ${mod.id} (own ${kb(mod.bytes)}, ${mod.retainedModuleCount} module(s))${via}`,
3038
+ );
3039
+ }
3040
+ if (!rows.length)
3041
+ console.log(
3042
+ ' (nothing sizeable is uniquely retained - the entries themselves hold the bytes)',
3043
+ );
3044
+ const note = packageRootNote(rows);
3045
+ if (note) console.log(`\n${note}`);
3046
+ console.log(
3047
+ `\nnext: ${CLI} what-if <module> - the exact modules+bytes that one deferral frees (add --keep a,b to hold some imports eager)`,
3048
+ );
3049
+ }
3050
+
3051
+ async function cmdWhatIf(argv) {
3052
+ const { values: opts, positionals } = parseArgs({
3053
+ args: argv,
3054
+ options: {
3055
+ ...TARGET_OPTS,
3056
+ report: { type: 'string' },
3057
+ keep: { type: 'string' },
3058
+ root: { type: 'string' },
3059
+ },
3060
+ allowPositionals: true,
3061
+ });
3062
+ if (!positionals.length)
3063
+ throw new Error(
3064
+ `usage: ${CLI} what-if <module> [<module> ...] [--keep a,b] [--root a,b] [--report <dir>]`,
3065
+ );
3066
+ const built = requireModuleGraph(opts);
3067
+ const resolve = (q) => {
3068
+ const hit = resolveModule(built, q);
3069
+ if (!hit)
3070
+ throw new Error(`no module matches '${q}' (ids are project-relative, e.g. src/router.ts)`);
3071
+ if (hit.ambiguous) {
3072
+ throw new Error(
3073
+ `'${q}' is ambiguous:\n ${hit.ambiguous.join('\n ')}\nuse a longer suffix.`,
3074
+ );
3075
+ }
3076
+ return hit.index;
3077
+ };
3078
+ const resolved = positionals.map(resolve);
3079
+ const keep = (opts.keep ?? '').split(',').filter(Boolean).map(resolve);
3080
+ // `--root`: modules that load on every start without a static import from an entry
3081
+ // (see withRoots) - the initial load priced below is the entries + their closure.
3082
+ const graph = withRoots(built, (opts.root ?? '').split(',').filter(Boolean).map(resolve));
3083
+ printRootedLoad(graph);
3084
+ // Entries cannot be deferred - they ARE the initial load (no import edge to cut).
3085
+ // Filtered up front so single and group queries answer the same way.
3086
+ const entrySet = new Set(graph.entryModules);
3087
+ const rootSet = new Set(graph.rootModules ?? []);
3088
+ const targets = [];
3089
+ for (const t of resolved) {
3090
+ if (rootSet.has(graph.modules[t].id)) {
3091
+ console.log(
3092
+ `${graph.modules[t].id} is a --root - it stays eager with its closure, so there is no import edge to defer. Skipped.`,
3093
+ );
3094
+ } else if (entrySet.has(graph.modules[t].id)) {
3095
+ console.log(
3096
+ `${graph.modules[t].id} is an entry - it IS the initial load, so there is no import edge to defer. Skipped; \`${CLI} graph\` ranks its heavy children instead.`,
3097
+ );
3098
+ } else {
3099
+ targets.push(t);
3100
+ }
3101
+ }
3102
+ if (!targets.length) return;
3103
+ if (targets.length > 1) {
3104
+ printCombinedWhatIf(graph, targets, keep);
3105
+ return;
3106
+ }
3107
+ const target = targets[0];
3108
+ const result = whatIf(graph, target, keep);
3109
+
3110
+ console.log(`what-if deferred: ${result.target.id}`);
3111
+ if (result.notStaticallyReachable) {
3112
+ console.log(
3113
+ result.alreadyLazy
3114
+ ? `already lazy: every path to this module crosses a dynamic import - it costs the initial load nothing.${rootSet.size ? '' : ROOT_HINT}`
3115
+ : 'not reachable from the entries at all in this build.',
3116
+ );
3117
+ return;
3118
+ }
3119
+ if (result.cutEdges.length) {
3120
+ console.log(
3121
+ `cut ${result.cutEdges.length} static import edge(s), from: ${result.cutEdges.join(', ')}`,
3122
+ );
3123
+ }
3124
+ console.log(
3125
+ `removes ${kb(result.removedBytes)} / ${result.removedCount} module(s) from the initial load${keep.length ? ` (keeping ${keep.length} sentry module(s) eager)` : ''}:`,
3126
+ );
3127
+ const shown = result.removed.slice(0, 20);
3128
+ for (const mod of shown) {
3129
+ console.log(` ${kb(mod.bytes).padStart(10)} ${mod.id}`);
3130
+ }
3131
+ if (result.removed.length > shown.length) {
3132
+ console.log(` ... +${result.removed.length - shown.length} more`);
3133
+ }
3134
+ // Adoption fact (rule 3): when a single upstream cut detaches the module with FEWER
3135
+ // edits than deferring every direct import, point at `cut`. Only prints when it wins,
3136
+ // so the common (naive-is-minimal) case stays byte-identical.
3137
+ const cut = minCut(graph, target, keepProtectedEdges(graph, keep));
3138
+ if (
3139
+ !cut.blockedByProtected &&
3140
+ !cut.hasUncuttableSink &&
3141
+ cut.flow > 0 &&
3142
+ cut.flow < result.cutEdges.length
3143
+ ) {
3144
+ console.log(
3145
+ `\nmin cut: ${cut.flow} edge(s) instead of ${result.cutEdges.length} - \`${CLI} cut ${result.target.id}\` lists them.`,
3146
+ );
3147
+ }
3148
+ console.log(
3149
+ `\nnext: make those importer(s) load it with a dynamic import(), rebuild, run the app's functional check, then \`${CLI} scan\` to confirm the LCP effect.`,
3150
+ );
3151
+ }
3152
+
3153
+ // Every static import edge into a `--keep` sentry is protected: the min cut must not
3154
+ // recommend deferring an import the user is holding eager, so it routes around them.
3155
+ function keepProtectedEdges(graph, keep) {
3156
+ const edges = [];
3157
+ for (const s of keep) for (const p of graph.staticPreds[s]) edges.push({ from: p, to: s });
3158
+ return edges;
3159
+ }
3160
+
3161
+ // "Already lazy" in the build can still be startup code: an app whose boot chain is a
3162
+ // row of dynamic imports (`import('./initReact')`) pays for them on every load.
3163
+ const ROOT_HINT =
3164
+ ' If the app runs that dynamic import on every start, pass the module it loads as --root to price it as startup code.';
3165
+
3166
+ // With --root, the "initial load" every line below prices is the startup load: the
3167
+ // static closure of the entries + the roots. Say so once, with its size.
3168
+ function printRootedLoad(graph) {
3169
+ if (!graph.rootModules?.length) return;
3170
+ const base = evalOverrides(graph, []);
3171
+ console.log(`roots: ${graph.rootModules.join(', ')}`);
3172
+ console.log(
3173
+ `"initial load" below = the static closure of the entries + ${graph.rootModules.length} root(s): ${kb(base.eagerBytes)} / ${base.eager.size} module(s)\n`,
3174
+ );
3175
+ }
3176
+
3177
+ // Multiple targets = ONE combined deferral, priced with a single overlay. The payoff is
3178
+ // the comparison against the summed-individually total: shared internals dominated by no
3179
+ // single target only leave the initial load when the whole group is deferred, so the
3180
+ // combined free can exceed the sum (the firebase / sibling-routes pattern).
3181
+ function printCombinedWhatIf(graph, targets, keep) {
3182
+ const keepRoots = keep.filter((k) => !targets.includes(k));
3183
+ const overrides = targets.flatMap((t) => deferAllInto(graph, t));
3184
+ const combined = evalOverrides(graph, overrides, keepRoots.length ? keepRoots : null);
3185
+ const summed = targets.reduce((sum, t) => sum + whatIf(graph, t, keep).removedBytes, 0);
3186
+ const ids = targets.map((t) => graph.modules[t].id);
3187
+ console.log(`what-if deferred (combined): ${ids.join(', ')}`);
3188
+ console.log(
3189
+ `removes ${kb(combined.removedBytes)} / ${combined.removedCount} module(s) from the initial load` +
3190
+ `${keepRoots.length ? ` (keeping ${keepRoots.length} sentry module(s) eager)` : ''}:`,
3191
+ );
3192
+ const shown = combined.removed.slice(0, 20);
3193
+ for (const mod of shown) console.log(` ${kb(mod.bytes).padStart(10)} ${mod.id}`);
3194
+ if (combined.removed.length > shown.length)
3195
+ console.log(` ... +${combined.removed.length - shown.length} more`);
3196
+ const delta = combined.removedBytes - summed;
3197
+ const together = targets.length === 2 ? 'both' : 'all';
3198
+ if (delta > 0) {
3199
+ console.log(
3200
+ `\ncombined ${kb(combined.removedBytes)} vs ${kb(summed)} summed individually` +
3201
+ ` - ${kb(delta)} of shared internals count only when ${together} are deferred.`,
3202
+ );
3203
+ } else if (delta < 0) {
3204
+ console.log(
3205
+ `\ncombined ${kb(combined.removedBytes)} vs ${kb(summed)} summed individually` +
3206
+ " - the targets' retained sets overlap, so the group frees less than the naive sum.",
3207
+ );
3208
+ } else {
3209
+ console.log(
3210
+ `\ncombined ${kb(combined.removedBytes)} vs ${kb(summed)} summed individually` +
3211
+ ' - no shared internals; the targets are independent, so the combined free equals the sum.',
3212
+ );
3213
+ }
3214
+ console.log(
3215
+ `\nnext: make each importer load its target with a dynamic import(), rebuild, run the app's functional check, then \`${CLI} scan\` to confirm the LCP effect.`,
3216
+ );
3217
+ }
3218
+
3219
+ async function cmdCut(argv) {
3220
+ const { values: opts, positionals } = parseArgs({
3221
+ args: argv,
3222
+ options: {
3223
+ ...TARGET_OPTS,
3224
+ report: { type: 'string' },
3225
+ keep: { type: 'string' },
3226
+ root: { type: 'string' },
3227
+ },
3228
+ allowPositionals: true,
3229
+ });
3230
+ const query = positionals[0];
3231
+ if (!query) {
3232
+ throw new Error(`usage: ${CLI} cut <module> [--keep a,b] [--root a,b] [--report <dir>]`);
3233
+ }
3234
+ const graph = requireModuleGraph(opts);
3235
+ const resolve = (q) => {
3236
+ const hit = resolveModule(graph, q);
3237
+ if (!hit)
3238
+ throw new Error(`no module matches '${q}' (ids are project-relative, e.g. src/router.ts)`);
3239
+ if (hit.ambiguous) {
3240
+ throw new Error(
3241
+ `'${q}' is ambiguous:\n ${hit.ambiguous.join('\n ')}\nuse a longer suffix.`,
3242
+ );
3243
+ }
3244
+ return hit.index;
3245
+ };
3246
+ const target = resolve(query);
3247
+ const keep = (opts.keep ?? '').split(',').filter(Boolean).map(resolve);
3248
+ // `--root`: modules whose whole closure must stay eager because the runtime loads them
3249
+ // without a static import from an entry - a Durable Object or Workflow class the
3250
+ // entry exports and the runtime loads by name, or a dynamic import the app runs on
3251
+ // every start. They join the entries (withRoots), so the cut lands on the edges BELOW
3252
+ // them, and a module only a root reaches becomes cuttable. `--keep` cannot say this:
3253
+ // protecting a root's in-edges only pushes the cut up to the entry.
3254
+ const roots = (opts.root ?? '').split(',').filter(Boolean).map(resolve);
3255
+ const rooted = withRoots(graph, roots);
3256
+ const mod = rooted.modules[target];
3257
+ printRootedLoad(rooted);
3258
+
3259
+ // Trivial cases reuse what-if's flags: a module that costs the initial load nothing
3260
+ // has nothing to cut.
3261
+ if (mod.staticReachable === false) {
3262
+ console.log(`min cut for ${mod.id}`);
3263
+ console.log(
3264
+ mod.dynamicOnly === true
3265
+ ? `already lazy: every path to this module crosses a dynamic import - nothing to cut.${roots.length ? '' : ROOT_HINT}`
3266
+ : 'not reachable from the entries at all in this build - nothing to cut.',
3267
+ );
3268
+ return;
3269
+ }
3270
+ const cut = minCut(rooted, target, keepProtectedEdges(rooted, keep));
3271
+ if (cut.hasUncuttableSink) {
3272
+ console.log(`min cut for ${mod.id}`);
3273
+ console.log(
3274
+ roots.includes(target)
3275
+ ? 'this module is a --root - it stays eager with its closure; drop it from --root to cut it.'
3276
+ : 'this module is an entry - it cannot be detached by cutting imports (you would delete the module itself).',
3277
+ );
3278
+ return;
3279
+ }
3280
+ if (cut.blockedByProtected) {
3281
+ console.log(`min cut for ${mod.id}`);
3282
+ console.log(
3283
+ 'every path to it runs through a --keep-protected import - no cut avoids them. Relax --keep to cut it.',
3284
+ );
3285
+ return;
3286
+ }
3287
+ if (cut.flow === 0 || !cut.cutEdges.length) {
3288
+ console.log(`min cut for ${mod.id}`);
3289
+ console.log('nothing to cut - it is already detached from the initial load.');
3290
+ return;
3291
+ }
3292
+ const naive = whatIf(rooted, target, keep).cutEdges.length;
3293
+ const priced = evalOverrides(
3294
+ rooted,
3295
+ cut.cutEdges.map((e) => ({ ...e, kind: 'defer' })),
3296
+ );
3297
+ console.log(
3298
+ `min cut for ${mod.id} - ${cut.flow} import edge(s) to make dynamic (naive what-if would touch ${naive}):`,
3299
+ );
3300
+ for (const e of cut.cutEdges) {
3301
+ console.log(` ${rooted.modules[e.from].id} -> ${rooted.modules[e.to].id}`);
3302
+ }
3303
+ console.log(
3304
+ `make each import dynamic (import()) -> frees ${kb(priced.removedBytes)} / ${priced.removedCount} module(s) from the initial load` +
3305
+ `${keep.length ? ` (keeping ${keep.length} sentry module(s) eager)` : ''}` +
3306
+ `${roots.length ? ` (${roots.length} root module(s) stay eager with their closure)` : ''}.`,
3307
+ );
3308
+ console.log(
3309
+ `\nnext: open those file(s), change the static import to a dynamic import(), rebuild, run the app's functional check, then \`${CLI} scan\` to confirm the LCP effect.`,
3310
+ );
3311
+ }
3312
+
3313
+ async function cmdGraphDiff(argv) {
3314
+ const opts = parse(argv, {
3315
+ ...TARGET_OPTS,
3316
+ report: { type: 'string' },
3317
+ against: { type: 'string' },
3318
+ });
3319
+ const target = resolveTarget(opts);
3320
+ const current = loadModuleGraph(
3321
+ moduleGraphCandidates({
3322
+ reportDir: opts.report,
3323
+ demoMetricsDir: target.isDemo ? BUILD_METRICS_DIR : null,
3324
+ dist: target.dist,
3325
+ }),
3326
+ );
3327
+ if (!current) {
3328
+ const cause =
3329
+ target.isDemo || opts.report ? null : reportSupportNote(path.dirname(target.dist), CLI);
3330
+ throw new Error(
3331
+ (cause ? `${cause}\n\n` : '') +
3332
+ 'no current module-graph.json found - it is written by rolldown devtools metrics builds.\n' +
3333
+ 'vite >= 8: add `build.rolldownOptions.devtools = { mode: "report" }` to the vite config,\n' +
3334
+ 'rebuild, then re-run. Or point at an existing report with --report <dir>.',
3335
+ );
3336
+ }
3337
+ if (!opts.report) assertReportMatchesDist(current, target.dist);
3338
+ // Default: compare against the pinned baseline snapshot; --against compares vs any saved
3339
+ // sidecar (a dir or a module-graph.json path). Absent baseline exits normally (no error).
3340
+ const baseline = opts.against
3341
+ ? loadModuleGraph(moduleGraphCandidates({ reportDir: opts.against }))
3342
+ : loadModuleGraph([target.paths.baselineModuleGraph]);
3343
+ if (!baseline) {
3344
+ console.log(
3345
+ opts.against
3346
+ ? `no module graph found at --against ${opts.against}`
3347
+ : `no pinned baseline module graph yet - pin one with \`${CLI} scan --pin\` or \`${CLI} baseline\`, or pass --against <file>.`,
3348
+ );
3349
+ return;
3350
+ }
3351
+ console.log(
3352
+ `graph-diff: ${opts.against ? 'against' : 'pinned baseline'} ${baseline.file}\n -> current ${current.file}\n`,
3353
+ );
3354
+ printGraphDiff(diffGraphs(baseline, current));
3355
+ }
3356
+
3357
+ async function cmdBaseline(argv) {
3358
+ const opts = parse(argv, { ...TARGET_OPTS });
3359
+ pinBaseline(resolveTarget(opts));
3360
+ }
3361
+
3362
+ async function cmdTarget(argv) {
3363
+ const { values: opts, positionals: positional } = parseArgs({
3364
+ args: argv,
3365
+ options: {
3366
+ demo: { type: 'boolean', default: false },
3367
+ 'net-scale': { type: 'string' },
3368
+ ...NETWORK_OPTS,
3369
+ },
3370
+ allowPositionals: true,
3371
+ });
3372
+ const networkFlag = parseNetworkFlags(opts);
3373
+ if (opts.demo) {
3374
+ fs.rmSync(TARGET_FILE, { force: true });
3375
+ console.log('target cleared - commands now default to the demo app');
3376
+ return;
3377
+ }
3378
+ if (positional[0]) {
3379
+ const dist = resolveAppDist(positional[0]);
3380
+ writeJson(TARGET_FILE, { dist, setAtMs: Date.now() });
3381
+ console.log(`target: ${dist}`);
3382
+ } else {
3383
+ const sticky = readJson(TARGET_FILE);
3384
+ console.log(
3385
+ sticky?.dist
3386
+ ? `target: ${sticky.dist}`
3387
+ : 'no target set - commands default to the demo app (set one: node harness.mjs target <appDir>)',
3388
+ );
3389
+ if (!sticky?.dist && !opts['net-scale'] && !networkFlag) return;
3390
+ }
3391
+ // Pin the network without a probe run - the operator declares the network a
3392
+ // deployed environment measured; every later scan/measure inherits it.
3393
+ if (networkFlag) {
3394
+ const target = resolveTarget({});
3395
+ const existing = readJson(target.paths.throttleProfile);
3396
+ const id = throttleId({ network: networkFlag });
3397
+ writeJson(target.paths.throttleProfile, {
3398
+ schemaVersion: 1,
3399
+ network: networkFlag,
3400
+ source: 'target-cmd',
3401
+ pinnedAtMs: Date.now(),
3402
+ });
3403
+ console.log(
3404
+ `throttle profile pinned: ${id}${existing && throttleId(existing) !== id ? ` (was ${throttleId(existing)} - any pinned baseline is now incomparable, re-pin with scan --pin)` : ''}`,
3405
+ );
3406
+ }
3407
+ // Pin the net-scale without a probe run - the operator (or eval prep) declares
3408
+ // it; every later scan/measure and the scoring legs inherit it.
3409
+ if (opts['net-scale'] != null) {
3410
+ const netScale = Number(opts['net-scale']);
3411
+ if (!CALIBRATE_LADDER.includes(netScale)) {
3412
+ throw new Error(
3413
+ `--net-scale must be one of ${CALIBRATE_LADDER.join('/')} (named, reproducible steps only)`,
3414
+ );
3415
+ }
3416
+ const target = resolveTarget({});
3417
+ const existing = readJson(target.paths.throttleProfile);
3418
+ writeJson(target.paths.throttleProfile, {
3419
+ schemaVersion: 1,
3420
+ netScale,
3421
+ source: 'target-cmd',
3422
+ pinnedAtMs: Date.now(),
3423
+ });
3424
+ console.log(
3425
+ `throttle profile pinned: net x${netScale}${existing && throttleId(existing) !== throttleId({ netScale }) ? ` (was ${throttleId(existing)} - any pinned baseline is now incomparable, re-pin with scan --pin)` : ''}`,
3426
+ );
3427
+ }
3428
+ }
3429
+
3430
+ async function cmdDefer(argv, mode) {
3431
+ const feature = argv[0];
3432
+ if (!feature)
3433
+ throw new Error(
3434
+ `usage: node harness.mjs ${mode === 'deferred' ? 'defer' : 'undefer'} <${FEATURE_NAMES.join('|')}>`,
3435
+ );
3436
+ const result = setFeatureMode(APP_DIR, feature, mode);
3437
+ console.log(`${feature}: ${mode}${result.changed ? '' : ' (already)'}`);
3438
+ if (result.changed) console.log('rebuild before measuring: node harness.mjs build');
3439
+ }
3440
+
3441
+ async function cmdStatus() {
3442
+ const sticky = readJson(TARGET_FILE);
3443
+ console.log(sticky?.dist ? `target: ${sticky.dist}` : 'target: (none - demo app)');
3444
+ const target = resolveTarget({});
3445
+ const entryName = detectEntry(target.dist);
3446
+ console.log(
3447
+ `entry chunk: ${
3448
+ entryName && fs.existsSync(path.join(target.dist, entryName))
3449
+ ? `${entryName} ${kb(fs.statSync(path.join(target.dist, entryName)).size)}`
3450
+ : '(not built)'
3451
+ }`,
3452
+ );
3453
+ const last = readJson(target.paths.runtimeState);
3454
+ if (last)
3455
+ console.log(
3456
+ `last measure: ${last.label ?? '(unlabeled)'} LCP ${ms(last.metrics['runtime.lcp_ms'])}`,
3457
+ );
3458
+ const baseline = readJson(target.paths.runtimeBaseline);
3459
+ console.log(
3460
+ baseline
3461
+ ? `pinned baseline: ${baseline.label ?? '(unlabeled)'} LCP ${ms(baseline.metrics['runtime.lcp_ms'])}`
3462
+ : 'pinned baseline: none',
3463
+ );
3464
+ const parity = parityStatus(target);
3465
+ console.log(
3466
+ parity.manifest
3467
+ ? `original build: saved ${new Date(parity.manifest.createdAtMs).toISOString().slice(0, 16).replace('T', ' ')} UTC, ${parity.manifest.pages.length} page(s) - rendered output ${parity.state}`
3468
+ : 'original build: not saved yet (the first scan/measure/parity saves it)',
3469
+ );
3470
+ if (target.isDemo) {
3471
+ const modes = featureModes(APP_DIR);
3472
+ if (modes) {
3473
+ console.log('feature modes:');
3474
+ for (const [name, mode] of Object.entries(modes)) console.log(` ${name.padEnd(12)} ${mode}`);
3475
+ }
3476
+ }
3477
+ }
3478
+
3479
+ async function cmdServe(argv) {
3480
+ const opts = parse(argv, { ...TARGET_OPTS, port: { type: 'string', default: '4646' } });
3481
+ const target = resolveTarget(opts);
3482
+ const server = await startServer(target.dist, Number(opts.port));
3483
+ console.log(`serving ${target.dist} at ${server.origin} (Ctrl+C to stop)`);
3484
+ await new Promise(() => {});
3485
+ }
3486
+
3487
+ // --- dispatch ----------------------------------------------------------------
3488
+
3489
+ // --- server-runtime startup mode ---------------------------------------------------
3490
+ // The browser half of this harness measures a page load; this half measures a process
3491
+ // coming up. They share the question — "what does the initial load pay for, and what
3492
+ // can leave it?" — and therefore share the whole static half: `graph`, `what-if`, `cut`
3493
+ // and `graph-diff` price a deferral identically for a server bundle, because a
3494
+ // top-level import and a render-blocking import are the same edge in the module graph.
3495
+ // Only the measurement differs, and it differs completely: no navigation, no paint, no
3496
+ // transfer, so no throttle (see lib/node/measure.mjs).
3497
+
3498
+ const NODE_TARGET_FILE = path.join(STATE_DIR, 'node-target.json');
3499
+ const NODE_CACHE_MODES = new Set(['cold', 'warm', 'off']);
3500
+
3501
+ const NODE_TARGET_OPTS = {
3502
+ entry: { type: 'string' },
3503
+ ready: { type: 'string' },
3504
+ args: { type: 'string' },
3505
+ exec: { type: 'string' },
3506
+ cache: { type: 'string' },
3507
+ cwd: { type: 'string' },
3508
+ };
3509
+
3510
+ function nodeTargetPaths(entry) {
3511
+ const dir = path.join(STATE_DIR, 'node', targetSlug(entry));
3512
+ return {
3513
+ dir,
3514
+ metrics: path.join(dir, 'startup-metrics.json'),
3515
+ state: path.join(dir, '.state.json'),
3516
+ baseline: path.join(dir, 'baseline.json'),
3517
+ history: path.join(dir, 'history.jsonl'),
3518
+ };
3519
+ }
3520
+
3521
+ /**
3522
+ * Resolve (and remember) the server target. Readiness is part of the target, not of a
3523
+ * single invocation: a scan that measured "port 3000 accepting" and a later one that
3524
+ * measured "process exited" are not comparable, so the spec is pinned with the entry
3525
+ * and reused by every later bare command.
3526
+ */
3527
+ function resolveNodeTarget(opts) {
3528
+ const sticky = readJson(NODE_TARGET_FILE);
3529
+ const entryArg = opts.entry ?? sticky?.entry;
3530
+ if (!entryArg) {
3531
+ throw new Error(
3532
+ 'no server target yet - name the BUILT entry script (the file you deploy) and how it\n' +
3533
+ 'signals readiness, e.g.\n' +
3534
+ ` ${CLI} node-scan --entry dist/server.js --ready port:3000\n` +
3535
+ ` ${CLI} node-scan --entry dist/cli.js --ready exit`,
3536
+ );
3537
+ }
3538
+ const entry = path.resolve(entryArg);
3539
+ if (!fs.existsSync(entry)) {
3540
+ throw new Error(
3541
+ `no such entry script: ${entry}\n` +
3542
+ 'Build the app first, then aim --entry at the BUILT file. Never change the build to fit\n' +
3543
+ 'this tool - aim the tool at the build instead.',
3544
+ );
3545
+ }
3546
+ const readySpec = opts.ready ?? sticky?.ready ?? 'exit';
3547
+ const cache = opts.cache ?? sticky?.cache ?? 'cold';
3548
+ if (!NODE_CACHE_MODES.has(cache)) {
3549
+ throw new Error(`--cache must be one of cold|warm|off (got ${cache}).`);
3550
+ }
3551
+ const target = {
3552
+ entry,
3553
+ dist: path.dirname(entry),
3554
+ readySpec,
3555
+ ready: parseReadySpec(readySpec),
3556
+ args: (opts.args ?? sticky?.args ?? '').split(' ').filter(Boolean),
3557
+ exec: opts.exec ?? sticky?.exec ?? process.execPath,
3558
+ cwd: path.resolve(opts.cwd ?? sticky?.cwd ?? path.dirname(entry)),
3559
+ cache,
3560
+ paths: nodeTargetPaths(entry),
3561
+ };
3562
+ const changed =
3563
+ sticky?.entry !== entry ||
3564
+ sticky?.ready !== readySpec ||
3565
+ sticky?.cache !== cache ||
3566
+ sticky?.exec !== target.exec ||
3567
+ sticky?.cwd !== target.cwd;
3568
+ if (changed) {
3569
+ writeJson(NODE_TARGET_FILE, {
3570
+ entry,
3571
+ ready: readySpec,
3572
+ args: target.args.join(' '),
3573
+ exec: target.exec,
3574
+ cwd: target.cwd,
3575
+ cache,
3576
+ setAtMs: Date.now(),
3577
+ });
3578
+ console.log(`target: ${entry} (ready: ${describeReady(target.ready)}) - remembered`);
3579
+ }
3580
+ return target;
3581
+ }
3582
+
3583
+ /**
3584
+ * Run options for run `index`. In cold mode every run gets its own empty compile-cache
3585
+ * dir, so each pays full parse+compile the way a fresh container does; warm mode shares
3586
+ * one dir that the warmup run primes.
3587
+ */
3588
+ function nodeRunOptsFactory(target, { timeoutMs = 60_000 } = {}) {
3589
+ const slug = targetSlug(target.entry);
3590
+ const shared = target.cache === 'warm' ? freshCacheDir(STATE_DIR, `${slug}-warm`) : null;
3591
+ return (index) => ({
3592
+ execPath: target.exec,
3593
+ entry: target.entry,
3594
+ args: target.args,
3595
+ cwd: target.cwd,
3596
+ ready: target.ready,
3597
+ timeoutMs,
3598
+ env: runEnv({
3599
+ cacheMode: target.cache,
3600
+ cacheDir: target.cache === 'cold' ? freshCacheDir(STATE_DIR, `${slug}-${index}`) : shared,
3601
+ }),
3602
+ });
3603
+ }
3604
+
3605
+ function nodeEntrySources(target) {
3606
+ const mapFile = `${target.entry}.map`;
3607
+ if (!fs.existsSync(mapFile)) return null;
3608
+ try {
3609
+ return {
3610
+ code: fs.readFileSync(target.entry, 'utf8'),
3611
+ map: JSON.parse(fs.readFileSync(mapFile, 'utf8')),
3612
+ };
3613
+ } catch {
3614
+ return null;
3615
+ }
3616
+ }
3617
+
3618
+ function nodeModuleGraph(opts, target) {
3619
+ return loadModuleGraph(
3620
+ moduleGraphCandidates({ reportDir: opts.report, demoMetricsDir: null, dist: target.dist }),
3621
+ );
3622
+ }
3623
+
3624
+ async function cmdNodeMeasure(argv) {
3625
+ const opts = parse(argv, {
3626
+ ...NODE_TARGET_OPTS,
3627
+ // Startup runs cost milliseconds, not the minutes a throttled navigation costs,
3628
+ // so the default sample count is higher than the browser side's.
3629
+ runs: { type: 'string', default: '7' },
3630
+ warmup: { type: 'string', default: '1' },
3631
+ label: { type: 'string', default: '' },
3632
+ pin: { type: 'boolean', default: false },
3633
+ timeout: { type: 'string', default: '60' },
3634
+ });
3635
+ const target = resolveNodeTarget(opts);
3636
+ await assertReadyPortFree(target.ready);
3637
+ const summary = await runNodeTiming(target, {
3638
+ runs: Number(opts.runs),
3639
+ warmup: Number(opts.warmup),
3640
+ timeoutMs: Number(opts.timeout) * 1000,
3641
+ });
3642
+ const { report, hadBaseline } = writeNodeReport(target, summary, { label: opts.label });
3643
+ printNodeSummary(report, hadBaseline);
3644
+ if (opts.pin) pinNodeBaseline(target);
3645
+ console.log(`\nfull report: ${target.paths.metrics}`);
3646
+ }
3647
+
3648
+ async function runNodeTiming(target, { runs, warmup, timeoutMs }) {
3649
+ const runOptsFor = nodeRunOptsFactory(target, { timeoutMs });
3650
+ process.stderr.write(`boot floor (empty script on this runtime)...\n`);
3651
+ const floor = await measureBootFloor({ execPath: target.exec });
3652
+ const samples = await gatherStartupSamples(runOptsFor, {
3653
+ runs,
3654
+ warmup,
3655
+ onSample: (i, run) =>
3656
+ process.stderr.write(`run ${i + 1}/${runs}: ready in ${ms(run.elapsedMs)}\n`),
3657
+ });
3658
+ return summarizeStartup(samples, {
3659
+ bootFloorMs: floor.length ? floor.sort((a, b) => a - b)[Math.floor(floor.length / 2)] : null,
3660
+ ready: describeReady(target.ready),
3661
+ // Only the polled probes quantize; stdout and exit are observed on the event itself.
3662
+ resolutionMs: target.ready.kind === 'port' || target.ready.kind === 'http' ? POLL_MS : 0,
3663
+ });
3664
+ }
3665
+
3666
+ function writeNodeReport(target, summary, { label, coverage = null, profile = null }) {
3667
+ const prev = readJson(target.paths.state);
3668
+ const baseline = readJson(target.paths.baseline);
3669
+ const report = {
3670
+ schemaVersion: 1,
3671
+ generatedAtMs: Date.now(),
3672
+ label: label || null,
3673
+ entry: target.entry,
3674
+ ready: describeReady(target.ready),
3675
+ cache: target.cache,
3676
+ runs: summary.runs,
3677
+ metrics: summary.metrics,
3678
+ guard: summary.guard,
3679
+ coverage,
3680
+ profile,
3681
+ delta: prev ? deltaSection(prev.metrics, summary.metrics) : null,
3682
+ baselineDelta: baseline ? deltaSection(baseline.metrics, summary.metrics) : null,
3683
+ samples: summary.samples,
3684
+ };
3685
+ writeJson(target.paths.metrics, report);
3686
+ writeJson(target.paths.state, {
3687
+ schemaVersion: 1,
3688
+ tsMs: report.generatedAtMs,
3689
+ label: report.label,
3690
+ runs: report.runs,
3691
+ ready: report.ready,
3692
+ cache: report.cache,
3693
+ metrics: report.metrics,
3694
+ });
3695
+ fs.appendFileSync(
3696
+ target.paths.history,
3697
+ `${JSON.stringify({
3698
+ tsMs: report.generatedAtMs,
3699
+ label: report.label,
3700
+ metrics: report.metrics,
3701
+ guard: report.guard,
3702
+ })}\n`,
3703
+ );
3704
+ return { report, hadBaseline: Boolean(baseline) };
3705
+ }
3706
+
3707
+ function pinNodeBaseline(target) {
3708
+ const state = readJson(target.paths.state);
3709
+ if (!state) throw new Error('nothing to pin - run node-scan first');
3710
+ if (state.runs < 3) {
3711
+ throw new Error(
3712
+ `refusing to pin a ${state.runs}-run measurement - a thin median poisons every later delta. Re-run node-scan with the default sample count.`,
3713
+ );
3714
+ }
3715
+ writeJson(target.paths.baseline, state);
3716
+ console.log(`baseline pinned (startup ${ms(state.metrics['runtime.startup_ms'])})`);
3717
+ }
3718
+
3719
+ function printNodeSummary(report, hadBaseline) {
3720
+ const m = report.metrics;
3721
+ console.log(
3722
+ `\n${report.label ? `[${report.label}] ` : ''}${report.runs} runs, cache ${report.cache}`,
3723
+ );
3724
+ console.log(
3725
+ `startup ${ms(m['runtime.startup_ms'])} (p75 ${ms(m['runtime.startup_p75_ms'])}) | ` +
3726
+ `runtime boot floor ${ms(m['runtime.boot_floor_ms'])} | ` +
3727
+ `app ${ms(m['runtime.app_startup_ms'])} | ready: ${report.ready}`,
3728
+ );
3729
+ const spread = report.guard.spreadPct;
3730
+ console.log(
3731
+ `guard: ${report.guard.allRunsCompleted ? 'PASS' : 'FAIL (a run never became ready)'}` +
3732
+ `${spread > 25 ? ` - WARNING spread ${spread}% across runs: something outside the bundle moves this number, deltas are not trustworthy` : ''}`,
3733
+ );
3734
+ const limited = report.guard.resolutionLimited;
3735
+ if (limited) {
3736
+ console.log(
3737
+ ` note: ${round(limited.appMs)}ms of addressable startup is only a few ${limited.resolutionMs}ms probe intervals wide -` +
3738
+ ' changes smaller than a few ms cannot be resolved by a readiness poll. Judge byte/module counts here, not milliseconds.',
3739
+ );
3740
+ }
3741
+ const base = report.baselineDelta?.['runtime.app_startup_ms'];
3742
+ if (base) {
3743
+ const floor = Math.max(NODE_NOISE_FLOOR_MS, (NODE_NOISE_FLOOR_PCT / 100) * base.prev);
3744
+ const call =
3745
+ Math.abs(base.delta) < floor
3746
+ ? `within noise (floor ${Math.round(floor)}ms) - no effect proven`
3747
+ : base.delta < 0
3748
+ ? 'IMPROVEMENT beyond noise'
3749
+ : 'REGRESSION beyond noise';
3750
+ console.log(
3751
+ `vs pinned baseline: app startup ${base.delta >= 0 ? '+' : ''}${Math.round(base.delta)}ms (${base.pct}%) - ${call}`,
3752
+ );
3753
+ } else if (!hadBaseline) {
3754
+ console.log('no pinned baseline yet - pass --pin to make this the fixed reference');
3755
+ }
3756
+ }
3757
+
3758
+ // Everything in one go: timed runs, then the two instrumented runs that say where the
3759
+ // time went, then the leads. One command per iteration.
3760
+ async function cmdNodeScan(argv) {
3761
+ const opts = parse(argv, {
3762
+ ...NODE_TARGET_OPTS,
3763
+ report: { type: 'string' },
3764
+ runs: { type: 'string', default: '7' },
3765
+ warmup: { type: 'string', default: '1' },
3766
+ label: { type: 'string', default: '' },
3767
+ pin: { type: 'boolean', default: false },
3768
+ top: { type: 'string', default: '10' },
3769
+ timeout: { type: 'string', default: '60' },
3770
+ });
3771
+ const target = resolveNodeTarget(opts);
3772
+ await assertReadyPortFree(target.ready);
3773
+ const timeoutMs = Number(opts.timeout) * 1000;
3774
+ const summary = await runNodeTiming(target, {
3775
+ runs: Number(opts.runs),
3776
+ warmup: Number(opts.warmup),
3777
+ timeoutMs,
3778
+ });
3779
+
3780
+ const runOptsFor = nodeRunOptsFactory(target, { timeoutMs });
3781
+ process.stderr.write('coverage run (what had to execute to become ready)...\n');
3782
+ const cov = await startupCoverage(runOptsFor('cov'), { root: target.dist });
3783
+
3784
+ const sources = nodeEntrySources(target);
3785
+ let profile = null;
3786
+ if (sources) {
3787
+ process.stderr.write('profile run (where the pre-ready time went)...\n');
3788
+ const raw = await startupProfile(runOptsFor('prof'));
3789
+ if (raw) {
3790
+ profile = profileStartup(raw, {
3791
+ code: sources.code,
3792
+ map: sources.map,
3793
+ entryPath: target.entry,
3794
+ });
3795
+ }
3796
+ }
3797
+
3798
+ const { report, hadBaseline } = writeNodeReport(target, summary, {
3799
+ label: opts.label,
3800
+ coverage: {
3801
+ moduleCount: cov.modules.length,
3802
+ totalBytes: cov.modules.reduce((sum, m) => sum + m.totalBytes, 0),
3803
+ readyBytes: cov.modules.reduce((sum, m) => sum + m.readyBytes, 0),
3804
+ externalScriptCount: cov.externalScriptCount,
3805
+ modules: cov.modules,
3806
+ scripts: cov.scripts,
3807
+ },
3808
+ profile: profile
3809
+ ? { totalMs: profile.totalMs, ownership: profile.ownership, rows: profile.rows.slice(0, 40) }
3810
+ : null,
3811
+ });
3812
+ printNodeSummary(report, hadBaseline);
3813
+ printNodeLeads(report, cov, profile, nodeModuleGraph(opts, target), Number(opts.top));
3814
+ if (opts.pin) pinNodeBaseline(target);
3815
+ console.log(`\nfull report: ${target.paths.metrics}`);
3816
+ }
3817
+
3818
+ /**
3819
+ * The leads, ordered so the first question answered is whether the bundle is the cost
3820
+ * at all. A harness that always produces deferral advice will get deferral work done on
3821
+ * an app whose startup is 80% native addon loading — and the number will not move.
3822
+ */
3823
+ function printNodeLeads(report, cov, profile, graph, top) {
3824
+ if (profile) {
3825
+ const own = profile.ownership;
3826
+ console.log(
3827
+ `\npre-ready CPU ${round(own.accountedMs)}ms: app modules ${round(own.appMs)}ms (${own.appShare ?? 0}%), ` +
3828
+ `runtime/native ${round(own.runtimeMs)}ms, engine ${round(own.engineMs)}ms`,
3829
+ );
3830
+ if ((own.appShare ?? 0) < 25) {
3831
+ console.log(
3832
+ ' the bundle is NOT the main cost here - most pre-ready time is outside your modules',
3833
+ );
3834
+ console.log(
3835
+ ' (native addon load, TLS/crypto init, a DB or socket handshake). Deferring imports will',
3836
+ );
3837
+ console.log(' not move startup until that work moves. Top non-app frames:');
3838
+ for (const row of profile.rows.filter((r) => !r.bucket.startsWith('(engine')).slice(0, 5)) {
3839
+ console.log(` ${String(round(row.ms)).padStart(7)}ms ${row.bucket}`);
3840
+ }
3841
+ } else {
3842
+ console.log(' top self-time before ready:');
3843
+ for (const row of profile.rows.slice(0, Math.min(top, 8))) {
3844
+ console.log(` ${String(round(row.ms)).padStart(7)}ms ${row.bucket}`);
3845
+ }
3846
+ }
3847
+ } else {
3848
+ console.log(
3849
+ `\n(no boot profile: ${path.basename(report.entry)}.map is missing - build with sourcemaps to attribute pre-ready CPU to modules)`,
3850
+ );
3851
+ }
3852
+
3853
+ const cold = coldModules(cov.modules);
3854
+ const totalBytes = cov.modules.reduce((sum, m) => sum + m.totalBytes, 0);
3855
+ const readyBytes = cov.modules.reduce((sum, m) => sum + m.readyBytes, 0);
3856
+ console.log(
3857
+ `\nevaluated to become ready: ${kb(readyBytes)} of ${kb(totalBytes)} attributed ` +
3858
+ `(${cov.modules.length} modules; ${cov.externalScriptCount} non-local script(s) not attributed)`,
3859
+ );
3860
+ if (cold.length) {
3861
+ console.log(
3862
+ `\ncold at ready - shipped into the startup path, never executed to get there (${cold.length}):`,
3863
+ );
3864
+ for (const mod of cold.slice(0, top)) {
3865
+ const priced = graph ? priceDeferral(graph, mod.source) : null;
3866
+ console.log(
3867
+ ` ${kb(mod.totalBytes).padStart(10)} ${mod.source}${priced ? ` -> deferring frees ${kb(priced.removedBytes)} / ${priced.removedCount} module(s)` : ''}`,
3868
+ );
3869
+ }
3870
+ console.log(
3871
+ ` next: ${CLI} what-if <module> prices the exact deferral; move the import into the handler that needs it.`,
3872
+ );
3873
+ } else {
3874
+ console.log(
3875
+ '\ncold at ready: none above the floor - the startup path is not carrying dead weight.',
3876
+ );
3877
+ }
3878
+
3879
+ if (!graph) {
3880
+ console.log(
3881
+ '\n(no module-graph.json: a rolldown devtools-metrics build would price every deferral statically -' +
3882
+ ' set devtools = { mode: "report" } and rebuild, then `graph` / `what-if` / `cut` work on this target)',
3883
+ );
3884
+ }
3885
+ }
3886
+
3887
+ /** Price a coverage-derived module against the build graph (see `graphIdCandidates`). */
3888
+ function priceDeferral(graph, source) {
3889
+ for (const form of graphIdCandidates(source)) {
3890
+ const hit = resolveModule(graph, form);
3891
+ if (!hit || hit.ambiguous) continue;
3892
+ const result = whatIf(graph, hit.index);
3893
+ if (result.removedCount) return result;
3894
+ }
3895
+ return null;
3896
+ }
3897
+
3898
+ const round = (v) => (v == null ? 0 : Math.round(v));
3899
+
3900
+ // --- Cloudflare Worker startup mode ----------------------------------------------
3901
+ // The question is the server one ("what does startup pay for, and what can leave it?")
3902
+ // asked of a V8 isolate coming up under workerd. The target is a Vite-built Worker: the
3903
+ // output directory that holds the plugin's `wrangler.json` next to `index.js`. The lab
3904
+ // runs the app's own workerd through its miniflare; see lib/worker/launch.mjs for why
3905
+ // startup is moved inside a request. Requirements and rules: AGENTS.md.
3906
+
3907
+ const WORKER_TARGET_FILE = path.join(STATE_DIR, 'worker-target.json');
3908
+ const WORKER_TARGET_OPTS = {
3909
+ app: { type: 'string' },
3910
+ dist: { type: 'string' },
3911
+ worker: { type: 'string' },
3912
+ main: { type: 'string' },
3913
+ request: { type: 'string', multiple: true },
3914
+ tools: { type: 'string' },
3915
+ report: { type: 'string' },
3916
+ };
3917
+
3918
+ function workerTargetPaths(outDir) {
3919
+ const dir = path.join(STATE_DIR, 'worker', targetSlug(outDir));
3920
+ return {
3921
+ dir,
3922
+ metrics: path.join(dir, 'startup-metrics.json'),
3923
+ state: path.join(dir, '.state.json'),
3924
+ baseline: path.join(dir, 'baseline.json'),
3925
+ history: path.join(dir, 'history.jsonl'),
3926
+ profile: path.join(dir, 'startup.cpuprofile'),
3927
+ coverage: path.join(dir, 'startup-coverage.json'),
3928
+ attribProfile: path.join(dir, 'attrib.cpuprofile'),
3929
+ };
3930
+ }
3931
+
3932
+ /** Where the Worker's build report lands: report mode writes one directory per output dir. */
3933
+ function workerReportDirs(appDir, outDir, report = null) {
3934
+ const dirs = [];
3935
+ if (report) dirs.push(path.resolve(report));
3936
+ dirs.push(reportDirFor(appDir, outDir));
3937
+ return dirs.map((d) => path.join(d, 'module-graph.json'));
3938
+ }
3939
+
3940
+ /** The remembered Worker target's report candidates, when the query is about it. */
3941
+ function workerReportCandidates(opts) {
3942
+ const sticky = readJson(WORKER_TARGET_FILE);
3943
+ if (!sticky) return null;
3944
+ if (opts.app && path.resolve(opts.app) !== sticky.app) return null;
3945
+ if (opts.dist) return null;
3946
+ const browser = readJson(TARGET_FILE);
3947
+ if (!opts.app && browser?.dist && fs.existsSync(browser.dist)) return null;
3948
+ return workerReportDirs(
3949
+ sticky.app,
3950
+ path.resolve(sticky.app, sticky.dist ?? 'dist', sticky.worker),
3951
+ );
3952
+ }
3953
+
3954
+ /**
3955
+ * Resolve (and remember) the Worker target: the app directory, the output directory
3956
+ * that holds `wrangler.json`, the pinned request set and an optional tools directory.
3957
+ * Like the server target, what is pinned is what makes two scans comparable.
3958
+ */
3959
+ function resolveWorkerTarget(opts) {
3960
+ const sticky = readJson(WORKER_TARGET_FILE);
3961
+ const appArg = opts.app ?? sticky?.app;
3962
+ if (!appArg) {
3963
+ throw new Error(
3964
+ 'no Worker target yet - name the app directory (the one with vite.config and wrangler.jsonc):\n' +
3965
+ ` ${CLI} worker-scan --app <appDir>\n` +
3966
+ `Build it first with the report on: \`${CLI} worker-config --app <appDir>\` prints the build command.`,
3967
+ );
3968
+ }
3969
+ const appDir = path.resolve(appArg);
3970
+ const distRel = opts.dist ?? sticky?.dist ?? 'dist';
3971
+ const distDir = path.resolve(appDir, distRel);
3972
+ let workerName = opts.worker ?? sticky?.worker;
3973
+ if (!workerName || !fs.existsSync(path.join(distDir, workerName, 'wrangler.json'))) {
3974
+ const candidates = fs.existsSync(distDir)
3975
+ ? fs.readdirSync(distDir).filter((d) => fs.existsSync(path.join(distDir, d, 'wrangler.json')))
3976
+ : [];
3977
+ if (candidates.length !== 1) {
3978
+ throw new Error(
3979
+ `expected one ${distRel}/<worker>/wrangler.json under ${appDir}, found ${candidates.length ? candidates.join(', ') : 'none'}.\n` +
3980
+ `Build the app with the Cloudflare Vite plugin first (\`${CLI} worker-config --app <appDir>\` prints the command),\n` +
3981
+ 'or pass --worker <name> / --dist <dir>. Never change the build layout to fit this tool.',
3982
+ );
3983
+ }
3984
+ workerName = candidates[0];
3985
+ }
3986
+ const outDir = path.join(distDir, workerName);
3987
+ const requests = opts.request?.length ? opts.request : (sticky?.requests ?? ['/']);
3988
+ const toolsDir = opts.tools ? path.resolve(opts.tools) : (sticky?.tools ?? null);
3989
+ const target = {
3990
+ appDir,
3991
+ distRel,
3992
+ distDir,
3993
+ workerName,
3994
+ outDir,
3995
+ configPath: path.join(outDir, 'wrangler.json'),
3996
+ requests,
3997
+ toolsDir,
3998
+ reportDirs: workerReportDirs(appDir, outDir, opts.report),
3999
+ paths: workerTargetPaths(outDir),
4000
+ };
4001
+ const changed =
4002
+ sticky?.app !== appDir ||
4003
+ sticky?.dist !== distRel ||
4004
+ sticky?.worker !== workerName ||
4005
+ JSON.stringify(sticky?.requests) !== JSON.stringify(requests) ||
4006
+ (sticky?.tools ?? null) !== toolsDir;
4007
+ if (changed) {
4008
+ writeJson(WORKER_TARGET_FILE, {
4009
+ app: appDir,
4010
+ dist: distRel,
4011
+ worker: workerName,
4012
+ requests,
4013
+ tools: toolsDir,
4014
+ setAtMs: Date.now(),
4015
+ });
4016
+ console.log(
4017
+ `target: ${path.relative(process.cwd(), outDir) || outDir} (requests: ${requests.join(' ')}${toolsDir ? `; coverage tools: ${toolsDir}` : ''}) - remembered`,
4018
+ );
4019
+ }
4020
+ return target;
4021
+ }
4022
+
4023
+ /** Load the runtime and the Worker's options; `outDir` may be an attribution build's. */
4024
+ async function loadWorkerContext(target, { outDir = target.outDir, main = null } = {}) {
4025
+ // Miniflare caches the `cf` object under the cwd (.wrangler/cache) unless told where;
4026
+ // the lab's state dir keeps that out of the package and out of the user's project.
4027
+ process.env.MINIFLARE_CACHE_DIR ??= path.join(STATE_DIR, 'worker', 'cache');
4028
+ const tools = await resolveWorkerTools(target.appDir).load();
4029
+ const configPath = path.join(outDir, 'wrangler.json');
4030
+ if (!fs.existsSync(configPath)) throw new Error(`no wrangler.json in ${outDir}`);
4031
+ const { workerOptions, main: configMain, fallback } = loadWorkerOptions(tools, configPath);
4032
+ const mainName = (main ?? path.relative(outDir, path.resolve(outDir, configMain))).replaceAll(
4033
+ '\\',
4034
+ '/',
4035
+ );
4036
+ const { modules, skipped } = classifyModules(outDir, workerOptions.modulesRules);
4037
+ if (!modules.some((m) => m.rel === mainName)) {
4038
+ throw new Error(
4039
+ `main module ${mainName} is not among the output modules of ${outDir} (pass --main <file>)`,
4040
+ );
4041
+ }
4042
+ const borrowed = target.toolsDir ? await resolveWorkerTools(target.toolsDir).load() : null;
4043
+ if (fallback) {
4044
+ console.log(
4045
+ `note: wrangler could not express every binding (${fallback}); running with date, flags and modules only`,
4046
+ );
4047
+ }
4048
+ return {
4049
+ ...target,
4050
+ outDir,
4051
+ tools,
4052
+ borrowed,
4053
+ workerOptions,
4054
+ mainName,
4055
+ modules,
4056
+ skipped,
4057
+ fallback,
4058
+ stderr: createStderrCollector(),
4059
+ };
4060
+ }
4061
+
4062
+ function printWorkerContext(ctx) {
4063
+ const v = ctx.tools.versions;
4064
+ const flags = ctx.workerOptions.compatibilityFlags ?? [];
4065
+ console.log(
4066
+ `${path.relative(ctx.appDir, ctx.outDir)}/${ctx.mainName} · compat ${ctx.workerOptions.compatibilityDate ?? '?'} [${flags.join(', ')}] · ` +
4067
+ `wrangler ${v.wrangler ?? '?'} / workerd ${v.workerd ?? '?'}${ctx.borrowed ? ` (coverage tools: workerd ${ctx.borrowed.versions.workerd})` : ''} · ` +
4068
+ `${ctx.modules.length} module(s)${ctx.skipped.length ? `, skipped ${ctx.skipped.join(', ')}` : ''}`,
4069
+ );
4070
+ }
4071
+
4072
+ async function runWorkerTiming(ctx, { runs, warmup }) {
4073
+ process.stderr.write('floor (the wrapper importing an empty module)...\n');
4074
+ const floor = await gatherSeries(ctx, { target: EMPTY_NAME, runs, warmup });
4075
+ process.stderr.write('main module...\n');
4076
+ const main = await gatherSeries(ctx, {
4077
+ target: ctx.mainName,
4078
+ runs,
4079
+ warmup,
4080
+ onSample: (i, run) => process.stderr.write(`run ${i + 1}/${runs}: import ${ms(run.ms)}\n`),
4081
+ });
4082
+ return summarizeWorkerStartup({ floor, main });
4083
+ }
4084
+
4085
+ function writeWorkerReport(
4086
+ ctx,
4087
+ summary,
4088
+ { label, requests = null, profile = null, coverage = null, notes = [] },
4089
+ ) {
4090
+ const prev = readJson(ctx.paths.state);
4091
+ const baseline = readJson(ctx.paths.baseline);
4092
+ const metrics = { ...summary.metrics };
4093
+ for (const r of requests ?? []) if (r.status < 500) metrics[`worker.request_ms:${r.path}`] = r.ms;
4094
+ const report = {
4095
+ schemaVersion: 1,
4096
+ generatedAtMs: Date.now(),
4097
+ label: label || null,
4098
+ worker: {
4099
+ app: ctx.appDir,
4100
+ outDir: ctx.outDir,
4101
+ main: ctx.mainName,
4102
+ compatibilityDate: ctx.workerOptions.compatibilityDate ?? null,
4103
+ compatibilityFlags: ctx.workerOptions.compatibilityFlags ?? [],
4104
+ versions: ctx.tools.versions,
4105
+ coverageTools: ctx.borrowed?.versions ?? null,
4106
+ },
4107
+ runs: summary.runs,
4108
+ metrics,
4109
+ guard: summary.guard,
4110
+ requests,
4111
+ profile,
4112
+ coverage,
4113
+ notes,
4114
+ delta: prev ? deltaSection(prev.metrics, metrics) : null,
4115
+ baselineDelta: baseline ? deltaSection(baseline.metrics, metrics) : null,
4116
+ samples: summary.samples,
4117
+ runtimeStderr: [...ctx.stderr.lines.entries()],
4118
+ };
4119
+ writeJson(ctx.paths.metrics, report);
4120
+ writeJson(ctx.paths.state, {
4121
+ schemaVersion: 1,
4122
+ tsMs: report.generatedAtMs,
4123
+ label: report.label,
4124
+ runs: report.runs,
4125
+ metrics,
4126
+ });
4127
+ fs.appendFileSync(
4128
+ ctx.paths.history,
4129
+ `${JSON.stringify({ tsMs: report.generatedAtMs, label: report.label, metrics, guard: report.guard })}\n`,
4130
+ );
4131
+ return { report, hadBaseline: Boolean(baseline) };
4132
+ }
4133
+
4134
+ function pinWorkerBaseline(ctx) {
4135
+ const state = readJson(ctx.paths.state);
4136
+ if (!state) throw new Error('nothing to pin - run worker-scan first');
4137
+ if (state.runs < 3) {
4138
+ throw new Error(
4139
+ `refusing to pin a ${state.runs}-run measurement - a thin median poisons every later delta. Re-run worker-scan with the default sample count.`,
4140
+ );
4141
+ }
4142
+ writeJson(ctx.paths.baseline, state);
4143
+ console.log(`baseline pinned (app startup ${ms(state.metrics['worker.app_startup_ms'])})`);
4144
+ }
4145
+
4146
+ function noiseCall(delta, prev, floorMs, floorPct) {
4147
+ const floor = Math.max(floorMs, (floorPct / 100) * prev);
4148
+ if (Math.abs(delta) < floor)
4149
+ return `within noise (floor ${Math.round(floor)}ms) - no effect proven`;
4150
+ return delta < 0 ? 'IMPROVEMENT beyond noise' : 'REGRESSION beyond noise';
4151
+ }
4152
+
4153
+ function printWorkerSummary(report, hadBaseline) {
4154
+ const m = report.metrics;
4155
+ console.log(
4156
+ `\n${report.label ? `[${report.label}] ` : ''}${report.runs} cold runs (fresh workerd each)`,
4157
+ );
4158
+ console.log(
4159
+ `startup ${ms(m['worker.startup_ms'])} (p75 ${ms(m['worker.startup_p75_ms'])}) | floor ${ms(m['worker.boot_floor_ms'])} | ` +
4160
+ `app ${ms(m['worker.app_startup_ms'])} | instance boot ${ms(m['worker.instance_boot_ms'])} (not counted)`,
4161
+ );
4162
+ const spread = report.guard.spreadPct;
4163
+ console.log(
4164
+ `guard: ${report.guard.allRunsCompleted ? 'PASS' : 'FAIL (a run never imported the main module)'}` +
4165
+ `${spread > 25 ? ` - WARNING spread ${spread}% across runs: something outside the bundle moves this number, deltas are not trustworthy` : ''}`,
4166
+ );
4167
+ const base = report.baselineDelta?.['worker.app_startup_ms'];
4168
+ if (base) {
4169
+ console.log(
4170
+ `vs pinned baseline: app startup ${base.delta >= 0 ? '+' : ''}${Math.round(base.delta)}ms (${base.pct}%) - ` +
4171
+ noiseCall(base.delta, base.prev, WORKER_NOISE_FLOOR_MS, WORKER_NOISE_FLOOR_PCT),
4172
+ );
4173
+ } else if (!hadBaseline) {
4174
+ console.log('no pinned baseline yet - pass --pin to make this the fixed reference');
4175
+ }
4176
+ // The pinned line adds up every change since the pin. The last change alone is the
4177
+ // delta against the previous scan, so a kept-but-not-pinned change does not hide it.
4178
+ const prevScan = report.delta?.['worker.app_startup_ms'];
4179
+ if (base && prevScan && prevScan.prev !== base.prev) {
4180
+ console.log(
4181
+ `vs previous scan: app startup ${prevScan.delta >= 0 ? '+' : ''}${Math.round(prevScan.delta)}ms (${prevScan.pct}%) - ` +
4182
+ noiseCall(prevScan.delta, prevScan.prev, WORKER_NOISE_FLOOR_MS, WORKER_NOISE_FLOOR_PCT) +
4183
+ ' (the last change alone)',
4184
+ );
4185
+ }
4186
+ if (report.requests?.length) {
4187
+ console.log(
4188
+ '\nfirst requests on the real entry (startup already done; a deferred module loads on its first hit):',
4189
+ );
4190
+ const gain = base && base.delta < 0 ? -base.delta : 0;
4191
+ const movedRoutes = [];
4192
+ for (const r of report.requests) {
4193
+ const d = report.baselineDelta?.[`worker.request_ms:${r.path}`];
4194
+ // A 4xx or 5xx answer never reached the app's handler: its time says nothing about a moved load.
4195
+ const answered = r.status < 400;
4196
+ const moved =
4197
+ answered && d && Math.abs(d.delta) >= WORKER_NOISE_FLOOR_MS
4198
+ ? ` vs baseline ${d.delta >= 0 ? '+' : ''}${Math.round(d.delta)}ms${d.delta > 0 ? ' - cost MOVED here from startup' : ''}`
4199
+ : '';
4200
+ if (answered && d && gain && d.delta >= gain / 2)
4201
+ movedRoutes.push({ path: r.path, delta: d.delta });
4202
+ console.log(
4203
+ ` ${r.path.padEnd(28)} ${String(r.status).padStart(3)} ${ms(r.ms).padStart(7)} ${r.bytes} bytes${r.error ? ` :: ${r.error}` : ''}${moved}`,
4204
+ );
4205
+ }
4206
+ if (report.requests.every((r) => r.status >= 400)) {
4207
+ console.log(
4208
+ ' every pinned route answered 4xx/5xx: the handler never ran, so a moved load cannot show here - pin routes the app answers (--request /path)',
4209
+ );
4210
+ }
4211
+ if (report.requests.some((r) => r.status >= 500)) {
4212
+ console.log(
4213
+ ' a 5xx here usually means the app needs its variables (a .dev.vars next to the config) - startup is unaffected, first-request numbers are not valid',
4214
+ );
4215
+ }
4216
+ for (const r of movedRoutes) {
4217
+ console.log(
4218
+ ` ${r.path}: +${Math.round(r.delta)}ms moved into this route against ${Math.round(gain)}ms gained at startup - an isolate whose first request is ${r.path} gains little or nothing`,
4219
+ );
4220
+ }
4221
+ }
4222
+ }
4223
+
4224
+ /**
4225
+ * The leads, in the order a reader should take them: is the Worker's own code the cost,
4226
+ * which modules run at startup for how long, and what is parsed but not executed.
4227
+ */
4228
+ function printWorkerLeads(report, cov, prof, graph, top) {
4229
+ if (prof?.rows) {
4230
+ const own = prof.ownership;
4231
+ const sm = prof.summary;
4232
+ console.log(
4233
+ `\nstartup CPU (profile): active ${ms(sm.activeMs)} = app modules ${ms(own.appMs)} (${own.appShare ?? 0}%) + runtime ${ms(own.runtimeMs)} + engine ${ms(own.engineMs)}` +
4234
+ ` + parse/compile "(program)" ${ms(sm.programMs)}; GC ${ms(sm.gcMs)}; ${sm.samples} samples`,
4235
+ );
4236
+ console.log(' top self-time at startup:');
4237
+ for (const row of prof.rows.slice(0, Math.min(top, 10))) {
4238
+ console.log(` ${String(round(row.ms)).padStart(7)}ms ${row.bucket}`);
4239
+ }
4240
+ const unmapped = prof.rows.find((r) => r.bucket === '(unmapped)');
4241
+ if (unmapped && unmapped.ms >= 2) {
4242
+ console.log(
4243
+ ` ${ms(unmapped.ms)} sits in chunk top-level code V8 gave no line ticks for - \`${CLI} worker-attrib\` on an attribution build charges it to the evaluating module`,
4244
+ );
4245
+ }
4246
+ } else if (prof?.missingSourcemap) {
4247
+ console.log(
4248
+ `\n(no startup CPU by module: ${report.worker.main}.map is missing - build the worker environment with sourcemaps; \`${CLI} worker-config\` sets that)`,
4249
+ );
4250
+ }
4251
+
4252
+ if (cov?.totals) {
4253
+ const t = cov.totals;
4254
+ console.log(
4255
+ `\nevaluated at startup: ${kb(t.readyBytes)} of ${kb(t.totalBytes)} in the static closure (${t.moduleCount} modules, ${Math.round((t.readyBytes / Math.max(1, t.totalBytes)) * 100)}% executed)` +
4256
+ `${cov.runtimeScripts.length ? `; ${cov.runtimeScripts.length} runtime module(s) loaded (${cov.runtimeScripts.slice(0, 5).join(', ')}${cov.runtimeScripts.length > 5 ? ', ...' : ''})` : ''}`,
4257
+ );
4258
+ if (cov.cold.length) {
4259
+ console.log(
4260
+ `\ncold at startup - in the closure, never executed to start (${cov.cold.length}):`,
4261
+ );
4262
+ for (const mod of cov.cold.slice(0, top)) {
4263
+ const priced = graph ? priceDeferral(graph, mod.source) : null;
4264
+ console.log(
4265
+ ` ${kb(mod.totalBytes).padStart(10)} ${mod.source}${priced ? ` -> deferring frees ${kb(priced.removedBytes)} / ${priced.removedCount} module(s)` : ''}`,
4266
+ );
4267
+ }
4268
+ }
4269
+ const idle = cov.idle
4270
+ .filter((m) => m.idleBytes >= 16 * 1024 && m.readyRatio > 0.02)
4271
+ .slice(0, top);
4272
+ if (idle.length) {
4273
+ console.log(
4274
+ '\nparsed but mostly not executed at startup (libraries run a slice of themselves at import; the rest is load cost):',
4275
+ );
4276
+ for (const mod of idle) {
4277
+ const priced = graph ? priceDeferral(graph, mod.source) : null;
4278
+ console.log(
4279
+ ` ${kb(mod.idleBytes).padStart(10)} idle of ${kb(mod.totalBytes).padStart(9)} ${String(Math.round(mod.readyRatio * 100)).padStart(3)}% executed ${mod.source}${priced ? ` -> deferring frees ${kb(priced.removedBytes)}` : ''}`,
4280
+ );
4281
+ }
4282
+ }
4283
+ console.log('\nby package (bytes in the startup closure, share executed):');
4284
+ for (const p of cov.packages.slice(0, Math.min(top, 12))) {
4285
+ console.log(
4286
+ ` ${kb(p.totalBytes).padStart(10)} ${String(Math.round((p.readyBytes / Math.max(1, p.totalBytes)) * 100)).padStart(3)}% ${String(p.modules).padStart(4)} module(s) ${p.pkg}`,
4287
+ );
4288
+ }
4289
+ }
4290
+
4291
+ if (!graph) {
4292
+ const cause = reportSupportNote(ctx.appDir, CLI);
4293
+ console.log(
4294
+ cause
4295
+ ? `\n(no module-graph.json for this worker)\n${cause}`
4296
+ : `\n(no module-graph.json for this worker: \`${CLI} worker-config --app <appDir>\` writes a build config that collects it; then graph / what-if / cut / why price every candidate)`,
4297
+ );
4298
+ } else {
4299
+ console.log(
4300
+ `\nnext: \`${CLI} why <module>\` names the importers and the chain from the entry; \`${CLI} cut <module> --root <class modules>\` the fewest imports to make dynamic.` +
4301
+ ' Durable Object and Workflow classes are loaded by name: pass the modules that define them as --root, or every cut points at the entry.',
4302
+ );
4303
+ const classes = workerClassNames(report.worker.outDir);
4304
+ if (classes.length) console.log(` class exports in wrangler.json: ${classes.join(', ')}`);
4305
+ }
4306
+ }
4307
+
4308
+ /** Durable Object and Workflow class names the config binds; their modules are roots. */
4309
+ function workerClassNames(outDir) {
4310
+ const config = readJson(path.join(outDir, 'wrangler.json'));
4311
+ if (!config) return [];
4312
+ const names = new Set();
4313
+ for (const b of config.durable_objects?.bindings ?? []) if (b.class_name) names.add(b.class_name);
4314
+ for (const w of config.workflows ?? []) if (w.class_name) names.add(w.class_name);
4315
+ return [...names];
4316
+ }
4317
+
4318
+ function workerModuleGraph(ctx) {
4319
+ return loadModuleGraph(ctx.reportDirs);
4320
+ }
4321
+
4322
+ function printRuntimeStderr(ctx) {
4323
+ if (!ctx.stderr.lines.size) return;
4324
+ console.log('\nworkerd stderr (deduplicated):');
4325
+ for (const [line, n] of ctx.stderr.lines) console.log(` ${n}x ${line}`);
4326
+ }
4327
+
4328
+ async function cmdWorkerMeasure(argv) {
4329
+ const opts = parse(argv, {
4330
+ ...WORKER_TARGET_OPTS,
4331
+ runs: { type: 'string', default: '5' },
4332
+ warmup: { type: 'string', default: '1' },
4333
+ label: { type: 'string', default: '' },
4334
+ pin: { type: 'boolean', default: false },
4335
+ });
4336
+ const ctx = await loadWorkerContext(resolveWorkerTarget(opts), { main: opts.main });
4337
+ printWorkerContext(ctx);
4338
+ const summary = await runWorkerTiming(ctx, {
4339
+ runs: Number(opts.runs),
4340
+ warmup: Number(opts.warmup),
4341
+ });
4342
+ const requests = await firstRequests(ctx, ctx.requests);
4343
+ const { report, hadBaseline } = writeWorkerReport(ctx, summary, { label: opts.label, requests });
4344
+ printWorkerSummary(report, hadBaseline);
4345
+ if (opts.pin) pinWorkerBaseline(ctx);
4346
+ printRuntimeStderr(ctx);
4347
+ console.log(`\nfull report: ${ctx.paths.metrics}`);
4348
+ }
4349
+
4350
+ async function cmdWorkerScan(argv) {
4351
+ const opts = parse(argv, {
4352
+ ...WORKER_TARGET_OPTS,
4353
+ runs: { type: 'string', default: '5' },
4354
+ warmup: { type: 'string', default: '1' },
4355
+ label: { type: 'string', default: '' },
4356
+ pin: { type: 'boolean', default: false },
4357
+ top: { type: 'string', default: '10' },
4358
+ interval: { type: 'string', default: '100' },
4359
+ 'no-coverage': { type: 'boolean', default: false },
4360
+ 'no-profile': { type: 'boolean', default: false },
4361
+ });
4362
+ const ctx = await loadWorkerContext(resolveWorkerTarget(opts), { main: opts.main });
4363
+ printWorkerContext(ctx);
4364
+ const notes = [];
4365
+ const summary = await runWorkerTiming(ctx, {
4366
+ runs: Number(opts.runs),
4367
+ warmup: Number(opts.warmup),
4368
+ });
4369
+ process.stderr.write('first requests on the real entry...\n');
4370
+ const requests = await firstRequests(ctx, ctx.requests);
4371
+
4372
+ let prof = null;
4373
+ if (!opts['no-profile']) {
4374
+ process.stderr.write('profile run (where the startup CPU went)...\n');
4375
+ const { profile, importRan, importText } = await cpuProfileRun(ctx, {
4376
+ intervalUs: Number(opts.interval),
4377
+ });
4378
+ if (!importRan)
4379
+ notes.push(`profile run: the import did not complete: ${importText.slice(0, 200)}`);
4380
+ fs.mkdirSync(ctx.paths.dir, { recursive: true });
4381
+ fs.writeFileSync(ctx.paths.profile, JSON.stringify(profile));
4382
+ prof = attributeProfile(profile, { outDir: ctx.outDir, mainName: ctx.mainName });
4383
+ }
4384
+
4385
+ let cov = null;
4386
+ if (!opts['no-coverage']) {
4387
+ process.stderr.write(
4388
+ 'coverage run (what executed at startup; in-process inspector session)...\n',
4389
+ );
4390
+ const run = await workerCoverageRun(ctx, { tools: ctx.borrowed });
4391
+ if (run.unsupported) {
4392
+ notes.push(`precise coverage unavailable: ${run.unsupported}`);
4393
+ } else {
4394
+ if (run.importError)
4395
+ notes.push(`coverage run: the import threw: ${run.importError.slice(0, 200)}`);
4396
+ fs.mkdirSync(ctx.paths.dir, { recursive: true });
4397
+ fs.writeFileSync(ctx.paths.coverage, JSON.stringify(run.result));
4398
+ cov = attributeCoverage(run.result, { outDir: ctx.outDir });
4399
+ cov.source = `precise, workerd ${run.workerd}${run.borrowed ? ' (borrowed through --tools)' : ''}; flags added for that run: ${run.addedFlags.join(', ') || 'none'}`;
4400
+ }
4401
+ }
4402
+
4403
+ const graph = workerModuleGraph(ctx);
4404
+ const { report, hadBaseline } = writeWorkerReport(ctx, summary, {
4405
+ label: opts.label,
4406
+ requests,
4407
+ profile: prof
4408
+ ? {
4409
+ summary: prof.summary,
4410
+ ownership: prof.ownership,
4411
+ totalMs: prof.totalMs,
4412
+ rows: prof.rows?.slice(0, 40) ?? null,
4413
+ }
4414
+ : null,
4415
+ coverage: cov
4416
+ ? {
4417
+ source: cov.source,
4418
+ totals: cov.totals,
4419
+ runtimeScripts: cov.runtimeScripts,
4420
+ modules: cov.modules.slice(0, 60),
4421
+ cold: cov.cold.slice(0, 20),
4422
+ idle: cov.idle.slice(0, 20),
4423
+ packages: cov.packages.slice(0, 30),
4424
+ }
4425
+ : null,
4426
+ notes,
4427
+ });
4428
+ printWorkerSummary(report, hadBaseline);
4429
+ printWorkerLeads(report, cov, prof, graph, Number(opts.top));
4430
+ if (cov?.source) console.log(`\ncoverage: ${cov.source}`);
4431
+ for (const note of notes) console.log(`note: ${note}`);
4432
+ if (opts.pin) pinWorkerBaseline(ctx);
4433
+ printRuntimeStderr(ctx);
4434
+ console.log(`\nfull report: ${ctx.paths.metrics}`);
4435
+ }
4436
+
4437
+ /**
4438
+ * CPU by EVALUATING module, from the attribution build (every module its own file):
4439
+ * `ROLLDOWN_PERF_KIT_ATTRIB=1 npx vite build --config perf-kit.vite.config.ts`.
4440
+ */
4441
+ async function cmdWorkerAttrib(argv) {
4442
+ const opts = parse(argv, {
4443
+ ...WORKER_TARGET_OPTS,
4444
+ 'attrib-dir': { type: 'string', default: ATTRIB_DIR },
4445
+ interval: { type: 'string', default: '100' },
4446
+ top: { type: 'string', default: '18' },
4447
+ });
4448
+ const target = resolveWorkerTarget(opts);
4449
+ const outDir = path.join(target.appDir, opts['attrib-dir'], target.workerName);
4450
+ if (!fs.existsSync(path.join(outDir, 'wrangler.json'))) {
4451
+ throw new Error(
4452
+ `no attribution build at ${outDir}.\n` +
4453
+ `Build one: ROLLDOWN_PERF_KIT_ATTRIB=1 npx vite build --config ${SHIM_FILE} (in ${target.appDir}; \`${CLI} worker-config\` writes that config)`,
4454
+ );
4455
+ }
4456
+ const ctx = await loadWorkerContext(target, { outDir, main: opts.main });
4457
+ printWorkerContext(ctx);
4458
+ process.stderr.write('profile run on the attribution build...\n');
4459
+ const { profile, importRan, importText } = await cpuProfileRun(ctx, {
4460
+ intervalUs: Number(opts.interval),
4461
+ });
4462
+ if (!importRan) {
4463
+ const first = importText
4464
+ .replace(/^startup threw: /, '')
4465
+ .split('\n')[0]
4466
+ .slice(0, 200);
4467
+ console.log(`\nthe attribution build did not finish its import: ${first}`);
4468
+ console.log(' the rows below cover only the modules that evaluated before that point.');
4469
+ if (/\brequire_\w+ is not a function/.test(importText)) {
4470
+ console.log(
4471
+ ' (a CommonJS module in an import cycle: with preserveModules every file evaluates\n' +
4472
+ ' `export default require_x()` eagerly, and a cycle reaches a wrapper before its own file ran.\n' +
4473
+ ` That is the bundler's output, not the app; the bundled profile of \`${CLI} worker-scan\` is the fallback.)`,
4474
+ );
4475
+ }
4476
+ }
4477
+ fs.mkdirSync(ctx.paths.dir, { recursive: true });
4478
+ fs.writeFileSync(ctx.paths.attribProfile, JSON.stringify(profile));
4479
+ const graph = loadModuleGraph(workerReportDirs(target.appDir, outDir, opts.report));
4480
+ const { rows, pairs } = evaluatingModules(profile, { outDir, graph, top: Number(opts.top) });
4481
+ console.log(
4482
+ `\nstartup CPU by evaluating module${importRan ? '' : ' (partial)'} (its own top level plus the library calls it makes):`,
4483
+ );
4484
+ for (const r of rows) {
4485
+ console.log(` ${String(r.ms).padStart(7)}ms ${r.module}`);
4486
+ if (r.chain?.length) console.log(` via ${r.chain.join(' -> ')}`);
4487
+ }
4488
+ console.log('\nevaluating module -> library it spends the time in:');
4489
+ for (const p of pairs.slice(0, 12)) console.log(` ${String(p.ms).padStart(7)}ms ${p.pair}`);
4490
+ if (!graph) {
4491
+ console.log(
4492
+ `\n(no report for the attribution build - the chains need ${path.relative(target.appDir, reportDirFor(target.appDir, outDir))})`,
4493
+ );
4494
+ }
4495
+ printRuntimeStderr(ctx);
4496
+ console.log(`\nprofile: ${ctx.paths.attribProfile}`);
4497
+ }
4498
+
4499
+ /** How to name an app directory in a hint: relative when it is below cwd, absolute otherwise. */
4500
+ function appArgFor(appDir) {
4501
+ const rel = path.relative(process.cwd(), appDir);
4502
+ return !rel ? '.' : rel.startsWith('..') ? appDir : rel;
4503
+ }
4504
+
4505
+ /** Write the Vite config shim and print the three build commands. */
4506
+ async function cmdWorkerConfig(argv) {
4507
+ const opts = parse(argv, { app: { type: 'string' }, force: { type: 'boolean', default: false } });
4508
+ const sticky = readJson(WORKER_TARGET_FILE);
4509
+ const appDir = path.resolve(opts.app ?? sticky?.app ?? '.');
4510
+ const { file, baseConfigFile, existed } = writeViteConfigShim(appDir, { force: opts.force });
4511
+ console.log(
4512
+ `${existed && !opts.force ? 'kept' : 'wrote'} ${file} (wraps ${baseConfigFile}; --force rewrites)`,
4513
+ );
4514
+ const cause = reportSupportNote(appDir, CLI);
4515
+ if (cause) console.log(`\nbefore you build:\n${cause}`);
4516
+ console.log(`\nbuild commands (run in ${appDir}):`);
4517
+ console.log(
4518
+ ` npx vite build --config ${SHIM_FILE} full build; report per environment, sourcemaps on the worker`,
4519
+ );
4520
+ console.log(
4521
+ ` ROLLDOWN_PERF_KIT_WORKER_ONLY=1 npx vite build --config ${SHIM_FILE} rebuild the worker environment only (the loop)`,
4522
+ );
4523
+ console.log(
4524
+ ` ROLLDOWN_PERF_KIT_ATTRIB=1 npx vite build --config ${SHIM_FILE} attribution build into ${ATTRIB_DIR}/ for \`${CLI} worker-attrib\``,
4525
+ );
4526
+ console.log(
4527
+ `\nthen: ${CLI} worker-scan --app ${appArgFor(appDir)} [--request /path ...] [--tools <dir with wrangler >= ${COVERAGE_MIN_WRANGLER}>]`,
4528
+ );
4529
+ }
4530
+
4531
+ /** Copy this checkout's built rolldown over the copy the app's vite resolves. */
4532
+ async function cmdRolldownInstall(argv) {
4533
+ const opts = parse(argv, { app: { type: 'string' }, from: { type: 'string' } });
4534
+ const sticky = readJson(WORKER_TARGET_FILE);
4535
+ const appDir = path.resolve(opts.app ?? sticky?.app ?? '.');
4536
+ const { from, dir, before, after } = installCheckoutRolldown(
4537
+ appDir,
4538
+ opts.from ? { checkout: path.resolve(opts.from) } : {},
4539
+ );
4540
+ console.log(
4541
+ `copied ${from.dir} (rolldown ${from.version}, ${from.binding}) over ${dir} (was rolldown ${before})`,
4542
+ );
4543
+ const loaded = spawnSync(
4544
+ process.execPath,
4545
+ ['-e', "import('rolldown').then((m) => console.log(m.VERSION))"],
4546
+ { cwd: appDir, encoding: 'utf8' },
4547
+ );
4548
+ const version = loaded.status === 0 ? loaded.stdout.trim() : null;
4549
+ if (!version) {
4550
+ throw new Error(
4551
+ `the copy does not load from ${appDir}: ${(loaded.stderr || '').split('\n').find(Boolean) ?? 'no output'}`,
4552
+ );
4553
+ }
4554
+ console.log(
4555
+ after.reportMode
4556
+ ? `rolldown ${version} with the report mode now loads from the app`
4557
+ : `rolldown ${version} loads from the app but still has no report mode - is the checkout on this branch?`,
4558
+ );
4559
+ console.log(
4560
+ '\nrebuild the app, then re-pin the baseline (the bundler changed). `<package manager> install --force` in the app restores the release.',
4561
+ );
4562
+ }
4563
+
4564
+ /** `why <module>`: direct static importers and one chain from the entry. */
4565
+ async function cmdWhy(argv) {
4566
+ const { values: opts, positionals } = parseArgs({
4567
+ args: argv,
4568
+ options: { ...TARGET_OPTS, report: { type: 'string' } },
4569
+ allowPositionals: true,
4570
+ });
4571
+ const query = positionals[0];
4572
+ if (!query) throw new Error(`usage: ${CLI} why <module> [--report <dir>]`);
4573
+ const graph = requireModuleGraph(opts);
4574
+ const hit = resolveModule(graph, query);
4575
+ if (!hit)
4576
+ throw new Error(`no module matches '${query}' (ids are project-relative, e.g. src/router.ts)`);
4577
+ if (hit.ambiguous) {
4578
+ throw new Error(
4579
+ `'${query}' is ambiguous:\n ${hit.ambiguous.join('\n ')}\nuse a longer suffix.`,
4580
+ );
4581
+ }
4582
+ const why = whyModule(graph, hit.index);
4583
+ console.log(`${why.module} (${kb(why.bytes)}): ${why.importers.length} static importer(s)`);
4584
+ for (const imp of why.importers.slice(0, 12))
4585
+ console.log(` <- ${imp.module} (${kb(imp.bytes)})`);
4586
+ if (why.importers.length > 12) console.log(` ... ${why.importers.length - 12} more`);
4587
+ const chain = why.chain.filter((id) => !/^(\\0|\0)/.test(id));
4588
+ console.log(`chain from the entry: ${[...chain, why.module].join(' -> ')}`);
4589
+ console.log(
4590
+ `\nnext: \`${CLI} cut ${why.module}\` lists the fewest imports to make dynamic; add --root <module> for a class the runtime loads by name (Durable Object, Workflow).`,
4591
+ );
4592
+ }
4593
+
4594
+ async function cmdHelp() {
4595
+ console.log(`browser-loading perf harness - measurement + diagnosis; you drive the loop
4596
+
4597
+ start here (the target is remembered after the first command):
4598
+ scan --app <appDir> 3 throttled runs + coverage (+ boot profile when pre-paint
4599
+ CPU >150ms; force with --profile) + verdict, one browser session.
4600
+ First scan of a target auto-pins the baseline. --runs 5 for noisy pages.
4601
+ scan same, against the remembered target
4602
+ scan --pin same, and re-pin the baseline afterwards (after an accepted change)
4603
+ scan --quick 1 run, no profile: a fast mid-iteration probe on slow apps.
4604
+ Indicative only - accept/revert/pin decisions need a full scan.
4605
+ scan --full re-scans print the compact coverage view (top 15 module rows;
4606
+ candidates/cold/large/sibling sections always complete) - --full
4607
+ restores the whole module table
4608
+ scan --no-early-stop scans with a pinned baseline stop after run 1 when |dLCP| is
4609
+ >=5x the noise threshold. This flag forces all runs to check
4610
+ both FCP and LCP before a decision. Pins always sample fully.
4611
+ scan --net-scale <1|2|4|8> pin the target's network-bandwidth scale explicitly.
4612
+ First scan CALIBRATES it automatically (walks x1->x8 until
4613
+ baseline LCP fits under 8s - only pathologically heavy apps
4614
+ leave x1; RTT and 4x CPU never change) and PINS it: the scale
4615
+ is part of the measurement identity for the whole session.
4616
+ Changing it makes the pinned baseline incomparable (re-pin).
4617
+ scan --bandwidth <Mbit/s> [--rtt <ms>] [--cpu <rate>] pin a network in real units instead
4618
+ of a scale step: the network a deployed environment measured (a
4619
+ record's model), e.g. --bandwidth 26 --rtt 120 --cpu 1 (1 = this
4620
+ machine's CPU). No calibration; RTT/CPU default to 150ms / 4x.
4621
+ Pinned and compared like --net-scale (re-pin after a change).
4622
+ verdict fuse the gathered signals -> OPEN/clear/UNKNOWN; the only "done" that counts
4623
+
4624
+ rendered output (parity) - is it still the same page? Compared ONCE, when you finish.
4625
+ The first scan/measure/parity of a target saves the build as the ORIGINAL (a file copy).
4626
+ Optimize freely; each scan only says whether the LCP element is still the original's
4627
+ (free). When done, \`scan --parity\` takes the final measurement and compares every
4628
+ pinned page with the original: first-screen + full-page pixels, page height, visible
4629
+ text, accessible names, new runtime errors, and the LCP element (a different, smaller or
4630
+ placeholder LCP element means the gain is not real). A difference starts RECOVER MODE:
4631
+ that build's LCP numbers do not count until it matches again.
4632
+ scan --parity the final check: measurement + comparison, every pinned page
4633
+ parity [--only /a,/b] [--label x] [--exit-code] the comparison alone (no timed runs);
4634
+ --only re-checks some pages (a build is clear once every page
4635
+ is), --exit-code exits 1 while a page differs (git bisect run)
4636
+ parity recover recover mode: what to do, how to re-check, how to bisect
4637
+ parity strategy each-tweak|on-demand in recover mode: every scan re-checks and records
4638
+ a snapshot, or you re-check when you decide (default)
4639
+ parity history every check with its snapshot images
4640
+ scan|parity --pages /pricing,/docs add client-side routes (entry HTML files are found
4641
+ automatically). Pages can be added any time, never removed.
4642
+ scan|parity --mask <selector> mask a region the ORIGINAL renders differently on
4643
+ every load (a live clock, a rotating banner). Masks added after
4644
+ the original was saved are listed in every verdict.
4645
+ parity anchor show the saved original (commit, pages, masks, LCP element)
4646
+ parity anchor --replace HUMAN ONLY: make the current build the original
4647
+ parity approve <page> --reason "..." HUMAN ONLY: accept one exact, intended difference
4648
+
4649
+ individual commands (same target rules):
4650
+ measure [--runs 5] [--label x] [--pin] timed runs only -> LCP + "vs pinned baseline" verdict
4651
+ coverage | profile one signal each
4652
+ graph STATIC split candidates ranked by retained size (what
4653
+ deferring each module removes from the initial load) -
4654
+ needs a rolldown devtools-metrics build (vite >= 8:
4655
+ build.rolldownOptions.devtools = { mode: "report" })
4656
+ what-if <module> [<module>...] [--keep a,b] [--root a,b] exact modules+bytes one deferral frees;
4657
+ sentries stay eager. TWO+ modules = one combined plan, priced
4658
+ with a single overlay + a vs-summed-individually line
4659
+ (shared internals can make the combined free exceed the sum)
4660
+ cut <module> [--keep a,b] [--root a,b] min edge cut: the FEWEST import statements to make dynamic
4661
+ to detach the module (last-hop edges, the exact files to
4662
+ edit) + the bytes freed. Beats what-if when one upstream
4663
+ cut detaches a multi-path module; --keep edges are protected;
4664
+ --root modules stay eager with their closure (a Durable
4665
+ Object class loaded by name) so the cut lands below them
4666
+ what-if|cut ... --root a,b roots also price startup code no static import reaches: a
4667
+ dynamic import the app runs on every start joins the
4668
+ entries, so "initial load" = entries + roots + closure
4669
+ why <module> the module's direct static importers + one chain from the
4670
+ entry: where it enters the initial load, and who to edit
4671
+ graph-diff [--against <file>] what a change did to the initial load (eager set): modules
4672
+ that entered/left with byte deltas, vs the pinned baseline
4673
+ graph (or any saved sidecar). Deterministic, no browser run
4674
+ baseline pin the last measurement as the fixed reference (also
4675
+ snapshots the module graph for graph-diff)
4676
+ target [<appDir>] [--demo] show / set / clear the remembered target
4677
+ [--net-scale <1|2|4|8>] ...and/or pin its throttle net-scale without probing
4678
+ [--bandwidth <Mbit/s> [--rtt <ms>] [--cpu <rate>]] ...or pin a network in real units
4679
+ gen | build | defer <f> | undefer <f> | status | serve demo-app helpers (README.md)
4680
+
4681
+ field mode (a DEPLOYED site in your own logged-in Chrome - real data, network, server):
4682
+ field --config <file.json> alternating timed runs per variant (cookie/query switches the
4683
+ build), the request LCP waited for and the script chain that
4684
+ started it, then one coverage + CPU profile run per variant.
4685
+ Writes runtime-metrics/coverage/profile.json per variant and
4686
+ field-report.json (paired deltas, 90% intervals) OUTSIDE the repo.
4687
+ Needs chrome://inspect/#remote-debugging on (or --cdp-port).
4688
+ --runs 6 pairs of timed runs
4689
+ --diagnose 1|0 one coverage + CPU profile run per variant, or none
4690
+ --cut gate|lcp|fcp coverage snapshot point; gate = when the config's "gate" request
4691
+ is sent ("what ran before the request LCP waits for")
4692
+ --throttle lab [--net-scale n] add the lab throttle ON TOP of the real network
4693
+ Module attribution needs "build" per variant: a local build whose chunks are
4694
+ byte-identical to the served ones (same commit, settings, bundler version).
4695
+
4696
+ replay (field numbers for a build that is NOT deployed):
4697
+ Standalone entry for agents (one records, another replays): node replay.mjs help
4698
+ (adds inspect, record --url/--cookie without a config, --json results, HANDOFF.txt).
4699
+ record --config <file.json> [--variant <name>] [--runs 6]
4700
+ load one deployed variant in your Chrome (like field) and write a
4701
+ replay archive to $TMPDIR: every response (safelisted headers, no
4702
+ cookies), its median server time, a fitted network model, the
4703
+ field medians. Holds REAL account data - it refuses a git work tree.
4704
+ replay --archive <dir> --dist [name=]<dir> [--dist [name=]<dir>] [--runs 6]
4705
+ serve the archive to headless Chrome from one local HTTP/2 server
4706
+ for every host: the local dist replaces the deployed files, API
4707
+ responses keep their recorded server time, all bodies share the
4708
+ recorded bandwidth. Same reports as field, paired deltas.
4709
+ replay ... --calibrate FIRST: replay the recorded build; its medians must match the field
4710
+ medians, or the model misses something and an A/B means nothing
4711
+ --bandwidth <Mbit/s> --rtt <ms> --static-ttfb <ms> --wire <kB> --ect <rating|off> override the model
4712
+ --document merged|local|recorded the dist's index.html plus the tags only the deployed
4713
+ HTML has (default: a host's injected loaders run in the replay too),
4714
+ the bare dist index.html, or the recorded HTML
4715
+ --storage warm|empty warm: an unmeasured load first, in the same browser context, so the
4716
+ build's own storage is there (default when the recorded page had storage)
4717
+ --diagnose 1 [--cut gate|lcp|fcp] coverage + CPU profile run per dist, as in field
4718
+
4719
+ server startup mode (node/deno - a process coming up, not a page loading):
4720
+ node-scan --entry dist/server.js --ready port:3000 timed spawn->ready runs + what had to
4721
+ execute to get there + where the pre-ready CPU went + leads.
4722
+ The entry and its readiness signal are remembered; later
4723
+ \`node-scan\` runs bare. --pin makes this the baseline.
4724
+ node-measure timing only, no instrumented runs
4725
+ --ready <spec> how the target signals it is up. This is part of the target -
4726
+ two scans with different specs are not comparable:
4727
+ port:3000 a TCP listener is accepting
4728
+ 'stdout:ready on' a line matching this regex is printed
4729
+ http://127.0.0.1:3000/health an HTTP probe returns 2xx
4730
+ exit the process exits 0 (CLIs, lambda entries)
4731
+ --cache cold|warm|off V8 compile cache. cold (default) gives every run an empty cache,
4732
+ which is what a new container pays; warm shares one primed dir.
4733
+ Measuring warm by accident measures the cache, not the bundle.
4734
+ --args "..." --exec <bin> --cwd <dir> argv for the entry / the runtime binary (deno,
4735
+ an older node) / working directory
4736
+ graph | what-if | cut | graph-diff work unchanged on a server build - pass
4737
+ --dist <builtDir> or --report <dir>. A top-level import and a
4738
+ render-blocking import are the same edge in the module graph.
4739
+ There is no throttle in this mode: server startup does no network I/O, so a throttle
4740
+ would invent a dimension the measurement does not have.
4741
+
4742
+ Cloudflare Worker startup mode (a V8 isolate coming up under the app's own workerd):
4743
+ worker-config --app <appDir> write perf-kit.vite.config.ts next to the app's vite config:
4744
+ the rolldown metrics report on EVERY environment (one dir each),
4745
+ sourcemaps on the worker, a worker-only rebuild and an attribution
4746
+ build behind env vars. Prints the build commands. Build, then:
4747
+ worker-scan --app <appDir> [--request /path ...] [--tools <dir>]
4748
+ N cold runs (fresh workerd each) of the main module's import minus
4749
+ an empty-import floor = app startup; first requests on the real
4750
+ entry; startup CPU by module (profile); what executed at startup
4751
+ (precise coverage from inside the isolate); the leads. --pin
4752
+ makes this the baseline. The app dir, the request set and
4753
+ --tools are remembered; later \`worker-scan\` runs bare.
4754
+ worker-measure timing + first requests only
4755
+ worker-attrib startup CPU by EVALUATING module, from the attribution build
4756
+ (ROLLDOWN_PERF_KIT_ATTRIB=1 ... build): charges library work at
4757
+ startup to the module whose top level runs it, with the import
4758
+ chain. The bundled profile cannot (no line ticks for chunk
4759
+ top-level code; ESM evaluation is not a call stack).
4760
+ rolldown-install --app <appDir> [--from <packages/rolldown>]
4761
+ copy a built rolldown package (dist with its native binding) over
4762
+ the copy the app's vite resolves; the default source is the
4763
+ rolldown package next to this lab (an installed lab has none:
4764
+ pass --from a checkout built with \`just build-rolldown-release\`).
4765
+ The npm release has no report mode, so without this no build
4766
+ writes the report; worker-config says when that is the case. The
4767
+ swap is not a config change. Rebuild and re-pin afterwards.
4768
+ \`<package manager> install --force\` restores the release.
4769
+ --request <path> a route to hit after startup on the real entry; the first hit
4770
+ of a route that imports a deferred module pays that load, and
4771
+ the report prints it next to the startup gain (a deferral MOVES
4772
+ work, it does not delete it). Repeatable.
4773
+ --tools <dir> a project whose wrangler is ${COVERAGE_MIN_WRANGLER} or later, used for the
4774
+ coverage run only (the in-process inspector session landed in
4775
+ workerd 1.20260706.1). Timing and the profile keep the app's own workerd.
4776
+ graph | what-if | cut --root | why work on the Worker's report (node_modules/.rolldown/
4777
+ report/dist/<worker>) once worker-scan remembered the target.
4778
+ Requirements, the coverage difference per mode, and the rules: AGENTS.md.
4779
+
4780
+ the loop:
4781
+ 1. build the app; scan --app <appDir> (first scan pins the baseline)
4782
+ 2. read EVERY signal in the scan output: render-blocking CSS gate + render gap (fix
4783
+ these FIRST - inline critical CSS / render with bundled defaults instead of awaiting
4784
+ fetches / un-hide a hero that an entry animation mounts at opacity 0: LCP counts
4785
+ the first visibly-painted frame), pre-paint CPU by module, static pre-paint
4786
+ transfer (fetched before paint, executed after - make it load on demand), cold
4787
+ bytes at paint (fetched+parsed before paint but mostly unread - a
4788
+ partially-executed vendor SDK usually hides one boot-time init call), defer
4789
+ candidates, large modules "executed" at paint (data evaluates on import -
4790
+ executed is not needed), sibling variant groups (locales/themes: load only the
4791
+ active one), statically retained imports (rolldown builds: the module graph
4792
+ prices every split candidate - if the verdict says the graph is not collected,
4793
+ enable it, it is one config line)
4794
+ 3. read the app source; find why the landing page pays for each finding. On rolldown
4795
+ devtools-metrics builds, \`graph\` + \`what-if <module>\` answer this statically: the
4796
+ exact import chain (via/idom) and the bytes a deferral frees; \`cut <module>\` gives the
4797
+ fewest import edits to detach a multi-path module - no chain-tracing by hand
4798
+ 4. change the app (never remove features); one change at a time
4799
+ 5. rebuild; run the app's functional check; scan (--quick to probe, full scan to decide)
4800
+ 6. "improvement beyond noise" + check passes -> keep, scan --pin (or baseline), commit;
4801
+ otherwise revert + rebuild
4802
+ 7. repeat. When you have finished optimizing: scan --parity. A difference starts
4803
+ recover mode - often the build was faster BECAUSE it broke (a style that stopped
4804
+ applying, a hero swapped for a placeholder); fix or revert until it matches.
4805
+ Declare done ONLY when the verdict reports every signal class clear -
4806
+ never because one report looks empty (a tool's silence is not "done").
4807
+ Stopping earlier is allowed ONLY with the verdict checklist copied into your
4808
+ summary and every OPEN lead justified (sub-noise measurement or concrete constraint)
4809
+
4810
+ judge only by "vs pinned baseline".
4811
+ ${CLI.startsWith('npx ') ? `state for this directory: ${STATE_DIR} (keep it out of commits)\n` : ''}full contract: ${path.join(ROOT, 'AGENTS.md')}
4812
+ demo-app details: ${path.join(ROOT, 'README.md')}
4813
+ (read them at those exact paths - never search the filesystem for them: a
4814
+ \`find / -iname ...\` grinds a full CPU core for hours on a large machine)`);
4815
+ }
4816
+
4817
+ const [command, ...rest] = process.argv.slice(2);
4818
+ const commands = {
4819
+ help: cmdHelp,
4820
+ scan: cmdScan,
4821
+ gen: cmdGen,
4822
+ build: cmdBuild,
4823
+ measure: cmdMeasure,
4824
+ coverage: cmdCoverage,
4825
+ profile: cmdProfile,
4826
+ verdict: cmdVerdict,
4827
+ parity: cmdParity,
4828
+ graph: cmdGraph,
4829
+ 'what-if': cmdWhatIf,
4830
+ cut: cmdCut,
4831
+ 'graph-diff': cmdGraphDiff,
4832
+ baseline: cmdBaseline,
4833
+ target: cmdTarget,
4834
+ 'node-scan': cmdNodeScan,
4835
+ 'node-measure': cmdNodeMeasure,
4836
+ 'worker-scan': cmdWorkerScan,
4837
+ 'worker-measure': cmdWorkerMeasure,
4838
+ 'worker-attrib': cmdWorkerAttrib,
4839
+ 'worker-config': cmdWorkerConfig,
4840
+ 'rolldown-install': cmdRolldownInstall,
4841
+ why: cmdWhy,
4842
+ field: cmdField,
4843
+ record: cmdRecord,
4844
+ replay: cmdReplay,
4845
+ defer: (argv) => cmdDefer(argv, 'deferred'),
4846
+ undefer: (argv) => cmdDefer(argv, 'baseline'),
4847
+ status: cmdStatus,
4848
+ serve: cmdServe,
4849
+ };
4850
+
4851
+ if (!command || !commands[command]) {
4852
+ await cmdHelp();
4853
+ process.exit(command ? 2 : 0);
4854
+ }
4855
+ try {
4856
+ await commands[command](rest);
4857
+ } catch (err) {
4858
+ console.error(`error: ${err.message}`);
4859
+ process.exit(1);
4860
+ }