1414 * 3. gzip = `zlib.gzipSync(buf, { level: 6 })`;kB = **十进制 ÷1000**(vite 自己打印的口径,不是 KiB ÷1024)。
1515 * 冻结校准点:今天单一 chunk 构建下入口 chunk = **654,722 B = 654.72 kB**,与 vite 打印值逐字相等
1616 * (计划者 / Task 1 / 控制方三方独立复现)。**若本脚本与此值不符,先怀疑脚本,绝不改数字。**
17+ * 4. **懒侧(仅动态可达的 `.js`)有独立的第二个预算**(批 7 §C9.6 / §C10.4):族前缀**清单等式** +
18+ * **逐族 gzip 上限** + **懒侧总 gzip 上限**(另加 chunk 数上限),基线 = `scripts/lazyBudget.json` 的
19+ * **实测冻结值**、只许降(写「预算值」式的乐观目标会立刻红)。判据键是**族前缀**(`vendor-katex-*`)而非
20+ * chunk 名(后者带内容 hash、不稳定)。🔴 首屏与懒侧是**两个独立读数**:懒侧判定不得影响首屏字段。
1721 *
1822 * 仪器陷阱(本批实测,勿改回):
1923 * - 静态边在产物里长 `from"./x-abc.js"`:**引号必须紧跟** `from` / `import`,故 `import(` 天然不匹配;
2630 *
2731 * 副作用:默认执行 `cd app && npm run build`(写 `app/dist/`);`--no-build` 只读既有产物。
2832 * `--self-test` 在 `os.tmpdir()` 下建夹具并在结束时删除,**不碰真实 `app/dist`**。
29- * 用法:node scripts/check-bundle-budget.mjs [--no-build] [--budget <kB>] [--json] [--self-test] [--dist <目录>]
33+ * 用法:node scripts/check-bundle-budget.mjs [--no-build] [--budget <kB>] [--json] [--self-test] [--dist <目录>] [--no-lazy]
3034 * `--dist` 供自检夹具 / 备用产物目录使用(默认 `app/dist`),与构建互斥、须配 `--no-build`。
3135 * `--json` 与默认构建同用会把 vite 输出混进 stdout;机器消费请配 `--no-build`。
32- * 退出码:0 = 达标 · 1 = 超预算 · 2 = 构建失败 / 产物缺失 / 自检失败。
36+ * `--no-lazy` **只**给 `--dist` 的夹具用,且**不得**指向默认产物目录 ⇒ CI / husky / 收口读数都不带它,
37+ * **真实产物上懒侧门禁恒开**(它不是「可以关掉的守卫」)。
38+ * 退出码:0 = **首屏与懒侧两个预算都达标** · 1 = **任一预算超标(首屏 / 懒侧)** ·
39+ * 2 = 构建失败 / 产物缺失 / 自检失败。两个读数**互相独立**:懒侧判定**不得**影响首屏字段(批 7 §C11.5)。
3340 */
3441import { readFileSync , existsSync , statSync , mkdtempSync , mkdirSync , writeFileSync , rmSync } from "node:fs" ;
3542import { join , resolve , dirname } from "node:path" ;
@@ -48,16 +55,58 @@ const opt = (f, dflt) => (argv.indexOf(f) >= 0 && argv[argv.indexOf(f) + 1] !==
4855const NO_BUILD = has ( "--no-build" ) ;
4956const AS_JSON = has ( "--json" ) ;
5057const SELF_TEST = has ( "--self-test" ) ;
58+ const NO_LAZY = has ( "--no-lazy" ) ;
5159const DIST = resolve ( opt ( "--dist" , join ( APP , "dist" ) ) ) ;
5260const BUDGET_KB = Number ( opt ( "--budget" , "200" ) ) ;
5361const BUDGET_SOURCE = "docs/standards/performance.md:28" ;
62+ const LAZY_BUDGET = join ( dirname ( SELF ) , "lazyBudget.json" ) ;
5463
5564/** 全部失败路径的统一出口:**必须** exit 2(1 只留给「超预算」),并给出可执行的下一条命令。 */
5665function fail ( msg ) {
5766 console . error ( `❌ 首屏预算守卫:${ msg } ` ) ;
5867 process . exit ( 2 ) ;
5968}
6069
70+ /**
71+ * 懒侧基线(冻结值):**缺失即失败**,绝不静默放行 —— 「读不到基线就当达标」是这类门禁最典型的失效形态。
72+ */
73+ function loadLazyBudget ( p ) {
74+ if ( ! existsSync ( p ) || ! statSync ( p ) . isFile ( ) ) fail ( `找不到懒侧基线 ${ rel ( p ) } —— 基线缺失时不得静默按「达标」放行。` ) ;
75+ const b = JSON . parse ( readFileSync ( p , "utf8" ) ) ;
76+ const ok = Array . isArray ( b . families ) && b . families . length > 0 && Number . isFinite ( b . lazyTotalGzipBytesMax ) && Number . isFinite ( b . lazyChunkCountMax ) && b . families . every ( ( f ) => typeof f . prefix === "string" && Number . isFinite ( f . gzipBytesMax ) ) ;
77+ if ( ! ok ) fail ( `懒侧基线 ${ rel ( p ) } 结构不完整(需要 families[{prefix,gzipBytesMax}] + lazyTotalGzipBytesMax + lazyChunkCountMax)。` ) ;
78+ return b ;
79+ }
80+
81+ /** 族前缀 = 去掉**8 位定长**内容 hash 的 chunk 名 + `-`。hash 字母表含 `-`(实测 `CaptureFloatPanel-CJ-lF7pj.js`)。 */
82+ const HASH_SUFFIX = / - [ A - Z a - z 0 - 9 _ - ] { 8 } \. j s $ / ;
83+ const familyPrefixOf = ( name ) => ( HASH_SUFFIX . test ( name ) ? name . replace ( HASH_SUFFIX , "" ) + "-" : null ) ;
84+
85+ /**
86+ * 懒侧四条判据:① **清单等式**(每个懒侧 chunk 都必须归属某个已登记族 —— 没有 `others` 白名单,就是要让
87+ * 产物演进**红**而不是静默通过)② **逐族 gzip 上限** ③ **总 gzip 上限** ④ chunk 数上限。
88+ * 每条**各产生一条具名 reason**(C9.6 第 3 条:必须红在具名断言上)。
89+ */
90+ function judgeLazy ( r , base ) {
91+ const fams = new Map ( base . families . map ( ( f ) => [ f . prefix , { prefix : f . prefix , gzipBytesMax : f . gzipBytesMax , chunks : [ ] , gzipBytes : 0 } ] ) ) ;
92+ const unlisted = [ ] ;
93+ for ( const c of r . lazy ) {
94+ const f = fams . get ( familyPrefixOf ( c . name ) ) ;
95+ if ( ! f ) { unlisted . push ( c . name ) ; continue ; }
96+ f . chunks . push ( c . name ) ;
97+ f . gzipBytes += c . gzipBytes ;
98+ }
99+ const families = [ ...fams . values ( ) ] ;
100+ const totalBytes = r . lazy . reduce ( ( a , c ) => a + c . gzipBytes , 0 ) ;
101+ const overFamilies = families . filter ( ( f ) => f . gzipBytes > f . gzipBytesMax ) ;
102+ const reasons = [ ] ;
103+ if ( unlisted . length ) reasons . push ( `① 族前缀清单等式:未归族 ${ unlisted . length } 个(${ unlisted . join ( ", " ) } )` ) ;
104+ for ( const f of overFamilies ) reasons . push ( `② 逐族上限:${ f . prefix } gzip ${ fmtB ( f . gzipBytes ) } B > 上限 ${ fmtB ( f . gzipBytesMax ) } B` ) ;
105+ if ( totalBytes > base . lazyTotalGzipBytesMax ) reasons . push ( `③ 懒侧总 gzip:${ fmtB ( totalBytes ) } B > 上限 ${ fmtB ( base . lazyTotalGzipBytesMax ) } B` ) ;
106+ if ( r . lazy . length > base . lazyChunkCountMax ) reasons . push ( `④ 懒侧 chunk 数:${ r . lazy . length } > 上限 ${ base . lazyChunkCountMax } ` ) ;
107+ return { families, unlisted, overFamilies, totalBytes, totalMax : base . lazyTotalGzipBytesMax , count : r . lazy . length , countMax : base . lazyChunkCountMax , reasons, pass : reasons . length === 0 } ;
108+ }
109+
61110/** 参考项:HTML 用 modulepreload 声明但静态闭包没覆盖的 .js —— 值得人看一眼,不影响判定。 */
62111function preloadUncovered ( dist , eager ) {
63112 const html = readFileSync ( join ( dist , "index.html" ) , "utf8" ) ;
@@ -66,7 +115,7 @@ function preloadUncovered(dist, eager) {
66115 return [ ...new Set ( tags . filter ( ( n ) => n . endsWith ( ".js" ) && ! eager . has ( n ) ) ) ] ;
67116}
68117
69- function printHuman ( r , pass ) {
118+ function printHuman ( r , pass , lazy ) {
70119 console . log ( `首屏预算守卫 · 产物 ${ rel ( r . dist ) } ` ) ;
71120 console . log ( ` 单位:kB = 字节 ÷ 1000(十进制,vite 口径;勿与 ÷1024 的 KiB 混用,混用会产生假 Δ)· gzip = zlib level 6` ) ;
72121 console . log ( ` 口径:module 入口 + 只沿静态 ESM import 可达的 .js,动态 import 不计 · 预算 ${ BUDGET_KB } kB gzip(来源 ${ BUDGET_SOURCE } )` ) ;
@@ -75,9 +124,13 @@ function printHuman(r, pass) {
75124 console . log ( ` ${ name . padEnd ( 34 ) } ${ fmtB ( c . bytes ) . padStart ( 10 ) } B → gzip ${ fmtB ( c . gzip ) . padStart ( 9 ) } B = ${ kB ( c . gzip ) . padStart ( 8 ) } kB` ) ;
76125 }
77126 console . log ( ` 首屏 JS 合计:原始 ${ fmtB ( r . rawBytes ) } B · gzip ${ fmtB ( r . eagerBytes ) } B = ${ kB ( r . eagerBytes ) } kB` ) ;
78- console . log ( ` ⇒ ${ pass ? "✅ 达标" : "❌ 超标" } :${ pass ? "余量" : "超出" } ${ kB ( Math . abs ( BUDGET_KB * 1000 - r . eagerBytes ) ) } kB${ pass ? "" : `(${ ( r . eagerBytes / 1000 / BUDGET_KB ) . toFixed ( 2 ) } × 预算)` } ` ) ;
127+ console . log ( ` ⇒ 首屏预算 ${ pass ? "✅ 达标" : "❌ 超标" } :${ pass ? "余量" : "超出" } ${ kB ( Math . abs ( BUDGET_KB * 1000 - r . eagerBytes ) ) } kB${ pass ? "" : `(${ ( r . eagerBytes / 1000 / BUDGET_KB ) . toFixed ( 2 ) } × 预算)` } ` ) ;
79128 const lazyBytes = r . lazy . reduce ( ( a , c ) => a + c . gzipBytes , 0 ) ;
80- console . log ( `懒加载 chunk(仅动态可达,不计入预算):${ r . lazy . length } 个 · gzip ${ fmtB ( lazyBytes ) } B = ${ kB ( lazyBytes ) } kB` ) ;
129+ console . log ( `懒加载 chunk(仅动态可达,独立于首屏预算):${ r . lazy . length } 个 · gzip ${ fmtB ( lazyBytes ) } B = ${ kB ( lazyBytes ) } kB` ) ;
130+ if ( lazy ) {
131+ console . log ( ` 懒侧口径:族前缀清单等式 + 逐族 gzip 上限 + 懒侧总 gzip 上限 · 基线 ${ rel ( LAZY_BUDGET ) } (实测冻结值,只许降)· 未归族与超上限都红` ) ;
132+ console . log ( ` ⇒ 懒侧预算 ${ lazy . pass ? "✅ 达标" : "❌ 超标" } :gzip ${ kB ( lazy . totalBytes ) } kB / 上限 ${ kB ( lazy . totalMax ) } kB · chunk ${ lazy . count } 个 / 上限 ${ lazy . countMax } 个 · 未归族 ${ lazy . unlisted . length } 个 · 超上限族 ${ lazy . overFamilies . length } 个` ) ;
133+ } else console . log ( ` ⇒ 懒侧预算 ⏭ 未判(--no-lazy,仅供 --dist 夹具;真实产物上恒判)` ) ;
81134 console . log ( `不计入预算但须报告(CSS / 字体):index.html ${ fmtB ( r . htmlBytes ) } B = ${ kB ( r . htmlBytes ) } kB · gzip ${ kB ( r . htmlGzip ) } kB` ) ;
82135 const byExt = new Map ( ) ;
83136 for ( const a of r . excluded ) {
@@ -90,7 +143,7 @@ function printHuman(r, pass) {
90143 }
91144}
92145
93- const jsonOf = ( r , pass ) => ( {
146+ const jsonOf = ( r , pass , lazy ) => ( {
94147 dist : rel ( r . dist ) ,
95148 budgetKb : BUDGET_KB ,
96149 budgetSource : BUDGET_SOURCE ,
@@ -99,20 +152,38 @@ const jsonOf = (r, pass) => ({
99152 count : r . eager . size ,
100153 chunks : [ ...r . eager ] . map ( ( [ name , c ] ) => ( { name, bytes : c . bytes , gzipBytes : c . gzip , gzipKb : Number ( kB ( c . gzip ) ) } ) ) ,
101154 totalBytes : r . eagerBytes ,
102- totalKb : Number ( kB ( r . eagerBytes ) ) ,
155+ totalKb : Number ( kB ( r . eagerBytes ) ) , pass ,
103156 } ,
104157 lazy : { count : r . lazy . length , chunks : r . lazy , totalBytes : r . lazy . reduce ( ( a , c ) => a + c . gzipBytes , 0 ) } ,
158+ lazyBudget : lazy
159+ ? {
160+ pass : lazy . pass ,
161+ baseline : rel ( LAZY_BUDGET ) ,
162+ count : lazy . count ,
163+ countMax : lazy . countMax ,
164+ totalBytes : lazy . totalBytes ,
165+ totalKb : Number ( kB ( lazy . totalBytes ) ) ,
166+ totalMaxBytes : lazy . totalMax ,
167+ unlisted : lazy . unlisted ,
168+ fails : lazy . reasons ,
169+ families : lazy . families . map ( ( f ) => ( { prefix : f . prefix , chunkCount : f . chunks . length , gzipBytes : f . gzipBytes , gzipBytesMax : f . gzipBytesMax , pass : f . gzipBytes <= f . gzipBytesMax } ) ) ,
170+ }
171+ : { pass : true , skipped : "--no-lazy(仅供 --dist 夹具;真实产物上恒判)" } ,
105172 excludedFromBudget : { indexHtml : { bytes : r . htmlBytes , gzipBytes : r . htmlGzip } , assets : r . excluded } ,
106173 pass,
107174} ) ;
108175
109- /** 报表 + 退出码:0 = 达标 · 1 = 超预算。超预算时把差额与倍数一并说清(可执行、不含糊)。 */
176+ /**
177+ * 报表 + 退出码:0 = **两个预算都达标** · 1 = **任一超标**(首屏 / 懒侧,各自点名)。
178+ * 🔴 顶层 `pass` 是**首屏** pass(既有消费方的兼容字段);懒侧见 `lazyBudget.pass`。
179+ */
110180function finish ( r ) {
111181 const pass = r . eagerBytes / 1000 < BUDGET_KB ;
182+ const lazy = NO_LAZY ? null : judgeLazy ( r , loadLazyBudget ( LAZY_BUDGET ) ) ;
112183 if ( AS_JSON ) {
113- console . log ( JSON . stringify ( jsonOf ( r , pass ) , null , 2 ) ) ;
184+ console . log ( JSON . stringify ( jsonOf ( r , pass , lazy ) , null , 2 ) ) ;
114185 } else {
115- printHuman ( r , pass ) ;
186+ printHuman ( r , pass , lazy ) ;
116187 const uncovered = preloadUncovered ( r . dist , r . eager ) ;
117188 if ( uncovered . length ) console . log ( `ℹ️ modulepreload 声明但静态闭包未覆盖 ${ uncovered . length } 个:${ uncovered . join ( ", " ) } (参考项,不影响判定)` ) ;
118189 }
@@ -122,7 +193,8 @@ function finish(r) {
122193 `(${ ( r . eagerBytes / 1000 / BUDGET_KB ) . toFixed ( 2 ) } ×)—— 退出码 1。降首屏只能靠 import() 切断静态边;manualChunks 只切文件、一字节不降。` ,
123194 ) ;
124195 }
125- process . exit ( pass ? 0 : 1 ) ;
196+ if ( lazy && ! lazy . pass ) console . error ( `❌ 懒侧 gzip 预算超标(退出码 1)—— 具名断言:${ lazy . reasons . join ( ";" ) } 。基线 ${ rel ( LAZY_BUDGET ) } 只许降(§C9.6 第 2 条)。` ) ;
197+ process . exit ( pass && ( ! lazy || lazy . pass ) ? 0 : 1 ) ;
126198}
127199
128200/**
@@ -166,7 +238,8 @@ function selfTest() {
166238 for ( const d of [ "empty" , "noscript/assets" , "partial/assets" ] ) mkdirSync ( join ( dir , d ) , { recursive : true } ) ;
167239 w ( "noscript/index.html" , `<!doctype html><html><body></body></html>` ) ;
168240 w ( "partial/index.html" , `<script type="module" src="/assets/gone.js"></script>` ) ;
169- const child = ( ...args ) => spawnSync ( process . execPath , [ SELF , "--no-build" , "--dist" , ...args ] , { encoding : "utf8" } ) ;
241+ // 夹具产物是人工造的(`dyn-only.js` 之类不可能命中真实基线)⇒ 夹具子进程显式关懒侧判定,本自检继续只证**首屏**口径与退出码契约 ⇒ 读数逐字不变。⚠️ `--no-lazy` 必须排在**所有带值旗标之后**(`opt()` 取旗标的下一个 argv 元素;紧跟 `--dist` 会把它吃掉)。
242+ const child = ( ...args ) => spawnSync ( process . execPath , [ SELF , "--no-build" , "--dist" , ...args , "--no-lazy" ] , { encoding : "utf8" } ) ;
170243 const cases = [
171244 [ "dist 不存在" , 2 , [ join ( dir , "nope" ) ] ] ,
172245 [ "空 dist(无 index.html)" , 2 , [ join ( dir , "empty" ) ] ] ,
@@ -207,6 +280,9 @@ if (!Number.isFinite(BUDGET_KB) || BUDGET_KB <= 0) {
207280 fail ( `--budget 需要正数(实得 "${ opt ( "--budget" , "" ) } ")—— 预算解析失败时绝不能静默按「超标」处理。` ) ;
208281}
209282if ( has ( "--dist" ) && ! NO_BUILD ) fail ( `--dist 与构建互斥:--dist 用于既有产物或自检夹具,请配 --no-build。` ) ;
283+ // `--no-lazy` 是**夹具通道**而不是「守卫开关」:只许与 `--dist` 同用,且不得指向默认产物目录
284+ // ⇒ 真实产物(CI / husky / 收口读数)上懒侧门禁**恒开**。
285+ if ( NO_LAZY && ( ! has ( "--dist" ) || DIST === resolve ( join ( APP , "dist" ) ) ) ) fail ( `--no-lazy 只许配 --dist 且不得指向默认产物目录 ${ rel ( join ( APP , "dist" ) ) } —— 真实产物上懒侧门禁恒判。` ) ;
210286// 度量已搬进 `./lib/bundleMeasure.mjs`:那边的 `fail()` **只抛** `BudgetError`(度量模块不得持有进程
211287// 出口),这里把它桥回本件的统一出口(`console.error` + `exit 2`,文案逐字不变)。用**专属类型**是为了
212288// 让其余异常继续原样上抛 —— 拆前它们就是未捕获栈 + exit 1,无差别转 exit 2 才是真的行为变化。
0 commit comments