Skip to content

Commit df72e8b

Browse files
committed
test(app): harden manualChunks pins and comments
1 parent a7d74da commit df72e8b

3 files changed

Lines changed: 163 additions & 9 deletions

File tree

‎app/src/build/manualChunks.test.ts‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { readFileSync } from "node:fs";
1212
import { dirname, join } from "node:path";
1313
import { fileURLToPath } from "node:url";
1414
import { describe, expect, it } from "vitest";
15-
import { manualChunks, packageNameOf, vendorGroupOf, type VendorGroup } from "./manualChunks";
15+
import { EXACT, manualChunks, packageNameOf, vendorGroupOf, type VendorGroup } from "./manualChunks";
1616

1717
const HERE = dirname(fileURLToPath(import.meta.url));
1818

@@ -104,3 +104,148 @@ describe("覆盖闸:package.json 的每个运行时依赖都必须有归属",
104104
expect(ungrouped).toEqual([]);
105105
});
106106
});
107+
108+
// ─────────────────────────────────────────────────────────────────────────────
109+
// Task 3 加固(评审 task-3-review.md Important-1 / Minor-2)。以上 40 条一字未动,
110+
// 以下全是**加测试**;实现侧只多一个 `export const EXACT` 供键集断言用,分组逻辑零改动。
111+
// ─────────────────────────────────────────────────────────────────────────────
112+
113+
/**
114+
* EXACT 表的**全量字面钉值**(Minor-2:原 49 条里 28 条无钉值 ⇒ 改错分组不报错)。
115+
* 期望值必须**字面写死**、不能从实现里读出来比对:期望若取自被审查的同一张表,
116+
* 「把 `bail` 挪进 `vendor-katex`」这类编辑会连期望值一起改,断言恒真(自指空转)。
117+
* 键集一致断言(本段最后一条)补齐另一半:新增/删除登记行也必须同步改这里。
118+
*/
119+
const EXACT_PINS: readonly (readonly [string, VendorGroup])[] = [
120+
["gsap", "vendor-gsap"],
121+
["@gsap/react", "vendor-gsap"],
122+
["react", "vendor-react"],
123+
["react-dom", "vendor-react"],
124+
["scheduler", "vendor-react"],
125+
["@tauri-apps/api", "vendor-tauri"],
126+
["@tauri-apps/plugin-dialog", "vendor-tauri"],
127+
["katex", "vendor-katex"],
128+
["rehype-katex", "vendor-katex"],
129+
["hast-util-from-dom", "vendor-katex"],
130+
["hast-util-to-text", "vendor-katex"],
131+
["hast-util-from-html-isomorphic", "vendor-katex"],
132+
["hast-util-is-element", "vendor-katex"],
133+
["hast-util-parse-selector", "vendor-katex"],
134+
["hastscript", "vendor-katex"],
135+
["property-information", "vendor-katex"],
136+
["web-namespaces", "vendor-katex"],
137+
["codemirror", "vendor-editor"],
138+
["crelt", "vendor-editor"],
139+
["style-mod", "vendor-editor"],
140+
["w3c-keyname", "vendor-editor"],
141+
["classcat", "vendor-canvas"],
142+
["zustand", "vendor-canvas"],
143+
["use-sync-external-store", "vendor-canvas"],
144+
["react-markdown", "vendor-md"],
145+
["unified", "vendor-md"],
146+
["bail", "vendor-md"],
147+
["trough", "vendor-md"],
148+
["vfile", "vendor-md"],
149+
["vfile-message", "vendor-md"],
150+
["extend", "vendor-md"],
151+
["is-plain-obj", "vendor-md"],
152+
["devlop", "vendor-md"],
153+
["zwitch", "vendor-md"],
154+
["ccount", "vendor-md"],
155+
["longest-streak", "vendor-md"],
156+
["markdown-table", "vendor-md"],
157+
["trim-lines", "vendor-md"],
158+
["escape-string-regexp", "vendor-md"],
159+
["decode-named-character-reference", "vendor-md"],
160+
["comma-separated-tokens", "vendor-md"],
161+
["space-separated-tokens", "vendor-md"],
162+
["html-url-attributes", "vendor-md"],
163+
["style-to-js", "vendor-md"],
164+
["style-to-object", "vendor-md"],
165+
["inline-style-parser", "vendor-md"],
166+
["estree-util-is-identifier-name", "vendor-md"],
167+
["@ungap/structured-clone", "vendor-md"],
168+
["@types/katex", "vendor-katex"],
169+
];
170+
171+
/**
172+
* PREFIX 族的**有效**钉值(Minor-2:`^rehype-` / `^vfile` 原先没有任何由它们决定分组的
173+
* 钉值 —— `rehype-katex` / `vfile` 都被 EXACT 抢走 ⇒ 改前缀的组不会变红)。
174+
* 每个前缀一条代表;`EXACT[name]` 必须为 undefined 是**有效性判据**:代表一旦被 EXACT
175+
* 接管,这条钉值就不再约束前缀表(原缺口正是这么产生的)。
176+
*/
177+
const PREFIX_PINS: readonly (readonly [string, VendorGroup])[] = [
178+
["@codemirror/view", "vendor-editor"],
179+
["@lezer/common", "vendor-editor"],
180+
["@marijn/find-cluster-break", "vendor-editor"],
181+
["@xyflow/system", "vendor-canvas"],
182+
["d3-zoom", "vendor-canvas"],
183+
["micromark", "vendor-md"],
184+
["mdast-util-to-hast", "vendor-md"],
185+
["hast-util-to-jsx-runtime", "vendor-md"],
186+
["unist-util-visit", "vendor-md"],
187+
["remark-gfm", "vendor-md"],
188+
["rehype-raw", "vendor-md"], // 未安装(惰性,同 gsap 行):冻结 `^rehype-` 族归属本身
189+
["vfile-location", "vendor-md"], // 已随 react-markdown 装上且非 EXACT ⇒ 真由 `^vfile` 决定
190+
];
191+
192+
describe("EXACT 表全量钉值(Minor-2:28/49 条原先无钉值)", () => {
193+
for (const [name, group] of EXACT_PINS) {
194+
it(`EXACT ${name} → ${group}`, () => {
195+
expect(EXACT[name]).toBe(group); // 表内字面值
196+
const id = `/r/node_modules/${name}/index.js`;
197+
expect(vendorGroupOf(id)).toBe(group); // 解析路径:packageNameOf → EXACT
198+
expect(manualChunks(id)).toBe(group); // 接线面:Task 4 接的正是它
199+
});
200+
}
201+
202+
it("EXACT 键集与钉值表逐字一致(新增/删除登记行必须同步钉值)", () => {
203+
expect(Object.keys(EXACT).sort()).toEqual(EXACT_PINS.map(([n]) => n).sort());
204+
});
205+
});
206+
207+
describe("PREFIX 族有效钉值(每个前缀至少一条由它决定分组)", () => {
208+
for (const [name, group] of PREFIX_PINS) {
209+
it(`PREFIX ${name} → ${group}`, () => {
210+
expect(EXACT[name]).toBeUndefined(); // 有效性:确实由前缀表决定,未被 EXACT 接管
211+
expect(vendorGroupOf(`/r/node_modules/${name}/index.js`)).toBe(group);
212+
});
213+
}
214+
});
215+
216+
describe("确定性与无状态(Important-1:原 40 条对跨调用/跨顺序零约束)", () => {
217+
// 覆盖四类 id,且**走到末尾 `return undefined` 的未登记包恰为 2 个(偶数)**:
218+
// 「逆序重放」只有在此数为偶数时才能观察到「按调用计数」的状态 —— 若为奇数,
219+
// 正序与逆序给同一 id 的计数奇偶相同、抖动互相抵消,断言会假绿。
220+
const ids: readonly string[] = [
221+
"/r/node_modules/react/index.js", // EXACT
222+
"/r/node_modules/bail/lib/index.js", // EXACT(加固前无钉值的 28 条之一)
223+
"/r/node_modules/remark-gfm/index.js", // PREFIX ^remark-
224+
"/r/node_modules/@xyflow/react/dist/index.js", // EXACT(裸 token 陷阱)
225+
"/r/node_modules/katex/dist/katex.mjs", // EXACT
226+
"/r/node_modules/not-a-real-package/index.js", // 未登记 ①(末尾 return undefined)
227+
"/r/node_modules/zzz-also-not-real/index.js", // 未登记 ②(同上)
228+
"/r/app/src/App.tsx", // 非 node_modules(提前 return,不计数)
229+
"/r/vendor/node_modules_backup/react/index.js", // 伪路径(同上)
230+
];
231+
232+
it("同一 id 连续两次调用结果相同(计数器/缓存类跨调用状态必红)", () => {
233+
for (const id of ids) expect(manualChunks(id)).toBe(manualChunks(id));
234+
});
235+
236+
it("逆序重放后逐 id 映射与正序相同(无顺序依赖)", () => {
237+
expect(ids.map(manualChunks)).toEqual([...ids].reverse().map(manualChunks).reverse());
238+
});
239+
});
240+
241+
describe("接线面全量一致(manualChunks 不许有第二套逻辑)", () => {
242+
it("整份混合列表上 manualChunks 与 vendorGroupOf 逐元素相同", () => {
243+
const ids = [
244+
"/r/node_modules/react/index.js",
245+
"/r/node_modules/bail/lib/index.js",
246+
"/r/node_modules/not-a-real-package/index.js",
247+
"/r/app/src/App.tsx",
248+
];
249+
expect(ids.map(manualChunks)).toEqual(ids.map((id) => vendorGroupOf(id)));
250+
});
251+
});

‎app/src/build/manualChunks.ts‎

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,13 @@
33
*
44
* @ai-context: 批 2 包体治理的原子层。Vite 的 `build.rollupOptions.output.manualChunks`
55
* 接收模块 id,返回 chunk 名;返回 `undefined` 表示「交回 rollup 默认算法」。
6-
* @ai-context: 为什么**必须按「解析出的包名」精确匹配**,而不是 `id.includes("node_modules/react")`:
7-
* 本仓实测的子串碰撞 —— `node_modules/react` 同时是
8-
* `react-dom` / `react-markdown` / `@xyflow/react` 的子串。用 includes 会把
9-
* markdown 栈与画布栈并进 `vendor-react`,懒加载边界**静默失效**
6+
* @ai-context: 为什么**必须按「解析出的包名」精确匹配**,而不是 `id.includes(...)`:
7+
* 本仓实测(单测 `manualChunks.test.ts` 的「子串碰撞陷阱」段即机器判据)——
8+
* 裸 token `react` 才是 `react-dom` / `react-markdown` / `@xyflow/react` 三者的
9+
* 共同子串;`@xyflow/react` 的模块 id 里**没有**连续子串 `node_modules/react`
10+
* (中间隔着 `@xyflow/`)。于是两种朴素口径各错一半:裸 `includes("react")`
11+
* 把 markdown 栈与画布栈一起吞进 `vendor-react`;`includes("node_modules/react")`
12+
* 只吞前两者,把画布栈**漏回 rollup 默认算法**。两者都让懒加载边界**静默失效**
1013
* (构建照样成功、gzip 照样降一点,但批 6 的 GSAP 独立 chunk 承诺当场落空)。
1114
* 这是批 1 六例「裸 includes 判活」教训在构建配置上的镜像。
1215
* @ai-context: 为什么 GSAP 现在就在表里(而依赖还没装):规格 §13 风险表要求
@@ -29,8 +32,11 @@ export type VendorGroup =
2932
| "vendor-canvas"
3033
| "vendor-md";
3134

32-
/** 精确包名 → 分组。**先于前缀族匹配**,因此 `rehype-katex` 不会被 `rehype-` 抢走。 */
33-
const EXACT: Readonly<Record<string, VendorGroup>> = {
35+
/** 精确包名 → 分组。**先于前缀族匹配**,因此 `rehype-katex` 不会被 `rehype-` 抢走。
36+
* 导出**只为单测**:钉值表要能断言「本表每一条都有字面钉值、且键集与钉值表逐字一致」
37+
* (评审 Minor-2 —— 28/49 条无钉值时,把 `bail` 挪进 `vendor-katex` 不会变红)。
38+
* 生产代码不得 import 它(分组只能经 `vendorGroupOf` / `manualChunks` 表达)。 */
39+
export const EXACT: Readonly<Record<string, VendorGroup>> = {
3440
// 批 6 预留(本批不安装;规则惰性,见文件头注释)
3541
gsap: "vendor-gsap",
3642
"@gsap/react": "vendor-gsap",

‎app/vite.config.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ export default defineConfig(async () => ({
1313
// Why 放在这里而不是对象形式 `{ "vendor-react": ["react", …] }`:
1414
// ① 对象形式在包未安装时**直接让构建失败**(rollup 解析不到入口),
1515
// 而本批必须为批 6 的 GSAP 预留一个「现在惰性、将来自动生效」的槽;
16-
// ② 函数形式可以按**解析出的包名**精确匹配,避开 `node_modules/react`
17-
// 同时是 react-dom / react-markdown / @xyflow/react 子串的碰撞陷阱。
16+
// ② 函数形式可以按**解析出的包名**精确匹配:实测**裸 token `react`** 才是 react-dom /
17+
// react-markdown / @xyflow/react 三者的共同子串,而 `node_modules/react` **不是**
18+
// (`@xyflow/react` 的 id 中间隔着 `@xyflow/`)。用 id.includes 一类朴素口径会把
19+
// markdown 栈与画布栈一起吞进 react chunk、或把画布栈漏回 rollup 默认算法 ——
20+
// 两种都让懒加载边界静默失效(判据见 src/build/manualChunks.test.ts)。
1821
// 规则与单测见 src/build/manualChunks.ts(纯函数,tsc 与 vitest 都覆盖得到)。
1922
build: {
2023
rollupOptions: {

0 commit comments

Comments
 (0)