|
| 1 | +/** |
| 2 | + * engine.guard.test.ts — GSAP **懒加载三件套守卫**(R2.1 ①②③ · R11.1 闭包交集 · R11.5 产物用例 · R14.7-I-2 插件逐序) |
| 3 | + * + 控制方 2026-09-13 追加的 **③‴ 动态绕开禁令**(与静态闭包判据**互补**,二者缺一不可)。 |
| 4 | + * |
| 5 | + * @ai-context 业务背景:规格 §13 风险表逐字要求「GSAP 独立 chunk 懒加载」。本文件把它变成**常驻机器判据** —— |
| 6 | + * T3 建了全仓唯一入口 `app/src/motion/engine.ts`(顶层 `registerPlugin`),本文件就是那把锁:谁把 GSAP 拉进 |
| 7 | + * 首屏静态闭包 / 谁绕开 engine 直接用 GSAP / 谁改掉插件集合或顺序,谁就在这里变红。 |
| 8 | + * @ai-context 判据本体(**不是白名单**;R11.1):「`app/src/**` 中**静态** import `gsap` / `@gsap/react` 的文件 |
| 9 | + * 集合 ∩ **首屏静态可达闭包** = ∅」。importer 集合「恰等于 {motion/engine.ts}」只是**诊断读数**(易腐化, |
| 10 | + * 供失败定位),判据取**交集** ⇒ 这是**今天无条件可跑**的那一半(导出树 / 新克隆里也有牙)。 |
| 11 | + * @ai-context ③‴:**动态** gsap 家族说明符(`import("gsap")` 等)只许出现在 `motion/engine.ts`。静态判据看不见 |
| 12 | + * 「绕开 engine 的动态 import」—— 那种写法让 `registerPlugin` **从未执行**、插件静默退化(陷阱 B1-6), |
| 13 | + * 而 ①②③ 全绿。**判据域 = 生产文件**(`*.test.ts(x)` 除外):测试不进产物,且 T3 的 `engine.test.ts:104/:115` |
| 14 | + * 用 `await import("gsap/CustomEase")` **读**注册结果(观察 ≠ 绕开)。 |
| 15 | + * @ai-context 产物判据 ①②(R11.5):`app/dist` **不入库** ⇒ 导出树里必然不运行,故取「`runIf(existsSync(dist))` |
| 16 | + * 用例 + **真实构建强制命令行** + 未运行计入报告**「未验证」单列**」三轨。🔴 **未运行 ≠ 通过**,本守卫不得 |
| 17 | + * 静默通过。T3 已在导出树做过注入式正向实测(`void import("./motion/engine")` ⇒ `vendor-gsap-BQLVn3Z6.js` |
| 18 | + * 105,584 B / gzip 41,161 B,首屏仍 3 chunk 不含它)⇒ 机理已证;该 chunk **首次真实出现 = 第一个调用点落地时 |
| 19 | + * (波 B/C)**。今天源图(静态∪动态)到不了 GSAP ⇒ ① 的「存在性分支」**未运行**(用例 `console.warn` 点名)。 |
| 20 | + * @ai-context 插件集合与顺序(R14.7-I-2):ADR-035 `:48` 自称「恰 4 个、**顺序逐字**」⇒ 对拍必须是**逐序数组 |
| 21 | + * 相等**(集合相等测不出纯换序)。四插件「真的注册了」的**行为级**判据在 `engine.test.ts`(B3/B4/B5)。 |
| 22 | + * @ai-context 仪器:剥注释复用 `ui/primitives/sliceScan.ts` 的状态机(R8.7);静态图遍历是**正则级**(不认 |
| 23 | + * tsconfig paths、不认非字面量 `import()`)—— 边界见任务报告。副作用:只读 `app/src` 与 `app/dist`;② 起一个 |
| 24 | + * `check-bundle-budget --no-build` 子进程;遍历自证在 `os.tmpdir()` 建小树并自删,**不碰 `app/src`**。 |
| 25 | + * @ai-context 🔴 本文件自己也在扫描域内 ⇒ 夹具里的 GSAP 说明符一律**拼接构造**(`fromSpec()`),源码文本里 |
| 26 | + * **不得**出现字面量的 GSAP import 语句,否则本文件会把自己扫成 importer 而自伤。 |
| 27 | + */ |
| 28 | +import { execFileSync } from "node:child_process"; |
| 29 | +import { existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "node:fs"; |
| 30 | +import { tmpdir } from "node:os"; |
| 31 | +import { dirname, join, relative, resolve, sep } from "node:path"; |
| 32 | +import { fileURLToPath } from "node:url"; |
| 33 | +import { describe, expect, it } from "vitest"; |
| 34 | +import { stripComments, walkSources } from "../ui/primitives/sliceScan"; |
| 35 | + |
| 36 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 37 | +const SRC = resolve(HERE, ".."); // app/src |
| 38 | +const ROOT = resolve(HERE, "..", "..", ".."); // 仓库根 |
| 39 | +const ENTRY = join(SRC, "main.tsx"); |
| 40 | +const ENGINE_REL = "motion/engine.ts"; |
| 41 | +const CLASSROOM_REL = "pages/ClassroomPage.tsx"; |
| 42 | +const DIST = join(ROOT, "app", "dist"); |
| 43 | +const ADR = join(ROOT, "docs", "adr", "ADR-035-l4-motion-grammar-and-engine.md"); |
| 44 | +const relTo = (base: string, file: string): string => relative(base, file).split(sep).join("/"); |
| 45 | + |
| 46 | +// ───────────────────────── 仪器 1:GSAP 说明符扫描(静态 + 动态,剥注释) ───────────────────────── |
| 47 | + |
| 48 | +/** `import|export … from "<gsap 家族>"`;排除动态形态与 `import type`(类型边不进产物)。 */ |
| 49 | +const RE_GSAP_FROM = /\b(?:import|export)\b(?!\s*\()\s(?!type\b)[^;]*?\bfrom\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']/g; |
| 50 | +/** 裸 `import "<gsap 家族>"`(副作用式静态边)。 */ |
| 51 | +const RE_GSAP_BARE = /\bimport\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']/g; |
| 52 | +/** **动态** `import("<gsap 家族>")` —— 绕开 engine 的那条路(③‴ 的判据域)。 */ |
| 53 | +const RE_GSAP_DYNAMIC = /\bimport\s*\(\s*["'](gsap(?:\/[^"']*)?|@gsap\/react)["']\s*\)/g; |
| 54 | + |
| 55 | +function specsOf(code: string, res: readonly RegExp[]): string[] { |
| 56 | + const out: string[] = []; |
| 57 | + for (const re of res) { |
| 58 | + re.lastIndex = 0; |
| 59 | + let m: RegExpExecArray | null; |
| 60 | + while ((m = re.exec(code)) !== null) out.push(m[1]); |
| 61 | + } |
| 62 | + return out; |
| 63 | +} |
| 64 | + |
| 65 | +/** 一段源码里的 GSAP **静态**说明符(剥注释后扫;纯函数,行为自证见下方 describe)。 */ |
| 66 | +const gsapSpecifiers = (src: string): string[] => specsOf(stripComments(src), [RE_GSAP_FROM, RE_GSAP_BARE]); |
| 67 | +/** 一段源码里的 GSAP **动态**说明符。 */ |
| 68 | +const dynamicGsapSpecifiers = (src: string): string[] => specsOf(stripComments(src), [RE_GSAP_DYNAMIC]); |
| 69 | + |
| 70 | +// ───────────────────────── 仪器 2:静态图遍历(正则级) ───────────────────────── |
| 71 | + |
| 72 | +const EXT = ["", ".ts", ".tsx", ".js", ".jsx", "/index.ts", "/index.tsx"]; |
| 73 | +const RE_STATIC_FROM = /\b(?:import|export)\b(?!\s*\()\s(?!type\b)[^;]*?\bfrom\s*["']([^"']+)["']/g; |
| 74 | +const RE_STATIC_BARE = /\bimport\s*["']([^"']+)["']/g; |
| 75 | +const RE_DYNAMIC = /\bimport\s*\(\s*["']([^"']+)["']\s*\)/g; |
| 76 | + |
| 77 | +function resolveRelative(fromFile: string, spec: string): string | null { |
| 78 | + if (!spec.startsWith(".")) return null; |
| 79 | + const base = resolve(dirname(fromFile), spec); |
| 80 | + for (const e of EXT) { |
| 81 | + const p = base + e; |
| 82 | + // 用 `!isDirectory()` 而不是 `Stats.isFile()`:本仓 `@types/node` 版本下后者报 TS2339(先例 `nativeButton.ratchet.test.ts:220`) |
| 83 | + if (existsSync(p) && !statSync(p).isDirectory()) return p; |
| 84 | + } |
| 85 | + return null; |
| 86 | +} |
| 87 | + |
| 88 | +/** 从入口出发的可达文件集(绝对路径)。`dynamic: false` ⇒ 只沿**静态**边(= 首屏闭包口径)。 */ |
| 89 | +function reachable(entry: string, opts: { dynamic: boolean }): Set<string> { |
| 90 | + const seen = new Set<string>(); |
| 91 | + const queue: string[] = [entry]; |
| 92 | + while (queue.length > 0) { |
| 93 | + const file = queue.shift() as string; |
| 94 | + if (seen.has(file)) continue; |
| 95 | + seen.add(file); |
| 96 | + let text: string; |
| 97 | + try { |
| 98 | + text = readFileSync(file, "utf8"); |
| 99 | + } catch { |
| 100 | + continue; |
| 101 | + } |
| 102 | + const specs = specsOf(text, opts.dynamic ? [RE_STATIC_FROM, RE_STATIC_BARE, RE_DYNAMIC] : [RE_STATIC_FROM, RE_STATIC_BARE]); |
| 103 | + for (const spec of specs) { |
| 104 | + const r = resolveRelative(file, spec); |
| 105 | + if (r !== null && !seen.has(r)) queue.push(r); |
| 106 | + } |
| 107 | + } |
| 108 | + return seen; |
| 109 | +} |
| 110 | + |
| 111 | +// ───────────────────────── 开工读数(模块级,只读一次) ───────────────────────── |
| 112 | + |
| 113 | +const allSources = walkSources(SRC); |
| 114 | +/** ③‴ 的域 = 生产文件:测试不进产物,且 T3 的 `engine.test.ts` 用动态 gsap import **读**注册结果。 */ |
| 115 | +const productionSources = allSources.filter((f) => !/\.test\.tsx?$/.test(f)); |
| 116 | +const importerFiles = allSources |
| 117 | + .filter((f) => gsapSpecifiers(readFileSync(f, "utf8")).length > 0) |
| 118 | + .map((f) => relTo(SRC, f)) |
| 119 | + .sort(); |
| 120 | +const eagerClosure = new Set([...reachable(ENTRY, { dynamic: false })].map((f) => relTo(SRC, f))); |
| 121 | +const buildGraph = new Set([...reachable(ENTRY, { dynamic: true })].map((f) => relTo(SRC, f))); |
| 122 | +/** 「GSAP 会进构建」的源图口径:任一声明式 importer 落在静态∪动态可达集合里。 */ |
| 123 | +const gsapInBuildGraph = importerFiles.some((f) => buildGraph.has(f)); |
| 124 | +const hasDist = existsSync(join(DIST, "index.html")); |
| 125 | +const distJs = hasDist ? readdirSync(join(DIST, "assets")).filter((f) => f.endsWith(".js")) : []; |
| 126 | +const gsapChunks = distJs.filter((f) => /^vendor-gsap-/.test(f)); |
| 127 | + |
| 128 | +// 夹具构造:拼接出语句,源码文本里不出现字面量的 GSAP import(见文件头最后一条) |
| 129 | +const Q = '"'; |
| 130 | +const fromSpec = (spec: string): string => "from " + Q + spec + Q + ";"; |
| 131 | +const importOf = (pkg: string): string => "import { x } " + fromSpec(pkg); |
| 132 | +const typeImportOf = (pkg: string): string => "import type { X } " + fromSpec(pkg); |
| 133 | +const dynamicOf = (pkg: string): string => "const m = await import(" + Q + pkg + Q + ");"; |
| 134 | +const PKG = "gsap"; |
| 135 | +const PKG_REACT = "@gsap/react"; |
| 136 | + |
| 137 | +describe("仪器自证(防真空:喂已知样本必须命中、无意义串必须 0)", () => { |
| 138 | + it("说明符扫描器:静态具名 / 多行 / 裸 import / export…from / 子路径 与 动态形态 都必须命中", () => { |
| 139 | + expect(gsapSpecifiers(importOf(PKG))).toEqual([PKG]); |
| 140 | + expect(gsapSpecifiers(importOf(PKG_REACT))).toEqual([PKG_REACT]); |
| 141 | + expect(gsapSpecifiers(importOf(PKG + "/Flip"))).toEqual([PKG + "/Flip"]); |
| 142 | + expect(gsapSpecifiers("import {\n Flip,\n} " + fromSpec(PKG + "/CustomEase"))).toEqual([PKG + "/CustomEase"]); |
| 143 | + expect(gsapSpecifiers("import " + Q + PKG + Q + ";")).toEqual([PKG]); |
| 144 | + expect(gsapSpecifiers("export { x } " + fromSpec(PKG))).toEqual([PKG]); |
| 145 | + expect(dynamicGsapSpecifiers(dynamicOf(PKG))).toEqual([PKG]); |
| 146 | + expect(dynamicGsapSpecifiers(dynamicOf(PKG + "/ScrollToPlugin"))).toEqual([PKG + "/ScrollToPlugin"]); |
| 147 | + }); |
| 148 | + |
| 149 | + it("说明符扫描器:注释 / type-only / 无意义串 都必须 0 命中,且静态与动态互不串味", () => { |
| 150 | + expect(gsapSpecifiers("// " + importOf(PKG))).toEqual([]); |
| 151 | + expect(gsapSpecifiers("/* " + importOf(PKG) + " */")).toEqual([]); |
| 152 | + expect(gsapSpecifiers(typeImportOf(PKG))).toEqual([]); |
| 153 | + expect(gsapSpecifiers("export type { X } " + fromSpec(PKG_REACT))).toEqual([]); |
| 154 | + expect(gsapSpecifiers(importOf("gsap-core"))).toEqual([]); |
| 155 | + expect(gsapSpecifiers(importOf("not-gsap"))).toEqual([]); |
| 156 | + expect(dynamicGsapSpecifiers(importOf(PKG))).toEqual([]); // 静态 ≠ 动态 |
| 157 | + expect(dynamicGsapSpecifiers("// " + dynamicOf(PKG))).toEqual([]); |
| 158 | + expect(dynamicGsapSpecifiers(dynamicOf("not-gsap"))).toEqual([]); |
| 159 | + expect(dynamicGsapSpecifiers("const s = 'gsap';")).toEqual([]); |
| 160 | + }); |
| 161 | + |
| 162 | + it("静态图遍历:只沿静态边(含传递边与目录 index),动态边只在 dynamic 口径下跟", () => { |
| 163 | + const dir = mkdtempSync(join(tmpdir(), "ed-t4-guard-")); |
| 164 | + try { |
| 165 | + const w = (name: string, text: string): void => { |
| 166 | + const p = join(dir, name); |
| 167 | + mkdirSync(dirname(p), { recursive: true }); |
| 168 | + writeFileSync(p, text); |
| 169 | + }; |
| 170 | + w("entry.ts", "import { a } " + fromSpec("./a") + "\n" + dynamicOf("./dyn")); |
| 171 | + w("a.ts", "import " + Q + "./sub" + Q + ";"); |
| 172 | + w("sub/index.tsx", "export const s = 1;"); |
| 173 | + w("dyn.ts", "import " + Q + "./dynChild" + Q + ";"); |
| 174 | + w("dynChild.ts", "export const d = 2;"); |
| 175 | + w("unreached.ts", "export const u = 3;"); |
| 176 | + const names = (dynamic: boolean): string[] => |
| 177 | + [...reachable(join(dir, "entry.ts"), { dynamic })].map((f) => relTo(dir, f)).sort(); |
| 178 | + expect(names(false)).toEqual(["a.ts", "entry.ts", "sub/index.tsx"]); |
| 179 | + expect(names(true)).toEqual(["a.ts", "dyn.ts", "dynChild.ts", "entry.ts", "sub/index.tsx"]); |
| 180 | + expect(names(true)).not.toContain("unreached.ts"); |
| 181 | + } finally { |
| 182 | + rmSync(dir, { recursive: true, force: true }); |
| 183 | + } |
| 184 | + }); |
| 185 | +}); |
| 186 | + |
| 187 | +describe("③ 懒加载边界(R2.1 判据 ③ → R11.1 的闭包交集;今天无条件可跑的那一半)", () => { |
| 188 | + it("③′ 诊断读数:静态 import GSAP 家族的文件集恰 = {motion/engine.ts},且说明符 ≥ 4(防真空)", () => { |
| 189 | + expect(importerFiles).toEqual([ENGINE_REL]); |
| 190 | + expect(gsapSpecifiers(readFileSync(join(SRC, ENGINE_REL), "utf8")).length).toBeGreaterThanOrEqual(4); |
| 191 | + }); |
| 192 | + |
| 193 | + it("③ 判据本体:静态 GSAP importer 集合 ∩ 首屏静态可达闭包 = ∅(含两个阳性对照)", () => { |
| 194 | + // 阳性对照:遍历必须能到达两个**已知在首屏**的文件 —— 否则「交集为空」可能只是遍历静默失效 |
| 195 | + expect(eagerClosure.has("ui/primitives/index.ts"), "遍历到不了 ui/primitives/index.ts ⇒ 仪器失效,交集读数不可信").toBe(true); |
| 196 | + expect(eagerClosure.has(CLASSROOM_REL), "遍历到不了课堂页 ⇒ 仪器失效,交集读数不可信").toBe(true); |
| 197 | + const inter = importerFiles.filter((f) => eagerClosure.has(f)); |
| 198 | + expect(inter, `交集非空 ⇒ GSAP 进了首屏静态闭包(vendor-gsap 独立懒 chunk 的承诺落空):${inter.join(", ")}`).toEqual([]); |
| 199 | + }); |
| 200 | + |
| 201 | + it("③″ 派生禁令(R11.1 连带③):课堂页(首屏静态页)自身零 GSAP 静态说明符", () => { |
| 202 | + expect(gsapSpecifiers(readFileSync(join(SRC, CLASSROOM_REL), "utf8"))).toEqual([]); |
| 203 | + }); |
| 204 | + |
| 205 | + it("③‴ 动态绕开禁令(控制方追加):生产文件里的**动态** gsap 说明符只许出现在 motion/engine.ts", () => { |
| 206 | + expect(productionSources.length, "域为空 ⇒ 判据恒真,先修域").toBeGreaterThan(100); |
| 207 | + const offenders = productionSources |
| 208 | + .map((f) => [relTo(SRC, f), dynamicGsapSpecifiers(readFileSync(f, "utf8"))] as const) |
| 209 | + .filter(([, hits]) => hits.length > 0) |
| 210 | + .map(([f]) => f) |
| 211 | + .sort(); |
| 212 | + expect(offenders, `动态 gsap 绕开 engine ⇒ registerPlugin 从未执行(陷阱 B1-6 插件静默退化):${offenders.join(", ")}`).toEqual([]); |
| 213 | + }); |
| 214 | +}); |
| 215 | + |
| 216 | +interface BudgetJson { |
| 217 | + firstScreen: { count: number; chunks: { name: string }[] }; |
| 218 | +} |
| 219 | + |
| 220 | +describe("①② 产物判据(R11.5:runIf(existsSync(dist));🔴 未运行 ≠ 通过)", () => { |
| 221 | + it.runIf(hasDist)( |
| 222 | + "① 产物独立性:源图到达 GSAP ⇔ 出现独立的 vendor-gsap-*.js(今天源图未到达 ⇒ 存在性分支未运行,见报告「未验证」单列)", |
| 223 | + () => { |
| 224 | + if (!gsapInBuildGraph) { |
| 225 | + // 今天的真相:源图(静态∪动态)零边到达 GSAP ⇒ 下面那条存在性断言今天**跑不到**(R11.5:不计入绿) |
| 226 | + console.warn( |
| 227 | + "[guard①] 存在性分支今天未运行:源图未到达任何 GSAP 静态 importer ⇒ 本分支计入报告「未验证」单列。" + |
| 228 | + "机理已证(T3 注入树:vendor-gsap-BQLVn3Z6.js 105,584 B / gzip 41,161 B,首屏 3 chunk 不含它);" + |
| 229 | + "首次真实出现 = 第一个调用点落地时(波 B/C)。", |
| 230 | + ); |
| 231 | + } else { |
| 232 | + expect(gsapChunks, "源图已到达 GSAP ⇒ 产物必须出现 vendor-gsap-*.js;实得零个").not.toEqual([]); |
| 233 | + } |
| 234 | + // 反向(今天就跑):产物不得出现源图到不了的「幽灵 gsap chunk」—— 或源图漏判,或 dist 过期 |
| 235 | + expect( |
| 236 | + gsapChunks.length > 0 && !gsapInBuildGraph, |
| 237 | + `产物出现 ${gsapChunks.join(", ")},但源图(静态∪动态)没有任何边到达 GSAP ⇒ 遍历漏判或 app/dist 过期`, |
| 238 | + ).toBe(false); |
| 239 | + }, |
| 240 | + ); |
| 241 | + |
| 242 | + it.runIf(hasDist)("② firstScreen.chunks 不含 vendor-gsap(仓库自己的 check-bundle-budget --no-build --json)", () => { |
| 243 | + const raw = execFileSync(process.execPath, [join(ROOT, "scripts", "check-bundle-budget.mjs"), "--no-build", "--json"], { |
| 244 | + cwd: ROOT, |
| 245 | + encoding: "utf8", |
| 246 | + maxBuffer: 8 * 1024 * 1024, |
| 247 | + }); |
| 248 | + const budget = JSON.parse(raw) as BudgetJson; |
| 249 | + expect(budget.firstScreen.count, "首屏 chunk 读数为 0 ⇒ JSON 解析或产物异常,别把空读数当绿").toBeGreaterThan(0); |
| 250 | + const names = budget.firstScreen.chunks.map((c) => c.name); |
| 251 | + expect(names.filter((n) => n.includes("vendor-gsap")), `首屏出现 vendor-gsap:${names.join(", ")}`).toEqual([]); |
| 252 | + }); |
| 253 | +}); |
| 254 | + |
| 255 | +/** `registerPlugin(...)` 的实参序列(只认**标识符**级实参;`…` 这类占位注释形态天然被过滤)。 */ |
| 256 | +function pluginArgVariants(code: string): string[][] { |
| 257 | + const out: string[] = []; |
| 258 | + for (const m of code.matchAll(/registerPlugin\(([^)]*)\)/g)) { |
| 259 | + const args = m[1].split(",").map((s) => s.trim()); |
| 260 | + if (args.length > 0 && args.every((a) => /^[A-Za-z_$][\w$]*$/.test(a))) out.push(JSON.stringify(args)); |
| 261 | + } |
| 262 | + return [...new Set(out)].map((s) => JSON.parse(s) as string[]); |
| 263 | +} |
| 264 | + |
| 265 | +describe("ADR-035 §2 ⇄ engine.ts 插件**逐序**数组相等(R14.7-I-2;ADR:48「恰 4 个、顺序逐字」)", () => { |
| 266 | + it("抽取仪器自证:标识符级实参、注释里的占位调用不算、无调用返回空", () => { |
| 267 | + expect(pluginArgVariants("gsap.registerPlugin(A, B);")).toEqual([["A", "B"]]); |
| 268 | + expect(pluginArgVariants("// registerPlugin(useGSAP, …)")).toEqual([]); |
| 269 | + expect(pluginArgVariants("const x = 1;")).toEqual([]); |
| 270 | + // 剥注释后只剩真调用(engine.ts 的头注里也写了同名调用 ⇒ 不剥注释会取到注释那份) |
| 271 | + expect(pluginArgVariants(stripComments("// registerPlugin(A, B)\ngsap.registerPlugin(A, C);"))).toEqual([["A", "C"]]); |
| 272 | + }); |
| 273 | + |
| 274 | + it("engine.ts 的 registerPlugin 实参序列 == ADR-035 §2 冻结快照的实参序列(逐序,恰 4 个)", () => { |
| 275 | + const fence = /```ts\r?\n([\s\S]*?)```/.exec(readFileSync(ADR, "utf8")); |
| 276 | + expect(fence, "ADR-035 §2 的 ts 冻结快照代码块不见了").not.toBeNull(); |
| 277 | + const adrVariants = pluginArgVariants((fence as RegExpExecArray)[1]); |
| 278 | + const engineVariants = pluginArgVariants(stripComments(readFileSync(join(SRC, ENGINE_REL), "utf8"))); |
| 279 | + expect(adrVariants, "ADR-035 §2 里出现互相不一致的插件序列(标题与代码块漂移)").toHaveLength(1); |
| 280 | + expect(engineVariants, "engine.ts 里出现互相不一致的插件序列").toHaveLength(1); |
| 281 | + expect(engineVariants[0]).toHaveLength(4); |
| 282 | + expect(engineVariants[0]).toEqual(adrVariants[0]); |
| 283 | + }); |
| 284 | +}); |
0 commit comments