@salesforce/storefront-next-dev 1.4.0 → 1.5.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +13 -2
- package/dist/assets.js +25 -0
- package/dist/audit/catalog/categories/best-practices/CATEGORY.md +21 -0
- package/dist/audit/catalog/categories/best-practices/rules/commerce-api-client.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.js +575 -0
- package/dist/audit/catalog/categories/best-practices/rules/deferred-ui.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/error-boundaries.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/production-integrations.md +20 -0
- package/dist/audit/catalog/categories/best-practices/rules/route-revalidation.md +19 -0
- package/dist/audit/catalog/categories/best-practices/rules/server-data-loading.md +19 -0
- package/dist/audit/catalog/categories/performance/CATEGORY.md +18 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/app-config-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.js +59 -0
- package/dist/audit/catalog/categories/performance/rules/cache-control.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/cls-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.js +35 -0
- package/dist/audit/catalog/categories/performance/rules/content-encoding.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.js +18 -0
- package/dist/audit/catalog/categories/performance/rules/dom-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.js +248 -0
- package/dist/audit/catalog/categories/performance/rules/font-display.md +21 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/html-document-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.js +141 -0
- package/dist/audit/catalog/categories/performance/rules/image-dimensions.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.js +117 -0
- package/dist/audit/catalog/categories/performance/rules/image-transformation.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.js +126 -0
- package/dist/audit/catalog/categories/performance/rules/javascript-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.js +187 -0
- package/dist/audit/catalog/categories/performance/rules/large-images.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.js +227 -0
- package/dist/audit/catalog/categories/performance/rules/lcp-image-priority.md +27 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.js +15 -0
- package/dist/audit/catalog/categories/performance/rules/page-data-size.md +22 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.js +97 -0
- package/dist/audit/catalog/categories/performance/rules/reduce-javascript-on-load.md +73 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.js +201 -0
- package/dist/audit/catalog/categories/performance/rules/render-blocking-font-size.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/server-request-waterfall.md +85 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.js +13 -0
- package/dist/audit/catalog/categories/performance/rules/tbt-budget.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.js +265 -0
- package/dist/audit/catalog/categories/performance/rules/third-party-fonts.md +20 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.js +17 -0
- package/dist/audit/catalog/categories/performance/rules/ttfb-budget.md +20 -0
- package/dist/audit/catalog/categories/security/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.js +37 -0
- package/dist/audit/catalog/categories/security/rules/browser-hardening-headers.md +20 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.js +40 -0
- package/dist/audit/catalog/categories/security/rules/content-security-policy.md +20 -0
- package/dist/audit/catalog/categories/security/rules/csp-runtime-compatibility.md +24 -0
- package/dist/audit/catalog/categories/security/rules/dependency-vulnerabilities.md +19 -0
- package/dist/audit/catalog/categories/security/rules/dynamic-code-execution.md +19 -0
- package/dist/audit/catalog/categories/security/rules/hardcoded-credentials.md +19 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.js +46 -0
- package/dist/audit/catalog/categories/security/rules/html-config-secrets.md +22 -0
- package/dist/audit/catalog/categories/security/rules/protected-route-authorization.md +19 -0
- package/dist/audit/catalog/categories/security/rules/public-client-secrets.md +20 -0
- package/dist/audit/catalog/categories/security/rules/raw-html-sinks.md +20 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.js +183 -0
- package/dist/audit/catalog/categories/security/rules/strict-transport-security.md +24 -0
- package/dist/audit/catalog/categories/seo/CATEGORY.md +17 -0
- package/dist/audit/catalog/categories/seo/rules/canonical-url.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/locale-alternates.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/meta-description.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/page-title.md +19 -0
- package/dist/audit/catalog/categories/seo/rules/robots-indexing.md +20 -0
- package/dist/audit/catalog/categories/seo/rules/structured-data.md +19 -0
- package/dist/audit/instructions/evaluate-rule.md +42 -0
- package/dist/audit/schemas/audit-result.schema.json +1237 -0
- package/dist/audit/ui/assets/banner-brand-default-CDRB3_S0.png +0 -0
- package/dist/audit/ui/assets/banner-group-public-default-BGRJzVY8.png +0 -0
- package/dist/audit/ui/assets/banner-user-default-BW81Ba3z.png +0 -0
- package/dist/audit/ui/assets/bg-info@2x-CzpP0vaE.png +0 -0
- package/dist/audit/ui/assets/einstein-figure-B1xFOo8O.svg +1 -0
- package/dist/audit/ui/assets/group_avatar_160-CrxVHlYA.png +0 -0
- package/dist/audit/ui/assets/group_avatar_200-CSLS8I0r.png +0 -0
- package/dist/audit/ui/assets/index-CtE_JUcs.css +1 -0
- package/dist/audit/ui/assets/index-aSdbHIeq.js +44 -0
- package/dist/audit/ui/assets/profile_avatar_160-CtQTUBo0.png +0 -0
- package/dist/audit/ui/assets/profile_avatar_200-DAztxshF.png +0 -0
- package/dist/audit/ui/assets/symbols-BRmpfDbO.svg +1 -0
- package/dist/audit/ui/assets/symbols-D0Ne6690.svg +1 -0
- package/dist/audit/ui/einstein.png +0 -0
- package/dist/audit/ui/index.html +13 -0
- package/dist/audit.js +1898 -0
- package/dist/cartridge-services/index.d.ts +5 -0
- package/dist/cartridge-services/index.d.ts.map +1 -1
- package/dist/cartridge-services/index.js +94 -7
- package/dist/cartridge-services/index.js.map +1 -1
- package/dist/catalog.js +89 -0
- package/dist/commands/audit/catalog.js +27 -0
- package/dist/commands/audit/finalize.js +42 -0
- package/dist/commands/audit/prepare.js +84 -0
- package/dist/commands/audit/progress.js +51 -0
- package/dist/commands/audit/serve.js +63 -0
- package/dist/commands/config/aggregate-extensions.js +1 -0
- package/dist/commands/create-bundle.js +2 -1
- package/dist/commands/create-instructions.js +1 -0
- package/dist/commands/create-storefront.js +1 -0
- package/dist/commands/deploy-cartridge.js +35 -11
- package/dist/commands/dev.js +2 -0
- package/dist/commands/extensions/create.js +1 -0
- package/dist/commands/extensions/install.js +1 -0
- package/dist/commands/extensions/list.js +1 -0
- package/dist/commands/extensions/remove.js +1 -0
- package/dist/commands/generate-cartridge.js +49 -5
- package/dist/commands/locales/aggregate-extensions.js +1 -0
- package/dist/commands/prepare-local.js +1 -0
- package/dist/commands/preview.js +2 -0
- package/dist/commands/push.js +37 -7
- package/dist/commands/validate-cartridge.js +13 -4
- package/dist/config.js +13 -2
- package/dist/document-size.js +231 -0
- package/dist/events.js +688 -0
- package/dist/flags2.js +34 -0
- package/dist/generate-cartridge.js +41 -6
- package/dist/har.js +205 -0
- package/dist/images.js +146 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/live.js +277 -0
- package/dist/logger.js +1 -4
- package/dist/measured-run.js +91 -0
- package/dist/metric-budget.js +125 -0
- package/dist/mrt/ssr.mjs +50 -50
- package/dist/mrt/ssr.mjs.map +1 -1
- package/dist/mrt/streamingHandler.mjs +56 -56
- package/dist/mrt/streamingHandler.mjs.map +1 -1
- package/dist/package.js +5 -0
- package/dist/sanitize.js +185 -0
- package/dist/schema.js +121 -0
- package/dist/server.js +8 -60
- package/dist/server2.js +185 -0
- package/dist/ts-import.js +57 -0
- package/package.json +24 -6
package/dist/audit.js
ADDED
|
@@ -0,0 +1,1898 @@
|
|
|
1
|
+
import { n as VERSION, t as AUDIT_ROOT } from "./assets.js";
|
|
2
|
+
import { n as discoverCatalog, t as defaultCategoriesDir } from "./catalog.js";
|
|
3
|
+
import { i as perfFromSummary } from "./measured-run.js";
|
|
4
|
+
import { n as sanitizeText, r as sanitizeUrl, t as sanitizeJsonValue } from "./sanitize.js";
|
|
5
|
+
import { i as BUNDLE_VISUALIZER, n as BUNDLE_DIR, o as bundleAnalysisSchema, r as BUNDLE_SCHEMA_VERSION, t as BUNDLE_ANALYSIS } from "./schema.js";
|
|
6
|
+
import { S as runIdFor, _ as validateWaterfall, c as SEVERITY_ORDER, d as evidenceSchema, f as parsePrepared, g as validateRecommendations, h as validateEvidence, i as latestRunEvents, l as auditResultSchema, m as validateAuditResult, o as readEvents, s as LIGHTHOUSE_REPORT, t as appendEvent, u as evaluationSchema, x as runDir, y as latestRunDir } from "./events.js";
|
|
7
|
+
import { a as parseRouterManifest, i as parseChunk, n as lexerReady, o as parseVisualizerHtml, r as moduleIdResolver, t as readLivePage } from "./live.js";
|
|
8
|
+
import { createRequire } from "node:module";
|
|
9
|
+
import { promisify } from "node:util";
|
|
10
|
+
import { constants, existsSync, readFileSync, readdirSync } from "node:fs";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { pathToFileURL } from "node:url";
|
|
13
|
+
import { access, chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, stat, writeFile } from "node:fs/promises";
|
|
14
|
+
import { execFile } from "node:child_process";
|
|
15
|
+
import { z } from "zod";
|
|
16
|
+
import AjvModule from "ajv/dist/2020.js";
|
|
17
|
+
|
|
18
|
+
//#region src/audit/project.ts
|
|
19
|
+
async function resolveProject(root) {
|
|
20
|
+
const requested = path.resolve(root);
|
|
21
|
+
if ((await lstat(requested)).isSymbolicLink()) throw new Error(`Project root must not be a symlink: ${requested}`);
|
|
22
|
+
const resolved = await realpath(requested);
|
|
23
|
+
if (!(await lstat(resolved)).isDirectory()) throw new Error(`Project is not a directory: ${resolved}`);
|
|
24
|
+
let packageName;
|
|
25
|
+
try {
|
|
26
|
+
packageName = JSON.parse(await readFile(path.join(resolved, "package.json"), "utf8")).name;
|
|
27
|
+
} catch {}
|
|
28
|
+
return {
|
|
29
|
+
root: resolved,
|
|
30
|
+
...packageName ? { packageName } : {}
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
//#endregion
|
|
35
|
+
//#region src/audit/playhouse.ts
|
|
36
|
+
const exec = promisify(execFile);
|
|
37
|
+
const Ajv = AjvModule;
|
|
38
|
+
const jsonObject = z.record(z.string(), z.unknown());
|
|
39
|
+
const artifactDescriptor = z.object({
|
|
40
|
+
path: z.string().min(1),
|
|
41
|
+
schema: z.record(z.string(), z.unknown())
|
|
42
|
+
}).passthrough();
|
|
43
|
+
const rawArtifactDescriptor = z.object({
|
|
44
|
+
path: z.string().min(1),
|
|
45
|
+
producedBy: z.string(),
|
|
46
|
+
contract: z.string().min(1)
|
|
47
|
+
}).passthrough();
|
|
48
|
+
const describeSchema = z.object({
|
|
49
|
+
tool: z.object({
|
|
50
|
+
name: z.string().optional(),
|
|
51
|
+
version: z.string().min(1)
|
|
52
|
+
}).passthrough(),
|
|
53
|
+
archiveLifecycle: z.object({ viewability: z.object({
|
|
54
|
+
requiresManifest: z.literal(true),
|
|
55
|
+
stateAuthority: z.literal("manifest.archiveState")
|
|
56
|
+
}).passthrough() }).passthrough(),
|
|
57
|
+
artifacts: z.array(artifactDescriptor),
|
|
58
|
+
rawArtifacts: z.array(rawArtifactDescriptor).optional().default([]),
|
|
59
|
+
outputs: z.object({ runResult: z.record(z.string(), z.unknown()) }).passthrough()
|
|
60
|
+
}).passthrough();
|
|
61
|
+
const runResultSchema = z.object({
|
|
62
|
+
tool: z.literal("playhouse"),
|
|
63
|
+
command: z.literal("run"),
|
|
64
|
+
targetUrl: z.string(),
|
|
65
|
+
archiveDir: z.string().min(1),
|
|
66
|
+
summary: jsonObject
|
|
67
|
+
}).passthrough();
|
|
68
|
+
const manifestSchema = z.object({
|
|
69
|
+
targetUrl: z.string(),
|
|
70
|
+
lighthouseVersion: z.string(),
|
|
71
|
+
summaryArtifacts: z.array(z.string()),
|
|
72
|
+
medianArtifacts: z.array(z.string()).optional().default([]),
|
|
73
|
+
profileArtifacts: z.array(z.string()).optional().default([]),
|
|
74
|
+
traceArtifacts: z.array(z.object({ path: z.string() }).passthrough()).optional().default([]),
|
|
75
|
+
runs: z.array(z.object({
|
|
76
|
+
runId: z.string(),
|
|
77
|
+
files: z.array(z.string())
|
|
78
|
+
}).passthrough()).optional().default([]),
|
|
79
|
+
scriptCount: z.number().int().nonnegative().optional().default(0),
|
|
80
|
+
archiveState: z.object({
|
|
81
|
+
status: z.enum(["complete", "incomplete"]),
|
|
82
|
+
failures: z.array(z.unknown()),
|
|
83
|
+
warnings: z.array(z.unknown())
|
|
84
|
+
})
|
|
85
|
+
}).passthrough();
|
|
86
|
+
const requiredArtifacts = [
|
|
87
|
+
"summary.json",
|
|
88
|
+
"opportunities.json",
|
|
89
|
+
"summary-tbt.json",
|
|
90
|
+
"summary-lcp.json",
|
|
91
|
+
"summary-third-party.json"
|
|
92
|
+
];
|
|
93
|
+
const representativeArtifacts = /(?:main-document|document|network|diagnostic)|\.html$/i;
|
|
94
|
+
/**
|
|
95
|
+
* How long one Playhouse or Playwright command may run before it is killed.
|
|
96
|
+
*
|
|
97
|
+
* Environment variables:
|
|
98
|
+
* - `SFNEXT_AUDIT_PLAYHOUSE_TIMEOUT_MS` (optional): a positive number of milliseconds, e.g. `1200000`. Default 10 minutes.
|
|
99
|
+
*/
|
|
100
|
+
const parsedTimeoutOverride = Number(process.env.SFNEXT_AUDIT_PLAYHOUSE_TIMEOUT_MS);
|
|
101
|
+
const DEFAULT_TIMEOUT_MS = Number.isFinite(parsedTimeoutOverride) && parsedTimeoutOverride > 0 ? parsedTimeoutOverride : 10 * 6e4;
|
|
102
|
+
const basename$1 = (value) => value.split("/").at(-1) ?? value;
|
|
103
|
+
const descriptorMatches = (pattern, name) => {
|
|
104
|
+
const escaped = basename$1(pattern).replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\\\{[^}]+\\\}/g, "[^/]+");
|
|
105
|
+
return (/* @__PURE__ */ new RegExp(`^${escaped}$`)).test(basename$1(name));
|
|
106
|
+
};
|
|
107
|
+
/** With `--json`, Playhouse prints its result, or `{"error":{"message":…}}` on failure, as the last stdout line. */
|
|
108
|
+
const lastJson = (stdout) => JSON.parse(stdout.trim().split("\n").at(-1) ?? "");
|
|
109
|
+
function jsonErrorMessage(stdout) {
|
|
110
|
+
if (typeof stdout !== "string") return void 0;
|
|
111
|
+
try {
|
|
112
|
+
const message = lastJson(stdout)?.error?.message;
|
|
113
|
+
return typeof message === "string" ? message : void 0;
|
|
114
|
+
} catch {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
async function invoke(playhouse, args, cwd) {
|
|
119
|
+
const env = {
|
|
120
|
+
...process.env,
|
|
121
|
+
...playhouse.browser ? { PLAYHOUSE_CHROME_PATH: playhouse.browser } : {}
|
|
122
|
+
};
|
|
123
|
+
try {
|
|
124
|
+
return (await exec(process.execPath, [playhouse.bin, ...args], {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: "utf8",
|
|
127
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
128
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
129
|
+
killSignal: "SIGKILL",
|
|
130
|
+
env
|
|
131
|
+
})).stdout.trim();
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw new Error(sanitizeText(`Playhouse command failed (${args.join(" ")}): ${jsonErrorMessage(error.stdout) ?? (error instanceof Error ? error.message : String(error))}`));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
const require = createRequire(import.meta.url);
|
|
137
|
+
/** The script a dependency's package.json `bin` names, for running with `node`. */
|
|
138
|
+
function packageBin(name, command) {
|
|
139
|
+
const manifest = require.resolve(`${name}/package.json`);
|
|
140
|
+
const { bin } = require(manifest);
|
|
141
|
+
const script = typeof bin === "string" ? bin : bin?.[command];
|
|
142
|
+
if (!script) throw new Error(`${name} has no ${command} bin`);
|
|
143
|
+
return path.resolve(path.dirname(manifest), script);
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The bundled @salesforce/playhouse CLI.
|
|
147
|
+
*
|
|
148
|
+
* Environment variables:
|
|
149
|
+
* - `SFNEXT_AUDIT_PLAYHOUSE_BIN` (optional): another Playhouse script to run with this Node, for tests,
|
|
150
|
+
* e.g. `/tmp/fake-playhouse.mjs`.
|
|
151
|
+
*/
|
|
152
|
+
function playhouseBin() {
|
|
153
|
+
return process.env.SFNEXT_AUDIT_PLAYHOUSE_BIN || packageBin("@salesforce/playhouse", "playhouse");
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* The Chrome that Playhouse measures with: PLAYHOUSE_CHROME_PATH when the user set it, else the Chrome for Testing
|
|
157
|
+
* that our playwright-core (pinned to Playhouse's) expects in the shared Playwright browser cache. Playwright
|
|
158
|
+
* installs it there when it is missing, and reuses it otherwise. Left to itself, Playhouse would take whatever
|
|
159
|
+
* browser revision another tool cached, or system Chrome, which managed-device policy can break.
|
|
160
|
+
*
|
|
161
|
+
* Environment variables:
|
|
162
|
+
* - `PLAYHOUSE_CHROME_PATH` (optional): a Chrome executable to measure with instead, e.g.
|
|
163
|
+
* `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`.
|
|
164
|
+
* - `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` (optional): read by Playwright when it downloads Chromium behind a
|
|
165
|
+
* proxy, e.g. `http://proxy.example.com:8080` and `/etc/ssl/corp-ca.pem`.
|
|
166
|
+
*/
|
|
167
|
+
async function ensureBrowser(log, cli = packageBin("playwright-core", "playwright-core")) {
|
|
168
|
+
if (process.env.PLAYHOUSE_CHROME_PATH) return process.env.PLAYHOUSE_CHROME_PATH;
|
|
169
|
+
const { chromium } = await import("playwright-core");
|
|
170
|
+
const executable = chromium.executablePath();
|
|
171
|
+
if (await present(executable)) return executable;
|
|
172
|
+
await log("Downloading Chromium for Playhouse (one time, about 180 MB)");
|
|
173
|
+
const command = [
|
|
174
|
+
"install",
|
|
175
|
+
"chromium",
|
|
176
|
+
"--no-shell"
|
|
177
|
+
];
|
|
178
|
+
try {
|
|
179
|
+
await exec(process.execPath, [cli, ...command], {
|
|
180
|
+
encoding: "utf8",
|
|
181
|
+
maxBuffer: 20 * 1024 * 1024,
|
|
182
|
+
timeout: DEFAULT_TIMEOUT_MS,
|
|
183
|
+
killSignal: "SIGKILL"
|
|
184
|
+
});
|
|
185
|
+
} catch (error) {
|
|
186
|
+
const { stdout, stderr } = error;
|
|
187
|
+
const errors = `${String(stderr ?? "")}\n${String(stdout ?? "")}`.split("\n").flatMap((line) => /^Error: (.+?)(?:, caused by)?$/.exec(line.trim())?.[1] ?? []);
|
|
188
|
+
const reason = errors.length ? [...new Set(errors)].join("; ") : error instanceof Error ? error.message : String(error);
|
|
189
|
+
const { version } = require("playwright-core/package.json");
|
|
190
|
+
throw new Error(`Could not download Chromium for Playhouse: ${sanitizeText(reason)}\nBehind a proxy, set HTTPS_PROXY (and NODE_EXTRA_CA_CERTS for a TLS-inspecting proxy) and run prepare again, or install it yourself with: npx playwright-core@${version} ${command.join(" ")}`);
|
|
191
|
+
}
|
|
192
|
+
if (!await present(executable)) throw new Error("Playwright reported installing Chromium, but it is still missing");
|
|
193
|
+
return executable;
|
|
194
|
+
}
|
|
195
|
+
async function present(file) {
|
|
196
|
+
try {
|
|
197
|
+
await access(file, constants.X_OK);
|
|
198
|
+
return true;
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
function contained$2(base, candidate) {
|
|
204
|
+
const relative$1 = path.relative(base, candidate);
|
|
205
|
+
return relative$1 === "" || !relative$1.startsWith("..") && !path.isAbsolute(relative$1);
|
|
206
|
+
}
|
|
207
|
+
async function assertNoSymlinks(base, target) {
|
|
208
|
+
if (!contained$2(base, target)) throw new Error(`Path escaped the controlled output directory: ${target}`);
|
|
209
|
+
let current = base;
|
|
210
|
+
for (const part of path.relative(base, target).split(path.sep).filter(Boolean)) {
|
|
211
|
+
current = path.join(current, part);
|
|
212
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error(`Playhouse path must not contain a symlink: ${current}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
async function assertAbsolutePathHasNoSymlinks(target) {
|
|
216
|
+
if (!path.isAbsolute(target)) throw new Error("Playhouse archive must use an absolute path");
|
|
217
|
+
if ((await lstat(target)).isSymbolicLink()) throw new Error(`Playhouse path must not contain a symlink: ${target}`);
|
|
218
|
+
}
|
|
219
|
+
async function inspectPlayhouse(playhouse, cwd) {
|
|
220
|
+
const describe = describeSchema.parse(JSON.parse(await invoke(playhouse, ["describe"], cwd)));
|
|
221
|
+
for (const name of ["manifest.json", ...requiredArtifacts]) if (!describe.artifacts.some((item) => basename$1(item.path) === name)) throw new Error(`Playhouse describe is missing required artifact descriptor: ${name}`);
|
|
222
|
+
for (const family of [
|
|
223
|
+
{
|
|
224
|
+
label: "document",
|
|
225
|
+
pattern: /(?:main-)?document/i
|
|
226
|
+
},
|
|
227
|
+
{
|
|
228
|
+
label: "network",
|
|
229
|
+
pattern: /network/i
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
label: "diagnostic",
|
|
233
|
+
pattern: /diagnostic/i
|
|
234
|
+
}
|
|
235
|
+
]) if (!describe.artifacts.some((item) => family.pattern.test(item.path))) throw new Error(`Playhouse describe is missing required ${family.label} artifact descriptor`);
|
|
236
|
+
return {
|
|
237
|
+
version: describe.tool.version,
|
|
238
|
+
describe
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
async function collectWithPlayhouse(playhouse, target, outputCwd, runs = 1) {
|
|
242
|
+
const contract = await inspectPlayhouse(playhouse, outputCwd);
|
|
243
|
+
const ajv = new Ajv({ strict: false });
|
|
244
|
+
const resultInput = lastJson(await invoke(playhouse, [
|
|
245
|
+
"run",
|
|
246
|
+
target.url,
|
|
247
|
+
"--json",
|
|
248
|
+
"--runs",
|
|
249
|
+
String(runs),
|
|
250
|
+
"--retry-pattern-html",
|
|
251
|
+
"a^"
|
|
252
|
+
], outputCwd));
|
|
253
|
+
if (!ajv.validate(contract.describe.outputs.runResult, resultInput)) throw new Error(`Playhouse run result failed its describe schema: ${ajv.errorsText()}`);
|
|
254
|
+
const result = runResultSchema.parse(resultInput);
|
|
255
|
+
const outputRoot = await realpath(outputCwd);
|
|
256
|
+
const archiveDir = path.resolve(outputRoot, result.archiveDir);
|
|
257
|
+
await assertNoSymlinks(outputRoot, archiveDir);
|
|
258
|
+
const canonicalArchive = await realpath(archiveDir);
|
|
259
|
+
if (!contained$2(outputRoot, canonicalArchive)) throw new Error(`Playhouse archive escaped the controlled output directory: ${canonicalArchive}`);
|
|
260
|
+
if (archiveDir.endsWith(".partial")) throw new Error(`Playhouse retained a partial archive: ${archiveDir}`);
|
|
261
|
+
return readValidatedArchive(contract, target, canonicalArchive, async (artifactPath) => assertNoSymlinks(outputRoot, artifactPath));
|
|
262
|
+
}
|
|
263
|
+
async function readExistingPlayhouseArchive(playhouse, target, archiveDir, cwd) {
|
|
264
|
+
await assertAbsolutePathHasNoSymlinks(archiveDir);
|
|
265
|
+
const canonicalArchive = await realpath(archiveDir);
|
|
266
|
+
if (!(await lstat(canonicalArchive)).isDirectory()) throw new Error(`Playhouse archive is not a directory: ${canonicalArchive}`);
|
|
267
|
+
return readValidatedArchive(await inspectPlayhouse(playhouse, cwd), target, canonicalArchive, async (artifactPath) => assertNoSymlinks(canonicalArchive, artifactPath));
|
|
268
|
+
}
|
|
269
|
+
async function readValidatedArchive(contract, target, canonicalArchive, validatePath) {
|
|
270
|
+
const ajv = new Ajv({ strict: false });
|
|
271
|
+
const manifestPath = path.join(canonicalArchive, "manifest.json");
|
|
272
|
+
await validatePath(manifestPath);
|
|
273
|
+
const rawManifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
274
|
+
const manifestDescriptor = contract.describe.artifacts.find((item) => basename$1(item.path) === "manifest.json");
|
|
275
|
+
if (!manifestDescriptor) throw new Error("Playhouse describe is missing required artifact descriptor: manifest.json");
|
|
276
|
+
if (!ajv.validate(manifestDescriptor.schema, rawManifest)) throw new Error(`manifest.json failed its describe schema: ${ajv.errorsText()}`);
|
|
277
|
+
const manifest = manifestSchema.parse(rawManifest);
|
|
278
|
+
if (manifest.targetUrl !== target.url) throw new Error(`Playhouse manifest target mismatch for ${target.label}`);
|
|
279
|
+
if (manifest.archiveState.status !== "complete" || manifest.archiveState.failures.length) throw new Error(`Playhouse archive is incomplete for ${target.label}`);
|
|
280
|
+
for (const name of requiredArtifacts) if (!manifest.summaryArtifacts.includes(name)) throw new Error(`Playhouse manifest is missing required artifact: ${name}`);
|
|
281
|
+
const declaredArtifacts = [
|
|
282
|
+
...manifest.summaryArtifacts,
|
|
283
|
+
...manifest.medianArtifacts,
|
|
284
|
+
...manifest.profileArtifacts,
|
|
285
|
+
...manifest.traceArtifacts.map((item) => item.path),
|
|
286
|
+
...manifest.runs.flatMap((run) => run.files.map((file) => `runs/${run.runId}/${file}`)),
|
|
287
|
+
...manifest.scriptCount > 0 ? ["scripts/index.json"] : []
|
|
288
|
+
];
|
|
289
|
+
const names = declaredArtifacts.filter((name, index) => declaredArtifacts.indexOf(name) === index);
|
|
290
|
+
for (const name of names) {
|
|
291
|
+
const artifactPath = path.join(canonicalArchive, name);
|
|
292
|
+
await validatePath(artifactPath);
|
|
293
|
+
const descriptor = contract.describe.artifacts.find((item) => descriptorMatches(item.path, name));
|
|
294
|
+
const rawDescriptor = contract.describe.rawArtifacts.find((item) => descriptorMatches(item.path, name));
|
|
295
|
+
if (!descriptor && !rawDescriptor && !name.endsWith(".html")) continue;
|
|
296
|
+
await access(artifactPath, constants.R_OK);
|
|
297
|
+
if (descriptor && !name.startsWith("runs/") && name.endsWith(".json") && (requiredArtifacts.includes(name) || representativeArtifacts.test(name)) && !ajv.validate(descriptor.schema, jsonObject.parse(JSON.parse(await readFile(artifactPath, "utf8"))))) throw new Error(`${name} failed its describe schema: ${ajv.errorsText()}`);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
label: target.label,
|
|
301
|
+
url: sanitizeUrl(target.url),
|
|
302
|
+
archiveDir: canonicalArchive,
|
|
303
|
+
playhouseVersion: contract.version
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
//#endregion
|
|
308
|
+
//#region src/audit/evidence.ts
|
|
309
|
+
function contained$1(base, candidate) {
|
|
310
|
+
const relative$1 = path.relative(base, candidate);
|
|
311
|
+
return relative$1 === "" || !relative$1.startsWith("..") && !path.isAbsolute(relative$1);
|
|
312
|
+
}
|
|
313
|
+
async function safeFile(root, relativePath, maxBytes) {
|
|
314
|
+
if (!relativePath || path.isAbsolute(relativePath) || relativePath.split(/[\\/]/).includes("..")) throw new Error(`Unsafe relative path: ${relativePath}`);
|
|
315
|
+
const base = await realpath(root);
|
|
316
|
+
const candidate = path.resolve(base, relativePath);
|
|
317
|
+
if (!contained$1(base, candidate)) throw new Error(`Path escaped its root: ${relativePath}`);
|
|
318
|
+
let current = base;
|
|
319
|
+
for (const part of path.relative(base, candidate).split(path.sep).filter(Boolean)) {
|
|
320
|
+
current = path.join(current, part);
|
|
321
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error(`Path must not contain a symlink: ${relativePath}`);
|
|
322
|
+
}
|
|
323
|
+
const info = await lstat(candidate);
|
|
324
|
+
if (!info.isFile()) throw new Error(`Path is not a file: ${relativePath}`);
|
|
325
|
+
if (maxBytes !== void 0 && info.size > maxBytes) throw new Error(`File exceeds ${maxBytes} bytes: ${relativePath}`);
|
|
326
|
+
return candidate;
|
|
327
|
+
}
|
|
328
|
+
/** Every file under `root`, as sorted POSIX paths relative to it. Symlinks are skipped. */
|
|
329
|
+
async function listArchiveFiles(root) {
|
|
330
|
+
return (await readdir(root, {
|
|
331
|
+
recursive: true,
|
|
332
|
+
withFileTypes: true
|
|
333
|
+
})).filter((entry) => entry.isFile()).map((entry) => path.relative(root, path.join(entry.parentPath, entry.name)).split(path.sep).join("/")).slice().sort();
|
|
334
|
+
}
|
|
335
|
+
async function exists$2(file) {
|
|
336
|
+
try {
|
|
337
|
+
await lstat(file);
|
|
338
|
+
return true;
|
|
339
|
+
} catch (error) {
|
|
340
|
+
if (error.code === "ENOENT") return false;
|
|
341
|
+
throw error;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
var PlayhouseArtifactReader = class {
|
|
345
|
+
#pages;
|
|
346
|
+
constructor(pages) {
|
|
347
|
+
this.#pages = new Map(pages.map((page) => [page.label, page]));
|
|
348
|
+
}
|
|
349
|
+
artifactNames(pageLabel) {
|
|
350
|
+
return listArchiveFiles(this.#page(pageLabel).archiveDir);
|
|
351
|
+
}
|
|
352
|
+
async readJson(pageLabel, artifactName, schema) {
|
|
353
|
+
const text = await this.readText(pageLabel, artifactName);
|
|
354
|
+
if (text === void 0) return void 0;
|
|
355
|
+
const parsed = schema.safeParse(JSON.parse(text));
|
|
356
|
+
if (!parsed.success) throw new Error(`${pageLabel}/${artifactName} is invalid: ${parsed.error.message}`);
|
|
357
|
+
return parsed.data;
|
|
358
|
+
}
|
|
359
|
+
async readText(pageLabel, artifactName, maxBytes) {
|
|
360
|
+
const page = this.#page(pageLabel);
|
|
361
|
+
if (!await exists$2(path.join(page.archiveDir, artifactName))) return void 0;
|
|
362
|
+
return readFile(await safeFile(page.archiveDir, artifactName, maxBytes), "utf8");
|
|
363
|
+
}
|
|
364
|
+
#page(label) {
|
|
365
|
+
const page = this.#pages.get(label);
|
|
366
|
+
if (!page) throw new Error(`Unknown prepared page: ${label}`);
|
|
367
|
+
return page;
|
|
368
|
+
}
|
|
369
|
+
};
|
|
370
|
+
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/audit/preparation.ts
|
|
373
|
+
const decisionSchema = z.object({
|
|
374
|
+
status: z.enum([
|
|
375
|
+
"pass",
|
|
376
|
+
"fail",
|
|
377
|
+
"not-verified"
|
|
378
|
+
]),
|
|
379
|
+
severity: z.enum(SEVERITY_ORDER).optional(),
|
|
380
|
+
summary: z.string().min(1),
|
|
381
|
+
evidence: z.array(evidenceSchema)
|
|
382
|
+
}).strict().refine((value) => value.status === "not-verified" || value.evidence.length > 0, {
|
|
383
|
+
message: "a pass or fail decision requires evidence",
|
|
384
|
+
path: ["evidence"]
|
|
385
|
+
});
|
|
386
|
+
const preparedRuleResultSchema = z.object({
|
|
387
|
+
data: z.json(),
|
|
388
|
+
decision: decisionSchema.optional()
|
|
389
|
+
}).strict();
|
|
390
|
+
function contained(base, candidate) {
|
|
391
|
+
const relative$1 = path.relative(base, candidate);
|
|
392
|
+
return relative$1 === "" || !relative$1.startsWith("..") && !path.isAbsolute(relative$1);
|
|
393
|
+
}
|
|
394
|
+
function ruleParts(ruleId) {
|
|
395
|
+
const match = /^([a-z0-9-]+)\/([a-z0-9-]+)$/.exec(ruleId);
|
|
396
|
+
if (!match) throw new Error(`Invalid rule id: ${ruleId}`);
|
|
397
|
+
return {
|
|
398
|
+
category: match[1],
|
|
399
|
+
name: match[2]
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
function resultPathForRule(ruleId) {
|
|
403
|
+
const { category, name } = ruleParts(ruleId);
|
|
404
|
+
return path.posix.join("results", "categories", category, "rules", `${name}.json`);
|
|
405
|
+
}
|
|
406
|
+
/** Where the AI writes its evaluation of an undecided rule. */
|
|
407
|
+
function aiAnalysisPathForRule(ruleId) {
|
|
408
|
+
const { category, name } = ruleParts(ruleId);
|
|
409
|
+
return path.posix.join("ai-analysis", "categories", category, "rules", `${name}.json`);
|
|
410
|
+
}
|
|
411
|
+
function defaultCompanionRoot() {
|
|
412
|
+
return path.join(AUDIT_ROOT, "catalog", "categories");
|
|
413
|
+
}
|
|
414
|
+
async function exists$1(file) {
|
|
415
|
+
try {
|
|
416
|
+
await access(file);
|
|
417
|
+
return true;
|
|
418
|
+
} catch (error) {
|
|
419
|
+
if (error.code === "ENOENT") return false;
|
|
420
|
+
throw error;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
async function discoverCompanions(root, catalog) {
|
|
424
|
+
if (!await exists$1(root)) return /* @__PURE__ */ new Map();
|
|
425
|
+
const canonicalRoot = await realpath(root);
|
|
426
|
+
const known = new Set(catalog.ruleIds);
|
|
427
|
+
const companions = /* @__PURE__ */ new Map();
|
|
428
|
+
for (const category of (await readdir(canonicalRoot, { withFileTypes: true })).slice().sort((a, b) => a.name.localeCompare(b.name))) {
|
|
429
|
+
if (!category.isDirectory() || !/^[a-z0-9-]+$/.test(category.name)) continue;
|
|
430
|
+
const rulesDirectory = path.join(canonicalRoot, category.name, "rules");
|
|
431
|
+
if (!await exists$1(rulesDirectory)) continue;
|
|
432
|
+
if ((await lstat(rulesDirectory)).isSymbolicLink()) throw new Error(`Companion rules directory must not be a symlink: ${category.name}`);
|
|
433
|
+
for (const entry of (await readdir(rulesDirectory, { withFileTypes: true })).slice().sort((a, b) => a.name.localeCompare(b.name))) {
|
|
434
|
+
const match = /^(.+)\.[jt]s$/.exec(entry.name);
|
|
435
|
+
if (!entry.isFile() || !match || /\.(?:test|d)$/.test(match[1])) continue;
|
|
436
|
+
const name = match[1];
|
|
437
|
+
if (!/^[a-z0-9-]+$/.test(name)) throw new Error(`Invalid companion filename: ${entry.name}`);
|
|
438
|
+
const ruleId = `${category.name}/${name}`;
|
|
439
|
+
if (!known.has(ruleId)) throw new Error(`Companion has no matching catalog rule: ${ruleId}`);
|
|
440
|
+
const file = path.join(rulesDirectory, entry.name);
|
|
441
|
+
const canonicalFile = await realpath(file);
|
|
442
|
+
if (!contained(canonicalRoot, canonicalFile) || (await lstat(file)).isSymbolicLink()) throw new Error(`Unsafe companion path for ${ruleId}`);
|
|
443
|
+
companions.set(ruleId, canonicalFile);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
return companions;
|
|
447
|
+
}
|
|
448
|
+
function moduleShape(value, ruleId) {
|
|
449
|
+
const candidate = value;
|
|
450
|
+
if (typeof candidate?.default !== "function" || !candidate.dataSchema || typeof candidate.dataSchema.safeParse !== "function") throw new Error(`Companion for ${ruleId} must export dataSchema and a default function`);
|
|
451
|
+
return candidate;
|
|
452
|
+
}
|
|
453
|
+
async function prepareRuleResults(options) {
|
|
454
|
+
if (options.companionRoot === false) return [];
|
|
455
|
+
const companions = await discoverCompanions(options.companionRoot ?? defaultCompanionRoot(), options.catalog);
|
|
456
|
+
const rules = new Map(options.catalog.categories.flatMap((category) => category.rules.map((rule) => [rule.id, rule])));
|
|
457
|
+
const playhouse = new PlayhouseArtifactReader(options.pages);
|
|
458
|
+
const outputs = [];
|
|
459
|
+
for (const [ruleId, modulePath] of [...companions].sort(([a], [b]) => a.localeCompare(b))) {
|
|
460
|
+
const rule = rules.get(ruleId);
|
|
461
|
+
if (!rule) throw new Error(`Rule script ${ruleId} has no rule in the catalog`);
|
|
462
|
+
const module = moduleShape(await import(pathToFileURL(modulePath).href), ruleId);
|
|
463
|
+
const common = preparedRuleResultSchema.parse(await module.default({
|
|
464
|
+
rule,
|
|
465
|
+
pages: options.pages,
|
|
466
|
+
playhouse,
|
|
467
|
+
...options.bundle ? { bundle: options.bundle } : {}
|
|
468
|
+
}));
|
|
469
|
+
const parsedData = module.dataSchema.parse(common.data);
|
|
470
|
+
const sanitized = preparedRuleResultSchema.parse(sanitizeJsonValue({
|
|
471
|
+
...common,
|
|
472
|
+
data: parsedData
|
|
473
|
+
}));
|
|
474
|
+
const result = {
|
|
475
|
+
...sanitized,
|
|
476
|
+
data: module.dataSchema.parse(sanitized.data)
|
|
477
|
+
};
|
|
478
|
+
const relativePath = resultPathForRule(ruleId);
|
|
479
|
+
const outputPath = path.resolve(options.workspaceRoot, relativePath);
|
|
480
|
+
if (!contained(path.resolve(options.workspaceRoot), outputPath)) throw new Error(`Result path escaped workspace for ${ruleId}`);
|
|
481
|
+
await mkdir(path.dirname(outputPath), { recursive: true });
|
|
482
|
+
const canonicalWorkspace = await realpath(options.workspaceRoot);
|
|
483
|
+
const canonicalParent = await realpath(path.dirname(outputPath));
|
|
484
|
+
if (!contained(canonicalWorkspace, canonicalParent)) throw new Error(`Result path escaped workspace for ${ruleId}`);
|
|
485
|
+
await writeFile(path.join(canonicalParent, path.basename(outputPath)), `${JSON.stringify(result, null, 2)}\n`, { flag: "wx" });
|
|
486
|
+
outputs.push({
|
|
487
|
+
reference: {
|
|
488
|
+
ruleId,
|
|
489
|
+
path: relativePath
|
|
490
|
+
},
|
|
491
|
+
result
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
return outputs;
|
|
495
|
+
}
|
|
496
|
+
/** Read the rule script results present in `results/` for the given rules. Rules without a script have no file. */
|
|
497
|
+
async function readPreparedRuleResults(workspaceRoot, ruleIds) {
|
|
498
|
+
const workspace = path.resolve(workspaceRoot);
|
|
499
|
+
const outputs = [];
|
|
500
|
+
for (const ruleId of ruleIds) {
|
|
501
|
+
const relativePath = resultPathForRule(ruleId);
|
|
502
|
+
const file = path.resolve(workspace, relativePath);
|
|
503
|
+
if (!await exists$1(file)) continue;
|
|
504
|
+
if ((await lstat(file)).isSymbolicLink() || !contained(await realpath(workspace), await realpath(file))) throw new Error(`Unsafe prepared result path: ${relativePath}`);
|
|
505
|
+
const parsed = preparedRuleResultSchema.safeParse(JSON.parse(await readFile(file, "utf8")));
|
|
506
|
+
if (!parsed.success) throw new Error(`${relativePath} is invalid: ${parsed.error.message}`);
|
|
507
|
+
outputs.push({
|
|
508
|
+
reference: {
|
|
509
|
+
ruleId,
|
|
510
|
+
path: relativePath
|
|
511
|
+
},
|
|
512
|
+
result: parsed.data
|
|
513
|
+
});
|
|
514
|
+
}
|
|
515
|
+
return outputs;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
//#endregion
|
|
519
|
+
//#region src/audit/bundle/analyze.ts
|
|
520
|
+
/** An import that frees less than this isn't worth a candidate. */
|
|
521
|
+
const MIN_CANDIDATE_BYTES = 10 * 1024;
|
|
522
|
+
const MAX_CANDIDATES = 25;
|
|
523
|
+
const MAX_IMPORTERS = 8;
|
|
524
|
+
const MAX_CHUNK_OWNERS = 3;
|
|
525
|
+
const MAX_CHUNKS = 10;
|
|
526
|
+
const MAX_PACKAGE_MODULES = 5;
|
|
527
|
+
async function analyzeBundle(outputs, pages, playhouse) {
|
|
528
|
+
const notes = [...outputs?.notes ?? []];
|
|
529
|
+
let model = null;
|
|
530
|
+
if (outputs?.assetsDir) try {
|
|
531
|
+
model = await readBuild(outputs, notes);
|
|
532
|
+
} catch (error) {
|
|
533
|
+
notes.push(`The build output could not be read: ${error instanceof Error ? error.message : String(error)}`);
|
|
534
|
+
}
|
|
535
|
+
const result = {
|
|
536
|
+
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
537
|
+
status: model?.graph ? "complete" : model ? "partial" : "unavailable",
|
|
538
|
+
notes,
|
|
539
|
+
build: model && outputs ? {
|
|
540
|
+
packageManager: outputs.packageManager,
|
|
541
|
+
durationMs: outputs.durationMs,
|
|
542
|
+
visualizer: model.graph ? outputs.visualizer : null,
|
|
543
|
+
chunks: model.chunks.size,
|
|
544
|
+
withMaps: [...model.chunks.values()].filter((chunk) => chunk.hasMap).length,
|
|
545
|
+
bytes: [...model.chunks.values()].reduce((total, chunk) => total + chunk.bytes, 0),
|
|
546
|
+
attribution: model.attribution
|
|
547
|
+
} : null,
|
|
548
|
+
pages: [],
|
|
549
|
+
skipped: []
|
|
550
|
+
};
|
|
551
|
+
for (const page of pages) try {
|
|
552
|
+
const live = await readLivePage(playhouse, page.label);
|
|
553
|
+
if ("reason" in live) {
|
|
554
|
+
result.skipped.push({
|
|
555
|
+
page: page.label,
|
|
556
|
+
reason: live.reason
|
|
557
|
+
});
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
result.pages.push(analyzePage(live, page.url, model, notes));
|
|
561
|
+
} catch (error) {
|
|
562
|
+
result.skipped.push({
|
|
563
|
+
page: page.label,
|
|
564
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
return result;
|
|
568
|
+
}
|
|
569
|
+
async function readBuild(outputs, notes) {
|
|
570
|
+
await lexerReady;
|
|
571
|
+
const { assetsDir, projectRoot } = outputs;
|
|
572
|
+
if (!assetsDir) throw new Error("The build has no client assets directory.");
|
|
573
|
+
const chunks = /* @__PURE__ */ new Map();
|
|
574
|
+
for (const file of (await readdir(assetsDir)).filter((name) => name.endsWith(".js")).slice().sort()) {
|
|
575
|
+
const code = await readFile(path.join(assetsDir, file), "utf8");
|
|
576
|
+
const mapFile = path.join(assetsDir, `${file}.map`);
|
|
577
|
+
const map = existsSync(mapFile) ? await readFile(mapFile, "utf8") : void 0;
|
|
578
|
+
chunks.set(file, {
|
|
579
|
+
...parseChunk(file, code, map, assetsDir),
|
|
580
|
+
assigned: /* @__PURE__ */ new Map()
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
if (!chunks.size) throw new Error(`${path.relative(projectRoot, assetsDir)} has no JavaScript.`);
|
|
584
|
+
const manifests = [...chunks.values()].filter((chunk) => chunk.routerManifest);
|
|
585
|
+
let manifest = null;
|
|
586
|
+
const manifestFile = manifests.length === 1 ? manifests[0].file : null;
|
|
587
|
+
if (manifestFile) manifest = parseRouterManifest(await readFile(path.join(assetsDir, manifestFile), "utf8"));
|
|
588
|
+
else notes.push(manifests.length ? `The build has ${manifests.length} React Router manifests; routes can't be read from it.` : "The build has no React Router manifest; routes can't be read from it.");
|
|
589
|
+
const withoutMaps = [...chunks.values()].filter((chunk) => !chunk.hasMap && !chunk.routerManifest);
|
|
590
|
+
if (withoutMaps.length) notes.push(`${withoutMaps.length} of ${chunks.size} chunks have no source map, so their modules aren't known.`);
|
|
591
|
+
let graph = null;
|
|
592
|
+
if (outputs.visualizerHtml) {
|
|
593
|
+
const rootEntries = new Set(readdirSync(projectRoot));
|
|
594
|
+
try {
|
|
595
|
+
graph = parseVisualizerHtml(outputs.visualizerHtml, moduleIdResolver(projectRoot, rootEntries));
|
|
596
|
+
} catch (error) {
|
|
597
|
+
notes.push(`The visualizer report could not be read, so import suggestions are unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (graph) assignUnmappedModules(chunks, graph);
|
|
601
|
+
const moduleBytes = /* @__PURE__ */ new Map();
|
|
602
|
+
const attribution = {
|
|
603
|
+
modules: 0,
|
|
604
|
+
unmappedModules: 0,
|
|
605
|
+
preloadLists: 0,
|
|
606
|
+
routerManifest: 0,
|
|
607
|
+
glue: 0
|
|
608
|
+
};
|
|
609
|
+
for (const chunk of chunks.values()) {
|
|
610
|
+
if (chunk.routerManifest) {
|
|
611
|
+
attribution.routerManifest += chunk.bytes;
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
let mapped = 0;
|
|
615
|
+
let assigned = 0;
|
|
616
|
+
for (const [id, size] of chunk.modules) {
|
|
617
|
+
moduleBytes.set(id, (moduleBytes.get(id) ?? 0) + size);
|
|
618
|
+
mapped += size;
|
|
619
|
+
}
|
|
620
|
+
for (const [id, size] of chunk.assigned) {
|
|
621
|
+
moduleBytes.set(id, (moduleBytes.get(id) ?? 0) + size);
|
|
622
|
+
assigned += size;
|
|
623
|
+
}
|
|
624
|
+
attribution.modules += mapped;
|
|
625
|
+
attribution.unmappedModules += assigned;
|
|
626
|
+
attribution.preloadLists += chunk.preloadList;
|
|
627
|
+
attribution.glue += chunk.bytes - mapped - assigned - chunk.preloadList;
|
|
628
|
+
}
|
|
629
|
+
if (graph) {
|
|
630
|
+
const missing = [...moduleBytes.keys()].filter((id) => !graph.has(id)).length;
|
|
631
|
+
if (missing) notes.push(`${missing} modules in the build's source maps aren't in the visualizer's module graph; the two builds may differ.`);
|
|
632
|
+
}
|
|
633
|
+
const { owner, display } = ownership(projectRoot);
|
|
634
|
+
return {
|
|
635
|
+
projectRoot,
|
|
636
|
+
chunks,
|
|
637
|
+
moduleBytes,
|
|
638
|
+
manifest,
|
|
639
|
+
manifestFile,
|
|
640
|
+
graph,
|
|
641
|
+
attribution,
|
|
642
|
+
owner,
|
|
643
|
+
display
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* JSON modules (translations, mostly) have no mappings, so their bytes are unattributed in the map. The analyze
|
|
648
|
+
* build's chunk with the same modules says which modules those bytes belong to; they're split in proportion to the
|
|
649
|
+
* visualizer's pre-minification lengths, never more than the bytes left.
|
|
650
|
+
*/
|
|
651
|
+
function assignUnmappedModules(chunks, graph) {
|
|
652
|
+
const byAnalyzeChunk = /* @__PURE__ */ new Map();
|
|
653
|
+
for (const node of graph.values()) for (const name of node.chunks) {
|
|
654
|
+
const ids = byAnalyzeChunk.get(name) ?? /* @__PURE__ */ new Set();
|
|
655
|
+
byAnalyzeChunk.set(name, ids.add(node.id));
|
|
656
|
+
}
|
|
657
|
+
const mappedSomewhere = new Set([...chunks.values()].flatMap((chunk) => [...chunk.modules.keys()]));
|
|
658
|
+
for (const chunk of chunks.values()) {
|
|
659
|
+
if (!chunk.modules.size || chunk.unattributed <= 0) continue;
|
|
660
|
+
const overlap = /* @__PURE__ */ new Map();
|
|
661
|
+
for (const id of chunk.modules.keys()) for (const name of graph.get(id)?.chunks ?? []) overlap.set(name, (overlap.get(name) ?? 0) + 1);
|
|
662
|
+
let best;
|
|
663
|
+
let bestScore = 0;
|
|
664
|
+
for (const [name, shared] of overlap) {
|
|
665
|
+
const score = shared / (chunk.modules.size + (byAnalyzeChunk.get(name)?.size ?? 0) - shared);
|
|
666
|
+
if (score > bestScore) {
|
|
667
|
+
best = name;
|
|
668
|
+
bestScore = score;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (!best) continue;
|
|
672
|
+
const missing = [...byAnalyzeChunk.get(best) ?? []].filter((id) => !mappedSomewhere.has(id) && (graph.get(id)?.rendered ?? 0) > 0);
|
|
673
|
+
const rendered = missing.reduce((total, id) => total + (graph.get(id)?.rendered ?? 0), 0);
|
|
674
|
+
if (!rendered) continue;
|
|
675
|
+
const budget = Math.min(chunk.unattributed, rendered);
|
|
676
|
+
const shares = missing.map((id) => {
|
|
677
|
+
const exact = budget * (graph.get(id)?.rendered ?? 0) / rendered;
|
|
678
|
+
return {
|
|
679
|
+
id,
|
|
680
|
+
size: Math.floor(exact),
|
|
681
|
+
rest: exact % 1
|
|
682
|
+
};
|
|
683
|
+
});
|
|
684
|
+
let left = budget - shares.reduce((total, share) => total + share.size, 0);
|
|
685
|
+
for (const share of shares.slice().sort((a, b) => b.rest - a.rest)) {
|
|
686
|
+
if (left <= 0) break;
|
|
687
|
+
share.size += 1;
|
|
688
|
+
left -= 1;
|
|
689
|
+
}
|
|
690
|
+
for (const share of shares) if (share.size) chunk.assigned.set(share.id, share.size);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
/** Who owns a module, and how to show its path without the machine's directories. */
|
|
694
|
+
function ownership(projectRoot) {
|
|
695
|
+
const packages = /* @__PURE__ */ new Map();
|
|
696
|
+
const nearestPackage = (dir) => {
|
|
697
|
+
if (packages.has(dir)) return packages.get(dir) ?? null;
|
|
698
|
+
let found = null;
|
|
699
|
+
try {
|
|
700
|
+
const { name, version } = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
701
|
+
if (typeof name === "string" && name) found = {
|
|
702
|
+
name,
|
|
703
|
+
dir,
|
|
704
|
+
...typeof version === "string" ? { version } : {}
|
|
705
|
+
};
|
|
706
|
+
} catch {}
|
|
707
|
+
const parent = path.dirname(dir);
|
|
708
|
+
if (!found && parent !== dir) found = nearestPackage(parent);
|
|
709
|
+
packages.set(dir, found);
|
|
710
|
+
return found;
|
|
711
|
+
};
|
|
712
|
+
const inProject = (id) => id.startsWith(projectRoot + path.sep);
|
|
713
|
+
const afterNodeModules = (id) => {
|
|
714
|
+
const at = id.lastIndexOf(`${path.sep}node_modules${path.sep}`);
|
|
715
|
+
return at < 0 ? null : id.slice(at + 14).split(path.sep).join("/");
|
|
716
|
+
};
|
|
717
|
+
const packageName = (rest) => {
|
|
718
|
+
const parts = rest.split("/");
|
|
719
|
+
return parts[0].startsWith("@") ? `${parts[0]}/${parts[1]}` : parts[0];
|
|
720
|
+
};
|
|
721
|
+
return {
|
|
722
|
+
owner: (id) => {
|
|
723
|
+
const rest = afterNodeModules(id);
|
|
724
|
+
if (rest) {
|
|
725
|
+
const name = packageName(rest);
|
|
726
|
+
const version = nearestPackage(id.slice(0, id.lastIndexOf(`${path.sep}node_modules${path.sep}`) + 14) + name.split("/").join(path.sep))?.version;
|
|
727
|
+
return {
|
|
728
|
+
name,
|
|
729
|
+
kind: "npm",
|
|
730
|
+
...version ? { version } : {}
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
if (inProject(id)) {
|
|
734
|
+
const parts = path.relative(projectRoot, id).split(path.sep);
|
|
735
|
+
return {
|
|
736
|
+
name: parts.slice(0, Math.min(parts.length, 3)).join("/"),
|
|
737
|
+
kind: "project"
|
|
738
|
+
};
|
|
739
|
+
}
|
|
740
|
+
const pkg = nearestPackage(path.dirname(id));
|
|
741
|
+
return pkg ? {
|
|
742
|
+
name: pkg.name,
|
|
743
|
+
kind: "npm",
|
|
744
|
+
...pkg.version ? { version: pkg.version } : {}
|
|
745
|
+
} : {
|
|
746
|
+
name: "Other",
|
|
747
|
+
kind: "build"
|
|
748
|
+
};
|
|
749
|
+
},
|
|
750
|
+
display: (id) => {
|
|
751
|
+
const rest = afterNodeModules(id);
|
|
752
|
+
if (rest) return rest;
|
|
753
|
+
if (inProject(id)) return path.relative(projectRoot, id).split(path.sep).join("/");
|
|
754
|
+
const pkg = nearestPackage(path.dirname(id));
|
|
755
|
+
return pkg ? `${pkg.name}/${path.relative(pkg.dir, id).split(path.sep).join("/")}` : path.basename(id);
|
|
756
|
+
}
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
const BUILD_OWNERS = {
|
|
760
|
+
preloadLists: "Preload lists",
|
|
761
|
+
routerManifest: "React Router manifest",
|
|
762
|
+
glue: "Module glue"
|
|
763
|
+
};
|
|
764
|
+
function analyzePage(live, url, model, notes) {
|
|
765
|
+
const scripts = live.scripts;
|
|
766
|
+
const sum = (items, key) => items.reduce((total, item) => total + item[key], 0);
|
|
767
|
+
const beforeLoad = scripts.filter((script) => script.beforeLoad);
|
|
768
|
+
const page = {
|
|
769
|
+
page: live.page,
|
|
770
|
+
runId: live.runId,
|
|
771
|
+
lighthouseArtifact: live.lighthouseArtifact,
|
|
772
|
+
observedLoadMs: live.observedLoadMs,
|
|
773
|
+
live: {
|
|
774
|
+
scripts: scripts.length,
|
|
775
|
+
decodedBytes: sum(scripts, "decodedBytes"),
|
|
776
|
+
transferBytes: sum(scripts, "transferBytes"),
|
|
777
|
+
beforeLoad: beforeLoad.length,
|
|
778
|
+
beforeLoadDecodedBytes: sum(beforeLoad, "decodedBytes"),
|
|
779
|
+
beforeLoadTransferBytes: sum(beforeLoad, "transferBytes")
|
|
780
|
+
},
|
|
781
|
+
route: live.rendered ? {
|
|
782
|
+
ids: live.rendered.routes.map((route) => route.id),
|
|
783
|
+
source: "page"
|
|
784
|
+
} : null,
|
|
785
|
+
local: null
|
|
786
|
+
};
|
|
787
|
+
if (!model?.manifest) return page;
|
|
788
|
+
page.route = pageRoute(page.route, url, model.manifest, live.page, notes);
|
|
789
|
+
if (page.route) page.local = localPage(page.route.ids, model, model.manifest, live.page, notes);
|
|
790
|
+
return page;
|
|
791
|
+
}
|
|
792
|
+
/**
|
|
793
|
+
* The live page's route ids when the local build has all of them (route ids are route file names, so they survive a
|
|
794
|
+
* rebuild); else the local route branch whose paths match the page URL.
|
|
795
|
+
*/
|
|
796
|
+
function pageRoute(rendered, url, manifest, page, notes) {
|
|
797
|
+
const missing = rendered?.ids.filter((id) => !manifest.routes[id]) ?? [];
|
|
798
|
+
if (rendered && !missing.length) return rendered;
|
|
799
|
+
const ids = routeForUrl(url, manifest);
|
|
800
|
+
const why = rendered ? `the live page renders ${missing.join(", ")}, which the local build doesn't have` : "the live page's HTML doesn't name its routes";
|
|
801
|
+
notes.push(`${page}: ${why}; ${ids ? `its URL matches the local route ${ids.at(-1)}, which is analyzed instead` : "no local route matches its URL, so the page isn't analyzed in the local build"}.`);
|
|
802
|
+
return ids ? {
|
|
803
|
+
ids,
|
|
804
|
+
source: "url"
|
|
805
|
+
} : null;
|
|
806
|
+
}
|
|
807
|
+
/** The local route branch, root first, that React Router would render for a URL. */
|
|
808
|
+
function routeForUrl(url, manifest) {
|
|
809
|
+
let segments;
|
|
810
|
+
try {
|
|
811
|
+
segments = new URL(url).pathname.split("/").filter(Boolean).map((part) => decodeURIComponent(part));
|
|
812
|
+
} catch {
|
|
813
|
+
return null;
|
|
814
|
+
}
|
|
815
|
+
let best = null;
|
|
816
|
+
for (const id of Object.keys(manifest.routes)) {
|
|
817
|
+
const ids = [];
|
|
818
|
+
for (let route = manifest.routes[id]; route && ids.length < 32; route = route.parentId ? manifest.routes[route.parentId] : void 0) ids.unshift(route.id);
|
|
819
|
+
const score = matchPath(ids.flatMap((item) => (manifest.routes[item].path ?? "").split("/").filter(Boolean)), segments);
|
|
820
|
+
if (score !== null && (!best || score > best.score || score === best.score && ids.length > best.ids.length)) best = {
|
|
821
|
+
ids,
|
|
822
|
+
score
|
|
823
|
+
};
|
|
824
|
+
}
|
|
825
|
+
return best?.ids ?? null;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* React Router's ranking, simplified: a static segment outranks a dynamic one, and a splat loses to any route that
|
|
829
|
+
* matches without one (an index route at `/`). Null when the path doesn't match.
|
|
830
|
+
*/
|
|
831
|
+
function matchPath(pattern, segments) {
|
|
832
|
+
const [part, ...rest] = pattern;
|
|
833
|
+
if (part === void 0) return segments.length ? null : 0;
|
|
834
|
+
if (part === "*") return -1;
|
|
835
|
+
const optional = part.endsWith("?");
|
|
836
|
+
const text = optional ? part.slice(0, -1) : part;
|
|
837
|
+
const tail$1 = segments.length && segmentPattern(text).test(segments[0]) ? matchPath(rest, segments.slice(1)) : null;
|
|
838
|
+
const matched = tail$1 === null ? null : tail$1 + (text.startsWith(":") ? 3 : 10);
|
|
839
|
+
const skipped = optional ? matchPath(rest, segments) : null;
|
|
840
|
+
return matched === null ? skipped : skipped === null ? matched : Math.max(matched, skipped);
|
|
841
|
+
}
|
|
842
|
+
const segmentPattern = (text) => new RegExp(`^${text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/^:[\w-]+/, "[^/]+")}$`, "i");
|
|
843
|
+
/** What the page's routes load up front in the local build, what that code is, and the imports that pull in the most of it. */
|
|
844
|
+
function localPage(routeIds, model, manifest, page, notes) {
|
|
845
|
+
const files = /* @__PURE__ */ new Set();
|
|
846
|
+
const missing = /* @__PURE__ */ new Set();
|
|
847
|
+
const add = (file) => {
|
|
848
|
+
if (files.has(file)) return;
|
|
849
|
+
const chunk = model.chunks.get(file);
|
|
850
|
+
if (!chunk) {
|
|
851
|
+
missing.add(file);
|
|
852
|
+
return;
|
|
853
|
+
}
|
|
854
|
+
files.add(file);
|
|
855
|
+
for (const next of chunk.imports) add(next);
|
|
856
|
+
};
|
|
857
|
+
if (model.manifestFile) add(model.manifestFile);
|
|
858
|
+
for (const file of [
|
|
859
|
+
manifest.entry.module,
|
|
860
|
+
...manifest.entry.imports,
|
|
861
|
+
...routeIds.flatMap((id) => [manifest.routes[id].module, ...manifest.routes[id].imports ?? []])
|
|
862
|
+
]) add(file);
|
|
863
|
+
if (missing.size) notes.push(`${page}: ${missing.size} chunks the local manifest names aren't in the build's client assets.`);
|
|
864
|
+
const chunks = [...files].flatMap((file) => model.chunks.get(file) ?? []);
|
|
865
|
+
const pageBytes = /* @__PURE__ */ new Map();
|
|
866
|
+
for (const chunk of chunks) for (const [id, size] of [...chunk.modules, ...chunk.assigned]) pageBytes.set(id, (pageBytes.get(id) ?? 0) + size);
|
|
867
|
+
const owners = /* @__PURE__ */ new Map();
|
|
868
|
+
for (const chunk of chunks) for (const row of chunkOwnerTable(chunk, model)) {
|
|
869
|
+
const entry = owners.get(row.name) ?? {
|
|
870
|
+
name: row.name,
|
|
871
|
+
kind: row.kind,
|
|
872
|
+
bytes: 0,
|
|
873
|
+
modules: 0,
|
|
874
|
+
largestModules: [],
|
|
875
|
+
importers: [],
|
|
876
|
+
importerCount: 0
|
|
877
|
+
};
|
|
878
|
+
entry.bytes += row.bytes;
|
|
879
|
+
entry.modules += row.modules;
|
|
880
|
+
owners.set(row.name, entry);
|
|
881
|
+
}
|
|
882
|
+
const ownerModules = /* @__PURE__ */ new Map();
|
|
883
|
+
for (const [id, size] of pageBytes) {
|
|
884
|
+
const owner = model.owner(id);
|
|
885
|
+
const modules = ownerModules.get(owner.name) ?? [];
|
|
886
|
+
ownerModules.set(owner.name, modules);
|
|
887
|
+
modules.push({
|
|
888
|
+
file: model.display(id),
|
|
889
|
+
bytes: size,
|
|
890
|
+
...owner.version ? { version: owner.version } : {}
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
for (const [name, modules] of ownerModules) {
|
|
894
|
+
const entry = owners.get(name);
|
|
895
|
+
if (!entry) continue;
|
|
896
|
+
entry.largestModules = largest(modules, MAX_PACKAGE_MODULES).map(({ file, bytes }) => ({
|
|
897
|
+
file,
|
|
898
|
+
bytes
|
|
899
|
+
}));
|
|
900
|
+
const versions = [...new Set(modules.flatMap((module) => module.version ? [module.version] : []))].slice().sort();
|
|
901
|
+
if (entry.kind === "npm" && versions.length > 1) entry.versions = versions;
|
|
902
|
+
}
|
|
903
|
+
const local = {
|
|
904
|
+
chunks: chunks.length,
|
|
905
|
+
bytes: chunks.reduce((total, chunk) => total + chunk.bytes, 0),
|
|
906
|
+
largest: largest(chunks, MAX_CHUNKS).map((chunk) => ({
|
|
907
|
+
file: chunk.file,
|
|
908
|
+
bytes: chunk.bytes,
|
|
909
|
+
owners: largest(chunkOwnerTable(chunk, model), MAX_CHUNK_OWNERS).map(({ name, bytes }) => ({
|
|
910
|
+
name,
|
|
911
|
+
bytes
|
|
912
|
+
}))
|
|
913
|
+
})),
|
|
914
|
+
packages: [...owners.values()].sort((a, b) => b.bytes - a.bytes),
|
|
915
|
+
candidates: []
|
|
916
|
+
};
|
|
917
|
+
const graph = model.graph;
|
|
918
|
+
if (!graph) return local;
|
|
919
|
+
const { roots, hydration } = rootModules(routeIds, model, manifest);
|
|
920
|
+
if (!roots.size) return local;
|
|
921
|
+
const reach = new Reachability(graph, roots);
|
|
922
|
+
const imports = new ImportLines(model.projectRoot);
|
|
923
|
+
const isImporter = (id) => model.owner(id).kind === "project" && !hydration.has(id);
|
|
924
|
+
for (const entry of local.packages) {
|
|
925
|
+
if (entry.kind !== "npm") continue;
|
|
926
|
+
const edges = [];
|
|
927
|
+
for (const from of reach.reached) {
|
|
928
|
+
if (!isImporter(from)) continue;
|
|
929
|
+
for (const to of graph.get(from)?.static ?? []) if (reach.reached.has(to) && model.owner(to).name === entry.name && model.owner(to).kind === "npm") edges.push([from, to]);
|
|
930
|
+
}
|
|
931
|
+
if (!edges.length) continue;
|
|
932
|
+
const importers = [...new Map(edges).entries()];
|
|
933
|
+
entry.importerCount = importers.length;
|
|
934
|
+
entry.importers = importers.slice(0, MAX_IMPORTERS).map(([from, to]) => ({
|
|
935
|
+
file: model.display(from),
|
|
936
|
+
...imports.find(from, to, model)
|
|
937
|
+
}));
|
|
938
|
+
entry.deferrableBytes = reach.freedBy(edges, pageBytes).bytes;
|
|
939
|
+
}
|
|
940
|
+
const candidates = [];
|
|
941
|
+
for (const from of reach.reached) {
|
|
942
|
+
if (!isImporter(from)) continue;
|
|
943
|
+
for (const to of graph.get(from)?.static ?? []) {
|
|
944
|
+
if (roots.has(to) || !reach.reached.has(to)) continue;
|
|
945
|
+
const freed = reach.freedBy([[from, to]], pageBytes);
|
|
946
|
+
if (freed.bytes >= MIN_CANDIDATE_BYTES) candidates.push({
|
|
947
|
+
from,
|
|
948
|
+
to,
|
|
949
|
+
freed
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
const kept = [];
|
|
954
|
+
for (const candidate of candidates.slice().sort((a, b) => b.freed.bytes - a.freed.bytes || a.from.localeCompare(b.from) || a.to.localeCompare(b.to))) {
|
|
955
|
+
if (kept.some((outer) => outer.freed.ids.has(candidate.from) && outer.freed.bytes - candidate.freed.bytes < MIN_CANDIDATE_BYTES)) continue;
|
|
956
|
+
kept.push(candidate);
|
|
957
|
+
if (kept.length === MAX_CANDIDATES) break;
|
|
958
|
+
}
|
|
959
|
+
const lazyImporters = /* @__PURE__ */ new Map();
|
|
960
|
+
for (const node of graph.values()) {
|
|
961
|
+
if (model.owner(node.id).kind !== "project") continue;
|
|
962
|
+
for (const to of node.dynamic) lazyImporters.set(to, [...lazyImporters.get(to) ?? [], model.display(node.id)]);
|
|
963
|
+
}
|
|
964
|
+
local.candidates = kept.map(({ from, to, freed }) => {
|
|
965
|
+
const lazy = lazyImporters.get(to) ?? [];
|
|
966
|
+
return {
|
|
967
|
+
importer: {
|
|
968
|
+
file: model.display(from),
|
|
969
|
+
...imports.find(from, to, model)
|
|
970
|
+
},
|
|
971
|
+
target: model.display(to),
|
|
972
|
+
package: model.owner(to).name,
|
|
973
|
+
savedBytes: freed.bytes,
|
|
974
|
+
savedModules: freed.ids.size,
|
|
975
|
+
...lazy.length ? { lazyElsewhere: lazy.slice(0, MAX_IMPORTERS) } : {}
|
|
976
|
+
};
|
|
977
|
+
});
|
|
978
|
+
return local;
|
|
979
|
+
}
|
|
980
|
+
/**
|
|
981
|
+
* The modules the page starts from, found in the build's chunks for entry.client and the route modules; `hydration`
|
|
982
|
+
* is entry.client's share of them.
|
|
983
|
+
*/
|
|
984
|
+
function rootModules(routeIds, model, manifest) {
|
|
985
|
+
const roots = /* @__PURE__ */ new Set();
|
|
986
|
+
const hydration = /* @__PURE__ */ new Set();
|
|
987
|
+
const files = [manifest.entry.module, ...routeIds.map((id) => manifest.routes[id].module)];
|
|
988
|
+
for (const [index, file] of files.entries()) {
|
|
989
|
+
const chunk = model.chunks.get(file);
|
|
990
|
+
if (!chunk) continue;
|
|
991
|
+
const modules = [...chunk.modules.keys(), ...chunk.assigned.keys()];
|
|
992
|
+
const entries = modules.filter((id) => model.graph?.get(id)?.entry);
|
|
993
|
+
for (const id of entries.length ? entries : modules) {
|
|
994
|
+
roots.add(id);
|
|
995
|
+
if (index === 0) hydration.add(id);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
return {
|
|
999
|
+
roots,
|
|
1000
|
+
hydration
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
function chunkOwnerTable(chunk, model) {
|
|
1004
|
+
const table = /* @__PURE__ */ new Map();
|
|
1005
|
+
const add = (owner, size, modules) => {
|
|
1006
|
+
if (size <= 0) return;
|
|
1007
|
+
const entry = table.get(owner.name) ?? {
|
|
1008
|
+
name: owner.name,
|
|
1009
|
+
kind: owner.kind,
|
|
1010
|
+
bytes: 0,
|
|
1011
|
+
modules: 0
|
|
1012
|
+
};
|
|
1013
|
+
entry.bytes += size;
|
|
1014
|
+
entry.modules += modules;
|
|
1015
|
+
table.set(owner.name, entry);
|
|
1016
|
+
};
|
|
1017
|
+
if (chunk.routerManifest) {
|
|
1018
|
+
add({
|
|
1019
|
+
name: BUILD_OWNERS.routerManifest,
|
|
1020
|
+
kind: "build"
|
|
1021
|
+
}, chunk.bytes, 0);
|
|
1022
|
+
return [...table.values()];
|
|
1023
|
+
}
|
|
1024
|
+
let attributed = chunk.preloadList;
|
|
1025
|
+
for (const [id, size] of [...chunk.modules, ...chunk.assigned]) {
|
|
1026
|
+
add(model.owner(id), size, 1);
|
|
1027
|
+
attributed += size;
|
|
1028
|
+
}
|
|
1029
|
+
add({
|
|
1030
|
+
name: BUILD_OWNERS.preloadLists,
|
|
1031
|
+
kind: "build"
|
|
1032
|
+
}, chunk.preloadList, 0);
|
|
1033
|
+
add({
|
|
1034
|
+
name: BUILD_OWNERS.glue,
|
|
1035
|
+
kind: "build"
|
|
1036
|
+
}, chunk.bytes - attributed, 0);
|
|
1037
|
+
return [...table.values()];
|
|
1038
|
+
}
|
|
1039
|
+
const largest = (items, count) => items.slice().sort((a, b) => b.bytes - a.bytes).slice(0, count);
|
|
1040
|
+
/** Static reachability from the page's roots, and what removing some import edges would stop loading. */
|
|
1041
|
+
var Reachability = class {
|
|
1042
|
+
reached;
|
|
1043
|
+
constructor(graph, roots) {
|
|
1044
|
+
this.graph = graph;
|
|
1045
|
+
this.roots = roots;
|
|
1046
|
+
this.reached = this.#walk(/* @__PURE__ */ new Set());
|
|
1047
|
+
}
|
|
1048
|
+
freedBy(edges, moduleBytes) {
|
|
1049
|
+
const remaining = this.#walk(new Set(edges.map(([from, to]) => `${from}\n${to}`)));
|
|
1050
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1051
|
+
let bytes = 0;
|
|
1052
|
+
for (const id of this.reached) if (!remaining.has(id)) {
|
|
1053
|
+
bytes += moduleBytes.get(id) ?? 0;
|
|
1054
|
+
ids.add(id);
|
|
1055
|
+
}
|
|
1056
|
+
return {
|
|
1057
|
+
bytes,
|
|
1058
|
+
ids
|
|
1059
|
+
};
|
|
1060
|
+
}
|
|
1061
|
+
#walk(skip) {
|
|
1062
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1063
|
+
const stack = [...this.roots];
|
|
1064
|
+
for (let id = stack.pop(); id !== void 0; id = stack.pop()) {
|
|
1065
|
+
if (seen.has(id)) continue;
|
|
1066
|
+
seen.add(id);
|
|
1067
|
+
for (const to of this.graph.get(id)?.static ?? []) if (!skip.size || !skip.has(`${id}\n${to}`)) stack.push(to);
|
|
1068
|
+
}
|
|
1069
|
+
return seen;
|
|
1070
|
+
}
|
|
1071
|
+
};
|
|
1072
|
+
/** Finds the import statement in a project file that brings in a module, when exactly one does. */
|
|
1073
|
+
var ImportLines = class {
|
|
1074
|
+
#files = /* @__PURE__ */ new Map();
|
|
1075
|
+
constructor(projectRoot) {
|
|
1076
|
+
this.projectRoot = projectRoot;
|
|
1077
|
+
}
|
|
1078
|
+
find(from, to, model) {
|
|
1079
|
+
const text = this.#read(from);
|
|
1080
|
+
if (!text) return {};
|
|
1081
|
+
const target = model.owner(to);
|
|
1082
|
+
const matches = [];
|
|
1083
|
+
for (const match of text.matchAll(/(?:\bfrom\s*|\bimport\s*)(['"])([^'"\n]+)\1/g)) {
|
|
1084
|
+
const start$1 = Math.max(text.lastIndexOf("import", match.index), text.lastIndexOf("export", match.index));
|
|
1085
|
+
if (start$1 < 0 || /^(?:import|export)\s+type\b/.test(text.slice(start$1))) continue;
|
|
1086
|
+
if (this.#resolves(match[2], from, to, target)) matches.push({
|
|
1087
|
+
start: start$1,
|
|
1088
|
+
end: match.index + match[0].length
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
if (matches.length !== 1) return {};
|
|
1092
|
+
const { start, end } = matches[0];
|
|
1093
|
+
const excerpt = text.slice(start, end).replace(/\s+/g, " ").trim();
|
|
1094
|
+
return {
|
|
1095
|
+
line: text.slice(0, start).split("\n").length,
|
|
1096
|
+
excerpt: excerpt.length > 160 ? `${excerpt.slice(0, 157)}…` : excerpt
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
#resolves(specifier, from, to, target) {
|
|
1100
|
+
if (target.kind === "npm") return specifier === target.name || specifier.startsWith(`${target.name}/`);
|
|
1101
|
+
const bare = to.replace(/\.[cm]?[jt]sx?$/, "");
|
|
1102
|
+
const candidates = path.basename(bare) === "index" ? [bare, path.dirname(bare)] : [bare];
|
|
1103
|
+
if (specifier.startsWith(".")) {
|
|
1104
|
+
const resolved = path.resolve(path.dirname(from), specifier).replace(/\.[cm]?[jt]sx?$/, "");
|
|
1105
|
+
return candidates.includes(resolved);
|
|
1106
|
+
}
|
|
1107
|
+
const alias = specifier.match(/^(?:@|~|#[^/]*|src)\/(.+)$/)?.[1];
|
|
1108
|
+
return alias !== void 0 && candidates.some((candidate) => candidate.endsWith(`${path.sep}${alias.replace(/\.[cm]?[jt]sx?$/, "").split("/").join(path.sep)}`));
|
|
1109
|
+
}
|
|
1110
|
+
#read(file) {
|
|
1111
|
+
if (!this.#files.has(file)) {
|
|
1112
|
+
let text = null;
|
|
1113
|
+
if (file.startsWith(this.projectRoot + path.sep) && /\.[cm]?[jt]sx?$/.test(file)) try {
|
|
1114
|
+
text = readFileSync(file, "utf8");
|
|
1115
|
+
} catch {}
|
|
1116
|
+
this.#files.set(file, text);
|
|
1117
|
+
}
|
|
1118
|
+
return this.#files.get(file) ?? null;
|
|
1119
|
+
}
|
|
1120
|
+
};
|
|
1121
|
+
|
|
1122
|
+
//#endregion
|
|
1123
|
+
//#region src/audit/bundle/build.ts
|
|
1124
|
+
const runFile = promisify(execFile);
|
|
1125
|
+
const BUILD_TIMEOUT_MS = 600 * 1e3;
|
|
1126
|
+
const LOG_TAIL_CHARS = 64 * 1024;
|
|
1127
|
+
const LOCKFILES = [
|
|
1128
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
1129
|
+
["yarn.lock", "yarn"],
|
|
1130
|
+
["bun.lock", "bun"],
|
|
1131
|
+
["bun.lockb", "bun"],
|
|
1132
|
+
["package-lock.json", "npm"]
|
|
1133
|
+
];
|
|
1134
|
+
/** Where React Router writes the client build and the template's visualizer writes its report. */
|
|
1135
|
+
const ASSETS_DIR = "build/client/assets";
|
|
1136
|
+
const VISUALIZER_REPORT = "build/client-bundle-size.html";
|
|
1137
|
+
/** The package manager the project declares (`packageManager`), else the one whose lockfile is nearest. */
|
|
1138
|
+
async function detectPackageManager(projectRoot) {
|
|
1139
|
+
try {
|
|
1140
|
+
const declared = JSON.parse(await readFile(path.join(projectRoot, "package.json"), "utf8")).packageManager;
|
|
1141
|
+
const name = typeof declared === "string" ? declared.split("@")[0] : void 0;
|
|
1142
|
+
if (name && [
|
|
1143
|
+
"npm",
|
|
1144
|
+
"pnpm",
|
|
1145
|
+
"yarn",
|
|
1146
|
+
"bun"
|
|
1147
|
+
].includes(name)) return name;
|
|
1148
|
+
} catch {}
|
|
1149
|
+
for (let dir = projectRoot;; dir = path.dirname(dir)) {
|
|
1150
|
+
const found = LOCKFILES.find(([file]) => existsSync(path.join(dir, file)));
|
|
1151
|
+
if (found) return found[1];
|
|
1152
|
+
if (path.dirname(dir) === dir) return "npm";
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
/** Runs both builds and returns what they produced; failures become notes, never exceptions. */
|
|
1156
|
+
async function buildForAnalysis(projectRoot, runDir$1, progress = () => void 0) {
|
|
1157
|
+
const outDir = path.join(runDir$1, BUNDLE_DIR);
|
|
1158
|
+
await mkdir(outDir, { recursive: true });
|
|
1159
|
+
const packageManager = await detectPackageManager(projectRoot);
|
|
1160
|
+
const outputs = {
|
|
1161
|
+
projectRoot,
|
|
1162
|
+
packageManager,
|
|
1163
|
+
durationMs: {
|
|
1164
|
+
analyze: null,
|
|
1165
|
+
plain: null
|
|
1166
|
+
},
|
|
1167
|
+
assetsDir: null,
|
|
1168
|
+
visualizerHtml: null,
|
|
1169
|
+
visualizer: null,
|
|
1170
|
+
notes: []
|
|
1171
|
+
};
|
|
1172
|
+
let scripts = {};
|
|
1173
|
+
try {
|
|
1174
|
+
scripts = JSON.parse(await readFile(path.join(projectRoot, "package.json"), "utf8")).scripts ?? {};
|
|
1175
|
+
} catch {}
|
|
1176
|
+
if (typeof scripts.build !== "string") {
|
|
1177
|
+
outputs.notes.push("The project's package.json has no build script, so the bundle wasn't built.");
|
|
1178
|
+
return outputs;
|
|
1179
|
+
}
|
|
1180
|
+
const shims = await openShims(outDir);
|
|
1181
|
+
const before = await gitStatus(projectRoot);
|
|
1182
|
+
await progress("Building with the bundle visualizer");
|
|
1183
|
+
const analyze = await runBuild(projectRoot, packageManager, shims, true, path.join(outDir, "build-analyze.log"));
|
|
1184
|
+
outputs.durationMs.analyze = analyze.durationMs;
|
|
1185
|
+
if (analyze.error) outputs.notes.push(`The analyze build failed (${analyze.error}); see ${BUNDLE_DIR}/build-analyze.log.${installHint(analyze, packageManager)} Import suggestions are unavailable.`);
|
|
1186
|
+
else {
|
|
1187
|
+
const report = path.join(projectRoot, VISUALIZER_REPORT);
|
|
1188
|
+
if (await writtenSince(report, analyze.startedAt)) {
|
|
1189
|
+
await copyFile(report, path.join(runDir$1, BUNDLE_VISUALIZER));
|
|
1190
|
+
outputs.visualizerHtml = await readFile(report, "utf8");
|
|
1191
|
+
outputs.visualizer = BUNDLE_VISUALIZER;
|
|
1192
|
+
} else outputs.notes.push(`The build didn't write ${VISUALIZER_REPORT} with BUNDLES_SIZE_ANALYZE set (the template's bundle-visualizer Vite plugin), so import suggestions are unavailable.`);
|
|
1193
|
+
}
|
|
1194
|
+
await progress("Building for production");
|
|
1195
|
+
const plain = await runBuild(projectRoot, packageManager, shims, false, path.join(outDir, "build.log"));
|
|
1196
|
+
outputs.durationMs.plain = plain.durationMs;
|
|
1197
|
+
if (plain.error) outputs.notes.push(`The production build failed (${plain.error}); see ${BUNDLE_DIR}/build.log.${installHint(plain, packageManager)} The bundle's composition is unknown.`);
|
|
1198
|
+
else if (await scriptsWrittenSince(path.join(projectRoot, ASSETS_DIR), plain.startedAt)) outputs.assetsDir = path.join(projectRoot, ASSETS_DIR);
|
|
1199
|
+
else outputs.notes.push(`The production build didn't write ${ASSETS_DIR}, so the bundle's composition is unknown.`);
|
|
1200
|
+
const after = await gitStatus(projectRoot);
|
|
1201
|
+
if (before !== null && after !== null && before !== after) outputs.notes.push("The build changed files outside build/ (git status differs before and after); review them in the project.");
|
|
1202
|
+
return outputs;
|
|
1203
|
+
}
|
|
1204
|
+
/** A module the build can't resolve almost always means node_modules is older than the lockfile. */
|
|
1205
|
+
const installHint = (run, packageManager) => run.missingModule ? ` A module it needs isn't installed: run \`${packageManager} install\` in the project, then audit again.` : "";
|
|
1206
|
+
/**
|
|
1207
|
+
* Runs the project's `build` script once, with the user's environment plus:
|
|
1208
|
+
* - `BUNDLES_SIZE_ANALYZE`: `true` for the module-graph build, unset for the plain one;
|
|
1209
|
+
* - `BUNDLES_SIZE_OPEN`: `false`, so the visualizer doesn't open its report;
|
|
1210
|
+
* - `BUNDLES_SIZE_CHECK`: always unset.
|
|
1211
|
+
*/
|
|
1212
|
+
async function runBuild(projectRoot, packageManager, shims, analyze, logFile) {
|
|
1213
|
+
const env = {
|
|
1214
|
+
...process.env,
|
|
1215
|
+
PATH: [shims, process.env.PATH ?? ""].join(path.delimiter),
|
|
1216
|
+
BUNDLES_SIZE_OPEN: "false"
|
|
1217
|
+
};
|
|
1218
|
+
delete env.BUNDLES_SIZE_CHECK;
|
|
1219
|
+
if (analyze) env.BUNDLES_SIZE_ANALYZE = "true";
|
|
1220
|
+
else delete env.BUNDLES_SIZE_ANALYZE;
|
|
1221
|
+
const startedAt = Date.now();
|
|
1222
|
+
try {
|
|
1223
|
+
const { stdout, stderr } = await runFile(packageManager, ["run", "build"], {
|
|
1224
|
+
cwd: projectRoot,
|
|
1225
|
+
env,
|
|
1226
|
+
encoding: "utf8",
|
|
1227
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
1228
|
+
timeout: BUILD_TIMEOUT_MS,
|
|
1229
|
+
killSignal: "SIGKILL",
|
|
1230
|
+
shell: process.platform === "win32"
|
|
1231
|
+
});
|
|
1232
|
+
await writeLog(logFile, stdout, stderr);
|
|
1233
|
+
return {
|
|
1234
|
+
startedAt,
|
|
1235
|
+
durationMs: Date.now() - startedAt
|
|
1236
|
+
};
|
|
1237
|
+
} catch (error) {
|
|
1238
|
+
const failure = error;
|
|
1239
|
+
await writeLog(logFile, failure.stdout ?? "", failure.stderr ?? "");
|
|
1240
|
+
const cause = errorLine(failure.stderr ?? "") ?? errorLine(failure.stdout ?? "");
|
|
1241
|
+
const reason = failure.killed ? `timed out after ${BUILD_TIMEOUT_MS / 6e4} minutes` : failure.code === "ENOENT" ? `${packageManager} isn't installed or isn't on PATH` : typeof failure.code === "number" ? `exit code ${failure.code}${cause ? `: ${cause}` : ""}` : sanitizeText(failure.message ?? String(error));
|
|
1242
|
+
const missingModule = /Cannot find (?:module|package)|MODULE_NOT_FOUND/.test(`${failure.stderr ?? ""}\n${failure.stdout ?? ""}`);
|
|
1243
|
+
return {
|
|
1244
|
+
startedAt,
|
|
1245
|
+
durationMs: Date.now() - startedAt,
|
|
1246
|
+
error: reason,
|
|
1247
|
+
missingModule
|
|
1248
|
+
};
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
const MAX_CAUSE_CHARS = 200;
|
|
1252
|
+
/** The first line that reads like the build's error (`Error: Cannot find module 'x'`), so the note says why. */
|
|
1253
|
+
function errorLine(output) {
|
|
1254
|
+
const line = output.split("\n").map((item) => item.trim()).find((item) => /^(?:\w*Error|error|ERR_[A-Z_]+)\b/.test(item));
|
|
1255
|
+
if (!line) return void 0;
|
|
1256
|
+
const text = sanitizeText(line);
|
|
1257
|
+
return text.length > MAX_CAUSE_CHARS ? `${text.slice(0, MAX_CAUSE_CHARS - 1)}…` : text;
|
|
1258
|
+
}
|
|
1259
|
+
const tail = (text) => text.length > LOG_TAIL_CHARS ? `…\n${text.slice(-LOG_TAIL_CHARS)}` : text;
|
|
1260
|
+
async function writeLog(file, stdout, stderr) {
|
|
1261
|
+
await writeFile(file, sanitizeText(`${tail(stdout)}\n--- stderr ---\n${tail(stderr)}\n`));
|
|
1262
|
+
}
|
|
1263
|
+
/**
|
|
1264
|
+
* Projects from before `BUNDLES_SIZE_OPEN` always open the visualizer report. For them, no-op `open` and `xdg-open`
|
|
1265
|
+
* first on PATH keep it from launching a browser on macOS; the visualizer runs Linux's and Windows' openers by path.
|
|
1266
|
+
*/
|
|
1267
|
+
async function openShims(outDir) {
|
|
1268
|
+
const dir = path.join(outDir, ".bin");
|
|
1269
|
+
await mkdir(dir, { recursive: true });
|
|
1270
|
+
for (const name of ["open", "xdg-open"]) {
|
|
1271
|
+
const file = path.join(dir, name);
|
|
1272
|
+
await writeFile(file, "#!/bin/sh\nexit 0\n");
|
|
1273
|
+
await chmod(file, 493);
|
|
1274
|
+
}
|
|
1275
|
+
return dir;
|
|
1276
|
+
}
|
|
1277
|
+
async function writtenSince(file, startedAt) {
|
|
1278
|
+
try {
|
|
1279
|
+
return (await stat(file)).mtimeMs >= startedAt - 1e3;
|
|
1280
|
+
} catch {
|
|
1281
|
+
return false;
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
/** A directory's own time changes only when entries come and go, so its scripts are checked instead. */
|
|
1285
|
+
async function scriptsWrittenSince(dir, startedAt) {
|
|
1286
|
+
let names;
|
|
1287
|
+
try {
|
|
1288
|
+
names = (await readdir(dir)).filter((name) => name.endsWith(".js"));
|
|
1289
|
+
} catch {
|
|
1290
|
+
return false;
|
|
1291
|
+
}
|
|
1292
|
+
for (const name of names) if (await writtenSince(path.join(dir, name), startedAt)) return true;
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
async function gitStatus(projectRoot) {
|
|
1296
|
+
try {
|
|
1297
|
+
return (await runFile("git", [
|
|
1298
|
+
"status",
|
|
1299
|
+
"--porcelain",
|
|
1300
|
+
"--",
|
|
1301
|
+
"."
|
|
1302
|
+
], {
|
|
1303
|
+
cwd: projectRoot,
|
|
1304
|
+
encoding: "utf8",
|
|
1305
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
1306
|
+
timeout: 3e4
|
|
1307
|
+
})).stdout;
|
|
1308
|
+
} catch {
|
|
1309
|
+
return null;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
//#endregion
|
|
1314
|
+
//#region src/audit/audit.ts
|
|
1315
|
+
const EVALUATE_RULE_INSTRUCTIONS = path.join(AUDIT_ROOT, "instructions", "evaluate-rule.md");
|
|
1316
|
+
/** Ends the run a failed step was working on with run.error, so the dashboard shows the failure instead of waiting. */
|
|
1317
|
+
async function failRun(step) {
|
|
1318
|
+
const active = {};
|
|
1319
|
+
try {
|
|
1320
|
+
return await step(active);
|
|
1321
|
+
} catch (error) {
|
|
1322
|
+
if (active.run) await emit(active.run.eventsFile, {
|
|
1323
|
+
type: "run.error",
|
|
1324
|
+
message: sanitizeText(error instanceof Error ? error.message : String(error)),
|
|
1325
|
+
...active.run.stageId ? { stageId: active.run.stageId } : {}
|
|
1326
|
+
});
|
|
1327
|
+
throw error;
|
|
1328
|
+
}
|
|
1329
|
+
}
|
|
1330
|
+
const targets = (urls, allowHttp) => urls.map((entry) => {
|
|
1331
|
+
const split = entry.indexOf("=");
|
|
1332
|
+
if (split < 1) throw new Error("--url must use label=https://…");
|
|
1333
|
+
const target = {
|
|
1334
|
+
label: entry.slice(0, split),
|
|
1335
|
+
url: entry.slice(split + 1)
|
|
1336
|
+
};
|
|
1337
|
+
const parsed = new URL(target.url);
|
|
1338
|
+
if (!["https:", "http:"].includes(parsed.protocol)) throw new Error("URL must use http(s)");
|
|
1339
|
+
if (parsed.username || parsed.password) throw new Error("URL credentials are not allowed");
|
|
1340
|
+
if (parsed.protocol === "http:" && !allowHttp) throw new Error("HTTP requires explicit --allow-http");
|
|
1341
|
+
return target;
|
|
1342
|
+
});
|
|
1343
|
+
const archives = (values) => {
|
|
1344
|
+
const entries = values.map((entry) => {
|
|
1345
|
+
const split = entry.indexOf("=");
|
|
1346
|
+
if (split < 1 || !path.isAbsolute(entry.slice(split + 1))) throw new Error("--archive must use label=/absolute/archive");
|
|
1347
|
+
return {
|
|
1348
|
+
label: entry.slice(0, split),
|
|
1349
|
+
value: entry.slice(split + 1)
|
|
1350
|
+
};
|
|
1351
|
+
});
|
|
1352
|
+
assertUniqueLabels(entries);
|
|
1353
|
+
return new Map(entries.map(({ label, value }) => [label, value]));
|
|
1354
|
+
};
|
|
1355
|
+
async function writeJson(file, value) {
|
|
1356
|
+
await writeFile(file, `${JSON.stringify(value, null, 2)}\n`);
|
|
1357
|
+
}
|
|
1358
|
+
const PAGE_LABEL = /^[a-z][a-z0-9-]{0,63}$/;
|
|
1359
|
+
function assertUniqueLabels(items) {
|
|
1360
|
+
const labels = /* @__PURE__ */ new Set();
|
|
1361
|
+
for (const { label } of items) {
|
|
1362
|
+
if (!PAGE_LABEL.test(label)) throw new Error(`Invalid page label: ${label}`);
|
|
1363
|
+
if (labels.has(label)) throw new Error(`Duplicate page label: ${label}`);
|
|
1364
|
+
labels.add(label);
|
|
1365
|
+
}
|
|
1366
|
+
}
|
|
1367
|
+
/** The run's progress event log, unless --events <file> names another. */
|
|
1368
|
+
function eventsFileFor(events, run) {
|
|
1369
|
+
return events === void 0 ? path.join(run, "events.ndjson") : path.resolve(events);
|
|
1370
|
+
}
|
|
1371
|
+
/** Best-effort event emission: progress reporting must never fail the audit. */
|
|
1372
|
+
async function emit(file, event) {
|
|
1373
|
+
try {
|
|
1374
|
+
await appendEvent(file, event);
|
|
1375
|
+
} catch {}
|
|
1376
|
+
}
|
|
1377
|
+
/** Strip Playhouse's animated stderr progress frames (ANSI colors and
|
|
1378
|
+
* `[███░░░] 42% mobile/cold run 1/3` bars) from an error message so the real
|
|
1379
|
+
* failure reads cleanly in the activity log. */
|
|
1380
|
+
function stripProgressNoise(message) {
|
|
1381
|
+
return message.replace(/\x1b\[[0-9;]*m/g, "").replace(/\[[▀-▓\s]*\]/g, "").replace(/\s*\d+%\s*[\w/]*(?:\s+run\s+\d+\/\d+|\s+profiling)?/g, "").replace(/\s+/g, " ").trim();
|
|
1382
|
+
}
|
|
1383
|
+
/** Collects Playhouse evidence for each page and writes the run's prepared.json; returns its path. */
|
|
1384
|
+
function prepare(options) {
|
|
1385
|
+
return failRun(async (active) => {
|
|
1386
|
+
const runs = options.runs ?? 1;
|
|
1387
|
+
const project = await resolveProject(options.project ?? process.cwd());
|
|
1388
|
+
const customCategories = options.categories !== void 0;
|
|
1389
|
+
const catalog = await discoverCatalog(options.categories ?? defaultCategoriesDir());
|
|
1390
|
+
const workspace = path.resolve(options.workspace ?? path.join(project.root, ".site-audit"));
|
|
1391
|
+
const requestedTargets = targets(options.urls ?? [], options.allowHttp === true);
|
|
1392
|
+
assertUniqueLabels(requestedTargets);
|
|
1393
|
+
const requestedArchives = archives(options.archives ?? []);
|
|
1394
|
+
const pages = [];
|
|
1395
|
+
const pageFailures = [];
|
|
1396
|
+
for (const label of requestedArchives.keys()) if (!requestedTargets.some((target) => target.label === label)) throw new Error(`--archive ${label} requires a matching --url ${label}=https://…`);
|
|
1397
|
+
const startedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
1398
|
+
const runId = runIdFor(startedAt);
|
|
1399
|
+
const run = runDir(workspace, runId);
|
|
1400
|
+
await mkdir(path.dirname(run), { recursive: true });
|
|
1401
|
+
await mkdir(run);
|
|
1402
|
+
const preparedPath = path.join(run, "prepared.json");
|
|
1403
|
+
const eventsFile = eventsFileFor(options.events, run);
|
|
1404
|
+
const homeTarget = requestedTargets.find((target) => target.label === "home") ?? requestedTargets[0];
|
|
1405
|
+
await emit(eventsFile, {
|
|
1406
|
+
type: "run.start",
|
|
1407
|
+
runId,
|
|
1408
|
+
startedAt,
|
|
1409
|
+
target: homeTarget ? { url: sanitizeTargetUrl(homeTarget.url) } : void 0,
|
|
1410
|
+
plan: [
|
|
1411
|
+
...requestedTargets.map((target) => ({
|
|
1412
|
+
id: target.label,
|
|
1413
|
+
title: `Lighthouse · ${target.label}`,
|
|
1414
|
+
detail: sanitizeTargetUrl(target.url)
|
|
1415
|
+
})),
|
|
1416
|
+
{
|
|
1417
|
+
id: BUNDLE_STAGE,
|
|
1418
|
+
title: "Bundle analysis",
|
|
1419
|
+
detail: options.build === false ? "Measure the first-party JavaScript each page loaded" : "Build the project and break down the JavaScript each page loaded"
|
|
1420
|
+
},
|
|
1421
|
+
{
|
|
1422
|
+
id: "analyze",
|
|
1423
|
+
title: "AI code analysis",
|
|
1424
|
+
detail: "Evaluate the rule catalog against collected evidence and the repository"
|
|
1425
|
+
},
|
|
1426
|
+
{
|
|
1427
|
+
id: "compile",
|
|
1428
|
+
title: "Compile report",
|
|
1429
|
+
detail: "Validate analysis, score findings, and write the report"
|
|
1430
|
+
}
|
|
1431
|
+
]
|
|
1432
|
+
});
|
|
1433
|
+
active.run = { eventsFile };
|
|
1434
|
+
const browser = requestedTargets.some((target) => !requestedArchives.has(target.label)) ? await ensureBrowser((message) => emit(eventsFile, {
|
|
1435
|
+
type: "log",
|
|
1436
|
+
level: "info",
|
|
1437
|
+
message
|
|
1438
|
+
})) : void 0;
|
|
1439
|
+
const playhouse = {
|
|
1440
|
+
bin: playhouseBin(),
|
|
1441
|
+
...browser ? { browser } : {}
|
|
1442
|
+
};
|
|
1443
|
+
const collectionDir = path.join(run, "playhouse");
|
|
1444
|
+
await mkdir(collectionDir);
|
|
1445
|
+
for (const target of requestedTargets) {
|
|
1446
|
+
const safeUrl = sanitizeTargetUrl(target.url);
|
|
1447
|
+
await emit(eventsFile, {
|
|
1448
|
+
type: "stage.start",
|
|
1449
|
+
stageId: target.label
|
|
1450
|
+
});
|
|
1451
|
+
await emit(eventsFile, {
|
|
1452
|
+
type: "page.start",
|
|
1453
|
+
pageId: target.label,
|
|
1454
|
+
label: target.label,
|
|
1455
|
+
url: safeUrl
|
|
1456
|
+
});
|
|
1457
|
+
await emit(eventsFile, {
|
|
1458
|
+
type: "log",
|
|
1459
|
+
level: "info",
|
|
1460
|
+
message: `Collecting Playhouse evidence for ${target.label}`
|
|
1461
|
+
});
|
|
1462
|
+
try {
|
|
1463
|
+
const archive = requestedArchives.get(target.label);
|
|
1464
|
+
const collection = archive ? await copyArchive(await readExistingPlayhouseArchive(playhouse, target, archive, collectionDir), collectionDir) : await collectWithPlayhouse(playhouse, target, collectionDir, runs);
|
|
1465
|
+
pages.push(collection);
|
|
1466
|
+
const perf = perfFromSummary(await readJsonIfPresent(path.join(collection.archiveDir, "summary.json")));
|
|
1467
|
+
await emit(eventsFile, {
|
|
1468
|
+
type: "page.complete",
|
|
1469
|
+
pageId: target.label,
|
|
1470
|
+
score: perf.score,
|
|
1471
|
+
metrics: perf.metrics
|
|
1472
|
+
});
|
|
1473
|
+
await emit(eventsFile, {
|
|
1474
|
+
type: "stage.complete",
|
|
1475
|
+
stageId: target.label
|
|
1476
|
+
});
|
|
1477
|
+
await emit(eventsFile, {
|
|
1478
|
+
type: "log",
|
|
1479
|
+
level: "ok",
|
|
1480
|
+
message: `${target.label} collected`
|
|
1481
|
+
});
|
|
1482
|
+
} catch (error) {
|
|
1483
|
+
const message = sanitizeText(stripProgressNoise(error instanceof Error ? error.message : String(error)));
|
|
1484
|
+
pageFailures.push({
|
|
1485
|
+
label: target.label,
|
|
1486
|
+
url: safeUrl,
|
|
1487
|
+
error: message
|
|
1488
|
+
});
|
|
1489
|
+
await emit(eventsFile, {
|
|
1490
|
+
type: "page.run",
|
|
1491
|
+
pageId: target.label,
|
|
1492
|
+
runIndex: 1,
|
|
1493
|
+
total: runs,
|
|
1494
|
+
status: "failed"
|
|
1495
|
+
});
|
|
1496
|
+
await emit(eventsFile, {
|
|
1497
|
+
type: "stage.complete",
|
|
1498
|
+
stageId: target.label,
|
|
1499
|
+
summary: "Collection failed"
|
|
1500
|
+
});
|
|
1501
|
+
await emit(eventsFile, {
|
|
1502
|
+
type: "log",
|
|
1503
|
+
level: "warn",
|
|
1504
|
+
message: `${target.label} failed: ${message}`
|
|
1505
|
+
});
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
const expectedPages = options.expectedPages ?? [
|
|
1509
|
+
"home",
|
|
1510
|
+
"plp",
|
|
1511
|
+
"pdp"
|
|
1512
|
+
];
|
|
1513
|
+
const coverage = expectedPages.filter((label) => !pages.some((page) => page.label === label)).map((page) => ({
|
|
1514
|
+
page,
|
|
1515
|
+
status: "missing",
|
|
1516
|
+
note: pageFailures.find((failure) => failure.label === page)?.error ?? `No URL was supplied for expected page ${page}.`
|
|
1517
|
+
}));
|
|
1518
|
+
const bundle = await prepareBundle(project.root, run, pages, options.build !== false, eventsFile);
|
|
1519
|
+
const preparedRuleOutputs = await prepareRuleResults({
|
|
1520
|
+
catalog,
|
|
1521
|
+
pages,
|
|
1522
|
+
workspaceRoot: run,
|
|
1523
|
+
companionRoot: customCategories ? false : void 0,
|
|
1524
|
+
bundle
|
|
1525
|
+
});
|
|
1526
|
+
const outputs = new Map(preparedRuleOutputs.map((output) => [output.reference.ruleId, output]));
|
|
1527
|
+
const allRules = catalog.categories.flatMap((category) => category.rules);
|
|
1528
|
+
await writeJson(preparedPath, {
|
|
1529
|
+
schemaVersion: "1.0",
|
|
1530
|
+
run: {
|
|
1531
|
+
id: runId,
|
|
1532
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1533
|
+
toolVersion: VERSION,
|
|
1534
|
+
...pages[0] ? { playhouseVersion: pages[0].playhouseVersion } : {}
|
|
1535
|
+
},
|
|
1536
|
+
project,
|
|
1537
|
+
instructions: EVALUATE_RULE_INSTRUCTIONS,
|
|
1538
|
+
pages: pages.map((page) => ({
|
|
1539
|
+
label: page.label,
|
|
1540
|
+
url: page.url,
|
|
1541
|
+
archiveDir: path.relative(run, page.archiveDir).split(path.sep).join("/")
|
|
1542
|
+
})),
|
|
1543
|
+
pageFailures,
|
|
1544
|
+
expectedPages,
|
|
1545
|
+
coverage,
|
|
1546
|
+
rules: allRules.filter((rule) => !outputs.get(rule.id)?.result.decision).map((rule) => {
|
|
1547
|
+
const output = outputs.get(rule.id);
|
|
1548
|
+
return {
|
|
1549
|
+
ruleId: rule.id,
|
|
1550
|
+
title: rule.title,
|
|
1551
|
+
file: rule.file,
|
|
1552
|
+
...output ? { data: output.reference.path } : {},
|
|
1553
|
+
output: aiAnalysisPathForRule(rule.id)
|
|
1554
|
+
};
|
|
1555
|
+
}),
|
|
1556
|
+
decidedFindings: allRules.flatMap((rule) => {
|
|
1557
|
+
const decision = outputs.get(rule.id)?.result.decision;
|
|
1558
|
+
return decision?.status === "fail" ? [{
|
|
1559
|
+
ruleId: rule.id,
|
|
1560
|
+
title: rule.title,
|
|
1561
|
+
severity: decision.severity ?? rule.severity,
|
|
1562
|
+
summary: decision.summary
|
|
1563
|
+
}] : [];
|
|
1564
|
+
})
|
|
1565
|
+
});
|
|
1566
|
+
await emit(eventsFile, {
|
|
1567
|
+
type: "stage.start",
|
|
1568
|
+
stageId: "analyze"
|
|
1569
|
+
});
|
|
1570
|
+
await emit(eventsFile, {
|
|
1571
|
+
type: "log",
|
|
1572
|
+
level: "info",
|
|
1573
|
+
message: "Evidence collected; AI analysis in progress"
|
|
1574
|
+
});
|
|
1575
|
+
active.run = void 0;
|
|
1576
|
+
return preparedPath;
|
|
1577
|
+
});
|
|
1578
|
+
}
|
|
1579
|
+
/** Merges script results and ai-analysis/ into the latest run's audit-result.json; returns its path. */
|
|
1580
|
+
function finalize(options) {
|
|
1581
|
+
return failRun(async (active) => {
|
|
1582
|
+
const workspace = await latestRunDir(path.resolve(options.workspace));
|
|
1583
|
+
const eventsFile = eventsFileFor(options.events, workspace);
|
|
1584
|
+
const completed = latestRunEvents(await readEvents(eventsFile)).some((event) => event.type === "run.complete");
|
|
1585
|
+
if (!completed) active.run = {
|
|
1586
|
+
eventsFile,
|
|
1587
|
+
stageId: "analyze"
|
|
1588
|
+
};
|
|
1589
|
+
const prepared = parsePrepared(JSON.parse(await readFile(path.join(workspace, "prepared.json"), "utf8")));
|
|
1590
|
+
assertUniqueLabels(prepared.pages);
|
|
1591
|
+
if (prepared.run.toolVersion !== VERSION) throw new Error(`prepared.json was written by storefront-next-dev ${prepared.run.toolVersion}, but this is ${VERSION}; re-run prepare`);
|
|
1592
|
+
const catalog = await discoverCatalog(options.categories ?? defaultCategoriesDir());
|
|
1593
|
+
const rules = new Map(catalog.categories.flatMap((category) => category.rules.map((rule) => [rule.id, rule])));
|
|
1594
|
+
for (const rule of prepared.rules) if (!rules.has(rule.ruleId)) throw new Error(`prepared.json lists a rule that is not in the catalog: ${rule.ruleId}`);
|
|
1595
|
+
const decided = (await readPreparedRuleResults(workspace, catalog.ruleIds)).filter((output) => output.result.decision);
|
|
1596
|
+
const pages = await Promise.all(prepared.pages.map((page) => readPage(workspace, page, prepared.run.playhouseVersion)));
|
|
1597
|
+
const archiveFiles = new Map(pages.map((page) => [page.label, page.files]));
|
|
1598
|
+
const bundle = await readBundleAnalysis(workspace);
|
|
1599
|
+
const aiEvaluations = await readAiEvaluations(workspace, rules, new Set(prepared.rules.map((rule) => rule.ruleId)), archiveFiles, bundle);
|
|
1600
|
+
const summary = await readAiSummary(workspace, archiveFiles);
|
|
1601
|
+
const emptyCategories = catalog.categories.filter((category) => category.rules.length === 0).map((category) => ({
|
|
1602
|
+
category: category.id,
|
|
1603
|
+
status: "missing",
|
|
1604
|
+
note: "No codified rules in this category."
|
|
1605
|
+
}));
|
|
1606
|
+
const result = validateAuditResult({
|
|
1607
|
+
schemaVersion: "1.0",
|
|
1608
|
+
run: {
|
|
1609
|
+
id: prepared.run.id,
|
|
1610
|
+
generatedAt: prepared.run.generatedAt,
|
|
1611
|
+
toolVersion: prepared.run.toolVersion
|
|
1612
|
+
},
|
|
1613
|
+
project: { name: prepared.project.packageName },
|
|
1614
|
+
pages: pages.map(({ files: _files,...page }) => page),
|
|
1615
|
+
catalog: {
|
|
1616
|
+
categories: catalog.categories.map((category) => ({
|
|
1617
|
+
id: category.id,
|
|
1618
|
+
title: category.title,
|
|
1619
|
+
ruleIds: category.rules.map((rule) => rule.id)
|
|
1620
|
+
})),
|
|
1621
|
+
ruleIds: catalog.ruleIds
|
|
1622
|
+
},
|
|
1623
|
+
evaluations: [...decided.map((output) => deterministicEvaluation(ruleById(rules, output.reference.ruleId), output)), ...aiEvaluations],
|
|
1624
|
+
observations: summary.observations,
|
|
1625
|
+
coverage: [
|
|
1626
|
+
...prepared.coverage,
|
|
1627
|
+
...emptyCategories,
|
|
1628
|
+
...summary.coverage
|
|
1629
|
+
],
|
|
1630
|
+
summary: {
|
|
1631
|
+
guidance: "go",
|
|
1632
|
+
narrative: summary.narrative,
|
|
1633
|
+
highlights: summary.highlights
|
|
1634
|
+
},
|
|
1635
|
+
...bundle ? { bundle } : {}
|
|
1636
|
+
}, catalog, archiveFiles);
|
|
1637
|
+
const resultPath = path.join(workspace, "audit-result.json");
|
|
1638
|
+
await writeJson(resultPath, result);
|
|
1639
|
+
active.run = void 0;
|
|
1640
|
+
if (completed) return resultPath;
|
|
1641
|
+
const findingCount = result.evaluations.filter((evaluation) => evaluation.disposition === "finding").length;
|
|
1642
|
+
await emit(eventsFile, {
|
|
1643
|
+
type: "stage.complete",
|
|
1644
|
+
stageId: "analyze",
|
|
1645
|
+
summary: `${findingCount} finding${findingCount === 1 ? "" : "s"} across ${result.catalog.categories.length} categories`
|
|
1646
|
+
});
|
|
1647
|
+
await emit(eventsFile, {
|
|
1648
|
+
type: "stage.start",
|
|
1649
|
+
stageId: "compile"
|
|
1650
|
+
});
|
|
1651
|
+
for (const evaluation of result.evaluations) {
|
|
1652
|
+
if (evaluation.disposition !== "finding") continue;
|
|
1653
|
+
await emit(eventsFile, {
|
|
1654
|
+
type: "finding",
|
|
1655
|
+
id: evaluation.ruleId,
|
|
1656
|
+
severity: evaluation.severity ?? "info",
|
|
1657
|
+
title: evaluation.title,
|
|
1658
|
+
category: evaluation.ruleId.split("/")[0],
|
|
1659
|
+
source: "analysis",
|
|
1660
|
+
ruleId: evaluation.ruleId,
|
|
1661
|
+
preliminary: false
|
|
1662
|
+
});
|
|
1663
|
+
}
|
|
1664
|
+
const scores = result.pages.map((page) => ({
|
|
1665
|
+
label: page.label,
|
|
1666
|
+
score: perfFromSummary(page.playhouse.artifacts["summary.json"]).score
|
|
1667
|
+
})).filter((entry) => typeof entry.score === "number");
|
|
1668
|
+
await emit(eventsFile, {
|
|
1669
|
+
type: "stage.complete",
|
|
1670
|
+
stageId: "compile",
|
|
1671
|
+
summary: "Report compiled"
|
|
1672
|
+
});
|
|
1673
|
+
await emit(eventsFile, {
|
|
1674
|
+
type: "run.complete",
|
|
1675
|
+
guidance: result.summary?.guidance,
|
|
1676
|
+
scores
|
|
1677
|
+
});
|
|
1678
|
+
return resultPath;
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
const BUNDLE_STAGE = "bundle";
|
|
1682
|
+
/**
|
|
1683
|
+
* Builds the project (after collection, so the build never competes with a measurement) and writes
|
|
1684
|
+
* bundle/analysis.json. Never fails the audit: whatever goes wrong becomes a note in the analysis.
|
|
1685
|
+
*/
|
|
1686
|
+
async function prepareBundle(projectRoot, run, pages, build, eventsFile) {
|
|
1687
|
+
await emit(eventsFile, {
|
|
1688
|
+
type: "stage.start",
|
|
1689
|
+
stageId: BUNDLE_STAGE
|
|
1690
|
+
});
|
|
1691
|
+
const log = (message) => emit(eventsFile, {
|
|
1692
|
+
type: "log",
|
|
1693
|
+
level: "info",
|
|
1694
|
+
message
|
|
1695
|
+
});
|
|
1696
|
+
let analysis;
|
|
1697
|
+
try {
|
|
1698
|
+
const outputs = build ? await buildForAnalysis(projectRoot, run, log) : {
|
|
1699
|
+
projectRoot,
|
|
1700
|
+
packageManager: "",
|
|
1701
|
+
durationMs: {
|
|
1702
|
+
analyze: null,
|
|
1703
|
+
plain: null
|
|
1704
|
+
},
|
|
1705
|
+
assetsDir: null,
|
|
1706
|
+
visualizerHtml: null,
|
|
1707
|
+
visualizer: null,
|
|
1708
|
+
notes: ["The project wasn't built (--no-build), so only what the browser loaded is reported."]
|
|
1709
|
+
};
|
|
1710
|
+
await log("Analyzing the bundle");
|
|
1711
|
+
analysis = bundleAnalysisSchema.parse(await analyzeBundle(outputs, pages, new PlayhouseArtifactReader(pages)));
|
|
1712
|
+
} catch (error) {
|
|
1713
|
+
analysis = {
|
|
1714
|
+
schemaVersion: BUNDLE_SCHEMA_VERSION,
|
|
1715
|
+
status: "unavailable",
|
|
1716
|
+
notes: [sanitizeText(`The bundle analysis failed: ${error instanceof Error ? error.message : String(error)}`)],
|
|
1717
|
+
build: null,
|
|
1718
|
+
pages: [],
|
|
1719
|
+
skipped: []
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
await mkdir(path.join(run, BUNDLE_DIR), { recursive: true });
|
|
1723
|
+
await writeJson(path.join(run, BUNDLE_ANALYSIS), analysis);
|
|
1724
|
+
for (const note of analysis.notes) await emit(eventsFile, {
|
|
1725
|
+
type: "log",
|
|
1726
|
+
level: "warn",
|
|
1727
|
+
message: note
|
|
1728
|
+
});
|
|
1729
|
+
const measured = `${analysis.pages.length} page${analysis.pages.length === 1 ? "" : "s"}`;
|
|
1730
|
+
await emit(eventsFile, {
|
|
1731
|
+
type: "stage.complete",
|
|
1732
|
+
stageId: BUNDLE_STAGE,
|
|
1733
|
+
summary: analysis.status === "complete" ? `${measured} analyzed` : analysis.status === "partial" ? `${measured} analyzed (partial)` : `${measured} measured, no build`
|
|
1734
|
+
});
|
|
1735
|
+
return analysis;
|
|
1736
|
+
}
|
|
1737
|
+
function sanitizeTargetUrl(value) {
|
|
1738
|
+
const url = new URL(value);
|
|
1739
|
+
url.username = "";
|
|
1740
|
+
url.password = "";
|
|
1741
|
+
for (const key of [...url.searchParams.keys()]) url.searchParams.set(key, "[REDACTED]");
|
|
1742
|
+
url.hash = "";
|
|
1743
|
+
return url.toString();
|
|
1744
|
+
}
|
|
1745
|
+
function ruleById(rules, ruleId) {
|
|
1746
|
+
const rule = rules.get(ruleId);
|
|
1747
|
+
if (!rule) throw new Error(`Unknown rule id: ${ruleId}`);
|
|
1748
|
+
return rule;
|
|
1749
|
+
}
|
|
1750
|
+
function deterministicEvaluation(rule, output) {
|
|
1751
|
+
const { decision } = output.result;
|
|
1752
|
+
if (!decision) throw new Error(`${rule.id} has no decision from its script`);
|
|
1753
|
+
const remediation = decision.status === "fail" ? section(rule.instructions, "Remediation") : void 0;
|
|
1754
|
+
return {
|
|
1755
|
+
ruleId: rule.id,
|
|
1756
|
+
disposition: decision.status === "fail" ? "finding" : decision.status,
|
|
1757
|
+
title: rule.title,
|
|
1758
|
+
description: section(rule.instructions, "Intent"),
|
|
1759
|
+
...decision.status === "fail" ? { severity: decision.severity ?? rule.severity } : {},
|
|
1760
|
+
summary: decision.summary,
|
|
1761
|
+
...remediation ? { remediation } : {},
|
|
1762
|
+
evidence: decision.evidence,
|
|
1763
|
+
method: "script"
|
|
1764
|
+
};
|
|
1765
|
+
}
|
|
1766
|
+
/** The body of a `## <name>` section in a rule's markdown. */
|
|
1767
|
+
function section(markdown, name) {
|
|
1768
|
+
return new RegExp(`^## ${name}\\s*$([\\s\\S]*?)(?=^## |(?![\\s\\S]))`, "m").exec(markdown)?.[1]?.trim() || void 0;
|
|
1769
|
+
}
|
|
1770
|
+
async function exists(file) {
|
|
1771
|
+
try {
|
|
1772
|
+
await access(file);
|
|
1773
|
+
return true;
|
|
1774
|
+
} catch (error) {
|
|
1775
|
+
if (error.code === "ENOENT") return false;
|
|
1776
|
+
throw error;
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
async function readJsonIfPresent(file) {
|
|
1780
|
+
return await exists(file) ? JSON.parse(await readFile(file, "utf8")) : void 0;
|
|
1781
|
+
}
|
|
1782
|
+
/**
|
|
1783
|
+
* A reused `--archive` is copied into the run, which then owns it like a collected one.
|
|
1784
|
+
* Symlinks inside it are skipped, so the archive can't pull files from outside it into the run.
|
|
1785
|
+
*/
|
|
1786
|
+
async function copyArchive(collection, collectionDir) {
|
|
1787
|
+
const archiveDir = path.join(collectionDir, collection.label);
|
|
1788
|
+
await cp(collection.archiveDir, archiveDir, {
|
|
1789
|
+
recursive: true,
|
|
1790
|
+
filter: async (source) => source === collection.archiveDir || !(await lstat(source)).isSymbolicLink()
|
|
1791
|
+
});
|
|
1792
|
+
return {
|
|
1793
|
+
...collection,
|
|
1794
|
+
archiveDir
|
|
1795
|
+
};
|
|
1796
|
+
}
|
|
1797
|
+
/** Artifacts embedded in audit-result.json for the dashboard. Everything else stays in the archive. */
|
|
1798
|
+
const DASHBOARD_ARTIFACTS = [
|
|
1799
|
+
"summary.json",
|
|
1800
|
+
"opportunities.json",
|
|
1801
|
+
"summary-lcp.json",
|
|
1802
|
+
"network-mobile-cold.json"
|
|
1803
|
+
];
|
|
1804
|
+
async function readPage(workspace, page, playhouseVersion = "unknown") {
|
|
1805
|
+
const archiveDir = path.resolve(workspace, page.archiveDir);
|
|
1806
|
+
const files = new Set(await listArchiveFiles(archiveDir));
|
|
1807
|
+
const manifest = z.object({ lighthouseVersion: z.string() }).passthrough().parse(JSON.parse(await readFile(path.join(archiveDir, "manifest.json"), "utf8")));
|
|
1808
|
+
const artifacts = {};
|
|
1809
|
+
for (const name of DASHBOARD_ARTIFACTS) if (files.has(name)) artifacts[name] = JSON.parse(await readFile(path.join(archiveDir, name), "utf8"));
|
|
1810
|
+
if (files.has(LIGHTHOUSE_REPORT)) artifacts[LIGHTHOUSE_REPORT] = { available: true };
|
|
1811
|
+
return {
|
|
1812
|
+
label: page.label,
|
|
1813
|
+
url: page.url,
|
|
1814
|
+
files,
|
|
1815
|
+
playhouse: {
|
|
1816
|
+
archiveDir,
|
|
1817
|
+
version: playhouseVersion,
|
|
1818
|
+
lighthouseVersion: manifest.lighthouseVersion,
|
|
1819
|
+
artifacts
|
|
1820
|
+
}
|
|
1821
|
+
};
|
|
1822
|
+
}
|
|
1823
|
+
/** Run `check` and prefix any error with the workspace file it concerns, so the agent knows what to fix. */
|
|
1824
|
+
async function inFile(file, check) {
|
|
1825
|
+
try {
|
|
1826
|
+
return await check();
|
|
1827
|
+
} catch (error) {
|
|
1828
|
+
throw new Error(`${file}: ${error instanceof z.ZodError ? z.prettifyError(error) : error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
/** The run's bundle/analysis.json, when prepare wrote one. */
|
|
1832
|
+
async function readBundleAnalysis(workspace) {
|
|
1833
|
+
const file = path.join(workspace, BUNDLE_ANALYSIS);
|
|
1834
|
+
if (!await exists(file)) return void 0;
|
|
1835
|
+
return inFile(BUNDLE_ANALYSIS, async () => bundleAnalysisSchema.parse(JSON.parse(await readFile(file, "utf8"))));
|
|
1836
|
+
}
|
|
1837
|
+
async function readAiEvaluations(workspace, rules, aiRuleIds, archiveFiles, bundle) {
|
|
1838
|
+
const root = path.join(workspace, "ai-analysis", "categories");
|
|
1839
|
+
if (!await exists(root)) return [];
|
|
1840
|
+
const evaluations = [];
|
|
1841
|
+
for (const entry of await readdir(root, {
|
|
1842
|
+
recursive: true,
|
|
1843
|
+
withFileTypes: true
|
|
1844
|
+
})) {
|
|
1845
|
+
if (!entry.isFile()) continue;
|
|
1846
|
+
const file = path.relative(workspace, path.join(entry.parentPath, entry.name)).split(path.sep).join("/");
|
|
1847
|
+
evaluations.push(await inFile(file, async () => {
|
|
1848
|
+
const match = /^ai-analysis\/categories\/([a-z0-9-]+)\/rules\/([a-z0-9-]+)\.json$/.exec(file);
|
|
1849
|
+
if (!match) throw new Error("unexpected file; AI evaluations go in ai-analysis/categories/<category>/rules/<rule>.json");
|
|
1850
|
+
const ruleId = `${match[1]}/${match[2]}`;
|
|
1851
|
+
if (!aiRuleIds.has(ruleId)) throw new Error(rules.has(ruleId) ? `${ruleId} was decided by its script; delete this file` : `${ruleId} is not a rule in prepared.json`);
|
|
1852
|
+
const raw = z.object({ ruleId: z.string() }).passthrough().parse(JSON.parse(await readFile(path.join(workspace, file), "utf8")));
|
|
1853
|
+
if (raw.ruleId !== ruleId) throw new Error(`ruleId ${raw.ruleId} does not match the file path (${ruleId})`);
|
|
1854
|
+
const rule = ruleById(rules, ruleId);
|
|
1855
|
+
const evaluation = evaluationSchema.parse({
|
|
1856
|
+
...raw,
|
|
1857
|
+
title: rule.title,
|
|
1858
|
+
description: section(rule.instructions, "Intent"),
|
|
1859
|
+
method: "ai"
|
|
1860
|
+
});
|
|
1861
|
+
if (evaluation.disposition === "pass" && evaluation.evidence.length === 0) throw new Error(`${ruleId} pass requires evidence of what was checked`);
|
|
1862
|
+
for (const evidence of evaluation.evidence) validateEvidence(evidence, archiveFiles);
|
|
1863
|
+
validateWaterfall(evaluation, new Set(archiveFiles.keys()));
|
|
1864
|
+
if (evaluation.recommendations && !bundle) throw new Error(`recommendations need the run's ${BUNDLE_ANALYSIS}, which prepare didn't write`);
|
|
1865
|
+
validateRecommendations(evaluation, new Set(archiveFiles.keys()), bundle);
|
|
1866
|
+
return evaluation;
|
|
1867
|
+
}));
|
|
1868
|
+
}
|
|
1869
|
+
return evaluations;
|
|
1870
|
+
}
|
|
1871
|
+
const aiSummarySchema = z.object({
|
|
1872
|
+
narrative: z.string(),
|
|
1873
|
+
highlights: z.array(z.string().min(1)).max(6).optional().default([]),
|
|
1874
|
+
observations: auditResultSchema.shape.observations,
|
|
1875
|
+
coverage: auditResultSchema.shape.coverage.optional().default([])
|
|
1876
|
+
});
|
|
1877
|
+
async function readAiSummary(workspace, archiveFiles) {
|
|
1878
|
+
const file = "ai-analysis/summary.json";
|
|
1879
|
+
if (!await exists(path.join(workspace, file))) return {
|
|
1880
|
+
narrative: "",
|
|
1881
|
+
highlights: [],
|
|
1882
|
+
observations: [],
|
|
1883
|
+
coverage: []
|
|
1884
|
+
};
|
|
1885
|
+
return inFile(file, async () => {
|
|
1886
|
+
const { narrative, highlights, observations, coverage } = aiSummarySchema.parse(JSON.parse(await readFile(path.join(workspace, file), "utf8")));
|
|
1887
|
+
for (const observation of observations) for (const evidence of observation.evidence) validateEvidence(evidence, archiveFiles);
|
|
1888
|
+
return {
|
|
1889
|
+
narrative,
|
|
1890
|
+
highlights,
|
|
1891
|
+
observations,
|
|
1892
|
+
coverage
|
|
1893
|
+
};
|
|
1894
|
+
});
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
//#endregion
|
|
1898
|
+
export { finalize as n, prepare as r, eventsFileFor as t };
|