|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * @ai-context 首屏 JS 预算守卫 —— 批 2 包体治理把「达标」变成机器判据的唯一工具。 |
| 4 | + * |
| 5 | + * Why:规格 §10 的验收是「首屏 gzip 降到达标**或**给出瓶颈清单」,而「达标」「首屏」原本都没有机器 |
| 6 | + * 口径 —— 没有机器判据时,「达标」只是形容词。预算数字取自**既有标准**、非本脚本发明: |
| 7 | + * `docs/standards/performance.md:28`「页面包大小 (JS) | **< 200KB (gzip)** | Bundle 分析」。 |
| 8 | + * |
| 9 | + * 口径(控制方已批准,勿自行改): |
| 10 | + * 1. 首屏 chunk 集合 = `app/dist/index.html` 里 `<script type="module">` 指向的入口 chunk, |
| 11 | + * **加上从入口出发只沿静态 ESM `import` / `export … from` 可达的全部 `.js` chunk**; |
| 12 | + * 仅经 `import(…)` **动态**可达的 chunk 不计(动态边是断点)。 |
| 13 | + * 2. **只算 `.js`**:CSS / 字体资产不进这 200 kB,但**必须一并打印**(防它们借「不计入」失控)。 |
| 14 | + * 3. gzip = `zlib.gzipSync(buf, { level: 6 })`;kB = **十进制 ÷1000**(vite 自己打印的口径,不是 KiB ÷1024)。 |
| 15 | + * 冻结校准点:今天单一 chunk 构建下入口 chunk = **654,722 B = 654.72 kB**,与 vite 打印值逐字相等 |
| 16 | + * (计划者 / Task 1 / 控制方三方独立复现)。**若本脚本与此值不符,先怀疑脚本,绝不改数字。** |
| 17 | + * |
| 18 | + * 仪器陷阱(本批实测,勿改回): |
| 19 | + * - 静态边在产物里长 `from"./x-abc.js"`:**引号必须紧跟** `from` / `import`,故 `import(` 天然不匹配; |
| 20 | + * 另先整段剥掉 `import("…")` 形态再扫,双保险(自检含「仅动态可达必须不计」的负样本夹具)。 |
| 21 | + * - chunk basename 必须用 `[^"'/\\]+\.js`:产物名含 `-` 与 `.`,收窄成 `\w+` 会一个 chunk 都扫不到。 |
| 22 | + * - 扫描是正则级、不做 AST:注释/字符串里恰好出现 `from"./x.js"` 会误判(本仓产物实测未触发)。 |
| 23 | + * - `modulepreload` 交叉核对只作参考、不影响退出码(vite 对动态依赖的预载发生在运行时)。 |
| 24 | + * 边界:vite 的 chunk 一律**平铺**在 `dist/assets/` 下(本脚本按 basename 解析);不解析 sourcemap、 |
| 25 | + * 不解析 CSS 里的 `url()`、不认带 query 的说明符 —— 三者在本仓产物中都不存在。 |
| 26 | + * |
| 27 | + * 副作用:默认执行 `cd app && npm run build`(写 `app/dist/`);`--no-build` 只读既有产物。 |
| 28 | + * `--self-test` 在 `os.tmpdir()` 下建夹具并在结束时删除,**不碰真实 `app/dist`**。 |
| 29 | + * 用法:node scripts/check-bundle-budget.mjs [--no-build] [--budget <kB>] [--json] [--self-test] [--dist <目录>] |
| 30 | + * `--dist` 供自检夹具 / 备用产物目录使用(默认 `app/dist`),与构建互斥、须配 `--no-build`。 |
| 31 | + * `--json` 与默认构建同用会把 vite 输出混进 stdout;机器消费请配 `--no-build`。 |
| 32 | + * 退出码:0 = 达标 · 1 = 超预算 · 2 = 构建失败 / 产物缺失 / 自检失败。 |
| 33 | + */ |
| 34 | +import { readFileSync, readdirSync, existsSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; |
| 35 | +import { join, resolve, dirname, relative } from "node:path"; |
| 36 | +import { fileURLToPath } from "node:url"; |
| 37 | +import { gzipSync } from "node:zlib"; |
| 38 | +import { spawnSync } from "node:child_process"; |
| 39 | +import { tmpdir } from "node:os"; |
| 40 | + |
| 41 | +const SELF = fileURLToPath(import.meta.url); |
| 42 | +const ROOT = resolve(dirname(SELF), ".."); |
| 43 | +const APP = join(ROOT, "app"); |
| 44 | +const argv = process.argv.slice(2); |
| 45 | +const has = (f) => argv.includes(f); |
| 46 | +const opt = (f, dflt) => (argv.indexOf(f) >= 0 && argv[argv.indexOf(f) + 1] !== undefined ? argv[argv.indexOf(f) + 1] : dflt); |
| 47 | +const NO_BUILD = has("--no-build"); |
| 48 | +const AS_JSON = has("--json"); |
| 49 | +const SELF_TEST = has("--self-test"); |
| 50 | +const DIST = resolve(opt("--dist", join(APP, "dist"))); |
| 51 | +const BUDGET_KB = Number(opt("--budget", "200")); |
| 52 | +const BUDGET_SOURCE = "docs/standards/performance.md:28"; |
| 53 | + |
| 54 | +const rel = (p) => relative(ROOT, p).split("\\").join("/"); |
| 55 | +/** 全部失败路径的统一出口:**必须** exit 2(1 只留给「超预算」),并给出可执行的下一条命令。 */ |
| 56 | +function fail(msg) { |
| 57 | + console.error(`❌ 首屏预算守卫:${msg}`); |
| 58 | + process.exit(2); |
| 59 | +} |
| 60 | +const fmtB = (n) => String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ","); |
| 61 | +const kB = (n) => (Math.round(n / 10) / 100).toFixed(2); // 半进位:与 vite 打印口径一致(11745 B:toFixed 给 11.74、vite 给 11.75) |
| 62 | +const gzBytes = (buf) => gzipSync(buf, { level: 6 }).length; |
| 63 | + |
| 64 | +/** 动态形态先整段剥掉;静态形态要求引号紧跟 `from` / `import`(产物里就是 `from"./x.js"`)。 */ |
| 65 | +const RE_DYNAMIC = /import\s*\(\s*["'][^"']+["']\s*\)/g; |
| 66 | +const RE_STATIC = [/\bfrom\s*["']\.\/([^"'/\\]+\.js)["']/g, /\bimport\s*["']\.\/([^"'/\\]+\.js)["']/g]; |
| 67 | + |
| 68 | +/** 一段 chunk 代码里的静态 ESM 依赖(basename 集合);`export … from` 与裸 `import` 都算静态边。 */ |
| 69 | +function staticDeps(code) { |
| 70 | + const text = code.replace(RE_DYNAMIC, ""); |
| 71 | + const out = new Set(); |
| 72 | + for (const re of RE_STATIC) { |
| 73 | + re.lastIndex = 0; |
| 74 | + let m; |
| 75 | + while ((m = re.exec(text)) !== null) out.add(m[1]); |
| 76 | + } |
| 77 | + return out; |
| 78 | +} |
| 79 | + |
| 80 | +/** 从 index.html 取入口 chunk 的 basename;解析不到即产物问题 ⇒ exit 2(属性顺序无关)。 */ |
| 81 | +function entryChunkName(dist) { |
| 82 | + const htmlPath = join(dist, "index.html"); |
| 83 | + if (!existsSync(htmlPath) || !statSync(htmlPath).isFile()) { |
| 84 | + fail(`找不到 ${rel(htmlPath)} —— 产物缺失。先跑「cd app; npm run build」,或去掉 --no-build 让本脚本代跑。`); |
| 85 | + } |
| 86 | + const html = readFileSync(htmlPath, "utf8"); |
| 87 | + for (const tag of html.match(/<script\b[^>]*>/gi) ?? []) { |
| 88 | + if (!/\btype\s*=\s*["']module["']/i.test(tag)) continue; |
| 89 | + const m = /\bsrc\s*=\s*["']([^"']+)["']/i.exec(tag); |
| 90 | + const base = m ? m[1].split(/[?#]/)[0].split("/").pop() : ""; |
| 91 | + if (base && base.endsWith(".js")) return base; |
| 92 | + } |
| 93 | + fail(`${rel(htmlPath)} 里没有带 src 的 <script type="module"> —— 空/损坏的 dist 走这条,不是「零字节首屏」。`); |
| 94 | +} |
| 95 | + |
| 96 | +/** 首屏闭包:从入口 BFS,**只沿静态边**;动态可达的 chunk 不会进来。返回 Map<basename,{bytes,gzip}>。 */ |
| 97 | +function firstScreenChunks(dist) { |
| 98 | + const entry = entryChunkName(dist); |
| 99 | + const assets = join(dist, "assets"); |
| 100 | + if (!existsSync(assets) || !statSync(assets).isDirectory()) fail(`找不到 ${rel(assets)} 目录 —— 产物缺失。`); |
| 101 | + const eager = new Map(); |
| 102 | + const queue = [entry]; |
| 103 | + while (queue.length) { |
| 104 | + const name = queue.shift(); |
| 105 | + if (eager.has(name)) continue; |
| 106 | + const p = join(assets, name); |
| 107 | + if (!existsSync(p) || !statSync(p).isFile()) fail(`首屏 chunk 静态引用了 ${name},但 ${rel(p)} 不存在 —— 产物残缺。`); |
| 108 | + const bytes = readFileSync(p); |
| 109 | + eager.set(name, { bytes: bytes.length, gzip: gzBytes(bytes) }); |
| 110 | + for (const dep of staticDeps(bytes.toString("utf8"))) if (!eager.has(dep)) queue.push(dep); |
| 111 | + } |
| 112 | + return eager; |
| 113 | +} |
| 114 | + |
| 115 | +/** 产物资产清单(vite 把 chunk / CSS / 字体平铺在 dist/assets 下)。 */ |
| 116 | +function listAssets(dist) { |
| 117 | + const assets = join(dist, "assets"); |
| 118 | + if (!existsSync(assets) || !statSync(assets).isDirectory()) return []; |
| 119 | + return readdirSync(assets) |
| 120 | + .map((name) => join(assets, name)) |
| 121 | + .filter((p) => statSync(p).isFile()) |
| 122 | + .map((p) => ({ name: p.slice(assets.length + 1), path: p, bytes: statSync(p).size })); |
| 123 | +} |
| 124 | + |
| 125 | +/** 唯一的度量实现(报表与 JSON 共用):量一次产物,返回全部读数。 */ |
| 126 | +function measure(dist) { |
| 127 | + const assets = listAssets(dist); |
| 128 | + const eager = firstScreenChunks(dist); |
| 129 | + const gzFile = (p) => gzBytes(readFileSync(p)); |
| 130 | + const shaped = (a) => { const g = gzFile(a.path); return { name: a.name, bytes: a.bytes, gzipBytes: g, gzipKb: Number(kB(g)) }; }; |
| 131 | + const htmlPath = join(dist, "index.html"); |
| 132 | + return { |
| 133 | + dist, |
| 134 | + eager, |
| 135 | + eagerBytes: [...eager.values()].reduce((a, c) => a + c.gzip, 0), |
| 136 | + rawBytes: [...eager.values()].reduce((a, c) => a + c.bytes, 0), |
| 137 | + lazy: assets.filter((a) => a.name.endsWith(".js") && !eager.has(a.name)).map(shaped), |
| 138 | + excluded: assets.filter((a) => !a.name.endsWith(".js")).map(shaped), |
| 139 | + htmlBytes: existsSync(htmlPath) ? statSync(htmlPath).size : 0, |
| 140 | + htmlGzip: existsSync(htmlPath) ? gzFile(htmlPath) : 0, |
| 141 | + }; |
| 142 | +} |
| 143 | + |
| 144 | +/** 参考项:HTML 用 modulepreload 声明但静态闭包没覆盖的 .js —— 值得人看一眼,不影响判定。 */ |
| 145 | +function preloadUncovered(dist, eager) { |
| 146 | + const html = readFileSync(join(dist, "index.html"), "utf8"); |
| 147 | + const base = (tag) => { const s = /\bhref\s*=\s*["']([^"']+)["']/i.exec(tag); return s ? s[1].split(/[?#]/)[0].split("/").pop() : ""; }; |
| 148 | + const tags = [...html.matchAll(/<link\b[^>]*\brel\s*=\s*["']modulepreload["'][^>]*>/gi)].map((m) => base(m[0])); |
| 149 | + return [...new Set(tags.filter((n) => n.endsWith(".js") && !eager.has(n)))]; |
| 150 | +} |
| 151 | + |
| 152 | +function printHuman(r, pass) { |
| 153 | + console.log(`首屏预算守卫 · 产物 ${rel(r.dist)}`); |
| 154 | + console.log(` 单位:kB = 字节 ÷ 1000(十进制,vite 口径;勿与 ÷1024 的 KiB 混用,混用会产生假 Δ)· gzip = zlib level 6`); |
| 155 | + console.log(` 口径:module 入口 + 只沿静态 ESM import 可达的 .js,动态 import 不计 · 预算 ${BUDGET_KB} kB gzip(来源 ${BUDGET_SOURCE})`); |
| 156 | + console.log(`首屏 chunk(${r.eager.size} 个):`); |
| 157 | + for (const [name, c] of r.eager) { |
| 158 | + console.log(` ${name.padEnd(34)} ${fmtB(c.bytes).padStart(10)} B → gzip ${fmtB(c.gzip).padStart(9)} B = ${kB(c.gzip).padStart(8)} kB`); |
| 159 | + } |
| 160 | + console.log(` 首屏 JS 合计:原始 ${fmtB(r.rawBytes)} B · gzip ${fmtB(r.eagerBytes)} B = ${kB(r.eagerBytes)} kB`); |
| 161 | + console.log(` ⇒ ${pass ? "✅ 达标" : "❌ 超标"}:${pass ? "余量" : "超出"} ${kB(Math.abs(BUDGET_KB * 1000 - r.eagerBytes))} kB${pass ? "" : `(${(r.eagerBytes / 1000 / BUDGET_KB).toFixed(2)}× 预算)`}`); |
| 162 | + const lazyBytes = r.lazy.reduce((a, c) => a + c.gzipBytes, 0); |
| 163 | + console.log(`懒加载 chunk(仅动态可达,不计入预算):${r.lazy.length} 个 · gzip ${fmtB(lazyBytes)} B = ${kB(lazyBytes)} kB`); |
| 164 | + console.log(`不计入预算但须报告(CSS / 字体):index.html ${fmtB(r.htmlBytes)} B = ${kB(r.htmlBytes)} kB · gzip ${kB(r.htmlGzip)} kB`); |
| 165 | + const byExt = new Map(); |
| 166 | + for (const a of r.excluded) { |
| 167 | + const ext = (/\.[a-z0-9]+$/i.exec(a.name) ?? [".(无扩展名)"])[0].toLowerCase(); |
| 168 | + const cur = byExt.get(ext) ?? { n: 0, bytes: 0, gzip: 0 }; |
| 169 | + byExt.set(ext, { n: cur.n + 1, bytes: cur.bytes + a.bytes, gzip: cur.gzip + a.gzipBytes }); |
| 170 | + } |
| 171 | + for (const [ext, v] of [...byExt].sort()) { |
| 172 | + console.log(` ${ext.padEnd(8)} ${String(v.n).padStart(3)} 个 ${fmtB(v.bytes).padStart(10)} B = ${kB(v.bytes).padStart(8)} kB · gzip ${kB(v.gzip).padStart(7)} kB`); |
| 173 | + } |
| 174 | +} |
| 175 | + |
| 176 | +const jsonOf = (r, pass) => ({ |
| 177 | + dist: rel(r.dist), |
| 178 | + budgetKb: BUDGET_KB, |
| 179 | + budgetSource: BUDGET_SOURCE, |
| 180 | + unit: "kB = bytes / 1000(十进制,vite 口径)· gzip = zlib.gzipSync level 6 · 只算 .js", |
| 181 | + firstScreen: { |
| 182 | + count: r.eager.size, |
| 183 | + chunks: [...r.eager].map(([name, c]) => ({ name, bytes: c.bytes, gzipBytes: c.gzip, gzipKb: Number(kB(c.gzip)) })), |
| 184 | + totalBytes: r.eagerBytes, |
| 185 | + totalKb: Number(kB(r.eagerBytes)), |
| 186 | + }, |
| 187 | + lazy: { count: r.lazy.length, chunks: r.lazy, totalBytes: r.lazy.reduce((a, c) => a + c.gzipBytes, 0) }, |
| 188 | + excludedFromBudget: { indexHtml: { bytes: r.htmlBytes, gzipBytes: r.htmlGzip }, assets: r.excluded }, |
| 189 | + pass, |
| 190 | +}); |
| 191 | + |
| 192 | +/** 报表 + 退出码:0 = 达标 · 1 = 超预算。超预算时把差额与倍数一并说清(可执行、不含糊)。 */ |
| 193 | +function finish(r) { |
| 194 | + const pass = r.eagerBytes / 1000 < BUDGET_KB; |
| 195 | + if (AS_JSON) { |
| 196 | + console.log(JSON.stringify(jsonOf(r, pass), null, 2)); |
| 197 | + } else { |
| 198 | + printHuman(r, pass); |
| 199 | + const uncovered = preloadUncovered(r.dist, r.eager); |
| 200 | + if (uncovered.length) console.log(`ℹ️ modulepreload 声明但静态闭包未覆盖 ${uncovered.length} 个:${uncovered.join(", ")}(参考项,不影响判定)`); |
| 201 | + } |
| 202 | + if (!pass) { |
| 203 | + console.error( |
| 204 | + `❌ 首屏 JS gzip ${fmtB(r.eagerBytes)} B = ${kB(r.eagerBytes)} kB ≥ 预算 ${BUDGET_KB} kB` + |
| 205 | + `(${(r.eagerBytes / 1000 / BUDGET_KB).toFixed(2)}×)—— 退出码 1。降首屏只能靠 import() 切断静态边;manualChunks 只切文件、一字节不降。`, |
| 206 | + ); |
| 207 | + } |
| 208 | + process.exit(pass ? 0 : 1); |
| 209 | +} |
| 210 | + |
| 211 | +/** |
| 212 | + * 仪器自检:夹具证明「静态边真的在走」(含传递边与 `export … from`)、「动态边真的被切断」 |
| 213 | + * (含只经动态 chunk 传递可达者)、「只算 .js」、「modulepreload 不算首屏」, |
| 214 | + * 再用子进程证明退出码契约 2 / 1 / 0 真的成立。真实产物的校准点**只报告不断言**(Task 6 后合法下降)。 |
| 215 | + */ |
| 216 | +function selfTest() { |
| 217 | + const problems = []; |
| 218 | + const check = (ok, msg) => { console.log(` ${ok ? "✅" : "❌"} ${msg}`); if (!ok) problems.push(msg); }; |
| 219 | + const dir = mkdtempSync(join(tmpdir(), "dsh-budget-selftest-")); |
| 220 | + try { |
| 221 | + mkdirSync(join(dir, "assets"), { recursive: true }); |
| 222 | + const w = (p, text) => writeFileSync(join(dir, p), text); |
| 223 | + w("index.html", |
| 224 | + `<!doctype html><html><head><link rel="modulepreload" href="/assets/preload-only.js">` + |
| 225 | + `<script type="module" crossorigin src="/assets/entry-abc.js"></script>` + |
| 226 | + `<link rel="stylesheet" href="/assets/app.css"></head><body></body></html>`); |
| 227 | + w("assets/entry-abc.js", `import{d}from"./static-a.js";import"./static-b.js";export*from"./static-d.js";import("./dyn-only.js");`); |
| 228 | + w("assets/static-a.js", `import"./static-c.js";export const a=1;`); |
| 229 | + w("assets/static-b.js", `export const b=2;`); |
| 230 | + w("assets/static-c.js", `export const c=3;`); |
| 231 | + w("assets/static-d.js", `export const d=4;`); |
| 232 | + w("assets/dyn-only.js", `import"./dyn-child.js";`); |
| 233 | + w("assets/dyn-child.js", `export const e=5;`); |
| 234 | + w("assets/preload-only.js", `export const f=6;`); |
| 235 | + w("assets/app.css", `body{color:#000}`); |
| 236 | + |
| 237 | + const { eager } = measure(dir); |
| 238 | + const names = [...eager.keys()].sort(); |
| 239 | + const want = ["entry-abc.js", "static-a.js", "static-b.js", "static-c.js", "static-d.js"]; |
| 240 | + check(JSON.stringify(names) === JSON.stringify(want), `静态闭包 = ${names.join(",")}(期望 ${want.join(",")}:含传递边与 export…from)`); |
| 241 | + check(!eager.has("dyn-only.js"), "动态 import 的 chunk 不在首屏(dyn-only.js —— 动态边被切断)"); |
| 242 | + check(!eager.has("dyn-child.js"), "只经动态 chunk 传递可达者不在首屏(dyn-child.js —— 闭包没穿过动态边界)"); |
| 243 | + check(!eager.has("preload-only.js"), "只在 <link rel=modulepreload> 出现的 chunk 不在首屏(口径只认静态 ESM import 边)"); |
| 244 | + check(!eager.has("app.css"), "CSS 不在首屏 JS(只算 .js)"); |
| 245 | + check(eager.get("entry-abc.js").bytes === statSync(join(dir, "assets/entry-abc.js")).size, "入口 chunk 原始字节 == 文件系统字节(没量错文件)"); |
| 246 | + const sum = want.reduce((a, n) => a + gzipSync(readFileSync(join(dir, "assets", n)), { level: 6 }).length, 0); |
| 247 | + check([...eager.values()].reduce((a, c) => a + c.gzip, 0) === sum, `gzip 求合 == zlib level 6 独立复算(${sum} B)`); |
| 248 | + |
| 249 | + for (const d of ["empty", "noscript/assets", "partial/assets"]) mkdirSync(join(dir, d), { recursive: true }); |
| 250 | + w("noscript/index.html", `<!doctype html><html><body></body></html>`); |
| 251 | + w("partial/index.html", `<script type="module" src="/assets/gone.js"></script>`); |
| 252 | + const child = (...args) => spawnSync(process.execPath, [SELF, "--no-build", "--dist", ...args], { encoding: "utf8" }); |
| 253 | + const cases = [ |
| 254 | + ["dist 不存在", 2, [join(dir, "nope")]], |
| 255 | + ["空 dist(无 index.html)", 2, [join(dir, "empty")]], |
| 256 | + ["index.html 无 module script", 2, [join(dir, "noscript")]], |
| 257 | + ["index.html 指向不存在的 chunk(产物残缺)", 2, [join(dir, "partial")]], |
| 258 | + ["--budget 非数字", 2, [dir, "--budget", "abc"]], |
| 259 | + ["夹具超预算(--budget 0.001)", 1, [dir, "--budget", "0.001"]], |
| 260 | + ["夹具达标(--budget 100000)", 0, [dir, "--budget", "100000"]], |
| 261 | + ]; |
| 262 | + for (const [label, wantCode, args] of cases) { |
| 263 | + const r = child(...args); |
| 264 | + const firstErr = r.stderr ? r.stderr.trim().split("\n")[0] : ""; |
| 265 | + check(r.status === wantCode, `${label} ⇒ 期望 exit ${wantCode},实得 ${r.status}${r.status === wantCode ? "" : ` · ${firstErr}`}`); |
| 266 | + } |
| 267 | + |
| 268 | + const realDist = join(APP, "dist"); |
| 269 | + if (existsSync(join(realDist, "index.html"))) { |
| 270 | + const real = measure(realDist); |
| 271 | + console.log(`self-test|真实产物校准(只报告不断言,Task 6 之后该值会合法下降):`); |
| 272 | + console.log(` ${rel(realDist)}:首屏 ${real.eager.size} 个 chunk · gzip ${fmtB(real.eagerBytes)} B = ${kB(real.eagerBytes)} kB`); |
| 273 | + console.log(` 冻结校准点(单一 chunk 构建)= 654,722 B = 654.72 kB —— 不符时先怀疑脚本,绝不改数字。`); |
| 274 | + } else { |
| 275 | + console.log("self-test|未发现 app/dist/index.html ⇒ 跳过真实产物校准(夹具断言不依赖它)"); |
| 276 | + } |
| 277 | + } finally { |
| 278 | + rmSync(dir, { recursive: true, force: true }); |
| 279 | + } |
| 280 | + if (problems.length) { |
| 281 | + console.error(`❌ self-test 失败 ${problems.length} 项:`); |
| 282 | + for (const p of problems) console.error(` · ${p}`); |
| 283 | + process.exit(2); |
| 284 | + } |
| 285 | + console.log("✅ self-test 通过(静态跟随含传递边 / 动态切断 / 只算 .js / 退出码契约 0·1·2)"); |
| 286 | + process.exit(0); |
| 287 | +} |
| 288 | + |
| 289 | +if (!Number.isFinite(BUDGET_KB) || BUDGET_KB <= 0) { |
| 290 | + fail(`--budget 需要正数(实得 "${opt("--budget", "")}")—— 预算解析失败时绝不能静默按「超标」处理。`); |
| 291 | +} |
| 292 | +if (has("--dist") && !NO_BUILD) fail(`--dist 与构建互斥:--dist 用于既有产物或自检夹具,请配 --no-build。`); |
| 293 | +if (SELF_TEST) selfTest(); |
| 294 | +if (!NO_BUILD) { |
| 295 | + console.log(`▶ cd app && npm run build(--no-build 可跳过;产物写入 app/dist)`); |
| 296 | + const r = spawnSync("npm", ["run", "build"], { cwd: APP, stdio: "inherit", shell: process.platform === "win32" }); |
| 297 | + if (r.status !== 0) fail(`npm run build 失败(exit ${r.status})—— 构建不绿时「首屏 gzip」没有意义,先修构建。`); |
| 298 | +} |
| 299 | +finish(measure(DIST)); |
0 commit comments