|
4 | 4 | * |
5 | 5 | * Why 单独立一条**字面量**判据:`useColumnLayout` 把 key 当作 `localStorage` 的持久化键 |
6 | 6 | * (`layout:col-width:{key}` / `layout:col-fold:{key}`,`src/hooks/useColumnLayout.ts:43/53/87/88`) |
7 | | - * ⇒ **改一个 key = 所有用户已记住的列宽/折叠态静默丢失**,而既有判据**抓不到**: |
8 | | - * `columnRegistry.test.ts` ① 只判「13 行 · 键唯一 · 首尾键」—— 把 `notes-list` 改名成 |
9 | | - * `notes-list-v2` 在那条判据下**照样绿**(批 3 计划 `:1621` 想写的「键集与持久化键同形」 |
10 | | - * 最终没有落库)⇒ T0 报告 §二 · G3 把它列为**风险最高的一条缺口**。 |
| 7 | + * ⇒ **改一个 key = 所有用户已记住的列宽/折叠态静默丢失**,而既有判据里**不看键名的那一条** |
| 8 | + * (`columnRegistry.test.ts` ① `:121-126`:只判「13 行 · 键唯一 · 首尾键」)**抓不到** —— |
| 9 | + * 把 `notes-list` 改名成 `notes-list-v2` 在 `①` 下**照样绿**(该文件另两处 `④` 会红: |
| 10 | + * `:157` 的 `columnSpec("notes-list")` 与 `:163+` 的「行 ↔ 档位映射」)⇒ 本条的**真实增量** |
| 11 | + * 是「冻结清单 + 顺序 + ② 前缀」,**不是**补上完全无人看守的空档(批 3 计划 `:1621` 想写的 |
| 12 | + * 「键集与持久化键同形」最终没有落库)⇒ T0 报告 §二 · G3 仍把它列为**风险最高的一条缺口**。 |
11 | 13 | * |
12 | 14 | * ★ 期望值**逐字写字符串字面量**,**不许**用 `COLUMN_KEYS` / `columnSpec(...)` / 任何被测模块的 |
13 | 15 | * 导出参与构造 —— 本批已实证「自引用 = 永真断言」(期望值与被测值同源 ⇒ 两边一起改照样绿)。 |
|
18 | 20 | * 重排会让 `COLUMN_SPECS` 的行序漂移); |
19 | 21 | * ② 持久化键的**前缀**仍是 `layout:col-width:` / `layout:col-fold:` —— 前缀与键名是同一个 |
20 | 22 | * `localStorage` 键的两半,改任一半都让用户的列宽静默丢失(② 是本次为闭合该机制所加, |
21 | | - * T0 报告 §二 · G3 只点名了 ①)。 |
| 23 | + * T0 报告 §二 · G3 只点名了 ①)。判据口径 = **代码里**出现过该前缀的**连续字面量** |
| 24 | + * (**不绑书写形态**:模板字面量 / `"…" + key` 拼接 / 具名常量前缀都认;**注释里的不算数**)。 |
22 | 25 | * |
23 | 26 | * 副作用:只读 `src/hooks/useColumnLayout.ts`(②),不修改任何文件。 |
24 | 27 | * 边界:本条**不**证明「这 13 个名字 = 历史持久化键」—— 那需要 `git show` 的树外一次性对拍 |
25 | 28 | * (见 T0 报告 §二 · G3 与批 8/T18 的收口核对表);本条是「从今往后不许再漂」的棘轮。 |
26 | 29 | * 也不判宽度/阈值(那住 `columnRegistry.test.ts` ②③④)。跑在 vitest 全局 `node` 环境。 |
| 30 | + * ② 的口径边界(**刻意 fail-closed**):前缀若被拆成两段拼接、或搬到**别的文件**,② 会红 —— |
| 31 | + * 那是「本文件里找不到该前缀」,请连同本判据一起改,**不要**放宽成裸子串 grep |
| 32 | + * (批 1 的 6 例子串误判即由此而来)。 |
27 | 33 | */ |
28 | 34 | import { readFileSync } from "node:fs"; |
29 | 35 | import { dirname, join } from "node:path"; |
@@ -60,12 +66,43 @@ function keyDiff(actual: readonly string[]): string[] { |
60 | 66 | return out; |
61 | 67 | } |
62 | 68 |
|
63 | | -/** ② 的捕获器:`` `layout:<名字>:${key}` `` 形态的模板字面量(读 2 处 + 写 2 处共用同一前缀)。 */ |
64 | | -const KEY_TEMPLATE = /`layout:([A-Za-z0-9_-]+):\$\{key\}`/g; |
| 69 | +/** ② 的捕获器:**代码里**出现过的持久化键前缀(`layout:<名字>:`)。 |
| 70 | + * 口径 = 「前缀以**连续字面量**出现在本文件的代码里」,**不绑书写形态**:模板字面量 |
| 71 | + * `` `layout:col-width:${key}` `` · 拼接 `"layout:col-width:" + key` · 具名常量前缀 |
| 72 | + * `const P = "layout:col-width:"` 都认(原先只认第一种 ⇒ 行为等价的重构会假红)。 |
| 73 | + * 边界见文件头「② 的口径边界」。 */ |
| 74 | +const KEY_PREFIX = /layout:([A-Za-z0-9_-]+):/g; |
65 | 75 |
|
66 | | -/** ② 的判据本体:源码里用到的持久化键前缀集合(排序后比对;数出现次数会绑死实现形态)。 */ |
| 76 | +/** 剥行注释 / 块注释(字符串与模板字面量内部原样保留)。 |
| 77 | + * Why:注释里留着旧前缀不能算数 —— 前缀真从代码里搬走时,注释会把 ② 拖成假绿。 */ |
| 78 | +function stripComments(src: string): string { |
| 79 | + let out = ""; |
| 80 | + let i = 0; |
| 81 | + while (i < src.length) { |
| 82 | + const c = src[i]; |
| 83 | + const next = src[i + 1]; |
| 84 | + if (c === "/" && next === "/") { |
| 85 | + const nl = src.indexOf("\n", i); |
| 86 | + i = nl < 0 ? src.length : nl; |
| 87 | + } else if (c === "/" && next === "*") { |
| 88 | + const end = src.indexOf("*/", i + 2); |
| 89 | + i = end < 0 ? src.length : end + 2; |
| 90 | + } else if (c === '"' || c === "'" || c === "`") { |
| 91 | + let j = i + 1; |
| 92 | + while (j < src.length && src[j] !== c) j += src[j] === "\\" ? 2 : 1; |
| 93 | + out += src.slice(i, Math.min(j + 1, src.length)); |
| 94 | + i = j + 1; |
| 95 | + } else { |
| 96 | + out += c; |
| 97 | + i++; |
| 98 | + } |
| 99 | + } |
| 100 | + return out; |
| 101 | +} |
| 102 | + |
| 103 | +/** ② 的判据本体:源码里用到的持久化键前缀集合(**先剥注释**;排序后比对,数出现次数会绑死实现形态)。 */ |
67 | 104 | function keyPrefixes(src: string): string[] { |
68 | | - return [...new Set([...src.matchAll(KEY_TEMPLATE)].map((m) => m[1]))].sort(); |
| 105 | + return [...new Set([...stripComments(src).matchAll(KEY_PREFIX)].map((m) => m[1]))].sort(); |
69 | 106 | } |
70 | 107 |
|
71 | 108 | const HOOK_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "useColumnLayout.ts"); |
@@ -95,8 +132,12 @@ describe("列键名冻结(改名 = 用户列宽静默丢失)", () => { |
95 | 132 | "col-fold", |
96 | 133 | "col-width", |
97 | 134 | ]); |
98 | | - // 阴性样本:前缀改一个字符 ⇒ 同一函数立刻报出漂移 |
| 135 | + // 阴性样本(三种书写形态各一):任一处前缀改一个字符 ⇒ 同一函数立刻报出漂移 |
99 | 136 | expect(keyPrefixes("window.localStorage.getItem(`layout:col-w:${key}`)")).toEqual(["col-w"]); |
| 137 | + expect(keyPrefixes('window.localStorage.setItem("layout:col-w:" + key, v)')).toEqual(["col-w"]); |
| 138 | + expect(keyPrefixes('const P = "layout:col-w:";')).toEqual(["col-w"]); |
| 139 | + // 仪器自检:**注释里的旧前缀不算数**(否则前缀从代码里搬走时,注释会把 ② 拖成假绿) |
| 140 | + expect(keyPrefixes('// 旧写法 `layout:col-width:${key}`\nconst P = "layout:col-w:";')).toEqual(["col-w"]); |
100 | 141 | // 阳性对照:真实源码确实读到了(防路径写错 ⇒ 空集合假绿) |
101 | 142 | expect(HOOK_SRC).toContain("useColumnLayout("); |
102 | 143 | }); |
|
0 commit comments