For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 把 app/ 生产构建的首屏 JS gzip 从实测 654.72 kB(单一 chunk、零代码分割)降到 docs/standards/performance.md:28 的既有预算 < 200 kB;若控制方裁决否掉唯一能达标的路线,则按规格 §10 允许的另一半交付 —— 一份带实测读数、复现命令与归属批次的「包体瓶颈清单」。
Architecture: 本批只动三个面:① app/vite.config.ts 的 build.rollupOptions.output.manualChunks(vendor 分组 + 为批 6 预留 GSAP 独立 chunk 槽);② app/src/App.tsx 的模块加载边界(9 个静态页面 import → 按页动态 import + 首访挂载保活;两个窗口变体与全局对话面板同样改为按需);③ 新增两个纯函数/脚本交付物(app/src/build/manualChunks.ts + scripts/check-bundle-budget.mjs),让「首屏 gzip」与「GSAP 独立 chunk」从形容词变成机器判据。不新增任何依赖、不改任何 Rust、不迁移任何原语调用点、不重构 app/src/ui/primitives/**。
Tech Stack: Tauri 2 · React 19.1 · TypeScript 5.8(strict,禁 any)· Vite 7.3.6(build.rollupOptions)· Vitest 4(全局 environment: "node")· Node 24(scripts/*.mjs 门禁)
Spec: 2026-09-11-frontend-redesign-design.md(§2 现状基线「包体」行 · §3 红线 2「不引入路由库」· §10 批次划分批 2 行 · §11 验收口径第 9 条 · §13 风险表「包体继续膨胀」行)
输入材料(开工前五份,优先级即此序)
- 本计划的 §实测基线(计划者在
dev@f12aba6d实跑npm run build+ 两个探针得到的全部读数;下游任何任务都不得引用规格 §2 的 651.25 kB —— 那是过期快照,实测是 654.72 kB) .superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/(探针原始输出:build-before.txt·probe-pkg.txt·probe-eager-graph与probe-route-ceiling的脚本与输出;该目录不入库)- 规格对应节(§2 / §10 / §11 / §13 / §14)与
docs/standards/performance.md(预算真源) .superpowers/sdd/DISPATCH-TEMPLATE.md(实施者纪律)·.superpowers/sdd/REVIEW-TEMPLATE.md(评审者纪律)- 兄弟计划 2026-09-11-frontend-redesign-batch1-deletions.md —— 必读它的「收口回写」节(该节是批 1 用五次证伪换来的方法论,本计划的「连通性判据」「仪器自检」「提交纪律」三节全部从它派生)
-
本批范围(控制方 2026-09-12 交办 + 规格 §10 批 2 行):
manualChunks· 按页动态 import · 量首屏 gzip · 产出瓶颈清单 · 为批 6 预留 GSAP 独立懒加载 chunk 槽(不装 GSAP)。 -
★ 本批的四个非目标(违反即任务失败)
- 不装 GSAP(批 6 才装)。本批只让「批 6 装了之后 GSAP 必然落进自己的懒加载 chunk」这件事机器可证(Task 5)。
- 不动
app/src/ui/primitives/**(批 0-D 交付的 L1 层,42 个文件,契约见 ADR-033)。本批一个字节都不改它,也不以它作为前端基线计数依据。 - 不改运行行为、不改界面:业务逻辑、渲染结果、Tauri 调用面一律不变。既有 vitest 测试必须逐条原样通过(0 处断言修改、0 处新增
await、0 处改 mock)。唯一被允许的「行为差异」是 Task 6–8 的首访挂载时序(见下条待裁决项),且必须由控制方书面裁决后才可落地。 - 不碰任何 Rust 代码(
app/src-tauri/**本批只读;cargo test只作门禁,不作改动对象)。
-
★ 控制方待裁决项(本批唯一的前置阻塞;未裁决前 Task 6 不得开工,Task 1–5 / 7–12 不受阻) 「按页动态 import」在本仓不是套一层
React.lazy就成立:app/src/App.tsx今天静态 import 了全部 9 个页面(:20-34),并在<main>里同时渲染全部 9 个(:311-419,用display:none切可见性,type Page联合在:47)。所有页面永远被渲染 ⇒ 单纯lazy()不会推迟任何加载,首屏一个字节都不会降。要真降,必须让未访问的页面不被挂载,而这改变了启动时序(今天 9 页在启动时各自跑首屏数据加载与事件订阅,改后推迟到首次访问)。两条路线与实测上限:路线 内容 首屏仍可达的应用源文件 首屏仍拉入的 npm 包 依赖 gzip 近似 能否达标(<200) 代价 A · 叶级懒加载 只把已经被条件渲染的重组件改为 lazy:NoteMarkdown/RichEditorView/ChatMessageMarkdown/KnowledgeCanvasView/KnowledgeGraphView251 → 221 15 → 5 88.8 ❌ 不能(应用代码 221 个文件仍全在首屏) 测试面风险: KnowledgePage.test.tsx(同步getBy×6)·NotesPage.test.tsx(×7)·NoteReadingView.test.tsx都直接渲染这些消费者,lazy会让首帧挂起 ⇒ 可能需要加await,与「0 处断言修改」非目标直接冲突B′ · 页级 + 面板 + 两窗变体 9 页按页 lazy+ 首访挂载保活;CaptureFloatPanel/CaptureOverlayPanel按窗口变体lazy;AiConversationDock首开挂载251 → 47 15 → 4 64.1 ✅ 能(预估首屏 ≈ 105–135 kB) 运行行为有变:未访问过的页面在启动时不再挂载(其首屏数据加载与事件订阅推迟到首次访问) B′ 被否时的兜底 只做 manualChunks+ 窗口变体 + 对话面板(Task 7/8),页面级顺延给批 5 与「惰性挂载」一并做251 → 251 15 → 4 64.1 ❌ 不能 首屏 ≈ 250–300 kB ⇒ 按规格 §10 后半句交付瓶颈清单,本批仍算成功 要求:控制方在 Task 2 开工前给出书面裁决(写入
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/progress.md),二选一 —— 授权 B′(含对「不改运行行为」非目标的显式改判,因为二者不可兼得),或否掉 B′ 走兜底。实施者不得自行选择(批 1 已五次证明「计划自己拍板的范围决策会被现实证伪」,而这次涉及页面挂载时序,代价与批 1 的纯死代码删除不可同日而语)。无论选哪条,Task 1–5 与 Task 9–12 照常执行。若走 B′:未保存原文不会丢 —— B′ 是「首访挂载 + 保活」,一旦页面被访问过就永不卸载(与今天 TD-004 的保活语义一致),因此「笔记编辑态切页丢稿」这一类风险不在本批引入(那是批 5「切视图先 flushSave」的范围,规格 §7.3 约束 3)。这一条必须由控制方在裁决时一并确认。
-
★ 达标定义(见下节 §达标定义;规格未给数字,本计划从既有标准文档取得并提请批准)
-
行数红线(唯一有效口径):单文件 ≤300 行(全部行数,含空行)。唯一有效口径 =
[System.IO.File]::ReadAllLines($p,[System.Text.Encoding]::UTF8).Count==scripts/line-limits.mjs的countLines()。⚠️ 绝不使用:Get-Content(本机 PowerShell 5.1 + 码页gb2312按 GBK 解码、少算可达 56 行,文档域实测少算 138 行)·Measure-Object -Line(只数非空行)· 字节0x0A计数(末尾无换行的文件少算 1)。301–600 区间必须登记在docs/standards/line-limit-exemptions.md;>600 必须硬拆。本批只新增 3 个小文件 + 改 3 个既有文件,一律不得新增豁免登记;docs/standards/line-limit-exemptions.md里只许调整已有行的数值(由node scripts/line-limits.mjs --write生成,不得手改数字)。 -
改造后必须平齐的门禁基线(批 1 收口实测,
dev@f12aba6d)门禁 基线 node scripts/check-command-registry.mjsexit 0 · 定义 312 / 注册 312 / 重复 0 node scripts/line-limits.mjs --fullexit 0 · >6000 · 301–600 档 123 · 登记条目 123node scripts/docs-check.mjsexit 0 cd app; npx tsc --noEmitexit 0(0 错) cd app; npx vitest runexit 0 · 124 文件 / 1124 用例(本批只许持平;任何下降都要 STOP 并报控制方) cd app/src-tauri; cargo test --test app_lib_testsexit 0 · 2300 passed / 0 failed / 6 ignored(本批不动 Rust ⇒ 必须逐字持平) cd app/src-tauri; cargo clippy --all-targetsexit 0 · 19 条,集合与开工基线 identical 每个任务都必须以这六条全绿收尾,不可「稍后一起跑」。本批改动不碰 Rust,故
cargo两条必须逐字不动;一旦变动即说明改错了面。ffmpeg::tests::run_captured_handles_large_output是装载敏感偶发失败(内含 10s 墙钟超时)⇒ 门禁串行执行,或单独复跑该用例再下结论。2026-09-12 更正(Task 12 回写)—— clippy 的判据只能是「集合」,不能是「sha256」:Task 1 把开工基线冻结为
.superpowers/.../tmp/clippy-baseline.txt(BOM + CRLF,1724 B),而抽取器的输出是无 BOM + LF(1702 B)⇒ 比 sha256 会永远假红(Task 3 实测F889CA71…vs76319132…,集合本身 19/19 逐行相同)。⇒ 本表第四列「identical」一律理解为诊断文本 @ 文件:行:列排序去重后的集合逐行相等(Task 12 复跑:baseline rows: 19 | now rows: 19 · only in baseline: 0 · only in now: 0 · SET-IDENTICAL,比对器自检注入 1 条伪造诊断 ⇒ 报 1 处差异)。 -
⚠️ 判退出码的固定口径(PS 5.1):cargo往 stderr 写 warning 时,2>&1 | …会被 PS 5.1 包成NativeCommandError,让成功的命令报 exit 1(docs/versions/v0.22.md:156实测)。⇒ 原生命令一律2>file重定向或直接读$LASTEXITCODE,绝不2>&1 |;需要回看输出时重定向到文件再读回。 -
★ 仪器纪律(批 1「收口三」的四条硬纪律 + 本批新增第 5 条,逐条适用)
- 任何「0 命中」结论必须点名仪器,并先证明该仪器能命中一个已知存在的串、且对无意义串报 0。(批 1 实测 6 次假绿/假阴性。)本批每个「0 命中」型声明都必须附这样一对自检。
- 禁用裸
includes()/ 子串 grep 判连通性:用引号定界字面量或正则词边界。本批实测的同类陷阱就在眼前:node_modules/react是react-dom/react-markdown/@xyflow/react的子串 —— 用它做manualChunks会把三个不相干的包并进vendor-react,直接破坏懒加载边界(Task 3 有一条反例守卫专钉这条)。 \b在全角)(U+FF09)前永不匹配;需要边界时用显式字符类(如(?<![A-Za-z0-9_]))。- PowerShell 的
-Include在没有-Recurse时不生效:Get-ChildItem "app\src" -File -Include *.tsx返回 0 个文件(2026-09-12 控制方实测踩到,把「页面 import」误报成 0 命中)。正确写法 =Get-ChildItem -Recurse -File | Where-Object { $_.Extension -in '.tsx','.ts' }。这是批 1 记录的第 7 类假阴性仪器。 - 全树扫描必须排除
.superpowers/:各批tmp/下解包的历史归档副本会被当成命中源(批 1 一次假 313 命中)。本批的扫描命令统一加--glob '!.superpowers/**'或先Get-ChildItem -Exclude .superpowers。
2026-09-12 追加(Task 12 回写):本批实测又新增 8 类仪器陷阱(累计 13 类),后续批次一律照用。 6.
\b/正则遇全角)与中文:含中文的搜索模式不得经 PowerShell 字符串层传递(PS 5.1 按 GBK 误解码 ⇒ 把中文模式搜成瀛愪覆,给出 0 命中的假阴性);用 Node/Python 或 UTF-8 读文件。同族:无 BOM 的 UTF-8.ps1含中文会被 GBK 解码(-File执行时连脚本内的cmd行一起毁掉)⇒ 交给-File的脚本保持纯 ASCII。 7. 整树 ripgrep 会遵守.gitignore⇒ 对被忽略目录整体失明(本批实测:app/src/build/manualChunks.ts里的vendorGroupOf在app/下报 0 命中,窄域 grep 报 19)。⇒ 用rg --no-ignore/git grep --no-index/ 显式遍历。 8. PS-Include无-Recurse时静默返回 0 个文件(Get-ChildItem app\src -File -Include *.tsx= 0;正确写法-Recurse -File | Where-Object Extension)。 9. PS 5.1>重定向写 UTF-16LE ⇒ NodeJSON.parse读--json捕获文件会失败;用cmd /c "… > f"或Out-File -Encoding utf8。 10. Tauri 命令名不是唯一的 chunk 标记(close_capture_float在ClassroomPage.tsx里也有)⇒ 用「panel-only 标记 + 不跟随动态边」;chunk 依赖边是模块图的性质,不是归属表的性质 —— 迁移模块必须连它自己的依赖闭包一起迁,否则边还在(本批 md↔katex 环的根因)。 11.git archive在未跟踪子目录产出 10240 字节空归档 + exit 0;解包树里git grep静默 0 命中(无.git)⇒ 一律在仓库根 + 绝对-o。 12.Copy-Item保留 mtime ⇒ cargo 回放陈旧诊断,还原文件后先touch。 13. A/B Δ 恰为 0 时先当仪器故障(不是「无差异」):先自证仪器能测出已知差异,再下结论。 -
★ 连通性判据(批 1「收口一」第 1 条,本批的技术底座):「这个模块看起来还有人用」不是保留依据;判一块代码是否仍连着,唯一可靠的方法是从入口反向做可达性分析。本批四个任务(1 / 2 / 9 / 10)全部采用同一把尺:从
app/src/main.tsx出发、只沿静态 ESMimport/export … from走、把import(…)当作断点,得到「首屏可达模块集合」。探针脚本已在tmp/probe-eager-graph.mjs,Task 1 会把它固化成scripts/bundle-eager-graph.mjs。 -
★ 文本扫描型守卫会被注释/测试名里的字面量误伤(批 0-D Task 10 实测):若某个测试必须提到本批新增/改动的字符串,用拼接写法(
"vendor-" + "gsap")或改述,绝不为了绕开守卫去改守卫本身。 -
★ 提交纪律:
git commit --only -m "<msg>" -- <显式路径…>(本仓多 agent 并行,裸git commit会扫走别人已暂存的条目,包括D删除条目 —— 已实际发生)。禁止:git add -A·git add .·git stash·git checkout --·git restore·git clean·git reset --hard·--no-verify。提交信息 Conventional Commits:<type>(<scope>): <subject>,subject ≤50 字、动词开头、无结尾句号。本批禁止任何删除操作(无文件删除、无依赖卸载)—— 若某任务确需删除,STOP 并报控制方。2026-09-12 追加(收口评审 I-1 结清)—— 对「新建文件」,上一行的命令形态跑不通。 实测
git commit --only -m "<msg>" -- <未跟踪路径>报exit 1 / error: pathspec '<path>' did not match any file(s) known to git(台账 §四「三处漂移」第 1 条,控制方在提交两份计划文件时亲自踩到;Task 1 阶段复现并已登记)。正确形态(新增文件)= 两步:先git add -- <显式单路径>,再git commit --only -m "<msg>" -- <同一路径>。已跟踪文件可继续一步到位。禁用表不变(git add -A/git add ./git stash/git checkout --/git restore/git clean/git reset --hard/--no-verify)。本计划里属「新建文件」场景的提交命令共 3 处(Task 1 Step 7 · Task 2 Step 5 · Task 3 Step 6,三处均已就地加注)。 -
★ 判门禁要在提交树上判:要证「某个提交自洽」,必须导出该提交的树再跑(
git -c core.autocrlf=false archive -o <绝对路径>.tar <commit>→ 解包到.superpowers/sdd/<batch>/tmp/<子目录>/→ 在该树内跑它自带的脚本)。⚠️ git archive必须在仓库根执行 +-o用绝对路径(在未跟踪子目录里执行会产出 10240 字节空归档且 exit 0);且在解包树里没有.git,git grep会静默 0 命中(仪器坏,不是树干净)。 -
★
cmd会吃掉^:cmd /c "git show X^:path"静默变成看当前提交。读历史文件一律在 PowerShell 里读完整文件,绝不读 diff hunk 推断全文。 -
★
Copy-Item保留 mtime ⇒ cargo 判定产物最新、复验读到陈旧缓存里的诊断集。本批不碰 Rust,但若任何步骤还原了文件,先touch再复验。 -
★ 辅助函数/变量禁用 PowerShell 内置别名名:
rdrmmvcplscdcatscgisi—— 别名会静默覆盖你的函数(批 1 因此发生一次真实破坏性事故:7 个文件被删、lib.rs被写成 1 行)。 -
★
core.autocrlf=true+ 工作树存在 LF-only 异类文件 ⇒git hash-object相等与空的git diff都可能掩盖 EOL 差异;git status干净是唯一可靠判据。 -
报告与临时文件:报告写
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/task-<N>-report.md,评审写同目录task-<N>-review.md。该目录已被.superpowers/sdd/.gitignore(内容为*)整体忽略 ⇒ 永不git add -f。探针/日志/基线/解包树一律写.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/下的子目录,不许放仓库根。 -
★ 本批新门禁脚本不进
pre-commit(有意,须在报告中写明理由):.husky/pre-commit是共享文件,现跑line-limits --full && docs-check && check-command-registry三条纯文本只读全树扫描;本批的check-bundle-budget.mjs需要先跑一次 4.2 秒的 vite 构建并写app/dist/,把它塞进 pre-commit 会让每个 agent 的每次提交都依赖「此刻工作树能构建成功」,在并行期必然互相打断。⇒ 接线留给批 8(治理收口),本批只保证脚本存在、可跑、自检通过,并在每个涉及包体的任务里手工跑它。此项登记见 Task 11 的瓶颈清单与 Task 12 的 follow-ups。 -
★ 规格文档的写法纪律:本计划的 §实测基线 与 §达标定义 是计划者实测,若与规格 §2/§10 冲突,以本计划为准(并在 Task 12 回写规格)。但 Task 6 的路线裁决不得由实施者自行作出(见待裁决项)。
达标 = 首屏 JS gzip < 200 kB。
- 数字来源不是本计划发明的:
docs/standards/performance.md:28的绩效预算表已写死 —— 「页面包大小 (JS) | < 200KB (gzip) | Bundle 分析」。规格 §2 的包体行也引用了它(「performance.md预算为 <200KB gzip → 超标 3.26 倍」)。⇒ 本计划采用既有预算,不另立数字。 - 需要控制方批准的只有「首屏」这个词的口径(标准文档只写「页面包大小 (JS)」,未定义首屏):
- 首屏 chunk 集合 =
app/dist/index.html里<script type="module">指向的入口 chunk,加上从入口出发只沿静态 ESMimport/export … from可达的全部.jschunk;import(…)动态 chunk 不计入首屏。(理由:Tauri 走本地 asset 协议,入口 HTML 拉起的 module graph 就是「主窗出现之前的 JS 传输量」。) - 只算
.js:CSS(今天 11.75 kB gzip)与字体资产不计入这 200 kB(标准文档括号里写的是 JS)。但它们不得因此失控 —— Task 10 必须把 CSS/字体读数一并记录进瓶颈清单。 - 度量工具 =
node scripts/check-bundle-budget.mjs(Task 2 交付),其 gzip 用zlib.gzipSync(buf, { level: 6 })。已校准:对今天的唯一 chunk 该口径得 654,722 字节 = 654.72 kB,与 vite 自己打印的gzip: 654.72 kB逐字相等(vite 用十进制 kB,即 ÷1000,不是 KiB)。 - 附加达标线(窗口变体):
index.html?float=1与?overlay=1两个独立窗口的首屏 JS gzip,今天与主窗完全相同(同一个入口、同一个 654.72 kB chunk)—— 采集浮窗是alwaysOnTop的常驻窗口,为它加载完整主壳是纯浪费。本计划提议该值 < 60 kB(Task 7 后实测预期 ≈ 50–60 kB;作为建议项而非硬门禁,控制方可另定)。
- 首屏 chunk 集合 =
- 不达标时的合法交付(规格 §10 原文:「从 651KB gzip 降到达标或给出瓶颈清单」):若控制方否掉路线 B′,或落地后仍 > 200 kB,本批的验收物转为 Task 11 的瓶颈清单,且清单必须逐项给出 实测 gzip / 为什么降不下去 / 归属批次 / 复现命令 四列。「降不下去」是有证据的合法结论,不是失败。
命令(规格要求记录逐字命令;仓库根执行):
cd app; npm run build # = tsc && vite build观测输出(节选,全文见 tmp/build-before.txt):
vite v7.3.6 building client environment for production...
✓ 746 modules transformed.
dist/index.html 0.49 kB │ gzip: 0.31 kB
dist/assets/index-DEye2S6d.css 49.28 kB │ gzip: 11.75 kB
dist/assets/index-BctYHBP-.js 2,106.46 kB │ gzip: 654.72 kB
(!) Some chunks are larger than 500 kB after minification. Consider:
- Using dynamic import() to code-split the application
- Use build.rollupOptions.output.manualChunks to improve chunking: https://rollupjs.org/configuration-options/#output-manualchunks
✓ built in 4.18s
| 量 | 实测 | 说明 |
|---|---|---|
| JS chunk 数 | 1 | 零代码分割 |
| JS 原始 / gzip | 2,106.46 kB / 654.72 kB | 十进制 kB(vite 口径,÷1000);磁盘字节 2,106,457 |
| CSS 原始 / gzip | 49.28 kB / 11.75 kB | 单一 index-*.css |
index.html |
0.49 kB / 0.31 kB | |
| KaTeX 字体资产 | 59 个文件 / 1,047.80 kB(.woff2 19 / 250.16 · .woff 20 / 296.01 · .ttf 20 / 501.63) |
按需 @font-face,不进首屏,但进安装包 |
app/dist 合计 |
3,157.49 kB | |
| 构建耗时 | 4.18s · 746 modules | |
| 独立复算 | zlib.gzipSync(buf,{level:6}).length = 654,722 字节 |
与 vite 打印值逐字相等 ⇒ 该口径可作机器判据 |
2026-09-12 更正(Task 12 回写,本表两处单位错标):本表其余各行都是十进制 kB(÷1000),但 · 「KaTeX 字体资产 1,047.80 kB」实为 KiB ⇒ 十进制是 1,072,948 B = 1,072.95 kB; · 「
app/dist合计 3,157.49 kB」实为 KiB ⇒ 十进制是 3,233,274 B = 3,233.27 kB(批 2 终态 3,228,859 B = 3,228.86 kB)。 ⇒ 引用这两行必须连单位一起抄,否则会凭空多出 ≈25 kB 字体 / ≈76 kB dist 的假 Δ。(子项.woff2/.woff/.ttf三行同源,也是 KiB。)
规格漂移(第 1 处):规格 §2 现状基线写「JS 2,093.85 kB / gzip 651.25 kB」,实测 2,106.46 / 654.72(+12.61 kB 原始 / +3.47 kB gzip)。规格 §10 批 2 行与 §11 验收 9 的「651KB」同源过期。⇒ Task 12 回写。 规格漂移(第 2 处):规格 §2 那句「单个 chunk,零代码分割」成立,但 §10 把「
manualChunks」列为降首屏的手段 —— 实测/推理结论:manualChunks本身一个字节都不降首屏。它只把同一批字节切成多个文件,入口仍静态依赖全部 chunk。它的真实价值是 ① 让批 6 的 GSAP 能落进独立懒加载 chunk(规格 §13 风险表要求)② 让import()产生的懒 chunk 有稳定的共享 vendor 边界、不被 rollup 默认算法复制或打散。⇒ Task 12 在规格 §10 批 2 行补一条口径注。
仪器:tmp/probe-pkg-sizes.config.mjs(一次性 vite config,manualChunks(id) 按包名分组)
命令:
cd app; npx vite build --config "<仓库根>\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\probe-pkg-sizes.config.mjs"
⚠️ 仪器局限(必须先读再引用):该探针把 133 个包切成 133 个 chunk,总 gzip 从 654.72 膨胀到 695.95 kB(+41.23 kB / +6.3%) —— 增量是 chunk 样板与跨 chunkimport语句。⇒ 探针只用于「归因占比」,绝不可把探针之和当作落地后的尺寸预测。任务 10 的对比一律以真实构建输出为准。⚠️ 表的归组口径 ≠ 落地的归组口径(两者都在本计划里,别混用):本表的族边界取自探针的包名分组;落地分组的唯一真源是 Task 3 的EXACT/PREFIX表。两处对少数共享包的归属不同 —— 例:property-information/hastscript/web-namespaces本表记在vendor-md行,Task 3 记在vendor-katex。这类差异只在两个懒 chunk 之间搬字节,对首屏总量与达标判定一个字节都不影响(两边都是懒 chunk);引用数字时以本表看占比、以 Task 3 看落点。
2026-09-12 追加(收口评审 I-1 结清)—— 「251」这个分子与「276」这个分母不是同一个口径(台账 §四「三处漂移」第 2 条)。 本表「静态可达 251 个文件」与 §表 3 baseline 行的「251 / 276」里:分母 276 =
app/src非测试.ts(111) +.tsx(165),不含 CSS;而分子 251 含 2 个.css。⇒ 一致口径应为 249 / 276 或 251 / 288(app/src非测试共 289 个文件,含 1 个.svg)。分子读数本身无误(Task 1 的探针实测),但引用比值时必须先统一口径,否则「251/276」这个分数是两种口径混写出来的。 同族提醒:本表同处的<<应用源码>>占比 190.52 kB / 27.4% 是探针口径(总量比真实构建膨胀 +6.3%,见上条仪器局限),与 §表 1 的真实构建读数不可混用。
| 族 | gzip (kB) | 占比 | 内容 |
|---|---|---|---|
vendor-editor |
218.69 | 31.4% | @codemirror/view 62.59 · @lezer/javascript 30.69 · @codemirror/state 15.30 · @codemirror/autocomplete 12.66 · @lezer/markdown 12.02 · @codemirror/language 9.89 · @lezer/common 9.55 · @lezer/lr 8.83 · @lezer/css 8.08 · @codemirror/commands 7.83 · @codemirror/search 6.83 · @lezer/html 6.26 · @codemirror/lang-html 5.51 · @codemirror/lang-css 4.53 · @codemirror/lint 4.62 · @codemirror/lang-markdown 3.82 · @codemirror/lang-javascript 2.93 · @lezer/highlight 2.83 · 其余 5 个小件 |
<<应用源码>> |
190.52 | 27.4% | app/src/** 全部(静态可达 251 个文件,见 tmp/probe-eager-graph.mjs 输出) |
vendor-katex |
88.00 | 12.6% | rehype-katex 78.09(内含嵌套的 katex@0.16.47 JS)· katex CSS 8.06 · hast-util-from-dom 0.51 · hast-util-to-text 1.34 |
vendor-canvas |
65.66 | 9.4% | @xyflow/react 24.67 · @xyflow/system 17.16 · 9 个 d3-* 合计 19.33 · zustand 0.80 · use-sync-external-store 0.85 · classcat 0.18 |
vendor-md |
65.30 | 9.4% | react-markdown 1.35 · micromark-core-commonmark 7.17 · property-information 6.05 · micromark 4.10 · mdast-util-to-hast 3.41 · mdast-util-to-markdown 3.60 · mdast-util-from-markdown 3.22 · 其余 40 个 mdast/micromark/unist/hast/vfile 小件 |
vendor-react |
61.01 | 8.8% | react-dom 56.29 · react 3.14 · scheduler 1.58 |
vendor-tauri |
4.70 | 0.7% | @tauri-apps/api 4.28 · @tauri-apps/plugin-dialog 0.42 |
| 探针合计 | 695.95 | 真实构建 654.72(差 +6.3% 见上注) |
归因的三条硬结论(本批全部任务的排序依据)
- 三大「可选」族合计 372.35 kB gzip(editor 218.69 + katex 88.00 + canvas 65.66)= 真实总量 654.72 的 57%。而它们只被三个域用到:editor → 笔记编辑态;katex → 笔记阅读 / 对话消息;canvas → 体系画布 / 图谱。
react+react-dom+scheduler= 61.01 kB gzip 是不可谈判的底线(占 200 kB 预算的 31%)。- 应用源码自己就是 190.52 kB gzip(27.4%) —— 这一块的体量与三大族同量级,且只有把页面变成动态入口才能移出首屏。这解释了为什么路线 A(221/251 个源文件仍静态可达)不可能达标。
仪器:tmp/probe-route-ceiling.mjs(从 app/src/main.tsx 出发做静态 import BFS,按候选边界把 import 边切成动态)
命令:node .superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/probe-route-ceiling.mjs
| 场景 | 首屏应用源文件 | 首屏 npm 包 | 依赖 gzip 近似 | 达标 |
|---|---|---|---|---|
| 现状 baseline | 251 / 276(非测试源文件总数 276) | 15 | 259.00 | ❌ |
| 路线 A(叶级 6 处切边) | 221 | 5 | 88.80 | ❌(应用代码仍 221 文件) |
| 路线 B(8 页 + 2 窗变体) | 51 | 10 | 152.23 | 边界 |
| 路线 B + 对话面板 | 47 | 4 | 64.13 | ✅ |
| 路线 A+B | 50 | 4 | 64.13 | ✅(A 被 B 完全包含) |
| 路线 B+面板+课堂页也懒加载 | 17 | 4 | 64.13 | ✅(本批不做 —— 课堂是默认页,懒加载它等于首屏必拉一次动态 chunk) |
⇒ 关键发现(本计划最重要的一条,也是「按页动态 import」这个动作的真正理由):AiConversationDock 被 App.tsx:36 静态 import、在 :422 常驻挂载,而它 :8 静态 import 了 TaskConversationView,后者 :14 静态 import 了 ChatMessageMarkdown,后者 :12/:13 import 了 rehype-katex 与 katex/dist/katex.min.css。⇒ 即使把 9 个页面全部改成按页懒加载,主窗首屏仍会拉进整条 markdown + katex 栈(≈153 kB gzip),@xyflow/react 同理。
⇒ 路线 B 必须与 Task 7(两窗变体)+ Task 8(对话面板)合起来才达标;三条缺一不可。这条链只有靠「静态可达性」这把尺才看得见 —— 用「这个组件看起来按需渲染」的直觉判断会全部漏掉(批 1 的「不删清单」就是这么被证伪五次的)。
| 落点 | 事实 | 证据 |
|---|---|---|
app/vite.config.ts |
40 行;完全没有 build 段(无 rollupOptions/manualChunks);形态是 defineConfig(async () => ({ … })) |
全文已读 |
app/package.json |
"build": "tsc && vite build";12 个 dependencies / 9 个 devDependencies;无 gsap |
全文已读 |
app/src/App.tsx |
439 行(301–600 档,已在豁免表);:20-34 9 处静态页面 import;:36 AiConversationDock;:38 CaptureFloatPanel;:39 CaptureOverlayPanel;:47 type Page;:72-83 两个窗口变体早返回;:98 useState<Page>("classroom");:311-419 9 个 display:none 容器 |
全文已读 |
app/src/pages/ |
9 个页面:ClassroomPage.tsx 278 行 · NotesPage.tsx 295 · SessionsPage.tsx 352 · ActionPage.tsx 41 · ReviewPage.tsx 222 · ChatPage.tsx 593 · SettingsPage.tsx 146 · KnowledgePage.tsx 437 · GoalsPage.tsx 152 |
控制方 2026-09-12 实测 |
app/src/build/ |
不存在(本批新建目录,放纯函数 manualChunks.ts + 单测) |
Get-ChildItem app/src -Directory 实测 |
scripts/ |
现有 9 个 .mjs:check-command-registry · docs-check · line-limits · session-* ×3 · validate-all(重构前遗留、指向不存在的 client//server/,不要参考它)· version-bump |
实测 |
app/tsconfig.json |
include: ["src"],没有 @types/node;node:fs/node:path/node:url 的最小声明在 app/src/node-builtins.d.ts(只有 readFileSync/readdirSync/statSync · dirname/join/relative/sep · fileURLToPath;没有 resolve、没有 existsSync、没有 process) |
全文已读 |
| 测试文件路径范式 | const HERE = dirname(fileURLToPath(import.meta.url)) 然后 join(HERE, …)(app/src/ui/tokens.drift.test.ts:18、app/src/ui/icons/no-inline-svg.test.ts:18) |
实测 |
不存在 App.test.tsx |
⇒ 路线 B′ / Task 7 / Task 8 的测试面为 0(没有任何测试渲染 App.tsx),这正是 B′ 的测试风险低于路线 A 的原因 |
Get-ChildItem app/src -Recurse -File -Filter *.test.* 实测 |
| Task | 内容 | 依赖 | 可否并行 | 改的文件 |
|---|---|---|---|---|
| 1 | 基线冻结:真实构建 + 六门禁读数 + 把可达性探针固化成 scripts/bundle-eager-graph.mjs |
— | ✅ 与 T2 并行 | 新增 scripts/bundle-eager-graph.mjs |
| 2 | 首屏预算守卫 scripts/check-bundle-budget.mjs(把「达标」变成机器判据) |
T1(口径与校准值) | ✅ 与 T1 并行 | 新增 1 个脚本 |
| 3 | manualChunks 纯函数 + 单测(app/src/build/manualChunks.ts) |
— | ✅ 与 T1/T2 并行 | 新建 2 个文件 |
| 4 | vite.config.ts 接线 + 分块拓扑验收 |
T3 | ❌ 串行(T3 之后) | app/vite.config.ts |
| 5 | GSAP 独立 chunk 预留槽 + 反例守卫(不装 GSAP) | T3 | ❌ 串行(T3 之后) | app/src/build/manualChunks.ts + 测试 |
| 6 | 按页动态 import + 首访挂载保活(9 页) | T4 · 控制方路线裁决 | ❌ 串行(独占 App.tsx) |
app/src/App.tsx |
| 7 | 两个窗口变体懒加载(?float=1 / ?overlay=1) |
T6(同文件,顺位) | ❌ 串行 | app/src/App.tsx |
| 8 | 对话面板首开挂载(AiConversationDock + 保活) |
T7(同文件,顺位) | ❌ 串行 | app/src/App.tsx |
| 9 | 重依赖去重与传递链审计(分裂后复测;过「size-only ∧ behavior-neutral」双闸才动) | T4–T8 | ✅ 与 T10 前半并行 | 预计 0 个文件(审计型;若过闸则另立子步) |
| 10 | 终测:同命令复跑 + 前后对比 + 窗口变体首屏 | T4–T9 | ❌ 最后测量 | 0(只产出读数) |
| 11 | 瓶颈清单(规格交付物,写入本计划 §收口回写) | T10 | ❌ | 本计划文件 |
| 12 | 收口:六门禁 + 规格 §10 进度标记 + v0.22 台账 + follow-ups | 全部 | ❌ 最后 | 规格 · docs/versions/v0.22.md · 豁免表(如需) |
T6/T7/T8 必须串行:三者都改
app/src/App.tsx(共享文件),并行会造成git add/git commit交错污染(批 0-C 期实测 10 次事故)。同一时刻最多一个实施者动App.tsx。 T1/T2/T3 可安全并行(三套互不相交的新文件)。
-
先立影响面:开工前跑一次该任务会碰到的全部测试文件与门禁,把读数写进报告的「开工读数组」。(本批的任务会改模块加载边界,「改完才知道破了什么」是不可接受的。)
-
一次只切一个边界:改一处 → 跑门禁 → 绿则继续,红则回退这一处并记录,不得为了变绿去改测试、改 mock、加
await。 -
验收判据是「既有测试逐条原样通过」,不是「新测试覆盖了」。用例数只许持平(
124 文件 / 1124 用例);任何下降或断言改动 ⇒ STOP 并报控制方。 -
构建读数必须留痕:涉及
App.tsx/vite.config.ts的任务都要跑cd app; npm run build并保存完整输出到tmp/build-task<N>.txt,同时跑node scripts/check-bundle-budget.mjs --no-build记录首屏 gzip 的逐任务变化曲线(这是本批最有价值的过程数据)。 -
报告必含「你没能验证的地方」(诚实单列)。
-
提交:
git diff --stat复核只含自己的文件 →git commit --only -m "<msg>" -- <显式路径…>。2026-09-12 追加(收口评审 I-1 结清):若本步含新建文件,上一条命令会失败(
pathspec … did not match any file(s) known to git)⇒ 先git add -- <同一路径>再提交。完整口径见 Global Constraints「★ 提交纪律」的更正行。
为什么第一个做:规格要求「先测量」。本任务同时把计划者的一次性探针固化成可复跑的门禁级脚本
scripts/bundle-eager-graph.mjs—— 后续四个任务(2/4/9/10)都要用它量「首屏还剩多少模块」。本任务不改任何生产代码。
Files:
- Create:
scripts/bundle-eager-graph.mjs(≤300 行) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/baseline.md(不入库) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-before.txt(不入库) - Read(只读,不改):
app/package.json·app/src/App.tsx·app/vite.config.ts
Interfaces:
-
Produces:
scripts/bundle-eager-graph.mjs,CLI 契约 ——node scripts/bundle-eager-graph.mjs [--json] [--entry <path>],stdout 打印「首屏可达的应用源文件数 / 首屏 npm 包清单 / 逐文件清单」,退出码 0 正常、1 入口不存在。Task 2 会调用它的纯函数版本(见 Task 2 的--eager-files说明),Task 9/10 直接跑它。 -
Step 1: 记录门禁基线(六条,串行)
cd "D:\Program own\aicode\work space\Entropydecrease"
node scripts/line-limits.mjs --full
node scripts/docs-check.mjs
node scripts/check-command-registry.mjs
cd app; npx tsc --noEmit
cd app; npx vitest run
cd app/src-tauri; cargo test --test app_lib_tests 2>"..\..\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\cargo-before.txt"预期(与 Global Constraints 的基线表逐字相符):>600: 0 · 301–600: 123 · registered: 123 · docs-check 通过 · 定义 312 / 注册 312 / 重复 0 · tsc exit 0 · 124 个测试文件 / 1124 个用例 · 2300 passed / 0 failed / 6 ignored。
任一条不符 ⇒ 先停下报控制方(说明开工点不是干净的批 1 收口态),不要继续。
- Step 2: 跑真实生产构建并保存全文
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-before.txt`" 2>&1"
"exit=$LASTEXITCODE"这里用
cmd /c … > file 2>&1是允许的(重定向发生在 cmd 内、由文件承载,不走 PowerShell 管道);禁止的是npm run build 2>&1 | Select-Object …。
预期:exit=0,build-before.txt 里出现 dist/assets/index-*.js <N> kB │ gzip: 654.72 kB。记录实际文件名与数字(hash 会变,gzip 值应与本计划 §表 1 一致;不一致就是 HEAD 变了,立即报控制方)。
- Step 3: 独立复算 gzip 以校准口径
cd app
node -e "const z=require('zlib'),f=require('fs'),p=require('path');const dir='dist/assets';const js=f.readdirSync(dir).filter(n=>n.endsWith('.js'));for(const n of js){const b=f.readFileSync(p.join(dir,n));console.log(n,b.length,'bytes =',(b.length/1000).toFixed(2),'kB | gzip',z.gzipSync(b,{level:6}).length,'bytes =',(z.gzipSync(b,{level:6}).length/1000).toFixed(2),'kB');}"预期:index-*.js 2106457 bytes = 2106.46 kB | gzip 654722 bytes = 654.72 kB —— 与 vite 打印值逐字相等。这一条就是 Task 2 判据的校准依据,必须写进报告。
- Step 4: 写
scripts/bundle-eager-graph.mjs
#!/usr/bin/env node
/**
* @ai-context 首屏静态可达性探针(批 2 包体治理的连通性尺子)。
*
* Why:本仓唯一的「首屏」定义是**模块加载边界**,不是「组件看起来是不是按需渲染」。
* 批 1 的「不删清单」被证伪五次,根因就是用「看起来还有人用」代替「删除后的可达性」;
* 包体治理是同一类问题的镜像 —— `AiConversationDock` 看起来「按需唤起」,
* 但它被 App.tsx 静态 import 且常驻挂载,于是整条 markdown+katex 栈都在首屏。
* 唯一可靠的判据是:从 app/src/main.tsx 出发、只沿静态 ESM import 走。
*
* 口径:静态边 = `from "…"` 与裸 `import "…"`;`import(…)` 是动态边界,**不跟**。
* 相对说明符按 Node 解析顺序补扩展名("", .ts, .tsx, .js, .jsx, /index.ts, /index.tsx);
* 裸说明符按包名归并(`@scope/name` 取两段)。
*
* 副作用:只读文件系统;不写任何文件。
* 边界:不做 tsconfig paths 解析、不处理 `export * as ns from`(本仓未使用)。
* 用法:node scripts/bundle-eager-graph.mjs [--json] [--entry app/src/main.tsx]
* 退出码:0 = 正常;1 = 入口不存在。
*/
import { readFileSync, existsSync, statSync } from "node:fs";
import { dirname, resolve, relative, sep, join } from "node:path";
import { fileURLToPath } from "node:url";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const argv = process.argv.slice(2);
const AS_JSON = argv.includes("--json");
const entryArg = argv.indexOf("--entry") >= 0 ? argv[argv.indexOf("--entry") + 1] : "app/src/main.tsx";
const ENTRY = resolve(ROOT, entryArg);
const EXT = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"];
const RE_FROM = /\bfrom\s*["']([^"']+)["']/g;
const RE_BARE = /\bimport\s*["']([^"']+)["']/g;
function resolveSpec(fromFile, spec) {
if (!spec.startsWith(".")) return null;
const base = resolve(dirname(fromFile), spec);
for (const e of EXT) {
const p = base + e;
if (existsSync(p) && statSync(p).isFile()) return p;
}
return null;
}
export function eagerGraph(entry) {
const visited = new Set();
const packages = new Map();
const queue = [entry];
while (queue.length) {
const file = queue.shift();
if (visited.has(file)) continue;
visited.add(file);
let text;
try { text = readFileSync(file, "utf8"); } catch { continue; }
const specs = [];
for (const re of [RE_FROM, RE_BARE]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) specs.push(m[1]);
}
for (const spec of specs) {
const resolved = resolveSpec(file, spec);
if (resolved) { if (!visited.has(resolved)) queue.push(resolved); }
else if (!spec.startsWith(".")) {
const name = spec.startsWith("@") ? spec.split("/").slice(0, 2).join("/") : spec.split("/")[0];
packages.set(name, (packages.get(name) ?? 0) + 1);
}
}
}
return { files: [...visited].sort(), packages: [...packages.keys()].sort() };
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
if (!existsSync(ENTRY)) {
console.error(`❌ 入口不存在:${relative(ROOT, ENTRY)}`);
process.exit(1);
}
const { files, packages } = eagerGraph(ENTRY);
if (AS_JSON) {
console.log(JSON.stringify({ entry: relative(ROOT, ENTRY), files: files.map((f) => relative(ROOT, f)), packages }, null, 2));
} else {
console.log(`入口:${relative(ROOT, ENTRY)}`);
console.log(`首屏静态可达应用源文件:${files.length}`);
console.log(`首屏拉入的 npm 包:${packages.length}`);
for (const p of packages) console.log(` - ${p}`);
console.log(`逐文件清单:加 --json 输出`);
}
}- Step 5: 仪器自检(三条,缺一不可)
# (a) 已知存在的串必须命中:--entry 指向一个确定存在的文件,包清单里必须出现 react
node scripts/bundle-eager-graph.mjs --json | Select-String '"react"'
# (b) 无意义串必须 0 命中(同一个仪器,同一个口径)
node scripts/bundle-eager-graph.mjs --json | Select-String 'zzz-not-a-real-package'
# (c) 入口不存在必须 exit 1
node scripts/bundle-eager-graph.mjs --entry app/src/does-not-exist.tsx; "exit=$LASTEXITCODE"预期:(a) 命中 1 行 "react";(b) 0 行(Select-String 无输出);(c) ❌ 入口不存在 + exit=1。
把 (a)/(b)/(c) 的原始输出贴进报告 —— 这是本批「任何 0 命中结论都要点名仪器并自检」纪律的第一份证据。
- Step 6: 固化基线文档
把 Step 1–3 的读数写进 tmp/baseline.md,含:六门禁逐条读数与 exit · build-before.txt 的关键行 · gzip 复算值 · bundle-eager-graph 的三条自检输出 · app/src 非测试源文件总数(276)与首屏可达数(251)。
- Step 7: 提交
git diff --stat # 只应有 scripts/bundle-eager-graph.mjs 一个新增文件
git commit --only -m "build(scripts): add first-screen reachability probe" -- scripts/bundle-eager-graph.mjs2026-09-12 更正(Task 12 回写):上面这个 subject 51 字符,超
AGENTS.md§5 的 ≤50 字上限,且照抄落地了(e46e0e82)。门禁不会拦:commitlint.config.js的header-max-length沿用 config-conventional 的 100,未覆盖为 50。Task 2 Step 5 的 subject 同样是 51 字符(同一处失误,已实测入库f0592654)。⇒ 抄写提交命令前请人工数字符数。
2026-09-12 追加(收口评审 I-1 结清)—— 本步的提交命令还有第二处会失败:
scripts/bundle-eager-graph.mjs是新建文件。 裸git commit --only -m "…" -- scripts/bundle-eager-graph.mjs在文件未跟踪时报exit 1 / error: pathspec 'scripts/bundle-eager-graph.mjs' did not match any file(s) known to git(台账 §四「三处漂移」第 1 条的原始读数)。正确形态(两步):git add -- scripts/bundle-eager-graph.mjs→git commit --only -m "build(scripts): add first-screen reachability probe" -- scripts/bundle-eager-graph.mjs。同一形态另见 Task 2 Step 5(scripts/check-bundle-budget.mjs)与 Task 3 Step 6(app/src/build/两个新文件,注意该目录当时还被.gitignore的build/规则静默吞掉 ⇒ 那一步实际需要git add -f并请示控制方)。
Verification(本任务的验收,逐条给命令与期望)
| # | 命令 | 期望 |
|---|---|---|
| V1 | node scripts/bundle-eager-graph.mjs |
首屏静态可达应用源文件:251 · 首屏拉入的 npm 包:15(与 §表 3 baseline 行一致;不一致说明 HEAD 变了,报控制方) |
| V2 | 上条命令的输出里含 @xyflow/react codemirror katex rehype-katex react-markdown |
五个都在(它们就是本批要移出首屏的对象) |
| V3 | Step 5 的三条自检 | (a) 命中 · (b) 0 行 · (c) exit 1 |
| V4 | node scripts/line-limits.mjs --full |
exit 0(scripts/ 不在 SCAN_DIRS,本任务不改变任何读数) |
| V5 | git status --short |
只剩未跟踪的 .superpowers/(本来就未跟踪)与 docs/tech-debt/(批 1 登记过,不要动它) |
为什么第二个做:规格的验收是「首屏 gzip 达标或给出瓶颈清单」。没有机器判据时,「达标」是形容词。 本任务把 §达标定义 的口径变成一条可复跑的命令,后续每个任务都用它记录曲线。本任务不改任何生产代码。
Files:
- Create:
scripts/check-bundle-budget.mjs(≤300 行) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/budget-after-task2.txt(不入库) - Read:
app/dist/index.html·app/dist/assets/*.js·docs/standards/performance.md
Interfaces:
-
Consumes: 无(不 import Task 1 的脚本 —— 两者口径不同:Task 1 量源码模块图,本任务量产物 chunk 图;刻意不共用代码以免一个的口径污染另一个)
-
Produces:
node scripts/check-bundle-budget.mjs [--no-build] [--budget <kB>] [--json] [--self-test],stdout 打印首屏 chunk 清单 + 首屏 gzip 合计 + 预算判定,exit 0 = 达标、1 = 超标、2 = 构建失败/产物缺失。Task 4–10 每个任务都要跑它(--no-build)并记录读数。 -
Step 1: 写脚本(核心:入口 → 静态 import 闭包 → gzip 求和)
#!/usr/bin/env node
/**
* @ai-context 首屏 JS 预算守卫(批 2 包体治理的「达标」判据)。
*
* Why:规格把批 2 的验收写成「从 651KB gzip 降到达标或给出瓶颈清单」,
* 但「达标」与「首屏」两个字都没有机器口径。本脚本按计划 §达标定义 固化:
* 首屏 = dist/index.html 的 module 入口 + 从它出发**只沿静态 ESM import 可达**的全部 .js chunk;
* gzip 用 zlib level 6(已校准:与 vite 打印值逐字相等)。
*
* 口径陷阱(已实测,勿改):
* - vite 打印的 kB 是**十进制**(÷1000),不是 KiB(÷1024)。本脚本一律 ÷1000。
* - 动态 import 在产物里是 `import("./x.js")`(紧跟左括号),静态是 `import"…"` / `from"…"`。
* 故静态扫描的正则**要求引号紧跟**,天然不匹配 `import(`。为稳妥,先剥掉 `import(…)` 形态再扫。
* - rollup 的产物里同一 chunk 名可能被 `__vitePreload(()=>import(…))` 引用 —— 那是动态,不计。
*
* 副作用:默认会执行 `npm run build`(写 app/dist/);传 `--no-build` 则只读既有产物。
* 边界:只认 `./` 相对引用的同目录 chunk;不做 sourcemap、不解析 CSS 里的 url()。
* 用法:node scripts/check-bundle-budget.mjs [--no-build] [--budget 200] [--json] [--self-test]
* 退出码:0 达标 · 1 超标 · 2 构建失败或产物缺失
*/
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { join, resolve, dirname, relative } from "node:path";
import { fileURLToPath } from "node:url";
import { gzipSync } from "node:zlib";
import { spawnSync } from "node:child_process";
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const APP = join(ROOT, "app");
const DIST = join(APP, "dist");
const argv = process.argv.slice(2);
const NO_BUILD = argv.includes("--no-build");
const SELF_TEST = argv.includes("--self-test");
const AS_JSON = argv.includes("--json");
const BUDGET_KB = argv.includes("--budget") ? Number(argv[argv.indexOf("--budget") + 1]) : 200;
/** 动态 import 形态:`import(` 后紧跟引号/括号,先整段剔除,避免污染静态扫描。 */
const stripDynamic = (s) => s.replace(/import\s*\(\s*["'][^"']+["']\s*\)/g, "");
/** 静态形态:`from "./x.js"` 与裸 `import "./x.js"`。 */
const RE_STATIC_FROM = /\bfrom\s*["']\.\/([\w.-]+\.js)["']/g;
const RE_STATIC_BARE = /\bimport\s*["']\.\/([\w.-]+\.js)["']/g;
function gzipKb(bytes) {
return gzipSync(bytes, { level: 6 }).length / 1000;
}
/** 从 index.html 找入口 chunk 文件名。 */
function entryChunkName() {
const html = readFileSync(join(DIST, "index.html"), "utf8");
const m = /<script[^>]+type=["']module["'][^>]+src=["'][^"']*?([\w.-]+\.js)["']/.exec(html);
if (!m) throw new Error("dist/index.html 里找不到 type=module 的入口 script");
return m[1];
}
/** 入口 + 静态 import 闭包(BFS)。返回 Map<chunk 文件名, {bytes, gzip}>。 */
function firstScreenChunks() {
const eager = new Map();
const queue = [entryChunkName()];
while (queue.length) {
const name = queue.shift();
if (eager.has(name)) continue;
const p = join(DIST, "assets", name);
if (!existsSync(p) || !statSync(p).isFile()) throw new Error(`首屏引用了不存在的 chunk:${name}`);
const bytes = readFileSync(p);
eager.set(name, { bytes: bytes.length, gzip: gzipKb(bytes) });
const text = stripDynamic(bytes.toString("utf8"));
for (const re of [RE_STATIC_FROM, RE_STATIC_BARE]) {
re.lastIndex = 0;
let m;
while ((m = re.exec(text)) !== null) if (!eager.has(m[1])) queue.push(m[1]);
}
}
return eager;
}
/** 全部产物 chunk(首屏之外的即懒加载 chunk)。 */
function allChunks() {
return readdirSync(join(DIST, "assets")).filter((n) => n.endsWith(".js"));
}脚本余下部分(自检、构建、报表、退出码)按下面 Step 2–4 补完;整文件必须 ≤300 行,超了就把
allChunks/allJsTotal一段删掉(它们只服务于报表,不服务于判据)。
- Step 2: 补完
--self-test(仪器的三条自检,必须内置)
function selfTest() {
const problems = [];
// 1) 入口 chunk 必须真实存在于磁盘
const entry = entryChunkName();
if (!existsSync(join(DIST, "assets", entry))) problems.push(`入口 chunk 不存在:${entry}`);
// 2) gzip 口径必须与 vite 打印值一致(用入口 chunk 的实测值对拍)
// 已知校准点:单一 chunk 构建下 index-*.js 应为 654.72 kB(HEAD f12aba6d)
const eager = firstScreenChunks();
const total = [...eager.values()].reduce((a, c) => a + c.gzip, 0);
console.log(`self-test: 入口=${entry} 首屏 chunk=${eager.size} 合计 gzip=${total.toFixed(2)} kB`);
// 3) 无意义 chunk 名必须解析不到(证明「找不到就报错」这条路径真的会走)
let threw = false;
try { readFileSync(join(DIST, "assets", "zzz-not-a-real-chunk.js")); } catch { threw = true; }
if (!threw) problems.push("不存在的 chunk 名没有抛错 —— 仪器坏了");
if (problems.length) { problems.forEach((p) => console.error(`❌ ${p}`)); process.exit(2); }
console.log("✅ self-test 通过");
process.exit(0);
}- Step 3: 补完构建、报表与退出码
if (SELF_TEST) selfTest();
if (!NO_BUILD) {
const r = spawnSync("npm", ["run", "build"], { cwd: APP, stdio: "inherit", shell: process.platform === "win32" });
if (r.status !== 0) { console.error("❌ npm run build 失败"); process.exit(2); }
}
if (!existsSync(join(DIST, "index.html"))) { console.error("❌ 找不到 app/dist/index.html(先构建)"); process.exit(2); }
const eager = firstScreenChunks();
const eagerTotal = [...eager.values()].reduce((a, c) => a + c.gzip, 0);
const lazy = allChunks().filter((n) => !eager.has(n));
const lazyTotal = lazy.reduce((a, n) => a + gzipKb(readFileSync(join(DIST, "assets", n))), 0);
if (AS_JSON) {
console.log(JSON.stringify({
budgetKb: BUDGET_KB,
firstScreen: { chunks: [...eager].map(([name, v]) => ({ name, gzipKb: Number(v.gzip.toFixed(2)) })), totalKb: Number(eagerTotal.toFixed(2)) },
lazy: { count: lazy.length, totalKb: Number(lazyTotal.toFixed(2)) },
pass: eagerTotal < BUDGET_KB,
}, null, 2));
} else {
console.log(`首屏 chunk(${eager.size} 个):`);
for (const [name, v] of eager) console.log(` ${name.padEnd(34)} gzip ${v.gzip.toFixed(2).padStart(8)} kB`);
console.log(`首屏 JS gzip 合计:${eagerTotal.toFixed(2)} kB / 预算 ${BUDGET_KB} kB ⇒ ${eagerTotal < BUDGET_KB ? "✅ 达标" : "❌ 超标"}`);
console.log(`懒加载 chunk:${lazy.length} 个,gzip 合计 ${lazyTotal.toFixed(2)} kB`);
}
process.exit(eagerTotal < BUDGET_KB ? 0 : 1);- Step 4: 跑一次并存证(此时必然超标,这是预期的)
node scripts/check-bundle-budget.mjs 2>&1 | Out-File -Encoding utf8 ".superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\budget-after-task2.txt"; "exit=$LASTEXITCODE"
node scripts/check-bundle-budget.mjs --no-build --self-test; "selftest-exit=$LASTEXITCODE"预期:首屏 1 个 chunk / gzip 654.72 kB ⇒ ❌ 超标 + exit 1;self-test ✅ + exit 0。
⚠️ 第一条命令里的2>&1 |在这里安全(node不往 stderr 写 warning 型噪声),但更稳的写法是cmd /c "… > file 2>&1"—— 本项目对原生命令一律用后者。
- Step 5: 提交
git diff --stat
git commit --only -m "build(scripts): add first-screen bundle budget gate" -- scripts/check-bundle-budget.mjs2026-09-12 更正(Task 12 回写):上面这个 subject 51 字符,超
AGENTS.md§5 的 ≤50 字上限,且照抄落地了(f0592654)。门禁不会拦(commitlint.config.js的header-max-length= 100)。Task 1 Step 7 的 subject 同为 51 字符(e46e0e82);本批计划里超限的提交命令共 3 处(第三处是 Task 7 Step 5 的 53 字符)。
2026-09-12 追加(收口评审 I-1 结清)—— 本步的提交命令同样跑不通:
scripts/check-bundle-budget.mjs是新建文件。 未跟踪文件上执行git commit --only … -- scripts/check-bundle-budget.mjs报exit 1 / error: pathspec … did not match any file(s) known to git。正确形态(两步):git add -- scripts/check-bundle-budget.mjs→git commit --only -m "build(scripts): add first-screen bundle budget gate" -- scripts/check-bundle-budget.mjs。(本步的 subject 另有一处 51 字符超限问题,见上一段。)
Verification
| # | 命令 | 期望 |
|---|---|---|
| V1 | node scripts/check-bundle-budget.mjs --no-build |
首屏 JS gzip 合计:654.72 kB / 预算 200 kB ⇒ ❌ 超标,exit 1 |
| V2 | node scripts/check-bundle-budget.mjs --no-build --self-test |
✅ self-test 通过,exit 0 |
| V3 | node scripts/check-bundle-budget.mjs --no-build --json |
firstScreen.totalKb = 654.72(±0.01)且 firstScreen.chunks 长度 = 1 |
| V4 | 仪器自检的「无意义串」一条 | Step 2 第 3 项:不存在的 chunk 名必须抛错(在报告里贴出这段代码与它被触发的输出) |
| V5 | node scripts/line-limits.mjs --full |
exit 0 |
2026-09-12 追加(Task 12 回写)—— 本守卫交付后实测出两个缺口,读本节的后续批次必须先看这两条: (a) 嵌套输出路径盲区(真·静默假绿,已用真实构建复现):Step 1 的
allChunks()只readdirSync(join(DIST,"assets"))平铺一层,而firstScreen的正则[^"'/\\]+\.js排斥/⇒ 若产物出现assets/vendor/*.js,这些 chunk 既不计入首屏、也不计入懒加载,守卫仍打印✅ 达标+ exit 0。Task 4 评审用「commit 自带规则表 + 一行"vendor/" + manualChunks(id)」跑真实 vite 构建复现了这一假绿(产物assets/vendor/*.js,守卫报「达标:余量 12.60 kB」,而真实首屏 650.34 kB)。今天未触发(产物全平铺、VendorGroup返回值无/、批 6 的vendor-gsap同样不含/),但缺口是活的。 (b) 输出文案在说谎:守卫把「非首屏」一律标成「懒加载 chunk(仅动态可达)」,它从未验证动态可达性 —— 放一个孤儿 chunk 也会被计成「仅动态可达 1 个」。判定与冻结基线读数不受影响,但这句输出是假的(与本批宗旨「守卫的说法必须为真」同一条)。 ⇒ 两条均登记批 8(修法建议:检测到assets/下有子目录或外部.js即fail()exit 2 +--self-test加一条嵌套夹具;文案改「非首屏 chunk(懒/孤儿未区分)」或真的验证动态可达性)。完整证据见本计划 §收口回写 §瓶颈清单对应两行。
为什么先做纯函数:本仓的生成工作流是自底向上(原子层 → 业务层 → 系统层)。
manualChunks是一段纯映射逻辑,把它放进vite.config.ts会同时失去三样东西:tsc的检查(tsconfig.json的include只有src,vite.config.ts不在其中)、vitest 的覆盖、以及line-limits的管辖。放进app/src/build/后三样全有。本任务只新建文件,不接线。
Files:
- Create:
app/src/build/manualChunks.ts(≤300 行) - Create:
app/src/build/manualChunks.test.ts(≤300 行) - Read:
app/package.json(测试要读它的dependencies)
Interfaces:
-
Produces:
export type VendorGroup = "vendor-gsap" | "vendor-react" | "vendor-tauri" | "vendor-katex" | "vendor-editor" | "vendor-canvas" | "vendor-md";export function packageNameOf(moduleId: string): string | undefined—— 从 rollup 模块 id 解析出最内层包名(@scope/name两段)。export function vendorGroupOf(moduleId: string): VendorGroup | undefinedexport function manualChunks(moduleId: string): string | undefined—— 直接交给 vite 的output.manualChunks(Task 4 接线)。
-
Consumes: 无。
-
Step 1: 写测试(先红)
/**
* manualChunks.test.ts — vendor 分组规则的钉值测试(node 环境)。
*
* @ai-context: 为什么要有这个文件:分组规则一旦被子串匹配污染,懒加载边界会**静默失效**
* (构建照样成功、gzip 照样下降一点、但 GSAP 与 react 混在一个 chunk 里,
* 批 6 的「GSAP 独立 chunk 懒加载」当场落空)。这是本批唯一无法靠人工 review
* 稳定发现的失效模式,必须机器钉住。
* @ai-context: 副作用:读 app/package.json(只读)。边界:只覆盖
* `dependencies`(devDependencies 不进浏览器产物)。
*/
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { manualChunks, packageNameOf, vendorGroupOf, type VendorGroup } from "./manualChunks";
const HERE = dirname(fileURLToPath(import.meta.url));
describe("packageNameOf", () => {
it("取最内层 node_modules 之后的包名(嵌套依赖不能解析成外层)", () => {
expect(packageNameOf("/r/node_modules/katex/dist/katex.mjs")).toBe("katex");
expect(packageNameOf("/r/node_modules/rehype-katex/node_modules/katex/dist/katex.mjs")).toBe("katex");
expect(packageNameOf("/r/node_modules/@codemirror/view/dist/index.js")).toBe("@codemirror/view");
expect(packageNameOf("/r/app/src/App.tsx")).toBeUndefined();
expect(packageNameOf("D:\\r\\node_modules\\react-dom\\client.js")).toBe("react-dom");
});
});
describe("vendorGroupOf 钉值", () => {
const cases: readonly (readonly [string, VendorGroup | undefined])[] = [
["/r/node_modules/react/index.js", "vendor-react"],
["/r/node_modules/react-dom/client.js", "vendor-react"],
["/r/node_modules/scheduler/index.js", "vendor-react"],
["/r/node_modules/react-markdown/lib/index.js", "vendor-md"],
["/r/node_modules/@xyflow/react/dist/esm/index.js", "vendor-canvas"],
["/r/node_modules/@xyflow/system/dist/esm/index.js", "vendor-canvas"],
["/r/node_modules/d3-zoom/src/index.js", "vendor-canvas"],
["/r/node_modules/zustand/esm/index.mjs", "vendor-canvas"],
["/r/node_modules/use-sync-external-store/shim/index.js", "vendor-canvas"],
["/r/node_modules/classcat/index.js", "vendor-canvas"],
["/r/node_modules/gsap/index.js", "vendor-gsap"],
["/r/node_modules/@gsap/react/dist/index.js", "vendor-gsap"],
["/r/node_modules/katex/dist/katex.mjs", "vendor-katex"],
["/r/node_modules/rehype-katex/lib/index.js", "vendor-katex"],
["/r/node_modules/hast-util-to-text/lib/index.js", "vendor-katex"],
["/r/node_modules/hast-util-from-dom/lib/index.js", "vendor-katex"],
["/r/node_modules/hast-util-to-jsx-runtime/lib/index.js", "vendor-md"],
["/r/node_modules/property-information/lib/index.js", "vendor-katex"],
["/r/node_modules/hastscript/index.js", "vendor-katex"],
["/r/node_modules/@tauri-apps/api/core.js", "vendor-tauri"],
["/r/node_modules/@tauri-apps/plugin-dialog/dist-js/index.js", "vendor-tauri"],
["/r/node_modules/codemirror/dist/index.js", "vendor-editor"],
["/r/node_modules/@codemirror/lang-markdown/dist/index.js", "vendor-editor"],
["/r/node_modules/@lezer/javascript/dist/index.js", "vendor-editor"],
["/r/node_modules/@marijn/find-cluster-break/src/index.js", "vendor-editor"],
["/r/node_modules/crelt/index.js", "vendor-editor"],
["/r/node_modules/micromark-core-commonmark/dev/index.js", "vendor-md"],
["/r/node_modules/mdast-util-to-hast/lib/index.js", "vendor-md"],
["/r/node_modules/unist-util-visit/index.js", "vendor-md"],
["/r/node_modules/vfile/index.js", "vendor-md"],
["/r/node_modules/remark-gfm/index.js", "vendor-md"],
["/r/node_modules/@ungap/structured-clone/esm/index.js", "vendor-md"],
// 反例:应用源码与非 node_modules 路径一律交回 rollup 默认算法
["/r/app/src/App.tsx", undefined],
["/r/app/src/pages/NotesPage.tsx", undefined],
["/r/vendor/node_modules_backup/react/index.js", undefined],
["/r/node_modules/not-a-real-package/index.js", undefined],
];
for (const [id, expected] of cases) {
it(`${id} → ${String(expected)}`, () => expect(vendorGroupOf(id)).toBe(expected));
}
});
describe("子串碰撞陷阱(批 1 六例假活教训的镜像)", () => {
it("裸 includes('node_modules/react') 会把 react-dom / react-markdown / @xyflow/react 一起吞掉", () => {
const naive = (id: string) => (id.includes("node_modules/react") ? "vendor-react" : undefined);
// 先证明「朴素口径确实会错」——否则这条守卫是空转的
expect(naive("/r/node_modules/react-dom/client.js")).toBe("vendor-react");
expect(naive("/r/node_modules/@xyflow/react/dist/index.js")).toBe("vendor-react");
// 再证明本实现不会
expect(vendorGroupOf("/r/node_modules/react-dom/client.js")).toBe("vendor-react"); // 同组,但走的是精确匹配
expect(vendorGroupOf("/r/node_modules/react-markdown/lib/index.js")).toBe("vendor-md");
expect(vendorGroupOf("/r/node_modules/@xyflow/react/dist/index.js")).toBe("vendor-canvas");
});
it("manualChunks 是 vendorGroupOf 的直通(接线面不许有第二套逻辑)", () => {
expect(manualChunks("/r/node_modules/gsap/index.js")).toBe("vendor-gsap");
expect(manualChunks("/r/app/src/App.tsx")).toBeUndefined();
});
});
describe("覆盖闸:package.json 的每个运行时依赖都必须有归属", () => {
it("dependencies 全部可归组(漏一个就会被 rollup 默认算法打散,懒加载边界失效)", () => {
const pkg = JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
};
const deps = Object.keys(pkg.dependencies ?? {});
expect(deps.length).toBeGreaterThan(0);
const ungrouped = deps.filter((d) => vendorGroupOf(`/r/node_modules/${d}/index.js`) === undefined);
expect(ungrouped).toEqual([]);
});
});
⚠️ package.json在app/下,HERE是app/src/build/⇒ 相对路径是"..", "..", "package.json"(join(HERE, "..", "..", "package.json")=app/package.json)。不要写成".."—— 那会去读app/src/package.json(不存在),测试会以ENOENT红。⚠️ JSON.parse的返回标了类型断言;本仓禁any,断言到具体形状是允许的(先例:app/src/ui/tokens.drift.test.ts)。若tsc报noUnusedLocals相关错误,检查是否有多余解构。
- Step 2: 跑一次确认红
cd app; npx vitest run src/build/manualChunks.test.ts预期:FAIL —— Failed to resolve import "./manualChunks"(模块还不存在)。把这条红的原文贴进报告。
- Step 3: 写实现
/**
* manualChunks — 生产构建的 vendor 分组规则(纯函数、可单测)。
*
* @ai-context: 批 2 包体治理的原子层。Vite 的 `build.rollupOptions.output.manualChunks`
* 接收模块 id,返回 chunk 名;返回 `undefined` 表示「交回 rollup 默认算法」。
* @ai-context: 为什么**必须按「解析出的包名」精确匹配**,而不是 `id.includes("node_modules/react")`:
* 本仓实测的子串碰撞 —— `node_modules/react` 同时是
* `react-dom` / `react-markdown` / `@xyflow/react` 的子串。用 includes 会把
* markdown 栈与画布栈并进 `vendor-react`,懒加载边界**静默失效**
* (构建照样成功、gzip 照样降一点,但批 6 的 GSAP 独立 chunk 承诺当场落空)。
* 这是批 1 六例「裸 includes 判活」教训在构建配置上的镜像。
* @ai-context: 为什么 GSAP 现在就在表里(而依赖还没装):规格 §13 风险表要求
* 「GSAP 独立 chunk 懒加载」。本函数用**包名精确匹配**表达这条承诺,
* 因此 gsap 未安装时规则**天然惰性**(没有任何模块 id 会解析出包名 `gsap`),
* 装了之后**零改动自动生效**。切不可改写成对象形式
* `{ "vendor-gsap": ["gsap"] }` —— 那会在 gsap 未安装时**直接让构建失败**。
*
* 副作用:无(纯函数)。
* 边界:入参是 rollup 的模块 id(Windows 上可能含 `\`);不解析 tsconfig paths;不处理虚拟模块(`\0` 前缀)。
*/
/** vendor 分组名(同时决定产物 chunk 的文件名前缀)。 */
export type VendorGroup =
| "vendor-gsap"
| "vendor-react"
| "vendor-tauri"
| "vendor-katex"
| "vendor-editor"
| "vendor-canvas"
| "vendor-md";
/** 精确包名 → 分组。**先于前缀族匹配**,因此 `rehype-katex` 不会被 `rehype-` 抢走。 */
const EXACT: Readonly<Record<string, VendorGroup>> = {
// 批 6 预留(本批不安装;规则惰性,见文件头注释)
gsap: "vendor-gsap",
"@gsap/react": "vendor-gsap",
// React 运行时:必须同组,避免出现两份 React 实例
react: "vendor-react",
"react-dom": "vendor-react",
scheduler: "vendor-react",
// Tauri IPC
"@tauri-apps/api": "vendor-tauri",
"@tauri-apps/plugin-dialog": "vendor-tauri",
// 数学渲染(katex 在 node_modules 里有两份:顶层 0.18.4 与 rehype-katex 嵌套的 0.16.47,
// 但 `packageNameOf` 取最内层 ⇒ 两者都归到本组,不会出现「同名两 chunk」)
katex: "vendor-katex",
"rehype-katex": "vendor-katex",
"hast-util-from-dom": "vendor-katex",
"hast-util-to-text": "vendor-katex",
"hast-util-from-html-isomorphic": "vendor-katex",
"hast-util-is-element": "vendor-katex",
"hast-util-parse-selector": "vendor-katex",
hastscript: "vendor-katex",
"property-information": "vendor-katex",
"web-namespaces": "vendor-katex",
// 编辑器(CodeMirror 6 无 scope 的几件)
codemirror: "vendor-editor",
crelt: "vendor-editor",
"style-mod": "vendor-editor",
"w3c-keyname": "vendor-editor",
// 画布 / 图谱(无 scope 的几件)
classcat: "vendor-canvas",
zustand: "vendor-canvas",
"use-sync-external-store": "vendor-canvas",
// Markdown 管线
"react-markdown": "vendor-md",
unified: "vendor-md",
bail: "vendor-md",
trough: "vendor-md",
vfile: "vendor-md",
"vfile-message": "vendor-md",
extend: "vendor-md",
"is-plain-obj": "vendor-md",
devlop: "vendor-md",
zwitch: "vendor-md",
ccount: "vendor-md",
"longest-streak": "vendor-md",
"markdown-table": "vendor-md",
"trim-lines": "vendor-md",
"escape-string-regexp": "vendor-md",
"decode-named-character-reference": "vendor-md",
"comma-separated-tokens": "vendor-md",
"space-separated-tokens": "vendor-md",
"html-url-attributes": "vendor-md",
"style-to-js": "vendor-md",
"style-to-object": "vendor-md",
"inline-style-parser": "vendor-md",
"estree-util-is-identifier-name": "vendor-md",
"@ungap/structured-clone": "vendor-md",
};
/** 前缀族 → 分组。**按数组顺序匹配,先到先得**;不得加宽到能吃掉上面 EXACT 的包。 */
const PREFIX: readonly (readonly [RegExp, VendorGroup])[] = [
[/^@codemirror\//, "vendor-editor"],
[/^@lezer\//, "vendor-editor"],
[/^@marijn\//, "vendor-editor"],
[/^@xyflow\//, "vendor-canvas"],
[/^d3-/, "vendor-canvas"],
[/^micromark/, "vendor-md"],
[/^mdast-util/, "vendor-md"],
[/^hast-util/, "vendor-md"],
[/^unist-util/, "vendor-md"],
[/^remark-/, "vendor-md"],
[/^rehype-/, "vendor-md"],
[/^vfile/, "vendor-md"],
];
/** 从 rollup 模块 id 解析出**最内层** `node_modules` 之后的包名。 */
export function packageNameOf(moduleId: string): string | undefined {
const id = moduleId.replace(/\\/g, "/");
const marker = "/node_modules/";
const at = id.lastIndexOf(marker);
if (at < 0) return undefined;
const seg = id.slice(at + marker.length).split("/");
if (seg.length === 0 || seg[0] === "") return undefined;
if (seg[0].startsWith("@")) return seg.length >= 2 && seg[1] !== "" ? `${seg[0]}/${seg[1]}` : undefined;
return seg[0];
}
/** 模块 id → vendor 分组;非 node_modules 或未登记包返回 undefined(交回 rollup)。 */
export function vendorGroupOf(moduleId: string): VendorGroup | undefined {
const name = packageNameOf(moduleId);
if (name === undefined) return undefined;
const exact = EXACT[name];
if (exact !== undefined) return exact;
for (const [re, group] of PREFIX) if (re.test(name)) return group;
return undefined;
}
/** 直接交给 `build.rollupOptions.output.manualChunks`(Task 4 接线)。 */
export function manualChunks(moduleId: string): string | undefined {
return vendorGroupOf(moduleId);
}- Step 4: 跑测试确认绿,并检查行数
cd app; npx vitest run src/build/manualChunks.test.ts
cd ..; node scripts/line-limits.mjs --full
$p = "app\src\build\manualChunks.ts"; "manualChunks.ts = " + [System.IO.File]::ReadAllLines((Resolve-Path $p),[Text.Encoding]::UTF8).Count
$q = "app\src\build\manualChunks.test.ts"; "manualChunks.test.ts = " + [System.IO.File]::ReadAllLines((Resolve-Path $q),[Text.Encoding]::UTF8).Count预期:全绿(含覆盖闸那条);line-limits --full exit 0(两个新文件都 ≤300 ⇒ 不新增任何豁免登记)。
- Step 5: 跑全量门禁
cd app; npx tsc --noEmit; npx vitest run预期:tsc 0 错;vitest 124 文件 → 125 文件、1124 用例 → 1124 + N 用例(N = 本任务新增用例数,只增不减;把实际数字写进报告)。
- Step 6: 提交
git diff --stat
git commit --only -m "build(app): add pure manualChunks vendor grouping" -- app/src/build/manualChunks.ts app/src/build/manualChunks.test.ts2026-09-12 追加(收口评审 I-1 结清)—— 本步一次新建 2 个文件,命令必然失败(且该目录当时还被
.gitignore静默忽略)。 ①git commit --only … -- <未跟踪路径>报exit 1 / error: pathspec … did not match any file(s) known to git(台账 §四「三处漂移」第 1 条)⇒ 正确形态 = 先git add -- <两个显式路径>,再git commit --only -m "…" -- <同一对路径>。② 该步还有一个叠加陷阱:.gitignore:11的build/未锚定,会静默吞掉app/src/build/(git status完全看不见它)⇒ Task 3 实际是用git add -f -- <两个显式路径>入库并主动请示控制方的(根因修复见99fab220的点状取反与批 8 的锚定裁决)。这两条都是"照抄计划会卡住"的实例,下一批计划者请勿再照抄本行的原始形态。
Verification
| # | 命令 | 期望 |
|---|---|---|
| V1 | cd app; npx vitest run src/build/manualChunks.test.ts |
全绿;含「子串碰撞陷阱」两条与「覆盖闸」一条 |
| V2 | 该测试里 gsap / @gsap/react 两条 |
PASS(GSAP 未安装也能证明它在独立组,这是 Task 5 的核心证据) |
| V3 | cd app; npx tsc --noEmit |
exit 0 |
| V4 | node scripts/line-limits.mjs --full |
exit 0,且 301–600 档 仍为 123(两文件均 ≤300,未新增登记) |
| V5 | git show --stat HEAD |
只含两个新文件 |
Files:
- Modify:
app/vite.config.ts(现 40 行 → 预计 ~52 行;不在line-limits的SCAN_DIRS内,但仍须自守 ≤300) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-task4.txt(不入库)
Interfaces:
-
Consumes:
manualChunksfrom./src/build/manualChunks(Task 3) -
Produces: 生产构建的 chunk 拓扑(
vendor-react/vendor-tauri/vendor-katex/vendor-editor/vendor-canvas/vendor-md+ 自动的index)。这些 chunk 名是 Task 9/10/11 报表的稳定指称对象。 -
Step 1: 记录开工读数
node scripts/check-bundle-budget.mjs --no-build --json
node scripts/bundle-eager-graph.mjs预期:首屏 654.72 kB / 1 chunk;可达源文件 251、包 15。(Task 4 只改分块拓扑,不应改变这两个可达性数字 —— 它是本任务的对照不变量。)
- Step 2: 改
app/vite.config.ts(按它真实的异步函数形态加build段)
在 plugins: [react()], 之后插入:
// 批 2 包体治理:vendor 分组。
// Why 放在这里而不是对象形式 `{ "vendor-react": ["react", …] }`:
// ① 对象形式在包未安装时**直接让构建失败**(rollup 解析不到入口),
// 而本批必须为批 6 的 GSAP 预留一个「现在惰性、将来自动生效」的槽;
// ② 函数形式可以按**解析出的包名**精确匹配,避开 `node_modules/react`
// 同时是 react-dom / react-markdown / @xyflow/react 子串的碰撞陷阱。
// 规则与单测见 src/build/manualChunks.ts(纯函数,tsc 与 vitest 都覆盖得到)。
build: {
rollupOptions: {
output: {
manualChunks: (id: string) => manualChunks(id),
},
},
},并在文件顶部加 import:
import { manualChunks } from "./src/build/manualChunks";
⚠️ vite.config.ts不在app/tsconfig.json的include(只有["src"])里 ⇒tsc --noEmit看不到它,语法/类型错误只能靠vite build暴露。因此本任务必须跑构建,不能只跑tsc。⚠️ build键在defineConfig(async () => ({ … }))返回的对象里,与现有plugins/clearScreen/server平级 —— 不要误插进server里。
- Step 3: 构建并记录新拓扑
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-task4.txt`" 2>&1"
"exit=$LASTEXITCODE"
node scripts/check-bundle-budget.mjs --no-build预期:exit=0;产物出现 vendor-react-*.js / vendor-tauri-*.js / vendor-katex-*.js / vendor-editor-*.js / vendor-canvas-*.js / vendor-md-*.js 六个 vendor chunk;首屏 gzip 仍 ≈ 654.72 kB(±3 kB)⇒ 仍 ❌ 超标,这是预期的 —— 分块只改拓扑、不改首屏总量(见 §实测基线 表 1 的规格漂移第 2 处)。
本任务的验收不是「gzip 降了」,而是:① 六个 vendor chunk 全部生成;② 每个 chunk 的内容与它声明的族一致(用一个已知只属于某族的字符串抽查,见 V2);③ 首屏 gzip 变化 ≤ 3 kB(超过说明
manualChunks意外地把某些模块复制进了多个 chunk,必须查)。
- Step 4: 检查组内内容(用「只有这一族才有的字符串」抽查)
cd app
# react-dom 的独有串只应出现在 vendor-react 里
Select-String -Path "dist/assets/vendor-react-*.js" -Pattern "react-dom" -Quiet
# @xyflow 的独有串只应出现在 vendor-canvas 里(而**不应**出现在 vendor-react 里)
Select-String -Path "dist/assets/vendor-canvas-*.js" -Pattern "xyflow" -Quiet
Select-String -Path "dist/assets/vendor-react-*.js" -Pattern "xyflow" -Quiet预期:前两条 True;第三条 False(@xyflow/react 没被并进 vendor-react —— 这正是 Task 3 那条子串碰撞守卫在生产产物上的落地证据)。把三条输出贴进报告。
- Step 5: 全量门禁 + 提交
cd app; npx tsc --noEmit; npx vitest run
cd ..; node scripts/line-limits.mjs --full; node scripts/docs-check.mjs; node scripts/check-command-registry.mjs
git diff --stat
git commit --only -m "build(app): wire vendor manualChunks into vite" -- app/vite.config.tsVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | cd app; npm run build |
exit 0;产物含 6 个 vendor-* chunk |
| V2 | Step 4 的三条 Select-String |
True / True / False |
| V3 | node scripts/check-bundle-budget.mjs --no-build |
首屏 gzip 与 654.72 相差 ≤3 kB |
| V4 | node scripts/bundle-eager-graph.mjs |
仍为 251 / 15(分块不改变源码可达性) |
| V5 | cd app; npx vitest run |
124 文件 / 1124 用例,逐字持平(本任务不改源码) |
| V6 | 六条门禁 | 全 exit 0 |
本任务不装任何依赖、不改任何产物。 它交付的是「批 6 装了 GSAP 之后,它必然落进自己的懒加载 chunk」这件事的机器证明,且证明必须在 GSAP 缺席时就能跑。
Files:
- Modify:
app/src/build/manualChunks.test.ts(新增一个describe块) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/gsap-slot.md(不入库,存证据)
Interfaces:
-
Consumes:
vendorGroupOf/manualChunks(Task 3) -
Produces: 无新导出面。产出的是一条可复跑的判据,Task 12 会把它写进规格 §13 的缓解栏。
-
Step 1: 先证明「GSAP 现在没有装」
# 仪器自检:(a) 能命中一个已知存在的依赖名;(b) 对 gsap 报 0
Select-String -Path app/package.json -Pattern '"react"' | Measure-Object | Select-Object -ExpandProperty Count
Select-String -Path app/package.json -Pattern '"gsap"' | Measure-Object | Select-Object -ExpandProperty Count
Select-String -Path app/package.json -Pattern '"@gsap/react"' | Measure-Object | Select-Object -ExpandProperty Count
Test-Path app/node_modules/gsap预期:1 / 0 / 0 / False。第一条命中证明仪器有效,后三条是本任务的立足点。
⚠️ 不要用Get-ChildItem app\src -File -Include *.tsx这类写法(-Include无-Recurse时返回 0 个文件,2026-09-12 实测踩到)。上面的Select-String -Path <单文件>不涉及该陷阱。
- Step 2: 新增守卫(追加到
manualChunks.test.ts末尾)
describe("批 6 预留:GSAP 必须落进独立懒加载 chunk(本批不安装 GSAP)", () => {
it("GSAP 的两个包名都归 vendor-gsap,且与 react 不同组", () => {
const gsap = vendorGroupOf("/r/node_modules/gsap/index.js");
const gsapReact = vendorGroupOf("/r/node_modules/@gsap/react/dist/index.js");
expect(gsap).toBe("vendor-gsap");
expect(gsapReact).toBe("vendor-gsap");
expect(gsap).not.toBe(vendorGroupOf("/r/node_modules/react/index.js"));
});
it("分组表里存在 vendor-gsap 槽位,而 package.json 里此刻没有 gsap(两者必须同时成立)", () => {
const pkg = JSON.parse(readFileSync(join(HERE, "..", "..", "package.json"), "utf8")) as {
dependencies?: Record<string, string>;
devDependencies?: Record<string, string>;
};
const all = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
// 槽位在(上一条已证)且依赖不在 —— 一旦批 6 装了 gsap,这条会红,
// 提醒执行者:该去 build 一次并核对 vendor-gsap chunk 真的独立生成、且只在动态 import 里。
expect(Object.keys(all).filter((d) => d === "gsap" || d === "@gsap/react")).toEqual([]);
expect(vendorGroupOf("/r/node_modules/gsap/index.js")).toBe("vendor-gsap");
});
it("反例:vendor-gsap 槽位不得被任何前缀规则吞掉(防将来有人加宽 ^g 前缀)", () => {
expect(vendorGroupOf("/r/node_modules/gsap-core/index.js")).toBeUndefined();
expect(vendorGroupOf("/r/node_modules/not-gsap/index.js")).toBeUndefined();
});
});
⚠️ 第三条是反例守门(批 0-D 的范式 B):它证明vendor-gsap来自精确包名而不是某个宽松前缀 —— 否则gsap-core/not-gsap这类包会被误并进来。没有这条,前两条可能因一个过宽的规则而假绿。
- Step 3: 跑绿并写证据
cd app; npx vitest run src/build/manualChunks.test.ts
Select-String -Path vite.config.ts -Pattern "manualChunks" | ForEach-Object { "$($_.LineNumber): $($_.Line.Trim())" }预期:全绿;vite.config.ts 里 manualChunks 接线行可见(Task 4 产物)。
- Step 4: 把「批 6 落地清单」写进
tmp/gsap-slot.md(供 Task 11/12 引用)
必须写全:① 批 6 装 GSAP 后必须用 import()(静态 import 会让它落进首屏,槽位形同虚设);② 判据 = 构建产物里出现 vendor-gsap-*.js 且 node scripts/check-bundle-budget.mjs --no-build --json 的 firstScreen.chunks 不含它;③ 本批已把这条写进规格 §13 缓解栏(Task 12 执行)。
- Step 5: 全量门禁 + 提交
cd app; npx tsc --noEmit; npx vitest run
cd ..; node scripts/line-limits.mjs --full
git diff --stat
git commit --only -m "test(build): guard reserved gsap vendor chunk slot" -- app/src/build/manualChunks.test.tsVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | cd app; npx vitest run src/build/manualChunks.test.ts |
新增 3 条全绿 |
| V2 | Step 1 的四条探测 | 1 / 0 / 0 / False |
| V3 | Test-Path app/node_modules/gsap |
False(本批结束时仍须为 False —— 装了 GSAP 就是越界,STOP 报控制方) |
| V4 | cd app; npm run build 后 Get-ChildItem app/dist/assets -Filter "vendor-gsap*" |
空(槽位惰性:没有模块会命中它) |
| V5 | node scripts/check-bundle-budget.mjs --no-build |
首屏不含 vendor-gsap,gzip 与 Task 4 持平 |
⛔ 前置门禁:本任务的 Step 1 必须先确认控制方已就 Global Constraints 的「★ 控制方待裁决项」给出书面裁决,且裁决为「授权路线 B′」。若裁决为「否掉 B′」或裁决不存在 ⇒ 本任务整体跳过,改为在报告里写「未做(登记给批 5)」,并照常执行 Task 7–12**。** 这是本批唯一会改变运行行为的任务(未访问过的页面在启动时不再挂载)。改动集中在一个文件,可单点回退。
Files:
- Modify:
app/src/App.tsx(现 439 行,301–600 档已登记;改后预计 ~430 行,仍须落在 ≤600 且如实复核) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-task6.txt(不入库)
2026-09-12 更正(Task 12 回写,原文一律保留):本任务的 Files 清单漏了一项必须的改动 ——
docs/standards/line-limit-exemptions.md。App.tsx实测 439 → 519 行(仍落 301–600 档),登记值必须由node scripts/line-limits.mjs --write刷新,否则line-limits --full的 (e)「登记值 == 实测值」当场变红。Task 8 的 Files 清单有同一处遗漏。 2026-09-12 更正(Task 12 回写):本任务全篇写的「9 页按页lazy」是计划态,交付态是 8 懒 + 课堂页静态(控制方 2026-09-12 裁决 2 末条:课堂是默认页,懒它等于首屏必拉一次动态 chunk,且该 chunk 首屏即被抓取却被记作 lazy ⇒ 首屏读数被低估 20.04 kB)。受影响处(按步骤与原文定位,刻意不写行号 —— 本注自身已使用其下各行整体下移):Step 2 的预期行(「页面静态 import = 9」)· Step 3 标题(「把 9 个静态 import 换成lazy工厂」)· Step 3 的注释(「9 个页面从静态 import 改为按页动态 import」)· Step 3 代码块里的const ClassroomPage = lazy(…)一行(课堂页交付时仍是静态 import)· Step 8 的提交命令(落地 subject 是perf(app): lazy-load eight pages on first visit,见62c4c369)· V1(实测 8)· V2(实测 1,不是 0)。
Interfaces:
-
Consumes: Task 4 的 vendor chunk 拓扑
-
Produces:
PageSlot局部组件(文件内,不导出)—— Task 7/8 沿用同一模式改另两处。 -
Step 1: 核对路线裁决(硬前置)
Select-String -Path ".superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\progress.md" -Pattern "路线|裁决|B′|B'" | ForEach-Object { "$($_.LineNumber): $($_.Line)" }预期:能看到控制方对本批路线的书面裁决。没有就 STOP,报告「待裁决项未结清」,并转 Task 7。
同时固化不变量:cd app; npx vitest run → 124 / 1124(本任务改完必须逐字持平)。
- Step 2: 备份四类影响面读数
node scripts/bundle-eager-graph.mjs
node scripts/check-bundle-budget.mjs --no-build
cd app
Select-String -Path "src\App.tsx" -Pattern '^import .+ from "\./pages/' | Measure-Object | Select-Object -ExpandProperty Count预期:251 / 15 包;首屏 654.72 kB(6 个 vendor chunk 但总和不变);页面静态 import = 9。
第三条是「仪器自检」:若返回
0,说明你的正则没命中已知存在的 9 行(那 9 行确实在App.tsx:20-34),仪器坏了,先修仪器再往下走。
- Step 3: 把 9 个静态 import 换成
lazy工厂
把 app/src/App.tsx:20-34 的 9 行 import X from "./pages/X"; 全部删除,替换为:
// 批 2 包体治理:9 个页面从静态 import 改为按页动态 import。
// @ai-context: 「保留挂载(TD-004)」的语义按页保留 —— **访问过的页常驻、永不卸载**;
// 只是「从未访问过的页」不再进入首屏 module graph。
// @ai-context: 不引入路由库(规格 §3 红线 2):入口仍是 useState<Page> + NAV_ITEMS,
// 只是每个页面成为一个独立 chunk。
const ClassroomPage = lazy(() => import("./pages/ClassroomPage"));
const NotesPage = lazy(() => import("./pages/NotesPage"));
const SessionsPage = lazy(() => import("./pages/SessionsPage"));
const ActionPage = lazy(() => import("./pages/ActionPage"));
const ReviewPage = lazy(() => import("./pages/ReviewPage"));
const ChatPage = lazy(() => import("./pages/ChatPage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
const KnowledgePage = lazy(() => import("./pages/KnowledgePage"));
const GoalsPage = lazy(() => import("./pages/GoalsPage"));并把 :15 的 react import 改为:
import { lazy, Suspense, useEffect, useRef, useState } from "react";
useRef已在文件中使用(toastTimer),必须保留,否则npx tsc --noEmit会报TS6133。
- Step 4: 加页容器组件 + 挂载闸门
在 MainShell 函数之前(App 函数之后)加入:
/**
* PageSlot — 页面容器:首访挂载 + 保活 + display 门控 + 独立 Suspense。
*
* @ai-context: 批 2 包体治理的挂载闸门,也是「保留挂载」语义的唯一实现点。
* · mounted=false ⇒ 整棵子树不渲染 ⇒ 该页的 lazy chunk **不会被请求**(首屏收益的来源);
* · mounted=true 之后永不回到 false ⇒ 已访问页面常驻(TD-004 保活语义,状态与事件监听不重置);
* · 每页一个独立 Suspense(fallback=null):只有**新挂载**的页会挂起,
* 已经可见的页不会因为邻居加载而被替换成 fallback(避免可见的闪烁)。
* @ai-context: 为什么 fallback 是 null 而不是原语层的 Loading:本批是**尺寸治理批**,
* 引入原语会把它连同 CSS 一起拉进首屏,与目标冲突;「首访加载态」登记给批 3/4
* (壳层与加载原语一起做),见计划 Task 11 的瓶颈清单。
* @ai-context: 动态 import 失败时 React 会把它抛到最近的错误边界 —— App.tsx:87 的
* AppErrorBoundary 仍在最外层包着 MainShell,因此「chunk 加载失败」有兜底 UI,不会白屏。
* 副作用:无。边界:children 是懒组件元素,未 mounted 时不会被 React 渲染 ⇒ 不触发 dynamic import。
*/
function PageSlot({ show, mounted, children }: { show: boolean; mounted: boolean; children: React.ReactNode }) {
if (!mounted) return null;
return (
<div style={{ flex: 1, display: show ? "block" : "none", overflow: "hidden" }}>
<Suspense fallback={null}>{children}</Suspense>
</div>
);
}在 MainShell 内、const [page, setPage] = useState<Page>("classroom"); 之后加入:
// 批 2:首访挂载集合。初始只有默认页 —— 其余页面在首次被选中后的下一帧挂载。
// @ai-context: 用 useState + useEffect 而不是「渲染期 ref.add」:渲染期改 ref 在
// StrictMode 下虽幂等,但仍是渲染副作用;本仓 React 19 StrictMode 会双调用渲染。
// 代价是切页时首帧内容区为空(本来也要等 chunk 下载),无观感回归。
const [mountedPages, setMountedPages] = useState<ReadonlySet<Page>>(() => new Set<Page>(["classroom"]));
useEffect(() => {
setMountedPages((prev) => (prev.has(page) ? prev : new Set(prev).add(page)));
}, [page]);- Step 5: 把 9 个容器替换成
PageSlot
:311-419 的 9 个 <div style={{ flex: 1, display: page === "X" ? "block" : "none", overflow: "hidden" }}>…</div> 逐个改为 <PageSlot show={page === "X"} mounted={mountedPages.has("X")}>…</PageSlot>,子元素与 props 一字不改。逐页对照:
<PageSlot show={page === "classroom"} mounted={mountedPages.has("classroom")}>
<ClassroomPage onOpenSessions={(id) => { setFocusSessionId(id); setPage("sessions"); }} />
</PageSlot>
<PageSlot show={page === "sessions"} mounted={mountedPages.has("sessions")}>
<SessionsPage … 原 props 逐字保留 … />
</PageSlot>
<PageSlot show={page === "notes"} mounted={mountedPages.has("notes")}>
<NotesPage … 原 props 逐字保留 … />
</PageSlot>
<PageSlot show={page === "action"} mounted={mountedPages.has("action")}>
<ActionPage active={page === "action"} />
</PageSlot>
<PageSlot show={page === "review"} mounted={mountedPages.has("review")}>
<ReviewPage … 原 props 逐字保留 … />
</PageSlot>
<PageSlot show={page === "chat"} mounted={mountedPages.has("chat")}>
<ChatPage … 原 props 逐字保留 … />
</PageSlot>
<PageSlot show={page === "knowledge"} mounted={mountedPages.has("knowledge")}>
<KnowledgePage … 原 props 逐字保留 … />
</PageSlot>
<PageSlot show={page === "goals"} mounted={mountedPages.has("goals")}>
<GoalsPage />
</PageSlot>
<PageSlot show={page === "settings"} mounted={mountedPages.has("settings")}>
<SettingsPage active={page === "settings"} />
</PageSlot>
⚠️ … 原 props 逐字保留 …不是占位符:实施者必须打开App.tsx把该容器内原有的全部 props 与 JSX 注释原样搬进PageSlot。九处替换的验收判据是git diff里<XxxPage的 props 行数不变(见 V3)。
- Step 6: 构建 + 三条测量
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-task6.txt`" 2>&1"
"exit=$LASTEXITCODE"
node scripts/check-bundle-budget.mjs --no-build
node scripts/bundle-eager-graph.mjs
cd app; (Get-ChildItem dist/assets -Filter "*.js").Count预期:exit=0;首屏 gzip 显著下降(应落到 200–320 区间,因为 AiConversationDock 仍静态在首屏 —— Task 8 才摘掉它);可达源文件应降到 ≈51(与 §表 3「路线 B」行一致);产物 chunk 数明显增加(每个页面 + 若干共享 chunk)。
- Step 7: 全量门禁(本任务最关键的一步)
cd app; npx tsc --noEmit; npx vitest run
cd ..; node scripts/line-limits.mjs --full; node scripts/docs-check.mjs; node scripts/check-command-registry.mjs预期:vitest 必须逐字为 124 文件 / 1124 用例 全绿。若有任何一条红 ⇒ 回退本任务(git restore 被禁止,改用 git checkout HEAD -- app/src/App.tsx 也被禁止 ⇒ 正确做法是用 git show HEAD:app/src/App.tsx 读出原文、以写文件方式还原,然后重新设计边界),并在报告里写明「该边界不可为:会使 N 条既有断言失效」。绝不修改任何测试文件来迁就。
- Step 8: 提交
git diff --stat
git commit --only -m "perf(app): lazy-load nine pages on first visit" -- app/src/App.tsxVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | Select-String -Path app/src/App.tsx -Pattern 'lazy\(\(\) => import\("\./pages/' | Measure-Object |
9 |
| V2 | Select-String -Path app/src/App.tsx -Pattern '^import .+ from "\./pages/' | Measure-Object |
0(仪器自检:同一命令对 vite.config.ts 附近的已知串须能命中,见 V6) |
| V3 | git diff -- app/src/App.tsx 里九处 <XxxPage 的 props 行 |
与改动前逐字相同(评审者按 diff 逐条核对,这是本任务唯一的等价性证据) |
| V4 | node scripts/bundle-eager-graph.mjs |
可达源文件 ≈51(±3);包清单里不再有 codemirror / @codemirror/* / @xyflow/react / katex / rehype-katex |
| V5 | node scripts/check-bundle-budget.mjs --no-build |
首屏 gzip < 330 kB(本任务预期值;写实际值进报告) |
| V6 | cd app; npx vitest run |
124 文件 / 1124 用例全绿,且 git status --short 里没有任何测试文件被改 |
| V7 | [System.IO.File]::ReadAllLines("app\src\App.tsx",[Text.Encoding]::UTF8).Count |
≤600(仍落在已登记的 301–600 档;若 >600 ⇒ 立即 STOP,红线违规) |
2026-09-12 实测更正(Task 12 回写):V1 = 8(不是 9)· V2 = 1(不是 0,留下的是静态
ClassroomPage)—— 见上方 Files 节的两条更正。V6 的 vitest 基线过期:交付时是 125 文件 / 1233 用例(批 2 净增 1 个测试文件app/src/build/manualChunks.test.ts,全批 0 处断言修改 / 0 处新增await/ 0 处改 mock,Task 6 自己前后逐字相同)。本表 V1–V7 其余各项实测通过;App.tsx实际落在 519 行。
为什么单独一个任务:这两个面板今天被
App.tsx:38-39静态 import,因此采集浮窗(alwaysOnTop常驻窗口)与覆盖层截图窗每次打开都要加载完整主壳(2,057 kB JS)。二者各自只在自己的窗口里被渲染(:72-83的两个早返回),是纯度最高的懒加载边界。它与 Task 6 不共享代码路径,可独立回退。
Files:
- Modify:
app/src/App.tsx - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-task7.txt(不入库)
Interfaces:
-
Consumes: Task 6 的
PageSlot模式与Suspenseimport -
Produces: 两个窗口变体的独立 chunk(
CaptureFloatPanel闭包 /CaptureOverlayPanel闭包) -
Step 1: 记录开工读数
node scripts/check-bundle-budget.mjs --no-build
Select-String -Path app/src/App.tsx -Pattern '^import .+ from "\./components/(CaptureFloatPanel|CaptureOverlayPanel)"' | ForEach-Object { "$($_.LineNumber): $($_.Line.Trim())" }预期:能看到 :38 / :39 两行(这就是仪器自检 —— 若 0 行说明正则写错了)。
- Step 2: 改两处 import 为
lazy
// 批 2:两个窗口变体面板改为按需加载。
// @ai-context: 这两个面板只在**自己的窗口**里渲染(?float=1 / ?overlay=1 的早返回,
// 见 App() 函数顶部),主窗永远用不到;静态 import 会让采集浮窗(alwaysOnTop 常驻)
// 与覆盖层窗口每次启动都拉完整主壳。改为 lazy 后它们各自成为独立 chunk。
const CaptureFloatPanel = lazy(() => import("./components/CaptureFloatPanel"));
const CaptureOverlayPanel = lazy(() => import("./components/CaptureOverlayPanel"));- Step 3: 给两个早返回包上
Suspense
if (query.get("overlay") === "1") {
return (
<Suspense fallback={null}>
<CaptureOverlayPanel />
</Suspense>
);
}
if (query.get("float") === "1") {
return (
<CaptureStatusProvider>
<Suspense fallback={null}>
<CaptureFloatPanel />
</Suspense>
</CaptureStatusProvider>
);
}
⚠️ float分支里<CaptureStatusProvider>必须留在 Suspense 外层:provider 是该窗口内「每窗恰一个实例」的采集状态源(App.tsx:6-8的@ai-context明写),把它塞进 Suspense 会改变它的挂载时机。
- Step 4: 构建 + 量两个窗口变体的首屏
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-task7.txt`" 2>&1"
node scripts/check-bundle-budget.mjs --no-build
cd app; Get-ChildItem dist/assets -Filter "*.js" | Sort-Object Length -Descending | Select-Object -First 12 Name, @{n="kB";e={[math]::Round($_.Length/1000,2)}}预期:主窗首屏 gzip 再降一截;产物里出现包含 CaptureOverlayPanel 内容的小 chunk。
窗口变体首屏的度量:本批不做第二套入口 HTML,因此窗口变体首屏 =
index.html入口 chunk 的 gzip(同一份)+ 零 vendor 依赖(因为早返回在MainShell之前,9 个页面与 vendor 全部不会被拉)。⇒ 实测判据改为:check-bundle-budget.mjs --json里firstScreen.chunks只有入口那一个 chunk 时,totalKb就是窗口变体首屏的上界。把这条推理与实测值一起写进报告(Task 11 会引用)。
2026-09-12 更正(Task 12 回写)—— 上面这条推理是错的,实测推翻:变体首屏不是「入口 + 零 vendor」,而是 入口 + 4 个 vendor chunk(
vendor-react/vendor-md/vendor-katex/vendor-tauri)。根因:三个窗口共用同一份index.html(已证:恰 1 个<script type=module>、index.html里 0 处float=/overlay=;Rust 用WebviewUrl::App("index.html?float=1")打开),⇒ 入口的静态闭包逐字节相同,早返回只能切断动态边,切不断静态边。 实测(Task 7/Task 10):?float=197.29 kB ·?overlay=194.19 kB(口径 = 入口静态闭包 ∪ 本变体动态 chunk)。 ⇒ 连带作废的还有「变体 < 60 kB」这条建议项:vendor-react单独就是 60,367 B > 60 kB ⇒ 任何渲染 React 的窗口都不可能 < 60 kB,该值结构性不可达(幸好控制方裁决 3 已把它定为建议项而非硬门禁)。完整推理与两行实测见本计划 §收口回写 §瓶颈清单。
- Step 5: 全量门禁 + 提交
cd app; npx tsc --noEmit; npx vitest run
cd ..; node scripts/line-limits.mjs --full; node scripts/docs-check.mjs
git diff --stat
git commit --only -m "perf(app): lazy-load capture float and overlay panels" -- app/src/App.tsx2026-09-12 更正(Task 12 回写):上面这个 commit subject 53 字符,超
AGENTS.md§5 的 ≤50 字上限。交付时改用 42 字符的perf(app): lazy-load window variant panels(63536018,同一动作、同一显式路径)。⚠️ Task 12 逐条普查(码点计数,脚本tmp/task12/subject-len.mjs)发现:计划里超限的提交命令不是 1 处而是 3 处 —— 本行(53)· Task 1 的 Step 7 提交命令(51) · Task 2 的 Step 5 提交命令(51);后两条被逐字照抄落地(e46e0e82/f0592654)。根因是这条规则没有任何机器门禁:commitlint.config.js沿用@commitlint/config-conventional的header-max-length = 100,未覆盖为 50,故 51 字符的提交能通过 commit-msg 钩子(已实测:两条确实入库)。⇒ 后续批次派发词里的提交命令必须人工数字符数(或另立一条header-max-length规则 —— 登记批 8)。 📌 同一普查还发现本任务 V4 的 vitest 期望124 / 1124过期(见 Task 6 Files 注与 Task 10 注):批 2 交付态是 125 / 1233。
Verification
| # | 命令 | 期望 |
|---|---|---|
| V1 | `Select-String -Path app/src/App.tsx -Pattern 'import("./components/Capture(Float | Overlay)Panel")' | Measure-Object` |
| V2 | `Select-String -Path app/src/App.tsx -Pattern '^import Capture(Float | Overlay)Panel' | Measure-Object` |
| V3 | git diff 里 <CaptureStatusProvider> 仍在 <Suspense> 外层 |
是 |
| V4 | cd app; npx vitest run |
124 / 1124 全绿,测试文件 0 改动 |
| V5 | node scripts/check-bundle-budget.mjs --no-build --json |
firstScreen.chunks 里不含含 CaptureFloat/CaptureOverlay 的 chunk |
这是达标链条的最后一环(见 §实测基线 表 3 的关键发现):
AiConversationDock→TaskConversationView→ChatMessageMarkdown→rehype-katex+katexCSS,整条 ≈153 kB gzip 的栈今天就靠这一条静态 import 挂在首屏。摘掉它,首屏才可能进 200 kB。
Files:
- Modify:
app/src/App.tsx - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-task8.txt(不入库)
2026-09-12 更正(Task 12 回写,与 Task 6 同源):本 Files 清单同样漏了
docs/standards/line-limit-exemptions.md—— Task 6/7/8 三次都改app/src/App.tsx,交付终值 519 行(原 439),登记值必须node scripts/line-limits.mjs --write刷新。另:本任务 Verification 里的 vitest 期望 124 / 1124 已过期,交付态是 125 文件 / 1233 用例(批 2 净增 1 个测试文件)。
Interfaces:
-
Consumes: Task 6 的
Suspenseimport 与挂载闸门模式 -
Produces:
dockMounted状态(文件内,不导出) -
Step 1: 记录开工读数与保活契约
node scripts/bundle-eager-graph.mjs
Select-String -Path app/src/App.tsx -Pattern 'AiConversationDock|dockOpen' | ForEach-Object { "$($_.LineNumber): $($_.Line.Trim())" }预期:首屏包清单里仍有 react-markdown / rehype-katex / katex(这就是本任务要摘掉的东西 —— 也请顺手证明它确实来自这条链:Select-String -Path app/src/components/AiConversationDock.tsx -Pattern 'TaskConversationView' 与 app/src/components/TaskConversationView.tsx 里的 ChatMessageMarkdown、ChatMessageMarkdown.tsx 里的 rehype-katex,把三跳的 文件:行 贴进报告)。
保活契约(必须原样保留):App.tsx:421 的注释写明「常驻挂载——开合仅切 display,选中态/后台任务保活」。⇒ 本任务只把「首次打开之前不挂载」作为闸门,一旦打开过就永不卸载。
- Step 2: 改为
lazy+ 首开挂载
// 批 2:全局对话面板改为「首开挂载 + 之后常驻」。
// @ai-context: 原语义(App.tsx:421 注释)是「常驻挂载——开合仅切 display,选中态/后台任务保活」。
// 本任务只加一道**首开之前不挂载**的闸门:一旦 dockOpen 为真过一次,dockMounted 永为真,
// 之后的 open/close 仍是纯 display 切换 ⇒ 保活语义逐字保留。
// @ai-context: 收益来自它的静态依赖链 AiConversationDock → TaskConversationView →
// ChatMessageMarkdown → rehype-katex + katex/dist/katex.min.css(实测 ≈153 kB gzip)。
const AiConversationDock = lazy(() => import("./components/AiConversationDock"));在 const [dockOpen, setDockOpen] = useState(false); 之后加入:
// 批 2:首次打开之前的挂载闸门(打开过即常驻,见上条 @ai-context)
const [dockMounted, setDockMounted] = useState(false);
useEffect(() => {
if (dockOpen) setDockMounted(true);
}, [dockOpen]);把 :422-434 的 <AiConversationDock … /> 包起来:
{dockMounted && (
<Suspense fallback={null}>
<AiConversationDock … 原 props 逐字保留 … />
</Suspense>
)}- Step 3: 构建 + 达标判定
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-task8.txt`" 2>&1"
"exit=$LASTEXITCODE"
node scripts/check-bundle-budget.mjs --no-build; "budget-exit=$LASTEXITCODE"
node scripts/bundle-eager-graph.mjs预期:可达源文件 ≈47、包 4(react / react-dom / scheduler / @tauri-apps/api / @tauri-apps/plugin-dialog,计 5 个包名);首屏 gzip 应 < 200 kB ⇒ ✅ 达标 + budget-exit=0。
若仍 > 200 kB:不要惊慌、不要改判据。把实测值与首屏 chunk 清单写进报告(Task 11 会据此写瓶颈清单 —— 规格允许的合法交付),并把该场景报告给控制方。
- Step 4: 全量门禁 + 提交
cd app; npx tsc --noEmit; npx vitest run
cd ..; node scripts/line-limits.mjs --full; node scripts/docs-check.mjs; node scripts/check-command-registry.mjs
git diff --stat
git commit --only -m "perf(app): mount conversation dock on first open" -- app/src/App.tsxVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | node scripts/bundle-eager-graph.mjs |
包清单不含 react-markdown / rehype-katex / katex / remark-*;只含 react 系 + @tauri-apps/* |
| V2 | node scripts/check-bundle-budget.mjs --no-build; $LASTEXITCODE |
✅ 达标 且 exit 0(或如实记录仍超标并转 Task 11) |
| V3 | Select-String -Path app/src/App.tsx -Pattern 'dockMounted' | Measure-Object |
≥ 3(声明 + effect 里置真 + JSX 门控) |
| V4 | git diff 里 AiConversationDock 的 props |
与改动前逐字相同 |
| V5 | cd app; npx vitest run |
124 / 1124 全绿,测试文件 0 改动 |
| V6 | 六条门禁 | 全 exit 0 |
为什么是「审计型」任务:控制方交办要求「清理测量揭示的重复/重依赖」。计划者已经测过,结论是四条候选里没有一条同时满足「只减尺寸 ∧ 零行为变化」(见下表)。本任务的价值不是「动手删」,而是在分裂之后重测一遍(分裂改变了什么在首屏、什么在懒 chunk),把结论固化成 Task 11 瓶颈清单的一节,并把每条登记给正确的批次。
⚠️ 纪律:本任务默认产物是 0 个代码改动。若某条重测后过闸(同时满足「只减尺寸」与「零行为变化、零测试改动」),实施者必须先 STOP 报控制方,取得授权后再作为独立子步执行并单独提交。不得自行扩大范围。
2026-09-12 更正(Task 12 回写,原文保留):计数写错了 —— 上文与本任务 §Interfaces 的
Produces:行(「四行结论表」)都说「四条候选」,而 Step 1 实际逐条测的是 五条:(1)katex双版本 · (2)structuredBlocks.ts孤儿 · (3)@codemirror/lang-markdown传递链 · (4)basicSetup附带件 · (5) 59 个 KaTeX 字体。交付态是五行结论表(tmp/dep-audit.md),Task 11 的瓶颈清单逐条引用了全部五条,无一条过「size-only ∧ behavior-neutral」双闸。
Files:
- Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/dep-audit.md(不入库) - 预计 Modify: 无
Interfaces:
-
Consumes: Task 4–8 的产物(
app/dist/assets/*)与scripts/bundle-eager-graph.mjs -
Produces:
tmp/dep-audit.md—— 四行结论表(候选 / 实测影响 / 是否过闸 / 归属批次),Task 11 直接引用。 -
Step 1: 逐条重测四个候选(每条都要给命令与输出)
(1) katex 双版本(顶层 0.18.4 + rehype-katex 嵌套的 0.16.47)
cd app
node -e "console.log('top ', require('./node_modules/katex/package.json').version)"
node -e "console.log('nested', require('./node_modules/rehype-katex/node_modules/katex/package.json').version)"
Select-String -Path "dist/assets/*.js" -Pattern "katex" -List | ForEach-Object { $_.Path }已测结论(复核用):顶层 0.18.4 的 JS 根本没有进产物(探针构建里没有 katex 的 JS chunk)—— 因为它的唯一生产引用 app/src/components/structuredBlocks.ts:8 所在的模块在产物图里不可达。真正被打进去的是嵌套的 0.16.47(经 rehype-katex)。而被打进产物的是顶层 0.18.4 的 CSS(ChatMessageMarkdown.tsx:13 与 structuredBlocks.ts:9 都 import "katex/dist/katex.min.css",前者活)⇒ CSS 与 JS 版本偏斜。
⇒ 对首屏 gzip 的影响:0(除去重后版本不变)。归属:批 7(structuredBlocks.ts 整模块存废已由批 1 定在批 7;app/package.json 里的 katex + @types/katex 直接依赖随之处理)。登记理由:本条不是包体瓶颈,是版本偏斜风险。
(2) app/src/components/structuredBlocks.ts 是不是生产孤儿 —— 用产物反证
Select-String -Path app/src/**/*.ts,app/src/**/*.tsx -Pattern 'from "\./structuredBlocks"|from "\.\./components/structuredBlocks"' | ForEach-Object { "$($_.Path):$($_.LineNumber): $($_.Line.Trim())" }
# 仪器自检:同一命令对已知存在的引用必须命中
Select-String -Path app/src/**/*.ts,app/src/**/*.tsx -Pattern 'from "\./ChatMessageMarkdown"' | ForEach-Object { "$($_.Path):$($_.LineNumber)" }预期:第一条只有 structuredBlocks.test.ts 命中(生产零引用);第二条命中 ChatMessageList.tsx 与 TaskConversationView.tsx(自检通过)。
产物级独立佐证(本任务新增的判据):app/dist/assets 里不存在只含 katex 包 JS 的 chunk(structuredBlocks.ts:8 的 import katex from "katex" 若可达,必定生成一个;不存在 ⇒ 它被 tree-shake ⇒ 该模块从入口不可达)。
⇒ 归属:批 7(批 1 控制方裁决:「接线或删除二选一,批 1 未决前不得删」)。本任务只补证据,不动它。
(3) @codemirror/lang-markdown 拖进来的 html/js/css 传递链
cd app
node -e "const p=require('./node_modules/@codemirror/lang-markdown/package.json');console.log(JSON.stringify(p.dependencies,null,2))"
Get-ChildItem dist/assets -Filter "*.js" | ForEach-Object { $_.Name + ' ' + [math]::Round($_.Length/1000,2) + ' kB' } | Sort-Object已测结论(复核用):@codemirror/lang-html → @codemirror/lang-javascript + @codemirror/lang-css,连带 @lezer/javascript 30.69 · @lezer/css 8.08 · @lezer/html 6.26 · @codemirror/lang-html 5.51 · @codemirror/lang-css 4.53 · @codemirror/lang-javascript 2.93 = ≈59.07 kB gzip,全部由 RichEditorView.tsx:16 的一句 import { markdown } from "@codemirror/lang-markdown" 拖入。
⇒ 分裂之后它落在懒加载的 vendor-editor chunk 里,对首屏 gzip 影响:0。去掉它需要改 markdown({ htmlTagLanguage: … }) 之类配置,会改变编辑器里 markdown 内联 HTML 的高亮/补全行为 ⇒ 不过闸。归属:批 4/5(编辑器体验与视图层)。登记理由:非首屏瓶颈;属编辑器体验裁决。
(4) codemirror 的 basicSetup 附带件
Select-String -Path app/src/components/RichEditorView.tsx -Pattern "basicSetup" | ForEach-Object { "$($_.LineNumber): $($_.Line.Trim())" }已测结论:RichEditorView.tsx:15 用 basicSetup,它连带 @codemirror/autocomplete 12.66 + @codemirror/lint 4.62 + @codemirror/search 6.83 = ≈24.11 kB gzip。同样是懒 chunk 内,首屏影响 0;替换成显式扩展列表会去掉自动补全/搜索/诊断(行为变化)⇒ 不过闸。归属:批 4/5。
2026-09-12 更正(Task 12 回写,单位与口径):候选 3/4 的
≈59.07/≈24.11 kB是分裂前的探针读数(§表 2 的「每包一 chunk」探针,其总量比真实构建膨胀 +6.3%),不是落地后的可省字节。分裂后由 Task 9 用真实构建做的 A/B 实测值是: · 候选 3(stub@codemirror/lang-markdown传递链 9 包):vendor-editor208,585 → 124,549 B = −84.04 kB; · 候选 4(stubbasicSetup):vendor-editor208,585 → 163,484 B = −45.10 kB; 两者都 100% 落在懒 chunk 内,对首屏 0 B(判据是check-bundle-budget.mjs --no-build --dist <stub 产物>的lazy栏)。 ⇒ 引用时不得把 59.07 / 24.11 当作「可省的 kB 数」;它们在探针口径下只是归因占比。Task 11 的瓶颈清单已用 −84.04 / −45.10 入账。
(5) 59 个 KaTeX 字体(1,047.80 kB:.woff2 19/250.16 · .woff 20/296.01 · .ttf 20/501.63)
cd app
node -e "const z=require('zlib'),f=require('fs');for(const e of ['.woff2','.woff','.ttf']){const ns=f.readdirSync('dist/assets').filter(n=>n.endsWith(e));console.log(e,ns.length,'files',(ns.reduce((a,n)=>a+f.statSync('dist/assets/'+n).size,0)/1024).toFixed(2),'KiB');}"
Select-String -Path "node_modules/katex/dist/katex.min.css" -Pattern "font-face|src:" | Measure-Object | Select-Object -ExpandProperty Count已测结论:首屏与任何 JS chunk 都不含字体(CSS @font-face 按需取),对 200 kB 预算影响 0;只影响安装包体积。WebView2 支持 woff2 ⇒ 仅留 .woff2 可省 ≈797.64 kB 原始字节。⇒ 不过闸(要改的是第三方 CSS 的 src: 列表,属构建后处理,本批无此工具链)。归属:批 8(治理收口),与「安装包体积」一并裁决。
2026-09-12 更正(Task 12 回写,单位):本条的
1,047.80与797.64是 KiB(Step 1 的探针命令自己写着… /1024).toFixed(2),'KiB'),但被冠以kB。十进制(÷1000,vite 口径):字体总计 1,072,948 B = 1,072.95 kB;仅留.woff2可省 816,780 B = 816.78 kB。⇒ 按kB抄这行会凭空少 25.15 kB。§表 1第 122/123 行有同一处单位错标(见该表下注)。
- Step 2: 写
tmp/dep-audit.md
四列固定:候选 / 实测 gzip 影响 / 是否过「size-only ∧ behavior-neutral」双闸 / 归属批次 + 理由。每条必须带 Step 1 的复现命令与输出摘要。
- Step 3: 若全部不过闸(预期),本任务 0 代码改动
git status --short预期:工作树只多出未跟踪的 .superpowers/(本来就未跟踪)。没有提交 —— 在报告里写明「本任务产物是审计文档,无提交」。
- Step 4: 全量门禁(确认没被本任务碰坏)
node scripts/line-limits.mjs --full; node scripts/docs-check.mjs; node scripts/check-command-registry.mjs
node scripts/check-bundle-budget.mjs --no-build
cd app; npx tsc --noEmit; npx vitest runVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | Test-Path .superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/dep-audit.md |
True,且五条候选齐备、每条带复现命令 |
| V2 | git status --short |
除未跟踪的 .superpowers/ 外无改动 |
| V3 | Test-Path app/node_modules/gsap |
False |
| V4 | 五条门禁 | 全 exit 0;vitest 仍 124 / 1124 |
| V5 | 报告里「过闸项」一栏 | 要么是「无」,要么带控制方授权记录 |
Files:
- Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/build-after.txt(不入库) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/final-numbers.md(不入库)
Interfaces:
-
Consumes: Task 1 的
build-before.txt与baseline.md -
Produces:
final-numbers.md—— Task 11 瓶颈清单的唯一数据源。 -
Step 1: 用与 Task 1 逐字相同的命令重跑
$d = "D:\Program own\aicode\work space\Entropydecrease\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp"
cmd /c "cd /d `"D:\Program own\aicode\work space\Entropydecrease\app`" && npm run build > `"$d\build-after.txt`" 2>&1"
"exit=$LASTEXITCODE"「逐字相同」是要点:同一个
npm run build(=tsc && vite build)、同一个 shell、同一个 cwd。不要换成npx vite build(会跳过tsc),也不要加--minify/--mode之类的旗标(会破坏可比性)。
- Step 2: 逐 chunk 列表与总量
node scripts/check-bundle-budget.mjs --no-build
node scripts/check-bundle-budget.mjs --no-build --json > "$d\final-budget.json"
node scripts/bundle-eager-graph.mjs --json > "$d\final-graph.json"
cd app; Get-ChildItem dist/assets -Filter "*.js" | ForEach-Object { [pscustomobject]@{ Name=$_.Name; kB=[math]::Round($_.Length/1000,2) } } | Sort-Object kB -Descending | Format-Table -AutoSize- Step 3: 汇编前后对比表(写进
final-numbers.md)
必须含七行:① JS 总原始大小 ② JS 总 gzip(全部 chunk 之和,含懒的)③ 首屏 JS gzip(判据)④ 首屏 chunk 数 ⑤ CSS 原始/gzip ⑥ 字体资产(文件数 + 原始)⑦ dist 总大小。每行给「前 / 后 / Δ」,并注明读数来自哪个命令。
- Step 4: 六门禁终测(串行、单跑)
cd "D:\Program own\aicode\work space\Entropydecrease"
node scripts/line-limits.mjs --full
node scripts/docs-check.mjs
node scripts/check-command-registry.mjs
cd app; npx tsc --noEmit
cd app; npx vitest run
cd app/src-tauri; cargo test --test app_lib_tests 2>"..\..\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\cargo-after.txt"
cd app/src-tauri; cargo clippy --all-targets 2>"..\..\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\clippy-after.txt"预期:与基线逐字相同(0 / 123 / 123 · exit 0 · 312/312/0 · tsc 0 · 124 / 1124 · 2300/0/6 · clippy 19)。
2026-09-12 更正(Task 12 回写):
124 / 1124这条不可能逐字相同 —— 批 2 自己净增 1 个测试文件(app/src/build/manualChunks.test.ts,Task 3 建、Task 5/补强单元扩),交付终态是 125 文件 / 1233 用例(Task 12 收口复跑:Test Files 125 passed (125)·Tests 1233 passed (1233)· exit 0)。其余六项与基线逐字相同(0/123/123·312/312/0· tsc 0 · 2300 passed / 0 failed / 6 ignored · clippy 19 集合 identical)。⇒ 本表的 V4「与基线表逐字相同」应读作「除 vitest 用例数外逐字相同」,用例数判据是只增不减(0 处断言修改)。 若ffmpeg::tests::run_captured_handles_large_output偶发失败:单独复跑该用例再下结论(装载敏感,10s 墙钟超时)。
Verification
| # | 命令 | 期望 |
|---|---|---|
| V1 | build-after.txt 里出现 ✓ built in 且 exit 0 |
是 |
| V2 | final-numbers.md 七行齐备、每行带命令与「前/后/Δ」 |
是 |
| V3 | node scripts/check-bundle-budget.mjs --no-build; $LASTEXITCODE |
二选一并如实记录:✅ 达标 + exit 0,或 ❌ 超标 + exit 1(后者转 Task 11) |
| V4 | 六门禁终测 | 与基线表逐字相同 |
| V5 | git status --short |
除未跟踪 .superpowers/ 外无改动(终测不产生提交) |
规格 §10 批 2 行原文:「从 651KB gzip 降到达标或给出瓶颈清单」;§11 验收 9:「JS 包首屏 gzip 达标或给出瓶颈清单」。⇒ 这是一份正式交付物,不是备注。 它同时是批 3+ 计划的前置输入(规格 L534:「后一批的计划在前一批验收通过后再写 —— 因为批 3 之后的细节依赖批 0–2 的实测结果(尤其是包体治理量出的瓶颈清单)」)。
Files:
- Modify:
docs/superpowers/plans/2026-09-11-frontend-redesign-batch2-bundle.md(在文件末尾追加## 收口回写(Task 11/12)节 —— 与批 1 计划同构:后批来读的是兄弟计划,把清单写在这里,批 3 的计划者读得到) - Create:
.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp/bottleneck-draft.md(不入库;正式文本落到上面那份计划文件)
Interfaces:
-
Consumes:
final-numbers.md(Task 10)·dep-audit.md(Task 9)·gsap-slot.md(Task 5) -
Produces: 计划文件里的 §收口回写 节,含瓶颈清单表(固定六列:项 / 实测 gzip / 占比 / 为什么降不下去 / 归属批次 / 复现命令)与达标结论。
-
Step 1: 先写「降得下去的」与「降不下去的」两栏
清单必须包含(缺一即不合格):
| 项 | 内容要求 |
|---|---|
| 1 | 首屏剩下的每一个 chunk 及其 gzip(逐行列出,标注它为什么必须在首屏) |
| 2 | vendor-react(≈61 kB)—— 不可降,理由:React 运行时是硬底线 |
| 3 | @tauri-apps/api(≈4.7 kB)—— 不可降,理由:IPC 是所有页面的公共依赖 |
| 4 | 应用源码中仍静态可达的部分(用 scripts/bundle-eager-graph.mjs 的实际数字)—— 逐类说明为什么仍在首屏(壳层 / 默认页 / provider / 错误边界 / 浏览器痕迹抑制) |
| 5 | Task 9 的五条候选(katex 双版本 · structuredBlocks 孤儿 · codemirror html 传递链 · basicSetup 附带件 · 59 个 KaTeX 字体),逐条给「首屏影响 0,理由 = 落在懒 chunk / 不进 JS 预算」与归属批次 |
| 6 | 若 ❌ 超标:把超出的 kB 数与唯一能补上它的动作写清楚,并给出该动作的代价与归属批次 |
| 7 | 未做的两件事:路线 A(叶级懒加载,被路线 B 完全包含,且测试面风险更高)· check-bundle-budget.mjs 的门禁接线(归属批 8) |
- Step 2: 每条都要有复现命令,且仪器自检通过
清单里出现的每一个数字,必须能由一条写在清单里的命令复现。若某条命令的输出在自检里 0 命中(例如某个包名/字符串其实不存在),改掉那条结论 —— 这是批 1「收口三」的直接应用。
- Step 3: 写进计划文件
用 edit 工具在 docs/superpowers/plans/2026-09-11-frontend-redesign-batch2-bundle.md 末尾追加:
---
## 收口回写(Task 11/12,<YYYY-MM-DD> —— 批 2 终态 · 瓶颈清单 · 给批 3+ 的输入)
> 本节由**收口单元**写入。上文一律**保留不改**,本节只做**加注**与**归账**。
> **批 3 的计划者:你需要的瓶颈清单就是本节 §瓶颈清单。** 规格 L534 明写后批细节依赖本清单。
### 瓶颈清单(首屏 JS gzip 判据 = `docs/standards/performance.md:28` 的 < 200 kB)
<六列表格>
### 达标结论
<达标 / 未达标 + 实测读数 + 复现命令>
### 未做(登记,逐条带归属批次)
<…>- Step 4: 跑
docs-check与行数复核
node scripts/docs-check.mjs
$p="docs\superpowers\plans\2026-09-11-frontend-redesign-batch2-bundle.md"; [System.IO.File]::ReadAllLines((Resolve-Path $p),[Text.Encoding]::UTF8).Count预期:docs-check exit 0(新增的所有相对链接必须真实存在;.superpowers/... 的路径不要写成 Markdown 链接 —— 它不入库,会让新克隆断链,批 1 已有先例并刻意不写成链接)。
⚠️ 计划文件本身不受line-limits管辖(SCAN_DIRS只有app/src与app/src-tauri/src),但仍应保持结构清晰。
- Step 5: 提交
git diff --stat
git commit --only -m "docs(plans): record batch2 bundle bottleneck list" -- docs/superpowers/plans/2026-09-11-frontend-redesign-batch2-bundle.mdVerification
| # | 命令 | 期望 |
|---|---|---|
| V1 | git show --stat HEAD |
只含那一个计划文件 |
| V2 | 清单七类项齐备、每条带复现命令 | 是(评审者逐条核对) |
| V3 | node scripts/check-bundle-budget.mjs --no-build 的读数与清单首行 |
逐字一致 |
| V4 | node scripts/docs-check.mjs |
exit 0 |
| V5 | 清单里没有任何 Markdown 链接指向 .superpowers/ |
是 |
Files:
- Modify:
docs/superpowers/specs/2026-09-11-frontend-redesign-design.md(§10 批 2 行 + §2 包体行 + §11 验收 9 + §13 风险表「包体继续膨胀」行) - Modify:
docs/versions/v0.22.md(新增### 批 2 · 包体治理节,紧随批 1 节之后) - Modify:
docs/standards/line-limit-exemptions.md(只有当App.tsx行数变化时才刷新,且只走node scripts/line-limits.mjs --write) - Modify(如需要):
.husky/pre-commit—— 不改(本批有意不接线,理由已写在 Global Constraints;改它属越界,STOP)
Interfaces:
-
Consumes: 全部前序任务的报告与读数
-
Produces: 规格与台账的终态;本批的「未做(登记)」表(每条带具名归属批次)。
-
Step 1: 六门禁最终复核(串行、单跑)
cd "D:\Program own\aicode\work space\Entropydecrease"
node scripts/line-limits.mjs --full
node scripts/docs-check.mjs
node scripts/check-command-registry.mjs
cd app; npx tsc --noEmit
cd app; npx vitest run
cd app/src-tauri; cargo test --test app_lib_tests 2>"..\..\.superpowers\sdd\2026-09-11-frontend-redesign-batch2-bundle\tmp\cargo-final.txt"预期与基线表逐字相同(含 0 / 123 / 123、312/312/0、124 文件 / 1124 用例、2300 passed / 0 failed / 6 ignored)。
任一条不符 ⇒ 不许提交收口,先查。
- Step 2: 更新豁免表(只
--write,不手改)
node scripts/line-limits.mjs --write
git diff --stat -- docs/standards/line-limit-exemptions.mdApp.tsx 从 439 行变到新值 ⇒ 该行数值会被 --write 更新。若 diff 里出现本批未触碰的文件行(并行 agent 的在飞改动),照实登记「这一行是在哪次提交落库的」,不要为掩盖它去改别人的行。
- Step 3: 回写规格(四处,逐处给原文与新文)
- §2 现状基线「包体」行:把「JS 2,093.85 kB / gzip 651.25 kB(单个 chunk,零代码分割)」更正为批 2 终态实测值 + 一条注:「651.25 kB 是批 2 开工前的过期快照;
f12aba6d实测 654.72 kB」。 - §10 批 2 行:加进度标记(照批 1 行的写法),并在其后补一条口径注:「
manualChunks本身不降低首屏字节(同一批字节切成多文件,入口仍静态依赖全部 chunk);它的作用是 ① 让 GSAP 落进独立懒加载 chunk ② 给import()产生的懒 chunk 稳定的共享 vendor 边界。首屏下降到 <200 kB 靠的是按页动态 import + 首访挂载保活 + 窗口变体/对话面板按需。」 - §11 验收第 9 条:在「JS 包首屏 gzip 达标或给出瓶颈清单;GSAP 只在独立 chunk 懒加载」后加进度注(达标/未达标 + 实测值 + 「GSAP 独立 chunk 槽位已在
app/src/build/manualChunks.ts预留并被单测钉住(GSAP 未安装);批 6 装 GSAP 时必须用import(),判据见 Task 5」)。 - §13 风险表「包体继续膨胀」行:缓解栏补「批 2 已落:
manualChunks的vendor-gsap精确包名槽位(app/src/build/manualChunks.ts)+ 单测三条(含反例)+ 首屏预算守卫scripts/check-bundle-budget.mjs。批 6 接入时必须import()动态加载;接线到 pre-commit/CI 归属批 8」。
⚠️ §10 的批 2 行同时是「批 3+ 计划的前置输入」的指针:在该行末尾加一句「本批的包体瓶颈清单见批 2 计划 §收口回写」,并把它写成从docs/superpowers/specs/出发的相对链接../plans/2026-09-11-frontend-redesign-batch2-bundle.md。⚠️ 只许写这一个方向:计划文件在docs/superpowers/plans/,反向写./plans/…会解析成docs/superpowers/plans/plans/…(不存在)⇒docs-check当场变红。
- Step 4: 写台账
docs/versions/v0.22.md
在「批 1 · 删除批」节之后新增 ### 批 2 · 包体治理(<日期>,<起始提交>..<结束提交>,N 个提交),节内固定七段(照批 1 节的粒度):
交付 · 验收(六门禁实测) · 账(首屏 gzip 前后对比 + chunk 拓扑) · 规格漂移纠正 · 过程中纠正的计划错误 · 未做(登记,逐条带归属) · 瓶颈清单指针。
批 1 节里那句话(「每一笔计划外删除都由控制方在 STOP → 实测绿色解 → 授权 → 落地 → 门禁回绿的闭环里逐条裁决」)是本系列台账的写法标准:本批同样必须逐条归因,不得只写结论。
- Step 5: 写 follow-ups(每条必须带具名归属批次)
至少覆盖(按实测补充):
| # | 未做项 | 归属 |
|---|---|---|
| 1 | 路线 A(叶级懒加载 NoteMarkdown/RichEditorView/ChatMessageMarkdown/KnowledgeCanvasView/KnowledgeGraphView)—— 被路线 B′ 在首屏上完全包含 |
批 5(视图层「惰性挂载」本来就做这件事,规格 §7.3 约束 2) |
| 2 | scripts/check-bundle-budget.mjs 接线到 .husky/pre-commit / CI |
批 8(治理收口) |
| 3 | structuredBlocks.ts 整模块存废(含 katex + @types/katex 直接依赖与 katex CSS/JS 版本偏斜) |
批 7(批 1 已裁定) |
| 4 | @codemirror/lang-markdown 的 html/js/css 传递链(≈59 kB,懒 chunk 内)+ basicSetup 附带件(≈24 kB) |
批 4/5(编辑器体验裁决) |
| 5 | 59 个 KaTeX 字体(1,047.80 kB 原始,仅 woff2 可省 ≈797 kB) | 批 8(安装包体积一并裁决) |
| 6 | 首访加载态(Suspense fallback={null} 的观感)—— 应改用 L1 原语 Loading |
批 3/4(壳层 + 加载原语一起做) |
| 7 | 批 6 接入 GSAP 时必须用 import() 且核对 vendor-gsap 独立 chunk |
批 6 |
| 8 | 窗口变体首屏(?float=1/?overlay=1)的正式度量口径(本批只给上界推理) |
批 3(壳层会改动窗口启动路径) |
- Step 6: 最终门禁 + 提交
node scripts/line-limits.mjs --full; node scripts/docs-check.mjs; node scripts/check-command-registry.mjs
git diff --stat
git commit --only -m "docs(batch2): close bundle governance batch" -- docs/superpowers/specs/2026-09-11-frontend-redesign-design.md docs/versions/v0.22.md docs/standards/line-limit-exemptions.md
docs/standards/line-limit-exemptions.md只在 Step 2 的git diff --stat非空时才加进这次提交(空则从命令里去掉)。
Verification
| # | 命令 | 期望 |
|---|---|---|
| V1 | 六门禁 | 与基线表逐字相同 |
| V2 | node scripts/line-limits.mjs --full |
exit 0;>600: 0 · 301–600 档 = 登记条目数 |
| V3 | node scripts/docs-check.mjs |
exit 0(规格里新增的 ../plans/… 链接可达) |
| V4 | git show --stat HEAD |
只含 Step 6 列出的文件 |
| V5 | follow-ups 表 | ≥8 行,每行都有具名归属批次,无「不知道」 |
| V6 | git log --oneline -12 |
提交信息全为 Conventional Commits、subject ≤50 字、无句号 |
- 规格覆盖:§10 批 2 行的三个动作 →
manualChunks(T3/T4)· 按页动态 import(T6)· 量首屏 gzip(T1/T2/T10);§10 后半句「达标或瓶颈清单」→ §达标定义 + T11;§11 验收 9 的「GSAP 只在独立 chunk 懒加载」→ T5;§13 风险表「批 2 先治理」→ 全批 + T12 Step 3 第 4 处回写;§14「文档与提交」→ T12。 - 非目标覆盖:不装 GSAP(T5 的 V3/V4 + T9 V3 双重守门)· 不动
ui/primitives/**(全批无该目录路径)· 不改运行行为(Global Constraints 待裁决项 + T6/T7/T8 的「vitest 逐字持平 + 0 处测试改动」判据)· 不碰 Rust(Global Constraints + 六门禁的 cargo 两条必须逐字不动)。 - 类型一致性:
VendorGroup的七个成员在 T3 定义、T4 接线、T5 断言、T9/T10 报表引用,同名同形;manualChunks(moduleId: string): string | undefined在 T3 定义、T4 以(id: string) => manualChunks(id)消费;scripts/check-bundle-budget.mjs的--no-build/--json/--self-test/--budget四个旗标在 T2 定义,T4–T12 消费,无第五个。 - 占位符扫描:T6 Step 5 里的
… 原 props 逐字保留 …是刻意的(该处必须由实施者从现行文件搬运,写死会让 props 在并行改动后过期),并已在同一步给出机器判据 V3(git diff里 props 行逐字相同)—— 它不是「TBD」,而是「以现行文件为准 + 有机器验收」。 - 风险最高的两处已就地标注:T6 的测试面(红即回退,绝不改测试)· T8 的达标判定(不达标是合法结果,转 T11)。
本节由收口单元写入。上文一律保留不改,本节只做加注与归账。 批 3 的计划者:你需要的瓶颈清单就是本节 §瓶颈清单。 规格 L534 明写后批细节依赖本清单。 分工:Task 11 写 §瓶颈清单 · §达标结论 · §未做(登记);Task 12 在本节追加终态六门禁读数与规格、台账的回写指针。
读数口径:kB 一律十进制 ÷1000(vite 口径);凡 ÷1024 一律写作 KiB,并同时给出十进制值。
数据源:final-numbers.md(Task 10 终测,本清单唯一数据源)· dep-audit.md(Task 9)· gsap-slot.md(Task 5)。
占比 = 占首屏 92.79 kB 的比例;— = 非字节项(失败路径 / 行数 / 未做项),该列无意义。
「归属批次」列的 —(硬底线,不派单) 是明确决定(不是漏填):该行今天没有可执行的降本动作,要动它须先立 ADR。
<T> = 下表复现命令里的留档目录 .superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/tmp(不入库,故只写行内代码、不写成 Markdown 链接);命令 cwd 除注明外均为仓库根。
2026-09-12 追加(收口评审 M-1 结清)—— 本表「复现命令」列已全部自足。 收口评审指出两行不构成可独立执行的命令:
vendor-tauri行原写同上(依赖读者回看上一行)、KaTeX 字体行原写<T>/task9/cand2and5.mjs(缺解释器)。两处已就地改为上文那两条可独立执行的命令(同上→ 重复写出上一行的node scripts/check-bundle-budget.mjs --no-build;后者 →node <T>/task9/cand2and5.mjs)。原文与判定依据见收口评审 M-1(.superpowers/sdd/2026-09-11-frontend-redesign-batch2-bundle/batch-2-closing-review.md,该目录整体 gitignore)——评审者已实跑被引用脚本(exit 0:59 个 · 1,072,948 B · 仅留.woff2可省 816,780 B ·.js= 0),故这两行原先只是自足性瑕疵、数字为真。
2026-09-12 追加(收口评审 M-4 结清)—— 本表首行原写「壳层内 5 类构成见下一行」,而下一行实际逐类列了 7 类。 「5 类」是 Task 11 起草时的过期计数(
task-11-report.md的同一段落里也是 5),而同一份报告的 §3 与xcheck-entry-attrib.mjs的读数是 7 类占比合计 100.1%(四舍五入)(默认页依赖树 · 壳层App.tsx+main.tsx·ClassroomPage·BrowserChrome· vite 运行时垫片 ·AppErrorBoundary· types/utils/css)⇒ 计数已就地改为「7 类」。字节读数与各类占比不受影响(27.87 kB 与每类renderedLength均未变)。
| 项 | 实测 gzip | 占比 | 为什么降不下去 | 归属批次 | 复现命令 |
|---|---|---|---|---|---|
index-C4ezj_on.js(入口 chunk:main.tsx + App.tsx 导航壳 + 默认页) |
87,573 B 原始 → 27,874 B = 27.87 kB | 30.0% | 入口必须首屏;默认页 ClassroomPage 静态保留(首屏必渲染页,改 lazy 只把同一批字节挪进动态 chunk,且让守卫读数失真 —— 控制方 2026-09-12 裁决 2 末条)。壳层内 7 类构成见下一行 |
批 3/4(壳层:页级错误边界 + 加载原语)· 批 5(默认页内视图级惰性挂载) | node scripts/check-bundle-budget.mjs --no-build |
vendor-react-V0PkpIKo.js(react + react-dom + scheduler) |
192,521 B 原始 → 60,367 B = 60.37 kB | 65.1% | React 运行时是硬底线;它单独就 60,367 B > 60 kB ⇒ 计划「建议项 < 60 kB/窗变体」结构性不可达(见下方变体两行) | —(硬底线,不派单;要动只能改非 React 渲染或第二套入口 HTML,属架构变更、须先立 ADR —— 提请批 3/4 记录) | node scripts/check-bundle-budget.mjs --no-build |
vendor-tauri-UIF4jgRy.js(@tauri-apps/api + plugin-dialog) |
17,143 B 原始 → 4,548 B = 4.55 kB | 4.9% | IPC 是全部页面的公共依赖(21 处静态引用方);再分包只把它拆到更多文件,不减字节 | —(硬底线,不派单) | node scripts/check-bundle-budget.mjs --no-build |
| 应用源码中仍静态可达的部分(47 文件 / 4 包;入口 chunk 内 39 module · 161,368 B pre-minify) | 已计入首行那个 27.87 kB | 30.0% | 逐类(pre-minify renderedLength,仅表类别占比):默认页依赖树 26 module / 128,923 B(79.9%,含 provider useLiveCaptureControl.tsx 6,610 B)· 壳层 App.tsx+main.tsx 2 / 16,673 B(10.3%)· ClassroomPage 1 / 5,268 B(3.3%)· BrowserChrome(浏览器痕迹抑制,须全局生效)1 / 4,327 B(2.7%)· vite 运行时垫片 3 / 3,045 B(1.9%)· AppErrorBoundary(须在任何渲染之前存在)1 / 2,039 B(1.3%)· types/utils/css 5 / 1,093 B(0.7%) |
批 3/4(壳层 / 错误边界 / 加载原语)· 批 5(默认页内视图惰性挂载) | node scripts/bundle-eager-graph.mjs(47 文件 / 4 包)· 类别占比仪器见 <T>/task9/attrib-base.json + <T>/task11/xcheck-entry-attrib.mjs |
Task 9-① katex 双版本(顶层 0.18.4 + 嵌套 0.16.47 ×2 目录) |
首屏 0 B(唯一渲染出字节的副本在懒 chunk vendor-katex-7KF2nzwm.js 79,916 B = 79.92 kB) |
0% | 去重 = 0.16.47 → 0.18.4 = 换渲染库版本(行为不中性);实测去重首屏 Δ 0 B(92,789 → 92,783 = 命名域噪声)、vendor-katex +53 B、全部 JS +63 B ⇒ 尺寸闸与行为闸双双不过 |
批 7(版本偏斜 + structuredBlocks 存废 + package.json 的 katex/@types/katex 一并裁决) |
node -e "console.log(require('./app/node_modules/katex/package.json').version, require('./app/node_modules/rehype-katex/node_modules/katex/package.json').version)" → 0.18.4 0.16.47;A/B <T>/task9/attrib.mjs dedup-katex |
Task 9-② app/src/components/structuredBlocks.ts(生产孤儿) |
首屏 0 B,且不在任何 chunk 的模块表里(产物中不存在 ⇒ 无字节可省) | 0% | 删除会打断 app/src/ui/primitives/motion-coverage.test.ts:60,68 的钉值 ⇒ 非行为中性;存废须连它自身测试与 katex 直接依赖一起裁决 |
批 7 | git grep -n "structuredBlocks" -- app/src → 4 命中,唯一 importer 是它自己的测试(另 2 条是注释与 primitives 钉值);产物级 <T>/task9/recon.mjs |
Task 9-③ @codemirror/lang-markdown 传递链(9 包:@lezer/* + @codemirror/lang-{html,css,javascript}) |
首屏 0 B;整链全在懒 chunk(stub 后 vendor-editor 208,585 → 124,549 B = −84.04 kB) |
0% | 9 个包的懒 renderedLength = 338,319 B,100% 落在懒 chunk;去掉即改变编辑器内联 HTML/JS/CSS 的高亮与补全 ⇒ 非行为中性 |
批 4/5(编辑器体验裁决) | node scripts/check-bundle-budget.mjs --no-build --dist <T>/task9/dist-stub-langmd --json(lazy 里 vendor-editor-* = 124,549 B);A/B <T>/task9/attrib.mjs stub-langmd |
Task 9-④ codemirror 的 basicSetup 附带件(@codemirror/{autocomplete,search,lint}) |
首屏 0 B;懒 chunk 208,585 → 163,484 B = −45.10 kB | 0% | 换成显式扩展表 = 去掉自动补全 / 搜索 / 诊断 ⇒ 非行为中性;且与 ③ 耦合(stub 掉 basicSetup 后 @codemirror/autocomplete 仍因 ③ 保留 20,540 B)⇒ 两者必须一起裁决 |
批 4/5 | node scripts/check-bundle-budget.mjs --no-build --dist <T>/task9/dist-stub-basicsetup --json;A/B <T>/task9/attrib.mjs stub-basicsetup |
| Task 9-⑤ 59 个 KaTeX 字体 | 1,072,948 B = 1,072.95 kB(= 1,047.80 KiB);对 200 kB JS 预算的作用面 0 | 0%(不进 JS 预算) | 59 个文件里 .js = 0 个(仪器自检);字体只被 vendor-katex-CEK31ho9.css 的 59 处 url() 引用;仅留 .woff2 可省 816,780 B = 816.78 kB(797.64 KiB),但要改第三方 CSS ⇒ 与安装包体积一并裁决 |
批 8(安装包体积) | node <T>/task9/cand2and5.mjs |
vendor-editor-CTQefJ4X.js(懒 chunk 里最大的块) |
608,384 B 原始 / 208,585 B = 208.59 kB gzip | 0%(懒 chunk,不计入判据) | vite >500 kB 警告仍在:它把编辑器栈并成一块;③+④ 全做可降 −129.14 kB,但两条都不行为中性;拆成多文件不降字节(总 JS 只多不少) |
批 4/5 | cmd /c "cd /d <repo>\app && npm run build > <T>\task11\build.log 2>&1" 后 findstr /C:"larger than 500" <T>\task11\build.log(现成留档 <T>/build-after.txt:100) |
Task 6 评审判定 M-1:PageSlot 的 <Suspense fallback={null}> 之上只有全局 AppErrorBoundary ⇒ 懒 chunk 加载失败卸载整个 MainShell,已访问页状态(含笔记编辑态)一起丢 |
—(失败路径,非字节项) | — | 不是白屏、也不是规范违背(有兜底 UI),但与裁决 2「保活 + 不丢稿」取向相反:保活是为了不丢状态,这条路径一次丢光。这是本批新增的失败模式(单 chunk 时代不存在 chunk 拉取失败) | 批 3/4(与「首访加载态」同批) | git grep -n -e AppErrorBoundary -e 'Suspense fallback' -- app/src/App.tsx → 唯一边界 :119 包 MainShell,PageSlot 的 Suspense 在 :146(HEAD dea1a6a3) |
Task 7 风险 1:两个窗口变体分支没有错误边界(<Suspense fallback={null}> + 无 AppErrorBoundary)⇒ chunk 加载失败 = 该窗全空白 |
—(失败路径);变体首屏实测 ?float=1 97.29 kB / ?overlay=1 94.19 kB |
— | 这两个分支改动前后都没有边界;本批新增的只是「chunk 可能加载失败」这条失败模式。给变体加边界会改错误语义 ⇒ 超出 Task 7 字面范围 | 批 3/4 | git grep -n 'query.get' -- app/src/App.tsx → :95(overlay)/ :105(float)两个早返回;读数 <T>/task7/variant-firstscreen.mjs --budget-json <T>/final-budget.json |
| Task 7 转交:计划里的「建议项 < 60 kB/窗变体」结构性不可达 | 实测 97.29 / 94.19 kB(对 60 kB = 1.62× / 1.57×) | — | ① 三窗共用同一 index.html ⇒ 入口静态闭包逐字节相同,省不掉共用闭包(要真正分开必须第二套入口 HTML,本批禁改面);② vendor-react 单独即 60,367 B > 60 kB ⇒ 任何渲染 React 的窗口,首屏下限已超 60 kB |
批 3(正式口径:变体首屏 = 入口静态闭包 ∪ 本变体动态 chunk)+ Task 12 回写(计划 L1317「零 vendor 依赖」是错误推理,< 60 kB 须标注不可达) |
上行变体仪器命令 + node scripts/check-bundle-budget.mjs --no-build(vendor-react 行 60,367 B) |
Task 5/Task 4 转交:预算守卫的嵌套输出路径盲区(I2)—— 产物出现 assets/vendor/*.js 这类子目录 chunk 时,它既不计入首屏、也不计入懒加载,守卫仍打印 ✅ 达标 且 exit 0 |
夹具实测:守卫只多给一行「modulepreload 声明但静态闭包未覆盖 1 个(参考项,不影响判定)」 | — | 守卫只遍历 dist/assets/ 平铺层的 .js;本批产物全平铺(子目录 0 个、manualChunks 返回值无 /、批 6 的 vendor-gsap 同样不含 /)⇒ 本批未触发,但缺口是活的:将来谁改 chunkFileNames 或返回含 / 的名字,守卫会静默假绿 |
批 8(或改动者在当次一并处理) | node scripts/check-bundle-budget.mjs --no-build --dist <T>/task4/i2-fixture → 实测 ✅ 达标 + exit 0,而 assets/vendor/nested-vendor.js 计不进任何一栏(夹具真实存在) |
Task 5 转交:没有任何门禁看构建日志里的 Circular chunk(md↔katex 环) |
—(构建日志项) | — | 环本批已断(df72e8bf 警告 1 条 → 终态 0 条),但「micromark-extension-math → katex 今天是 tree-shake 掉的死边」——依赖升级可能让它静默复活,而守卫是规则层的、看不见 |
批 8(或后续批次的 Task 10 顺手加一条「构建日志不得出现 Circular chunk」断言) |
node -e "const s=require('fs').readFileSync('<T>/build-after.txt','utf8');console.log('Circular chunk x'+((s.match(/Circular chunk/g) ?? []).length))" → x0 |
Task 10 转交:scripts/check-bundle-budget.mjs 299/300 行(余 1 行)· app/src/build/manualChunks.test.ts 292/300 行(余 8 行) |
—(行数,非字节) | — | 两者都贴着 AGENTS.md 的单文件 300 行上限:任何新增都必须按语义拆分(守卫拆「口径 / CLI」,测试拆「分组规则 / 反例」),否则 line-limits --full 当场变红 |
批 8(治理收口)· 任何下次改动这两个文件的单元当次拆分 | [System.IO.File]::ReadAllLines((Resolve-Path scripts/check-bundle-budget.mjs),[Text.Encoding]::UTF8).Count → 299;同式对 app/src/build/manualChunks.test.ts → 292(manualChunks.ts 159) |
未做:路线 A 叶级懒加载(NoteMarkdown / RichEditorView / ChatMessageMarkdown / KnowledgeCanvasView / KnowledgeGraphView) |
—(未做项) | — | 被路线 B′ 在首屏上完全包含(B′ 已把整页移出首屏),且测试面风险更高 | 批 5(视图层「惰性挂载」,规格 §7.3 约束 2) | 判据同 node scripts/check-bundle-budget.mjs --no-build |
未做:check-bundle-budget.mjs 未接进 .husky/pre-commit / CI(本批有意:它要跑一次真实构建并写 app/dist/,塞进共享的 pre-commit 会让并行期每次提交都依赖「此刻工作树可构建」) |
—(未做项) | — | 本批策略 = 每个涉及包体的任务手工跑;自动化接线属治理收口 | 批 8 | git grep -n "check-bundle-budget" -- .husky → 0 命中(阳性对照:同路径 line-limits 命中 3 处,证明仪器与路径都对) |
- 判据:首屏 JS gzip < 200 kB —— 来源
docs/standards/performance.md:28(「页面包大小 (JS) | < 200KB (gzip)」)。 - 实测(HEAD
dea1a6a3):92,789 B = 92.79 kB ⇒ ✅ 达标,守卫 exit 0,余量 107,211 B = 107.21 kB(预算利用率 46.39%)。 - 开工前:654,722 B = 654.72 kB(单 chunk = 3.27× 预算)⇒ 本批 −561,933 B = −85.83%。
- 复现:
node scripts/check-bundle-budget.mjs --no-build(判据)·node scripts/check-bundle-budget.mjs --self-test(仪器自检 14 条,全绿)。 - 首屏 3 个 chunk:见 §瓶颈清单前三行;懒加载 22 个 · 574,841 B = 574.84 kB(不计入判据)。
- 两窗变体也在预算内:
?float=197.29 kB ·?overlay=194.19 kB(口径 = 入口静态闭包 ∪ 本变体动态 chunk)。 - 「若 ❌ 超标」分支不适用:Task 11 Step 1 第 6 项(写超出的 kB 数与唯一能补上它的动作)N/A —— 本批达标。
- 诚实代价(不许省略):① 全部 JS gzip +12,908 B = +12.91 kB(+1.97%)(654,722 → 667,630 B:25 个 chunk 的样板与跨 chunk
import开销,总量守恒不成立);②>500 kB警告仍在(vendor-editor208.59 kB,懒 chunk);③ CSS gzip +36 B(11,745 → 11,781)、index.html+166 B(488 → 654,两条modulepreload);④dist总量只降 4,415 B = −0.14%(字体 1,072,948 B 一字节未动 ⇒ 磁盘与安装包不因此变小)。 - 单位纪律:本清单所有 kB 均为十进制 ÷1000;字体 1,072.95 kB = 1,047.80 KiB、
dist3,228.86 kB = 3,153.18 KiB —— 计划 §表 1 曾把这两行的 KiB 值写成 kB,引用时必须连单位一起抄,否则凭空多出 25 kB 字体 / 76 kB dist 的假 Δ。
| # | 未做项 | 归属批次 |
|---|---|---|
| 1 | 路线 A 叶级懒加载(被 B′ 在首屏上完全包含 · 测试面更险) | 批 5 |
| 2 | scripts/check-bundle-budget.mjs 接线 .husky/pre-commit / CI(本批有意不接,理由见 Global Constraints 与清单末行) |
批 8 |
| 3 | 页级错误边界 / 懒 chunk 加载失败重试(M-1「整壳卸载」) | 批 3/4 |
| 4 | 窗口变体分支的错误边界(chunk 加载失败 = 空白窗) | 批 3/4 |
| 5 | 变体首屏正式度量口径 + 把 < 60 kB 标注为不可达(计划 L1317 推理错误) |
批 3(口径)· Task 12(回写) |
| 6 | 首访加载态:Suspense fallback={null} → L1 Loading 原语(本批是尺寸治理批,引入原语会把其 CSS 拉回首屏) |
批 3/4 |
| 7 | 预算守卫的嵌套输出路径盲区(I2 静默假绿) | 批 8 |
| 8 | 构建日志 Circular chunk 无门禁看管 + 依赖升级后死边复活风险 |
批 8 |
| 9 | check-bundle-budget.mjs 299/300 行 · manualChunks.test.ts 292/300 行 ⇒ 新增须按语义拆分 |
批 8 · 下次改动者当次拆 |
| 10 | structuredBlocks.ts 整模块存废 + katex 版本偏斜(含 @types/katex) |
批 7 |
| 11 | @codemirror/lang-markdown 传递链(懒 chunk −84.04 kB)+ basicSetup 附带件(−45.10 kB)—— 两条耦合,须一起裁决 |
批 4/5 |
| 12 | 59 个 KaTeX 字体 1,072.95 kB(仅 .woff2 可省 816.78 kB) |
批 8 |
| 13 | 批 6 装 GSAP 必须用 import(),且核对 vendor-gsap 不在首屏(槽位已预留并被单测钉住,判据见 Task 5) |
批 6 |
| 14 | 首屏三个 chunk 的逐 chunk 口径复核(本轮已由守卫 + 独立复算确认,无待办)—— 登记为「已复核」而非待办 | 已闭环 |
本节由 Task 12(收口单元)在 Task 11 的清单之后追加。上文一律保留不改,本节只做终态读数与归账。
终态六门禁(HEAD 9921767c,串行单跑,全部 exit 0)
| 门禁 | 终态实测 | 与开工基线(f12aba6d)的关系 |
|---|---|---|
node scripts/line-limits.mjs --full |
exit 0 · >600 0 · 301–600 档 123 · 登记条目 123 |
逐字持平(App.tsx 439 → 519 行,登记值已同步刷新) |
node scripts/docs-check.mjs |
exit 0 · 扫描 273 个 Markdown、检查 173 个,5 项全 ✅ | 持平(新增本批文档与链接) |
node scripts/check-command-registry.mjs |
exit 0 · 定义 312 / 注册 312 / 重复 0 | 逐字持平(本批 0 Rust 改动) |
cd app && npx tsc --noEmit |
exit 0 · 0 错 | 持平 |
cd app && npx vitest run |
exit 0 · 125 文件 / 1233 用例 | +1 文件 / +109 用例(唯一允许的偏离:批 2 新增 manualChunks.test.ts;0 处既有断言修改) |
cd app/src-tauri && cargo test --test app_lib_tests |
exit 0 · 2300 passed / 0 failed / 6 ignored | 逐字持平(本批 0 Rust 改动) |
node scripts/check-bundle-budget.mjs(含真实构建) |
exit 0 · 首屏 92,789 B = 92.79 kB · 余量 107.21 kB · 懒 22 个 574.84 kB | −561,933 B = −85.83%(654.72 → 92.79 kB) |
cd app/src-tauri && cargo clippy --all-targets |
exit 0 · 19 条诊断,集合 identical | 集合比对(禁止比 sha256,见 Global Constraints 注) |
回写落点(本轮逐条就地加注,原文均保留)
| # | 落点 | 更正内容 |
|---|---|---|
| 1 | Task 6 Files(+ Task 8 Files) | Files 清单漏 docs/standards/line-limit-exemptions.md 刷新 |
| 2 | Task 6 Files / Step 2 预期 / Step 3 标题+注释+代码 / Step 8 提交 / V1 / V2 | 「9 页 lazy」→ 交付态 8 懒 + 课堂静态;提交 subject 实为 eight pages;V1=8、V2=1 |
| 3 | Task 6 Verification 注 | vitest 基线 124/1124 → 交付态 125/1233 |
| 4 | Task 7 Step 5 注 | 计划给的 commit subject 53 字符超 50 限,实际用 42 字符 |
| 5 | Task 7「窗口变体首屏」段 | 「入口 + 零 vendor」为假,实测 入口 + 4 vendor = 97.29 / 94.19 kB;< 60 kB 结构性不可达 |
| 6 | Task 9 引言 / Produces | 「四条候选」→ 实为 五条(五行结论表) |
| 7 | Task 9 候选 3/4 | 59.07 / 24.11 kB 是分裂前探针值;落地 A/B 实测是 −84.04 / −45.10 kB,且 100% 在懒 chunk 内 |
| 8 | Task 9 候选 5 | 1,047.80 / 797.64 是 KiB,十进制 1,072.95 / 816.78 kB |
| 9 | §表 1 注 | 「字体 1,047.80 kB」「dist 3,157.49 kB」两行 KiB 误标为 kB,十进制 1,072.95 / 3,233.27 kB |
| 10 | Task 10 终测注 | vitest 124/1124 不可能逐字相同,判据改为只增不减 |
| 11 | Task 2 注 | 守卫的嵌套路径盲区(静默假绿,真实构建复现)+ 输出把「非首屏」说成「仅动态可达」的假文案 |
| 12 | Global Constraints 仪器纪律 | 追加第 6–13 类仪器陷阱(累计 13 类) |
| 13 | Global Constraints 门禁基线注 | clippy 判据 = 集合比对,禁止比 Task 1 的 sha256(BOM+CRLF vs LF,永远假红) |
| 14 | Task 1 Step 7 / Task 2 Step 5 / Task 7 Step 5 注 | 计划里超 ≤50 的提交命令共 3 处(51 / 51 / 53 字符,脚本 tmp/task12/subject-len.mjs 逐条普查),其中两处被照抄落地(e46e0e82 / f0592654);根因 = commitlint.config.js 的 header-max-length 沿用默认 100,该规则无机器门禁 |
规格与台账同步(Task 12 同批提交):规格 §2 包体行 / §10 批 2 行 + 口径注 + 瓶颈清单指针 / §11 验收 9 进度注 / §13 风险表 / §14 交付记录落点行;docs/versions/v0.22.md 新增 ### 批 2 · 包体治理 节(含七段固定结构与 v0.22.2 行、验收门槛 9 的进度标记)。
Task 12 追加登记的 follow-ups(Task 11 表之外的补充,逐条具名归属)
| # | 未做项 | 归属 |
|---|---|---|
| 15 | .gitignore 的 build/ 未锚定(本批只做了点状取反 !app/src/build/**,未动锚定语义)⇒ 任何新出现的 build/ 目录都会被静默吞掉 |
批 8 |
| 16 | scripts/**/*.mjs 不在 line-limits 的扫描域(SOURCE_EXT 不吃 .mjs、SCAN_DIRS 只有两处)⇒ 本批两个新脚本(299 / 86 行)无门禁保护 |
批 8 |
| 17 | app/vite.config.ts 同时不在 line-limits 扫描域与 tsc --noEmit 的 program(--listFilesOnly 879 文件 0 命中,正样本 main.tsx = 1)⇒ 只靠 vite build 兜底 |
批 8 |
| 18 | 计划自带的「子串碰撞」反例恒假红(@xyflow/react 的 id 里没有连续子串 node_modules/react)· @types/katex 覆盖闸必然红(dependencies 实为 14 个,计划写 12)· V3 判据「Δ ≤3 kB」实测 −4.39 kB 突破 ⇒ 已改判为「只许变小 ∧ modules 不变」 |
已就地更正(批 8 只承接 F4:把 @types/* 挪 devDependencies) |
| 19 | AGENTS.md §5 的「subject ≤50」是一条无门禁的约定 —— commitlint.config.js 未覆盖 header-max-length(默认 100),故 51 字符的提交能过 commit-msg 钩子并被照抄落地(本批 2 例)。建议加一条 'header-max-length': [2, 'always', 50],或至少在派发词里要求人工数字符数 |
批 8 |
Task 12 未验证(诚实单列):① 真 WebView 的时序 / 网络抓包 / 两窗变体实测(静态推理 + 守卫读数,未在真机跑);② CSS 三文件加载顺序未做 WebView 实测;③
dist未做安装包级实测(字体 1,072,948 B 一字节未动 ⇒ 安装包体积未改善);④ 本批的Circular chunk归零只在规则层被守卫,构建日志仍无门禁看管(登记批 8);⑤ 收口门禁跑在工作树而非导出提交树(node_modules不在归档内,故tsc/vitest/cargo无法在导出树上复跑 —— 与 Task 1 同一限制)。