|
| 1 | +// @vitest-environment node |
| 2 | +/** |
| 3 | + * @ai-context **原生按钮棘轮**(批 4 B4 裁决的落点):**只许减少,不许新增**。 |
| 4 | + * |
| 5 | + * Why 需要这条守卫:B4 裁定 `Button` 的范围 = `*Btn*` 常量族 + 本批新代码 + **棘轮**,理由是 |
| 6 | + * 全仓 **510 处 / 121 文件**原生 `<button>` 单批迁不完(且 121 个文件里 77 个无同名测试)。 |
| 7 | + * 没有棘轮,「永远迁不完」就会退化成「永远在迁」:新代码继续写原生按钮,而没有任何判据说话。 |
| 8 | + * |
| 9 | + * Why 判据是「计数」而不是「逐行原文」(与 `ui/zIndex.guard.test.ts` 的口径**刻意不同**): |
| 10 | + * 本批有**拆件**任务(B7:`ChatPage.tsx` 599/600 必须先拆)⇒ 逐行 key 会因为「同一行搬到了 |
| 11 | + * 新文件」从「命中」变成「新增」而**假红**。计数口径下,搬运由 `SPLIT_MOVES` 显式登记守恒。 |
| 12 | + * |
| 13 | + * ★ 扫描口径(与 `tmp/classify.mjs` 的 `buttonTag` **逐字同源**;口径本身是守卫的一部分) |
| 14 | + * ① 域 = `app/src/**` 的 `.ts`/`.tsx` 减 `*.test.ts(x)` 减 `ui/primitives/**` 减 `ui/icons/**`。 |
| 15 | + * 排除原语是因为 `ui/primitives/Button.tsx` **就是**那个 `<button>` 的落点。 |
| 16 | + * ② **先剥注释**(`//` 与块注释抹为等长空白);字符串/模板字面量**只跳过、不抹内容**。 |
| 17 | + * ③ 计「处」= 剥注释后**整段文本**上 `/<button[\s>]/g` 的匹配数(**不是逐行** —— 逐行会把 |
| 18 | + * 跨行标签漏掉:同一棵树实测逐行 385 vs 整段 510)。 |
| 19 | + * ④ 不记 `</button>`(不匹配)与 `<Button>`(大小写敏感,不匹配)。 |
| 20 | + * 已知边界(有意保留、并有用例钉住):字符串字面量里的 `<button` 会被计入 —— 实测本仓 0 例。 |
| 21 | + * |
| 22 | + * 副作用:只读磁盘(递归遍历 `app/src`),不修改任何文件。 |
| 23 | + * 边界:**批 4 只迁 T12 的 103 行 `style={xxxBtn}`**,余量 ≈ 407 处登记给批 5/7;本守卫冻结余量。 |
| 24 | + */ |
| 25 | +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; |
| 26 | +import { dirname, join, relative, sep } from "node:path"; |
| 27 | +import { fileURLToPath } from "node:url"; |
| 28 | +import { describe, expect, it } from "vitest"; |
| 29 | +import { |
| 30 | + FROZEN_BTN_STYLE_CONST_FILES, |
| 31 | + FROZEN_BTN_STYLE_CONST_LINES, |
| 32 | + FROZEN_NATIVE_BUTTON_BY_FILE, |
| 33 | + FROZEN_NATIVE_BUTTON_TOTAL, |
| 34 | + SPLIT_MOVES, |
| 35 | +} from "./nativeButtonBaseline"; |
| 36 | + |
| 37 | +const HERE = dirname(fileURLToPath(import.meta.url)); |
| 38 | +/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */ |
| 39 | +const SRC = join(HERE, "..", ".."); |
| 40 | + |
| 41 | +/** 计「处」的判据:`<button` 后跟空白或 `>`。`</button>` 与 `<Button>` 均不匹配。 */ |
| 42 | +const RE_BUTTON = /<button[\s>]/g; |
| 43 | +/** `const *Btn* = / :` 样式常量族(与 `classify.mjs` 的 `btnStyleConst` 同一正则)。 */ |
| 44 | +const RE_BTN_CONST = /const\s+\w*[Bb]tn\w*\s*[:=]/g; |
| 45 | + |
| 46 | +/** |
| 47 | + * 剥注释:**与 `tmp/scan-callsites.mjs` 的 `stripComments()` 同一状态机**(刻意重写一份而不是 |
| 48 | + * import —— 那个文件在 gitignored 的 `tmp/` 下,测试**不能**依赖它存在)。 |
| 49 | + * 抹成等长空白以保行号;字符串/模板/正则字面量只跳过不抹内容(改口径等于改守卫)。 |
| 50 | + */ |
| 51 | +function stripComments(src: string): string { |
| 52 | + const out = src.split(""); |
| 53 | + const blank = (a: number, b: number): void => { |
| 54 | + for (let i = a; i < b; i++) if (out[i] !== "\n") out[i] = " "; |
| 55 | + }; |
| 56 | + const isRegexStart = (k: number): boolean => { |
| 57 | + let j = k - 1; |
| 58 | + while (j >= 0 && /\s/.test(src.charAt(j))) j--; |
| 59 | + if (j < 0) return true; |
| 60 | + return "(,=:[!&|?{};+-*%~^<>".includes(src.charAt(j)); |
| 61 | + }; |
| 62 | + const n = src.length; |
| 63 | + let i = 0; |
| 64 | + while (i < n) { |
| 65 | + const c = src.charAt(i); |
| 66 | + if (c === "/" && src.charAt(i + 1) === "/") { |
| 67 | + const end = src.indexOf("\n", i); |
| 68 | + blank(i, end < 0 ? n : end); |
| 69 | + i = end < 0 ? n : end; |
| 70 | + continue; |
| 71 | + } |
| 72 | + if (c === "/" && src.charAt(i + 1) === "*") { |
| 73 | + const end = src.indexOf("*/", i + 2); |
| 74 | + blank(i, end < 0 ? n : end + 2); |
| 75 | + i = end < 0 ? n : end + 2; |
| 76 | + continue; |
| 77 | + } |
| 78 | + if (c === '"' || c === "'") { |
| 79 | + let j = i + 1; |
| 80 | + while (j < n && src.charAt(j) !== c) { |
| 81 | + if (src.charAt(j) === "\\") j++; |
| 82 | + if (src.charAt(j) === "\n") break; |
| 83 | + j++; |
| 84 | + } |
| 85 | + i = j + 1; |
| 86 | + continue; |
| 87 | + } |
| 88 | + if (c === "`") { |
| 89 | + let j = i + 1; |
| 90 | + while (j < n && src.charAt(j) !== "`") { |
| 91 | + if (src.charAt(j) === "\\") j++; |
| 92 | + j++; |
| 93 | + } |
| 94 | + i = j + 1; |
| 95 | + continue; |
| 96 | + } |
| 97 | + if (c === "/" && isRegexStart(i)) { |
| 98 | + let j = i + 1; |
| 99 | + let cls = false; |
| 100 | + let ok = false; |
| 101 | + while (j < n) { |
| 102 | + const d = src.charAt(j); |
| 103 | + if (d === "\\") { |
| 104 | + j += 2; |
| 105 | + continue; |
| 106 | + } |
| 107 | + if (d === "\n") break; |
| 108 | + if (d === "[") cls = true; |
| 109 | + else if (d === "]") cls = false; |
| 110 | + else if (d === "/" && !cls) { |
| 111 | + ok = true; |
| 112 | + break; |
| 113 | + } |
| 114 | + j++; |
| 115 | + } |
| 116 | + if (ok) { |
| 117 | + blank(i + 1, j); |
| 118 | + i = j + 1; |
| 119 | + continue; |
| 120 | + } |
| 121 | + } |
| 122 | + i++; |
| 123 | + } |
| 124 | + return out.join(""); |
| 125 | +} |
| 126 | + |
| 127 | +/** 纯计数(供扫描与仪器自证共用):`new RegExp(re.source,"g")` —— **不是** `eval`。 */ |
| 128 | +function countOf(text: string, re: RegExp): number { |
| 129 | + return (text.match(new RegExp(re.source, "g")) ?? []).length; |
| 130 | +} |
| 131 | + |
| 132 | +function walk(dir: string, out: string[] = []): string[] { |
| 133 | + for (const name of readdirSync(dir)) { |
| 134 | + const p = join(dir, name); |
| 135 | + if (statSync(p).isDirectory()) walk(p, out); |
| 136 | + else if (/\.(ts|tsx)$/.test(name)) out.push(p); |
| 137 | + } |
| 138 | + return out; |
| 139 | +} |
| 140 | + |
| 141 | +const isTest = (f: string): boolean => /\.test\.(ts|tsx)$/.test(f); |
| 142 | +const isPrim = (f: string): boolean => f.includes(`${sep}ui${sep}primitives${sep}`); |
| 143 | +const isIcons = (f: string): boolean => f.includes(`${sep}ui${sep}icons${sep}`); |
| 144 | + |
| 145 | +interface Scan { |
| 146 | + /** 相对 `app/src` 的正斜杠路径 → `<button` 处数(只含 >0 的文件) */ |
| 147 | + buttonByFile: Map<string, number>; |
| 148 | + buttonTotal: number; |
| 149 | + btnConstLines: number; |
| 150 | + btnConstFiles: number; |
| 151 | +} |
| 152 | + |
| 153 | +function scan(): Scan { |
| 154 | + const buttonByFile = new Map<string, number>(); |
| 155 | + let buttonTotal = 0; |
| 156 | + let btnConstLines = 0; |
| 157 | + let btnConstFiles = 0; |
| 158 | + for (const f of walk(SRC)) { |
| 159 | + if (isTest(f) || isPrim(f) || isIcons(f)) continue; |
| 160 | + const stripped = stripComments(readFileSync(f, "utf8")); |
| 161 | + const rel = relative(SRC, f).split(sep).join("/"); |
| 162 | + const n = countOf(stripped, RE_BUTTON); |
| 163 | + if (n > 0) { |
| 164 | + buttonByFile.set(rel, n); |
| 165 | + buttonTotal += n; |
| 166 | + } |
| 167 | + const c = countOf(stripped, RE_BTN_CONST); |
| 168 | + if (c > 0) { |
| 169 | + btnConstLines += c; |
| 170 | + btnConstFiles += 1; |
| 171 | + } |
| 172 | + } |
| 173 | + return { buttonByFile, buttonTotal, btnConstLines, btnConstFiles }; |
| 174 | +} |
| 175 | + |
| 176 | +const SCAN = scan(); |
| 177 | +const BASELINE = FROZEN_NATIVE_BUTTON_BY_FILE; |
| 178 | +const SPLIT_TARGETS = new Set(SPLIT_MOVES.map((m) => m.split("|")[0])); |
| 179 | + |
| 180 | +describe("原生按钮棘轮(B4):冻结 510 处 / 121 文件,只许减少", () => { |
| 181 | + it("仪器自证:能命中已知存在的串 · 对无意义串报 0 · 且剥注释生效(末条是**已知边界**)", () => { |
| 182 | + expect(countOf("const a = <button onClick={f}>x</button>;", RE_BUTTON)).toBe(1); |
| 183 | + expect(countOf("const a = <butto n />;", RE_BUTTON)).toBe(0); |
| 184 | + // 剥注释:整行注释与块注释里的标签都不算命中(仓内已多次注释误伤) |
| 185 | + expect(countOf(stripComments("// <button style={x}>\n"), RE_BUTTON)).toBe(0); |
| 186 | + expect(countOf(stripComments("/* <button style={x}> */\n"), RE_BUTTON)).toBe(0); |
| 187 | + // 已知边界(有意保留):字符串字面量里的标签**仍会被计入** —— 实测本仓 0 例 |
| 188 | + expect(countOf(stripComments('const s = "<button>";\n'), RE_BUTTON)).toBe(1); |
| 189 | + // 真实文件阳性对照:本文件被排除,另点一个**域内**且已知含按钮的文件 |
| 190 | + expect(SCAN.buttonByFile.get("components/ImageGallery.tsx") ?? 0).toBeGreaterThan(0); |
| 191 | + expect(BASELINE["components/ImageGallery.tsx"]).toBeGreaterThan(0); |
| 192 | + }); |
| 193 | + |
| 194 | + it("① 总量只减不增(`<button` 处数 ≤ 冻结基线)", () => { |
| 195 | + expect( |
| 196 | + SCAN.buttonTotal, |
| 197 | + `全仓原生 \`<button>\` 已从基线 ${FROZEN_NATIVE_BUTTON_TOTAL} 涨到 ${SCAN.buttonTotal} —— 新代码请用 \`Button\`(barrel: ui/primitives)`, |
| 198 | + ).toBeLessThanOrEqual(FROZEN_NATIVE_BUTTON_TOTAL); |
| 199 | + }); |
| 200 | + |
| 201 | + it("② 逐文件只减不增(拆分面回潮就是这里红)", () => { |
| 202 | + const regressions: string[] = []; |
| 203 | + for (const [file, cur] of SCAN.buttonByFile) { |
| 204 | + const base = BASELINE[file]; |
| 205 | + if (base === undefined) { |
| 206 | + if (!SPLIT_TARGETS.has(file)) regressions.push(`${file}: 新增文件带 ${cur} 处,基线里没有(拆件请登记 SPLIT_MOVES)`); |
| 207 | + continue; |
| 208 | + } |
| 209 | + if (cur > base) regressions.push(`${file}: ${base} → ${cur}(+${cur - base})`); |
| 210 | + } |
| 211 | + expect(regressions, `以下文件的原生按钮数超过冻结基线:\n${regressions.join("\n")}`).toEqual([]); |
| 212 | + }); |
| 213 | + |
| 214 | + it("③ 基线无僵尸键(清单里的每个文件都还在扫描域内存在)", () => { |
| 215 | + const zombies: string[] = []; |
| 216 | + for (const file of Object.keys(BASELINE)) { |
| 217 | + const abs = join(SRC, file); |
| 218 | + // 用 `existsSync` 而不是 `statSync(...).isFile()`:本仓的 `@types/node` 版本下 |
| 219 | + // `Stats.isFile` 在 tsc 下报 TS2339(同 `columnRegistry.test.ts:43` 记的 withFileTypes 缺口)。 |
| 220 | + if (!existsSync(abs)) zombies.push(`${file}: 文件不存在`); |
| 221 | + else if (isTest(abs) || isPrim(abs) || isIcons(abs)) zombies.push(`${file}: 已移出扫描域`); |
| 222 | + } |
| 223 | + expect(zombies, `基线里的键已失效(拆件/改名后请更新基线):\n${zombies.join("\n")}`).toEqual([]); |
| 224 | + }); |
| 225 | + |
| 226 | + it("④ 拆件搬运守恒(`SPLIT_MOVES` 的每条 `新文件|源文件` 两侧之和 == 基线源键)", () => { |
| 227 | + const broken: string[] = []; |
| 228 | + const seen = new Set<string>(); |
| 229 | + for (const move of SPLIT_MOVES) { |
| 230 | + const parts = move.split("|"); |
| 231 | + if (parts.length !== 2 || !parts[0] || !parts[1] || parts[0] === parts[1]) { |
| 232 | + broken.push(`${move}: 形态必须是 "新文件|源文件"(两段非空且不同)`); |
| 233 | + continue; |
| 234 | + } |
| 235 | + const [target, source] = parts; |
| 236 | + if (seen.has(target)) broken.push(`${target}: 同一个新文件登记了多次搬运`); |
| 237 | + seen.add(target); |
| 238 | + const sourceBase = BASELINE[source]; |
| 239 | + if (sourceBase === undefined) { |
| 240 | + broken.push(`${move}: 源文件 ${source} 不在基线里`); |
| 241 | + continue; |
| 242 | + } |
| 243 | + if (BASELINE[target] !== undefined) broken.push(`${move}: 目标 ${target} 已在基线里(它只能是拆件产物)`); |
| 244 | + const targetNow = SCAN.buttonByFile.get(target) ?? 0; |
| 245 | + const sourceNow = SCAN.buttonByFile.get(source) ?? 0; |
| 246 | + if (targetNow + sourceNow !== sourceBase) { |
| 247 | + broken.push(`${move}: 搬运不守恒 —— ${target}(${targetNow}) + ${source}(${sourceNow}) ≠ 基线 ${sourceBase}`); |
| 248 | + } |
| 249 | + } |
| 250 | + expect(broken, `SPLIT_MOVES 登记有误:\n${broken.join("\n")}`).toEqual([]); |
| 251 | + }); |
| 252 | + |
| 253 | + it("⑤ `const *Btn*` 样式常量族只减不增(行数与文件数双口径)", () => { |
| 254 | + expect( |
| 255 | + SCAN.btnConstLines, |
| 256 | + `\`const *Btn*\` 样式常量的行数已从 ${FROZEN_BTN_STYLE_CONST_LINES} 涨到 ${SCAN.btnConstLines} —— 新按钮请用 \`Button\`,别再声明局部样式常量`, |
| 257 | + ).toBeLessThanOrEqual(FROZEN_BTN_STYLE_CONST_LINES); |
| 258 | + expect( |
| 259 | + SCAN.btnConstFiles, |
| 260 | + `带 \`const *Btn*\` 的文件数已从 ${FROZEN_BTN_STYLE_CONST_FILES} 涨到 ${SCAN.btnConstFiles}`, |
| 261 | + ).toBeLessThanOrEqual(FROZEN_BTN_STYLE_CONST_FILES); |
| 262 | + }); |
| 263 | +}); |
0 commit comments