Skip to content

Commit cd800d6

Browse files
committed
fix(scripts): 懒侧基线改用新鲜构建口径
1 parent 80b1eab commit cd800d6

3 files changed

Lines changed: 53 additions & 52 deletions

File tree

‎docs/standards/performance.md‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,11 @@
3535

3636
### 懒侧字节门禁(批 7 · 与首屏预算**互相独立**)
3737

38-
- **口径**:懒加载 chunk(仅动态可达的 `.js`)单独判定 —— ① **族前缀清单等式**(每个懒侧 chunk 必须归属某个已登记族,**未归族即红**)② **逐族 gzip 上限** ③ **懒侧总 gzip 上限**(另加 chunk 数上限)。三条都只许降。
39-
- **判据键是族前缀**(`vendor-katex-*` 等):Vite 的 chunk 名带内容 hash(不稳定)⇒ 键只能是去掉 8 位 hash 的名字加 `-`。
40-
- **基线来源**:`scripts/lazyBudget.json` —— 首次写入值 = 2026-09-13 实测 **36 chunk / 698.86 kB**(**冻结现状,不是目标值**;写乐观「预算值」会立刻红)。
38+
- **口径**:懒加载 chunk(仅动态可达的 `.js`)单独判定 —— ① **族前缀清单等式**(每个懒侧 chunk 必须归属某个已登记族,**未归族即红**;键用**族前缀**如 `vendor-katex-*`,因为 Vite 的 chunk 名带内容 hash、不稳定)② **逐族 gzip 上限** ③ **懒侧总 gzip 上限**(另加 chunk 数上限)。三条都只许降。
39+
- **基线来源**:`scripts/lazyBudget.json` —— 首次写入值 = 2026-09-13 **真构建**实测 **36 chunk / 634.32 kB**(**冻结现状,不是目标值**;写乐观「预算值」会立刻红)。
40+
- 🔴 **口径:本判据只在「新鲜构建」的产物上成立** —— 旧 `dist` 带着上一个时代的残渣(实测:顶层 katex 被删后旧产物仍多 **64,497 B** gzip)⇒ **`--no-build` 的读数不可用于本判据**;要判既有产物就显式 `--dist` 指认(不给 `--dist` 的 `--no-build` 只判首屏,懒侧打 `⏭ 未判`)。
4141
- **硬约束**:上表「页面包大小 (JS) < 200KB (gzip)」与懒侧预算是**两个独立读数** —— 懒侧判定不得影响首屏读数,两者也不得并成一个预算。
42-
- **工具**:`node scripts/check-bundle-budget.mjs --no-build`(人类可读,**分别**打印两个 pass);加 `--json` 得平级的 `firstScreen` / `lazyBudget` 两栏。退出码 `1` = **任一**预算超标(首屏 / 懒侧)。
42+
- **工具**:`node scripts/check-bundle-budget.mjs`(不带 `--no-build` ⇒ 自行构建后再判两个预算,人类可读、**分别**打印两个 pass);加 `--json` 得平级的 `firstScreen` / `lazyBudget` 两栏。退出码 `1` = **任一**预算超标(首屏 / 懒侧)。
4343

4444
### 第二部分:测量与 Profiling
4545

‎scripts/check-bundle-budget.mjs‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -14,29 +14,31 @@
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):族前缀**清单等式** +
17+
* 4. **懒侧(仅动态可达的 `.js`)有独立的第二个预算**(批 7 §C9.6 / §C10.4 / §C21.3):族前缀**清单等式** +
1818
* **逐族 gzip 上限** + **懒侧总 gzip 上限**(另加 chunk 数上限),基线 = `scripts/lazyBudget.json` 的
19-
* **实测冻结值**、只许降(写「预算值」式的乐观目标会立刻红)。判据键是**族前缀**(`vendor-katex-*`)而非
20-
* chunk 名(后者带内容 hash、不稳定)。🔴 首屏与懒侧是**两个独立读数**:懒侧判定不得影响首屏字段。
19+
* **实测冻结值**、只许降。判据键是**族前缀**(`vendor-katex-*`)而非 chunk 名(带内容 hash、不稳定)。
20+
* 🔴 **口径:本判据只在新鲜构建的产物上成立** —— 旧 `dist` 带着上一个时代的残渣(实测:顶层 katex 被删后旧
21+
* 产物仍多 **64,497 B** gzip)⇒ **`--no-build` 的读数不可用于本判据**(要判既有产物就显式 `--dist` 指认)。
22+
* 首屏与懒侧是**两个独立读数**:懒侧判定不得影响首屏字段。
2123
*
2224
* 仪器陷阱(本批实测,勿改回):
2325
* - 静态边在产物里长 `from"./x-abc.js"`:**引号必须紧跟** `from` / `import`,故 `import(` 天然不匹配;
2426
* 另先整段剥掉 `import("…")` 形态再扫,双保险(自检含「仅动态可达必须不计」的负样本夹具)。
2527
* - chunk basename 必须用 `[^"'/\\]+\.js`:产物名含 `-` 与 `.`,收窄成 `\w+` 会一个 chunk 都扫不到。
2628
* - 扫描是正则级、不做 AST:注释/字符串里恰好出现 `from"./x.js"` 会误判(本仓产物实测未触发)。
2729
* - `modulepreload` 交叉核对只作参考、不影响退出码(vite 对动态依赖的预载发生在运行时)。
28-
* 边界:vite 的 chunk 一律**平铺**在 `dist/assets/` 下(本脚本按 basename 解析);不解析 sourcemap、
29-
* 不解析 CSS 里的 `url()`、不认带 query 的说明符 —— 三者在本仓产物中都不存在。
30+
* 边界:vite 的 chunk 一律**平铺**在 `dist/assets/` 下(本脚本按 basename 解析);不解析 sourcemap、CSS 里的 `url()`、带 query 的说明符 —— 三者在本仓产物中都不存在。
3031
*
3132
* 副作用:默认执行 `cd app && npm run build`(写 `app/dist/`);`--no-build` 只读既有产物。
3233
* `--self-test` 在 `os.tmpdir()` 下建夹具并在结束时删除,**不碰真实 `app/dist`**。
3334
* 用法:node scripts/check-bundle-budget.mjs [--no-build] [--budget <kB>] [--json] [--self-test] [--dist <目录>] [--no-lazy]
34-
* `--dist` 供自检夹具 / 备用产物目录使用(默认 `app/dist`),与构建互斥、须配 `--no-build`。
35+
* `--dist` 供自检夹具 / 备用产物目录使用(默认 `app/dist`),与构建互斥、须配 `--no-build`。给 `--dist`
36+
* 即视作**你把该产物作为受判对象**(懒侧判据据此启用);不给则 `--no-build` **只判首屏**(懒侧见下条)。
3537
* `--json` 与默认构建同用会把 vite 输出混进 stdout;机器消费请配 `--no-build`。
36-
* `--no-lazy` **只**给 `--dist` 的夹具用,且**不得**指向默认产物目录 ⇒ CI / husky / 收口读数都不带它,
37-
* **真实产物上懒侧门禁恒开**(它不是「可以关掉的守卫」)。
38+
* `--no-lazy` **只**给 `--dist` 的夹具用(人工造的夹具不可能命中真实基线),且**不得**指向默认产物目录。
3839
* 退出码:0 = **首屏与懒侧两个预算都达标** · 1 = **任一预算超标(首屏 / 懒侧)** ·
39-
* 2 = 构建失败 / 产物缺失 / 自检失败。两个读数**互相独立**:懒侧判定**不得**影响首屏字段(批 7 §C11.5)。
40+
* 2 = 构建失败 / 产物缺失 / 自检失败。两个读数**互相独立**:懒侧判定**不得**影响首屏字段(批 7 §C11.5);
41+
* 懒侧**未判**时(裸 `--no-build`)退出码回到「只判首屏」的旧契约 —— 不许把「没判」当「达标」。
4042
*/
4143
import { readFileSync, existsSync, statSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from "node:fs";
4244
import { join, resolve, dirname } from "node:path";
@@ -60,16 +62,16 @@ const DIST = resolve(opt("--dist", join(APP, "dist")));
6062
const BUDGET_KB = Number(opt("--budget", "200"));
6163
const BUDGET_SOURCE = "docs/standards/performance.md:28";
6264
const LAZY_BUDGET = join(dirname(SELF), "lazyBudget.json");
65+
// 懒侧判据的**合格口径 = 新鲜构建**(§C21.3):① 不带 `--no-build` ⇒ 本次真构建;② 显式 `--dist` ⇒ 操作者把该产物作为受判对象交进来;两者都不满足(裸 `--no-build`)⇒ 只判首屏,懒侧**不下判定**。
66+
const JUDGE_LAZY = !NO_LAZY && (!NO_BUILD || has("--dist"));
6367

6468
/** 全部失败路径的统一出口:**必须** exit 2(1 只留给「超预算」),并给出可执行的下一条命令。 */
6569
function fail(msg) {
6670
console.error(`❌ 首屏预算守卫:${msg}`);
6771
process.exit(2);
6872
}
6973

70-
/**
71-
* 懒侧基线(冻结值):**缺失即失败**,绝不静默放行 —— 「读不到基线就当达标」是这类门禁最典型的失效形态。
72-
*/
74+
/** 懒侧基线(冻结值):**缺失即失败**,绝不静默放行 —— 「读不到基线就当达标」是这类门禁最典型的失效形态。 */
7375
function loadLazyBudget(p) {
7476
if (!existsSync(p) || !statSync(p).isFile()) fail(`找不到懒侧基线 ${rel(p)} —— 基线缺失时不得静默按「达标」放行。`);
7577
const b = JSON.parse(readFileSync(p, "utf8"));
@@ -83,9 +85,8 @@ const HASH_SUFFIX = /-[A-Za-z0-9_-]{8}\.js$/;
8385
const familyPrefixOf = (name) => (HASH_SUFFIX.test(name) ? name.replace(HASH_SUFFIX, "") + "-" : null);
8486

8587
/**
86-
* 懒侧四条判据:① **清单等式**(每个懒侧 chunk 都必须归属某个已登记族 —— 没有 `others` 白名单,就是要让
87-
* 产物演进**红**而不是静默通过)② **逐族 gzip 上限** ③ **总 gzip 上限** ④ chunk 数上限。
88-
* 每条**各产生一条具名 reason**(C9.6 第 3 条:必须红在具名断言上)。
88+
* 懒侧四条判据:① **清单等式**(每个懒侧 chunk 都必须归属某个已登记族 —— 没有 `others` 白名单,就是要让产物
89+
* 演进**红**而不是静默通过)② **逐族 gzip 上限** ③ **总 gzip 上限** ④ chunk 数上限;每条各一条**具名 reason**。
8990
*/
9091
function judgeLazy(r, base) {
9192
const fams = new Map(base.families.map((f) => [f.prefix, { prefix: f.prefix, gzipBytesMax: f.gzipBytesMax, chunks: [], gzipBytes: 0 }]));
@@ -98,13 +99,12 @@ function judgeLazy(r, base) {
9899
}
99100
const families = [...fams.values()];
100101
const totalBytes = r.lazy.reduce((a, c) => a + c.gzipBytes, 0);
101-
const overFamilies = families.filter((f) => f.gzipBytes > f.gzipBytesMax);
102102
const reasons = [];
103103
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`);
104+
for (const f of families) if (f.gzipBytes > f.gzipBytesMax) reasons.push(`② 逐族上限:${f.prefix} gzip ${fmtB(f.gzipBytes)} B > 上限 ${fmtB(f.gzipBytesMax)} B`);
105105
if (totalBytes > base.lazyTotalGzipBytesMax) reasons.push(`③ 懒侧总 gzip:${fmtB(totalBytes)} B > 上限 ${fmtB(base.lazyTotalGzipBytesMax)} B`);
106106
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 };
107+
return { families, unlisted, overFamilies: families.filter((f) => f.gzipBytes > f.gzipBytesMax), totalBytes, totalMax: base.lazyTotalGzipBytesMax, count: r.lazy.length, countMax: base.lazyChunkCountMax, reasons, pass: reasons.length === 0 };
108108
}
109109

110110
/** 参考项:HTML 用 modulepreload 声明但静态闭包没覆盖的 .js —— 值得人看一眼,不影响判定。 */
@@ -128,9 +128,8 @@ function printHuman(r, pass, lazy) {
128128
const lazyBytes = r.lazy.reduce((a, c) => a + c.gzipBytes, 0);
129129
console.log(`懒加载 chunk(仅动态可达,独立于首屏预算):${r.lazy.length} 个 · gzip ${fmtB(lazyBytes)} B = ${kB(lazyBytes)} kB`);
130130
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 夹具;真实产物上恒判)`);
131+
console.log(` ⇒ 懒侧预算 ${lazy.pass ? "✅ 达标" : "❌ 超标"}:gzip ${kB(lazy.totalBytes)} kB / 上限 ${kB(lazy.totalMax)} kB · chunk ${lazy.count} 个 / 上限 ${lazy.countMax} 个 · 未归族 ${lazy.unlisted.length} 个 · 超上限族 ${lazy.overFamilies.length} 个(基线 ${rel(LAZY_BUDGET)},实测冻结值只许降)`);
132+
} else console.log(` ⇒ 懒侧预算 ⏭ 未判(${NO_LAZY ? "--no-lazy:自检夹具" : "--no-build 的读数不可用于本判据(旧产物会多算 ~64 kB)—— 去掉 --no-build 让本脚本代跑构建,或显式 --dist 指认受判产物"})`);
134133
console.log(`不计入预算但须报告(CSS / 字体):index.html ${fmtB(r.htmlBytes)} B = ${kB(r.htmlBytes)} kB · gzip ${kB(r.htmlGzip)} kB`);
135134
const byExt = new Map();
136135
for (const a of r.excluded) {
@@ -157,6 +156,7 @@ const jsonOf = (r, pass, lazy) => ({
157156
lazy: { count: r.lazy.length, chunks: r.lazy, totalBytes: r.lazy.reduce((a, c) => a + c.gzipBytes, 0) },
158157
lazyBudget: lazy
159158
? {
159+
judged: true,
160160
pass: lazy.pass,
161161
baseline: rel(LAZY_BUDGET),
162162
count: lazy.count,
@@ -168,18 +168,18 @@ const jsonOf = (r, pass, lazy) => ({
168168
fails: lazy.reasons,
169169
families: lazy.families.map((f) => ({ prefix: f.prefix, chunkCount: f.chunks.length, gzipBytes: f.gzipBytes, gzipBytesMax: f.gzipBytesMax, pass: f.gzipBytes <= f.gzipBytesMax })),
170170
}
171-
: { pass: true, skipped: "--no-lazy(仅供 --dist 夹具;真实产物上恒判)" },
171+
: { judged: false, pass: null, skipped: NO_LAZY ? "--no-lazy(自检夹具)" : "--no-build 的读数不可用于懒侧判据(口径要求新鲜构建;亦可在显式 --dist 下判定)" },
172172
excludedFromBudget: { indexHtml: { bytes: r.htmlBytes, gzipBytes: r.htmlGzip }, assets: r.excluded },
173173
pass,
174174
});
175175

176176
/**
177-
* 报表 + 退出码:0 = **两个预算都达标** · 1 = **任一超标**(首屏 / 懒侧,各自点名)。
178-
* 🔴 顶层 `pass` 是**首屏** pass(既有消费方的兼容字段);懒侧见 `lazyBudget.pass`。
177+
* 报表 + 退出码:0 = **两个预算都达标** · 1 = **任一超标**(首屏 / 懒侧,各自点名)。顶层 `pass` 是**首屏** pass
178+
* (既有消费方的兼容字段);懒侧见 `lazyBudget.pass`(`judged:false` 时它是 `null`,不是「达标」)。
179179
*/
180180
function finish(r) {
181181
const pass = r.eagerBytes / 1000 < BUDGET_KB;
182-
const lazy = NO_LAZY ? null : judgeLazy(r, loadLazyBudget(LAZY_BUDGET));
182+
const lazy = JUDGE_LAZY ? judgeLazy(r, loadLazyBudget(LAZY_BUDGET)) : null;
183183
if (AS_JSON) {
184184
console.log(JSON.stringify(jsonOf(r, pass, lazy), null, 2));
185185
} else {

‎scripts/lazyBudget.json‎

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,102 @@
11
{
2-
"generatedFrom": "node scripts/check-bundle-budget.mjs --no-build --json(2026-09-13 实测;批 7 T9)",
2+
"generatedFrom": "node scripts/check-bundle-budget.mjs(**真构建**,不带 --no-build)· 源码 = 80b1eabc(T9 的提交树) · 读数存档 tmp/t9/fresh-dist.json",
33
"unit": "gzip = zlib.gzipSync level 6 · 族前缀 = 去掉 8 位内容 hash 的 chunk 名 + '-'(chunk 名带内容 hash,不稳定,不能当键)",
4-
"semantics": "全部字段 = 当次实测的**冻结值**,只许降(批 7 §C9.6 第 2 条 / §C10.4);写乐观「预算值」会立刻红",
4+
"scope": "🔴 必须在**新鲜构建**产物上判定(§C21.3):旧 dist 上的 --no-build 读数含时代残渣,上界会虚高(实测 +64,497 B)⇒ 本判据的合格调用 = 不带 --no-build(本脚本自行构建)或显式 --dist 指认受判产物",
5+
"semantics": "全部字段 = 实测**冻结值**,只许降(批 7 §C9.6 第 2 条 / §C10.4);写乐观「预算值」会立刻红",
56
"lazyChunkCountMax": 36,
6-
"lazyTotalGzipBytesMax": 698859,
7+
"lazyTotalGzipBytesMax": 634316,
78
"families": [
89
{
910
"prefix": "ActionPage-",
10-
"gzipBytesMax": 8191
11+
"gzipBytesMax": 8190
1112
},
1213
{
1314
"prefix": "AiConversationDock-",
14-
"gzipBytesMax": 2807
15+
"gzipBytesMax": 2806
1516
},
1617
{
1718
"prefix": "CaptureFloatPanel-",
18-
"gzipBytesMax": 4538
19+
"gzipBytesMax": 4537
1920
},
2021
{
2122
"prefix": "CaptureOverlayPanel-",
2223
"gzipBytesMax": 1402
2324
},
2425
{
2526
"prefix": "ChatPage-",
26-
"gzipBytesMax": 11382
27+
"gzipBytesMax": 11383
2728
},
2829
{
2930
"prefix": "ConfirmDialog-",
30-
"gzipBytesMax": 765
31+
"gzipBytesMax": 764
3132
},
3233
{
3334
"prefix": "GoalsPage-",
3435
"gzipBytesMax": 12349
3536
},
3637
{
3738
"prefix": "KnowledgePage-",
38-
"gzipBytesMax": 27621
39+
"gzipBytesMax": 27620
3940
},
4041
{
4142
"prefix": "NoteCardFlowView-",
42-
"gzipBytesMax": 787
43+
"gzipBytesMax": 785
4344
},
4445
{
4546
"prefix": "NoteMarkdown-",
46-
"gzipBytesMax": 2948
47+
"gzipBytesMax": 2950
4748
},
4849
{
4950
"prefix": "NotesPage-",
50-
"gzipBytesMax": 47047
51+
"gzipBytesMax": 47045
5152
},
5253
{
5354
"prefix": "RefineLaunchDialog-",
5455
"gzipBytesMax": 7184
5556
},
5657
{
5758
"prefix": "RefineStrategyPicker-",
58-
"gzipBytesMax": 2710
59+
"gzipBytesMax": 2709
5960
},
6061
{
6162
"prefix": "ReviewPage-",
62-
"gzipBytesMax": 6815
63+
"gzipBytesMax": 6813
6364
},
6465
{
6566
"prefix": "SessionCardFlowView-",
66-
"gzipBytesMax": 767
67+
"gzipBytesMax": 766
6768
},
6869
{
6970
"prefix": "SessionNotePreview-",
70-
"gzipBytesMax": 4899
71+
"gzipBytesMax": 4898
7172
},
7273
{
7374
"prefix": "SessionProofView-",
74-
"gzipBytesMax": 1069
75+
"gzipBytesMax": 1067
7576
},
7677
{
7778
"prefix": "SessionScreenCard-",
7879
"gzipBytesMax": 255
7980
},
8081
{
8182
"prefix": "SessionTriTrackView-",
82-
"gzipBytesMax": 4325
83+
"gzipBytesMax": 4324
8384
},
8485
{
8586
"prefix": "SessionsPage-",
86-
"gzipBytesMax": 21012
87+
"gzipBytesMax": 20987
8788
},
8889
{
8990
"prefix": "SettingsPage-",
90-
"gzipBytesMax": 17762
91+
"gzipBytesMax": 17760
9192
},
9293
{
9394
"prefix": "TaskConversationView-",
9495
"gzipBytesMax": 2923
9596
},
9697
{
9798
"prefix": "ViewSwitcher-",
98-
"gzipBytesMax": 562
99+
"gzipBytesMax": 559
99100
},
100101
{
101102
"prefix": "colorPalette-",
@@ -123,7 +124,7 @@
123124
},
124125
{
125126
"prefix": "useColumnFlip-",
126-
"gzipBytesMax": 817
127+
"gzipBytesMax": 815
127128
},
128129
{
129130
"prefix": "useDbRefresh-",
@@ -143,7 +144,7 @@
143144
},
144145
{
145146
"prefix": "vendor-katex-",
146-
"gzipBytesMax": 144412
147+
"gzipBytesMax": 79915
147148
},
148149
{
149150
"prefix": "vendor-md-",

0 commit comments

Comments
 (0)