Skip to content

Commit ed07d49

Browse files
committed
test(ui): fix slice scanner closing tag bug
1 parent 091d1c3 commit ed07d49

3 files changed

Lines changed: 50 additions & 36 deletions

File tree

‎app/src/ui/primitives/sliceScan.ts‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,17 @@ export function stripComments(src: string): string {
2525
const regexStart = (k: number): boolean => {
2626
let j = k - 1;
2727
while (j >= 0 && /\s/.test(src[j])) j--;
28-
return j < 0 || "(,=:[!&|?{};+-*%~^<>".includes(src[j]);
28+
if (j < 0) return true;
29+
// ★ `<` 要**判定收紧**(收口评审 I-1):白名单里的 `<` 会把**每个 JSX 闭合标签** `</span>` 的 `/`
30+
// 判成正则起点(`/` 前最后一个非空白字符就是 `<` 后的标签名 —— 但标签名本身也在白名单外,
31+
// 真正吃命的是**前一个标签的 `>`**)⇒ 状态机从那里把「到本行下一个 `/`」之间的内容当正则体
32+
// 抹成等长空白 ⇒ 夹在两个闭合标签之间的字面量对**所有共用本仪器的棘轮不可见**
33+
// (实测:`NoteRowContextMenu.tsx:175` 的 `color: "#9ca3af"` 被抹掉 ⇒ 弱化灰少算 1)。
34+
// 判据:`<` 后**紧跟** `/`(中间无空白)= JSX 闭合标签,不是正则起点;`<` 与 `/` 之间有空白
35+
// = 比较运算符后跟正则(`a < /["']/.test(x)`)⇒ **仍按正则处理**(否则会把正则体当代码读)。
36+
// 两侧都有夹具自证:`textRatchet.test.ts` ⑥(灰字面量被数到)+ `loadingScan.ts` 的 `isHandwritten` 夹具。
37+
if (src[j] === "<") return j + 1 !== k;
38+
return "(,=:[!&|?{};+-*%~^<>".includes(src[j]);
2939
};
3040
let i = 0;
3141
while (i < n) {

‎app/src/ui/primitives/textBaseline.ts‎

Lines changed: 25 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,21 +11,25 @@
1111
* ★ 计次口径(选定后写死;换算已自证)
1212
* 域 = `app/src/**` 的 `.ts`/`.tsx` **减** `*.test.ts(x)` **减** `ui/primitives/**`
1313
* (原语层是墨度与字阶的真源,不是待收敛的调用点 —— 同 `statusLineBaseline` / T1 的 `prod`)。
14-
* **先剥注释**(复用 `sliceScan.ts` 的状态机)。弱化灰取**行**口径(同行多次算 1);字号越界取
15-
* **处**口径(逐次命中)。⚠️ 两口径在本域**恰好等值**(`tmp/t16a/unit-check.mjs` 实测「同一行两次
16-
* 命中」的行数为 **0** ⇒ 换算系数 **1.0000**);若将来出现同行两次命中,行口径会**少算** ⇒ 按处重测。
14+
* **先剥注释**(复用 `sliceScan.ts` 的状态机)。弱化灰取**行**口径(同行多次算 1);字号越界取**处**口径。
15+
* ⚠️ 两口径在本域**恰好等值**(`tmp/t16a/unit-check.mjs` 实测「同行两次命中」= 0 ⇒ 系数 **1.0000**)。
1716
* ★ 数字来源(同一支探针跑四棵树;机理逐条见 `tmp/t16a/mechanism.md`)
18-
* 弱化灰行/文件:计划 42e88740 **295/105**(计划文案写 296 ⇒ 手抄噪声)→ 探针 8f5bd9f1 **246/98**
19-
* → T16-A HEAD **249/100** → **T16-B 迁移后 63/44**(2026-09-12 T16-B 收紧)。
20-
* 字号越界处/文件:**604/124 → 576/122 → 558/120**(本单元**未动**:越界值按 R2 原样留在行内)。
21-
* 迁移账(T16-B,逐处见 `tmp/t16b/migrated.md`):**249 = 迁移 186 + 例外 63**(例外逐文件理由见
22-
* `RESIDUAL`;**不得**再出现「无理由的冻结」)。
17+
* 弱化灰行/文件:计划 42e88740 **295/105**(计划写 296 ⇒ **不是手抄噪声**:296 是「朴素剥注释」的真值,
18+
* 差 1 来自下面的仪器假阴,见收口评审 I-1/M-6)→ 探针 8f5bd9f1 **246/98** → T16-A HEAD **249/100**
19+
* → **T16-B 迁移后 63/44**(2026-09-12 T16-B 收紧)。字号越界处/文件:**604/124 → 576/122 → 558/120**
20+
* (本单元**未动**:越界值按 R2 原样留在行内)。迁移账(`tmp/t16b/migrated.md`):**249 = 迁移 186 +
21+
* 例外 63**(例外逐文件理由见 `RESIDUAL`;**不得**再出现「无理由的冻结」)。
22+
*
23+
* ★ **仪器修正后的重冻**(2026-09-12 收口修复单元 · 收口评审 **I-1**):`stripComments` 曾把 JSX 闭合标签
24+
* `</x>` 的 `/` 当正则起点 ⇒ 两个闭合标签之间的字面量被抹掉(`NoteRowContextMenu.tsx:175`),据实重冻
25+
* **+1**:总量 **63 → 64** / 该文件 **1 → 2**(条目数、锚、字号侧未动;机理详见 `textRatchet` ⑥)。
26+
*
2327
* ★ 冻结粒度 = **文件 → 计数**(同 `nativeButtonBaseline` / `loadingBaseline`):逐行 key 会在拆件
2428
* 与行号漂移时假红;棘轮要防的是「永远迁不完」⇒ 总量 + 逐文件只减不增是完整的。
25-
* 副作用:无(纯数据,被 `textRatchet.test.ts` 读)。边界:**棘轮只许降** —— 真迁走一处要
26-
* **手工收紧**(`FROZEN_*_TOTAL` 必须恰等于逐文件之和,由测试断言;常数不许手工改成自洽)。
29+
* 副作用:无(纯数据,被 `textRatchet.test.ts` 读)。边界:**棘轮只许降** —— 真迁走一处要**手工收紧**
30+
* (`FROZEN_*_TOTAL` 必须恰等于逐文件之和;常数不许手工改成自洽)。
2731
*/
28-
export const FROZEN_MUTED_GRAY_TOTAL = 63;
32+
export const FROZEN_MUTED_GRAY_TOTAL = 64;
2933

3034
/** 弱化灰冻结**文件数**(条目数;迁移清零一个文件 ⇒ 必须删键,条目数随之降) */
3135
export const FROZEN_MUTED_GRAY_FILES = 44;
@@ -38,18 +42,16 @@ export const FROZEN_FONT_OOB_FILES = 120;
3842

3943
/**
4044
* 锚(anti-table-swap):条目数 + 一个具名文件的冻结值;换一张同样「自洽」的表 ⇒ 锚必红。
41-
* ⚠️ **T16-B 换锚**:T16-A 的锚 `components/LiveActivityPanel.tsx`(9)正是本单元的迁移目标
42-
* (9 → 2)⇒ 随迁移失效。新锚 = `components/NoteLinkToSystem.tsx`:**5 处全部**是登记例外
43-
* (B1 硬守卫 + 品牌青三元),**不可能**在没有控制方裁决的情况下下降。
45+
* ⚠️ **T16-B 换锚**:T16-A 的锚(`LiveActivityPanel` 9)正是本单元的迁移目标(9 → 2)⇒ 随迁移失效。
46+
* 新锚 = `NoteLinkToSystem.tsx`:5 处**全部**是登记例外(B1 硬守卫 + 品牌青三元)⇒ 无裁决不可能下降。
4447
*/
4548
export const ANCHOR_MUTED_GRAY = { file: "components/NoteLinkToSystem.tsx", count: 5, files: 44 } as const;
4649
export const ANCHOR_FONT_OOB = { file: "components/action-center/ActionCenterPanel.tsx", count: 19, files: 120 } as const;
4750

4851
/**
4952
* 相对 `app/src` 的正斜杠路径 → 该文件**允许残留的弱化灰行数上限**(只许降,键不许新增)。
50-
* ⚠️ 迁移清零一个文件时**删键**(不是改成 0)—— 留 0 值键会让「基线未登记的文件命中 = 0」这条
51-
* 判据的语义变模糊(`loadingBaseline` 用 0 表示「已交给原语」,本表不采用那个约定)。
52-
* ★ 每个键都必须有一条 `RESIDUAL` 理由(由 `textRatchet.test.ts` 双向断言)。
53+
* ⚠️ 迁移清零一个文件时**删键**(不是改成 0)—— 留 0 值键会让「基线未登记的文件命中 = 0」这条判据的
54+
* 语义变模糊(`loadingBaseline` 用 0 表示「已交给原语」,本表不采用那个约定)。每个键都要有理由见 `RESIDUAL`。
5355
*/
5456
export const FROZEN_MUTED_GRAY_BY_FILE: Readonly<Record<string, number>> = {
5557
"components/AiProviderSettings.tsx": 1,
@@ -79,7 +81,7 @@ export const FROZEN_MUTED_GRAY_BY_FILE: Readonly<Record<string, number>> = {
7981
"components/NoteListToolbar.tsx": 1,
8082
"components/NoteMoveToGroupMenu.tsx": 1,
8183
"components/NoteReadingView.tsx": 1,
82-
"components/NoteRowContextMenu.tsx": 1,
84+
"components/NoteRowContextMenu.tsx": 2,
8385
"components/NoteTreeSection.tsx": 1,
8486
"components/PracticeQuestionsOverlays.tsx": 1,
8587
"components/ReadyCheckCard.tsx": 1,
@@ -104,9 +106,8 @@ export const FROZEN_MUTED_GRAY_BY_FILE: Readonly<Record<string, number>> = {
104106
* `ternary-no-equivalent` = A 类条件色,非灰支在 `TextTone` 九档**无**语义等价档 ·
105107
* `interactive-no-equivalent` = 两者叠加(交互元素 + 品牌青/实底白字)·
106108
* `nontext` = `background`/`border`/`stroke`(不是墨度,B19 第 5 条:不迁不动)·
107-
* `tag` = 宿主标签不在 `TextTag` 内(`details`)·
108-
* `colorMap` = 映射函数体里的 `return "#9ca3af"`(C 类,批 5/7 状态色收敛)·
109-
* `b1-non-migrated` = B1/B2 硬守卫的 `NON_MIGRATED_14`(文件整体不迁、不得 import 原语)。
109+
* `tag` = 宿主标签不在 `TextTag` 内(`details`)· `colorMap` = 映射函数体里的 `return "#9ca3af"`
110+
* (C 类,批 5/7 状态色收敛)· `b1-non-migrated` = B1/B2 硬守卫的 `NON_MIGRATED_14`(文件不迁)。
110111
*/
111112
export type TextResidualKind =
112113
| "interactive"
@@ -118,9 +119,8 @@ export type TextResidualKind =
118119
| "b1-non-migrated";
119120

120121
/**
121-
* 允许残留的**逐文件理由**(照 `loadingBaseline.ts` 的 `RESIDUAL` 范式)。每条都必须:分类合法 ∧
122-
* 理由非空 ∧ 该文件此刻**仍然命中** —— 防「僵尸豁免」(一处被迁走却还挂在豁免表里,下一个人就会
123-
* 以为它还在)。键集必须**逐字等于** `FROZEN_MUTED_GRAY_BY_FILE`(两侧都查)。
122+
* 允许残留的**逐文件理由**(照 `loadingBaseline.ts` 的 `RESIDUAL` 范式)。每条都必须:分类合法 ∧ 理由
123+
* 非空 ∧ 该文件此刻**仍然命中**(防「僵尸豁免」)。键集必须**逐字等于** `FROZEN_MUTED_GRAY_BY_FILE`。
124124
*/
125125
export const RESIDUAL: readonly { file: string; kind: TextResidualKind; reason: string }[] = [
126126
{ file: "components/AiProviderSettings.tsx", kind: "ternary-no-equivalent", reason: "A 类三元 `color: p.enabled ? \"#0d9488\" : \"#9ca3af\"`:非灰支是品牌青 #0d9488,`TextTone` 九档无等价档;强行迁移要么丢色、要么写行内 style(ADR-033 §4 禁止)、要么给原语加「条件墨度」槽(语义不成立)" },
@@ -150,7 +150,7 @@ export const RESIDUAL: readonly { file: string; kind: TextResidualKind; reason:
150150
{ file: "components/NoteListToolbar.tsx", kind: "interactive", reason: "原生 `<button onClick title>`(折叠列表)⇒ `Button` 域(批 5/7)" },
151151
{ file: "components/NoteMoveToGroupMenu.tsx", kind: "b1-non-migrated", reason: "B1/B2 硬守卫(同上):锚定菜单文件整体不迁 ⇒ 「暂无组」空态不迁" },
152152
{ file: "components/NoteReadingView.tsx", kind: "interactive", reason: "原生 `<button onClick title>`(收起大纲)⇒ `Button` 域(批 5/7)" },
153-
{ file: "components/NoteRowContextMenu.tsx", kind: "b1-non-migrated", reason: "B1/B2 硬守卫(同上):行右键菜单文件整体不迁 ⇒ 「暂无组」空态不迁" },
153+
{ file: "components/NoteRowContextMenu.tsx", kind: "b1-non-migrated", reason: "B1/B2 硬守卫(同上):行右键菜单文件整体不迁 ⇒ 「暂无组」空态不迁;`:175`(移动到组的 ▸ 指示符 `color: \"#9ca3af\"`)亦不迁 —— 该行夹在两个闭合标签之间,曾被剥注释器漏读(收口评审 I-1),修好后本文件由 1 处重冻为 2 处" },
154154
{ file: "components/NoteTreeSection.tsx", kind: "interactive", reason: "原生 `<button onClick title>`(折叠箭头)⇒ `Button` 域(批 5/7)" },
155155
{ file: "components/PracticeQuestionsOverlays.tsx", kind: "interactive", reason: "原生 `<button onClick>`(归档;`{...ghostBtn}` 样式常量)⇒ `Button` 域(批 5/7)" },
156156
{ file: "components/ReadyCheckCard.tsx", kind: "ternary-no-equivalent", reason: "A 类三元 `item.ok ? \"#9ca3af\" : \"#b45309\"`:**灰支是「通过」**、非灰支是失败琥珀,`due`(到期刻度)语义不等价 ⇒ 无档" },

‎app/src/ui/primitives/textRatchet.test.ts‎

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
import { dirname, join } from "node:path";
4242
import { fileURLToPath } from "node:url";
4343
import { describe, expect, it } from "vitest";
44-
import { readLines, relOf, walkSources } from "./sliceScan";
44+
import { readLines, relOf, stripComments, walkSources } from "./sliceScan";
4545
import {
4646
ANCHOR_FONT_OOB,
4747
ANCHOR_MUTED_GRAY,
@@ -106,6 +106,8 @@ export function scanFontOobByFile(): Map<string, number> {
106106
const GRAY = scanMutedGrayByFile();
107107
const OOB = scanFontOobByFile();
108108
const sumOf = (m: Map<string, number>): number => [...m.values()].reduce((a, b) => a + b, 0);
109+
/** 一段文本里的弱化灰字面量**行数**(⑥ 的剥注释夹具用:口径与 `GRAY` 同为行口径) */
110+
const countIn = (text: string): number => text.split(/\r?\n/).filter((l) => l.includes(MUTED_GRAY)).length;
109111
/** 逐文件「实际 > 冻结」的回潮清单(键不在基线里 ⇒ 冻结值按 0 算) */
110112
const regressions = (m: Map<string, number>, frozen: Readonly<Record<string, number>>): string[] =>
111113
[...m.entries()].filter(([f, n]) => n > (frozen[f] ?? 0)).map(([f, n]) => `${f}: ${frozen[f] ?? 0} → ${n}`);
@@ -151,18 +153,20 @@ describe("棘轮一 · 弱化灰 #9ca3af(只许减 · 迁移后本表须手工
151153
const positive = ANCHOR_MUTED_GRAY.file;
152154
expect(GRAY.get(positive) ?? 0, `仪器在 ${positive} 上看不见 #9ca3af ⇒ 扫描静默失效`).toBeGreaterThan(0);
153155
expect(FROZEN_MUTED_GRAY_BY_FILE[positive]).toBe(GRAY.get(positive));
154-
// 词表自证:字面量确实是 `#9ca3af`(不是抄错的码位 ⇒ 词表静默变窄)
156+
// ★ 剥注释器**判定收紧**的回归夹具(收口评审 I-1):闭合标签 `</span>` 的 `/` 曾被 `regexStart`
157+
// 判成正则起点 ⇒ 夹在两个闭合标签之间的字面量被抹掉(受害行 `NoteRowContextMenu.tsx:175`,
158+
// 弱化灰因此少算 1)。两条**必须同时**成立:闭合标签之间 ⇒ 数得到;`<` 后**带空白**的真正则 ⇒ 跳过。
159+
expect(countIn(stripComments('<span>a</span> 到 <i style={{ color: "#9ca3af" }} />\n')), "闭合标签之间的灰字面量被抹掉了").toBe(1);
160+
expect(countIn(stripComments('const r = x < /["\']#9ca3af/.test(y);\nconst b = 1;\n')), "正则体没被跳过(`<` 后带空白)").toBe(0);
161+
// 词表自证(字面量确实是 `#9ca3af` ⇒ 词表静默变窄时这里红)+ 域内**确实还有命中**(否则下面的棘轮是空真)
155162
expect(MUTED_GRAY).toBe("#" + "9ca3af");
156-
expect(MUTED_GRAY).toHaveLength(7);
157-
// 域内**确实还有命中**(否则下面的棘轮是空真);域本身非空
158163
expect(GRAY.size, "域内一处弱化灰都没有 ⇒ 基线表是空真").toBeGreaterThan(0);
159164
expect(sumOf(GRAY), "域内实测总数与冻结总数不符(读数与基线被同时改过)").toBe(FROZEN_MUTED_GRAY_TOTAL);
160-
// 域边界:测试文件与原语层都在域外(口径同 T1 的 prod)
161-
expect(DOMAIN.some((r) => /\.test\.tsx?$/.test(r)), "测试文件掉进了域内").toBe(false);
162-
expect(DOMAIN.some((r) => r.startsWith("ui/primitives/")), "原语层没有被排除出域").toBe(false);
163-
expect(DOMAIN.includes("ui/tokens.css"), "域口径漂了:CSS 不该进来").toBe(false);
164-
// 本文件在域外(否则 ⑥ 自己的字面量会自命中)
165-
expect(DOMAIN.includes("ui/primitives/textRatchet.test.ts")).toBe(false);
165+
// 域边界:测试文件、原语层、CSS 都必须在域外(口径同 T1 的 prod);本文件在域外,否则 ⑥ 自命中
166+
for (const rel of [DOMAIN.find((r) => /\.test\.tsx?$/.test(r)), DOMAIN.find((r) => r.startsWith("ui/primitives/")),
167+
DOMAIN.find((r) => r.endsWith(".css")), DOMAIN.find((r) => r === "ui/primitives/textRatchet.test.ts")]) {
168+
expect(rel, "域边界漂了:测试文件 / 原语层 / CSS / 本文件掉进了域内").toBeUndefined();
169+
}
166170
});
167171
});
168172

0 commit comments

Comments
 (0)