Skip to content

Commit cc49b09

Browse files
committed
fix(ui): add call-site registry channel to surface ratchet
1 parent 6d49bb2 commit cc49b09

5 files changed

Lines changed: 388 additions & 83 deletions

File tree

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

Lines changed: 34 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,19 @@ export const FROZEN_SHADOW_BY_FILE: Readonly<Record<string, number>> = {
209209
* 「调用点不得用行内 `style` 覆盖原语语义」这条判据对真实代码是**空真**的。T17-B 迁进 14 个开标签后
210210
* 该判据**第一次有了真实输入**(同批实测行内覆盖 = **0**,即 14 个开标签无一用 `style` 覆盖底/圆角/边框)。
211211
* 只许降(再迁一处 ⇒ 手工收紧本常数;**不许**为图省事把它调大去容纳未迁的调用点)。
212+
*
213+
* ★ **T15a 判据修正(控制方裁决 · 2026-09-12):由「标签数 == 冻结值」改为「新增调用点登记制」**
214+
* 事实:批 5 任何新视图只要用 `<Surface>` 就必然红(14 是批 4 的迁移面,而本批纪律是
215+
* `*Baseline.ts` 只读、向下重同步)⇒ T9 被迫改用 token 变量绕开原语。修正:**允许新文件把它的
216+
* `<Surface>` 调用点登记进逐文件表**(登记即计数),在保留全部防漂移牙齿的前提下允许合法增长。
217+
* **这不是放宽**:
218+
* · 既有 9 个文件(T17-B 迁移快照)的逐文件上限**原样锁死** —— 表 `≤` + 下列
219+
* `SURFACE_TAG_FROZEN_LEGACY_COUNT` 之和锁 + 「表 > 实测 ⇒ 红」三条一起把 legacy 钉住;
220+
* · 登记条目只许给**原本不在表里**的文件,且 `count` 必须**恰等于**实测(不是 ≤);
221+
* · `FROZEN_SURFACE_TAG_TOTAL == Σ 登记值` 且 `== Σ 实测(逐文件)`,锚条数同步;
222+
* · 未登记文件的命中仍然 = 0;登记的文件必须**真实存在 ∧ 此刻仍命中**(防僵尸登记)。
223+
* 登记表 = `surfaceResidual.ts` 的 `SURFACE_TAG_REGISTRY`(理由逐条在表里;放彼处是因为本件
224+
* 已贴近 300 行硬限,且该件正是「登记为什么」的归处)。
212225
*/
213226
export const FROZEN_SURFACE_TAG_TOTAL = 14;
214227

@@ -221,38 +234,28 @@ export const BORDER_ANCHOR = { entries: 107, file: "components/action-center/Act
221234
export const RADIUS_OUTLIER_ANCHOR = { entries: 108, file: "components/KnowledgeSystemWizard.tsx", value: 9 } as const;
222235
export const SHADOW_ANCHOR = { entries: 24, file: "components/NoteRowContextMenu.tsx", value: 1 } as const;
223236

224-
/** 阴影残留的分类:`exception` = 结构上表达不了 · `anchored-menu` = B1 锚定菜单(不迁原语)· `backlog` = 切片外余量 */
225-
export type ShadowResidualKind = "exception" | "anchored-menu" | "backlog";
237+
/**
238+
* ★ T17-B 迁移面的**冻结上界**(T15a 修正的牙齿):`SURFACE_TAG_REGISTRY` 里这些文件的登记值之和
239+
* **必须恒等于** `FROZEN_SURFACE_TAG_TOTAL`(今日 14)。
240+
*
241+
* Why 需要它(否则修正会漏出一条自助抬高的通道):`FROZEN_SURFACE_TAG_TOTAL == Σ 登记值` 这条只会
242+
* 在**手改总数时不改表**的情况下红。若拿掉本常数,一个够耐心的人可以「把某个既有文件的登记值降 1
243+
* + 新登记一个小文件 + 总数不动」——三条判据全绿,而既有文件的实测命中仍在 ⇒ 那条通道等于
244+
* 把 legacy 面腾出来给新调用点。本常数堵死它:**新增条目只能靠抬高总数,而总数对 9 个既有文件
245+
* 的实测值是一个紧上界**(Σ 实测 ≤ 总数,见 `surfaceRatchet.test.ts` ⑪)。
246+
* 与迁移面**同向**:真迁走一处 ⇒ 手工收紧本常数 + 总数 + 表值(三处同改)。
247+
*/
248+
export const SURFACE_TAG_FROZEN_LEGACY_COUNT = 14;
226249

227250
/**
228-
* 允许残留的**逐文件理由**(照 `loadingBaseline.ts` 的 `RESIDUAL` 范式)。
229-
* 每条都必须:分类合法 ∧ 理由非空 ∧ 该文件此刻**仍然命中** —— 防"僵尸豁免"(一处被迁走却仍挂在
230-
* 豁免表里,下一个人就会以为它还在)。键集必须**逐字等于**「冻结表里值 > 0」的键集(两侧都查)。
251+
* 登记制的锚(与三条棘轮的锚同形、独立;`entries` = 登记表条数 · `file/value` = 一个具名登记的
252+
* 冻结值)——防「登记表被静默清空 / 仪器把它读成 0 而总数判据仍绿」。**新登记 = 手工同步本锚。**
231253
*/
232-
export const SHADOW_RESIDUAL: readonly { file: string; kind: ShadowResidualKind; reason: string }[] = [
233-
{ file: "components/AiConversationDock.tsx", kind: "exception", reason: "`-8px 0 24px` 是**方向性**边缘投影(dock 向左升起);`--ed-shadow-1/2` 是双向环境投影(`0 1px 2px …, 0 4px 12px …`)⇒ 换成 token 会丢掉「从右边滑出来」的暗示,属观感变化,登记给批 5" },
234-
{ file: "components/BrowserChrome.tsx", kind: "anchored-menu", reason: "B1 锚定菜单族(计划 §表 4 的 TABLE4_ALIAS,`data-app-menu` 右键菜单,`zIndex(\"popover\")`)⇒ 不迁原语;T17-B 第 1 批已把字面量换成 `boxShadow: \"var(--ed-shadow-1)\"`(token 引用不是字面量)" },
235-
{ file: "components/CanvasNodeConcept.tsx", kind: "backlog", reason: "不在 B11 切片内(画布节点族)⇒ 余量登记给批 5/7" },
236-
{ file: "components/CanvasNodeModel.tsx", kind: "backlog", reason: "不在 B11 切片内(画布节点族)⇒ 余量登记给批 5/7" },
237-
{ file: "components/CanvasNodeQuestion.tsx", kind: "backlog", reason: "不在 B11 切片内(画布节点族)⇒ 余量登记给批 5/7" },
238-
{ file: "components/CaptureOverlayPanel.tsx", kind: "exception", reason: "`0 0 0 9999px rgba(0,0,0,0.45)` 是**用巨扩散当全屏遮罩**,不是投影;且该文件是 B2 的「不迁覆盖层」⇒ 结构上无法由 `--ed-shadow-*` 承载" },
239-
{ file: "components/GroupRowContextMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(硬守卫:该文件源码不得出现 `ui/primitives`)⇒ 不迁原语;T17-B 第 1 批已把字面量换成 `boxShadow: \"var(--ed-shadow-1)\"`(token 引用不是字面量)" },
240-
{ file: "components/GroupSidebarRow.tsx", kind: "anchored-menu", reason: "组色选择浮层(`position:absolute; top:100%` + `zIndex(\"popover\")`)⇒ 锚定菜单族,同 B1 处置" },
241-
{ file: "components/KnowledgeCanvasView.tsx", kind: "exception", reason: "`0 2px 8px rgba(15,118,110,0.25)` 是**语义色**(青绿)投影,配根节点 `background:#0f766e`;`--ed-shadow-1/2` 是暖墨中性投影 ⇒ 换 token 会丢语义,需批 5/8 裁决" },
242-
{ file: "components/KnowledgeGraphView.tsx", kind: "exception", reason: "`boxShadow: data.focused ? \"0 0 0 2px #fff, 0 0 0 4px #0f766e\" : \"…\"` 是**表达式双态**,focused 分支是焦点环不是投影 ⇒ 需逐分支改写,不在本批" },
243-
{ file: "components/NoteEditView.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(高亮气泡,透明点击层)⇒ 不迁原语" },
244-
{ file: "components/NoteHeaderActions.tsx", kind: "anchored-menu", reason: "笔记颜色选择浮层(`data-testid=\"note-color-picker-pop\"`,`zIndex(\"popover\")`)⇒ 锚定菜单族" },
245-
{ file: "components/NoteLinkToSystem.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(透明层)⇒ 不迁原语" },
246-
{ file: "components/NoteListBatchMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(批量菜单)⇒ 不迁原语" },
247-
{ file: "components/NoteMoveToGroupMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(移动菜单)⇒ 不迁原语" },
248-
{ file: "components/NoteRowContextMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(行右键菜单)⇒ 不迁原语;本文件同时是阴影棘轮的锚" },
249-
{ file: "components/RichEditorView.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(高亮气泡)⇒ 不迁原语" },
250-
{ file: "components/RouteInfoPopover.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(路由信息气泡)⇒ 不迁原语" },
251-
{ file: "components/ScreenSelectOverlay.tsx", kind: "exception", reason: "`0 0 0 1px rgba(255,255,255,0.25)` 是**白色反相描边环**(截图上的框选提示),不是投影;且该文件是 B2 的「不迁覆盖层」" },
252-
{ file: "components/SessionRowContextMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(会话行右键菜单)⇒ 不迁原语" },
253-
{ file: "components/SystemStatusBadge.tsx", kind: "anchored-menu", reason: "徽标的点击浮层(白底 + 边框 + `zIndex(\"popover\")`)⇒ 锚定菜单族" },
254-
{ file: "components/WindowSelectCard.tsx", kind: "anchored-menu", reason: "「推荐窗口」下拉浮层(`position:absolute; top:100%`)⇒ 锚定菜单族" },
255-
{ file: "components/chat/ChatLaunchMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(T13-b 从 `pages/ChatPage.tsx` 搬来的同一段)⇒ 不迁原语" },
256-
{ file: "components/note-selection/SelectionActionMenu.tsx", kind: "anchored-menu", reason: "B1 的 `NON_MIGRATED_14` 成员(选区动作菜单)⇒ 不迁原语" },
257-
];
254+
export const SURFACE_TAG_ANCHOR = { entries: 9, file: "components/TaskConversationView.tsx", value: 3 } as const;
258255

256+
/**
257+
* ★ 阴影残留的分类与逐文件理由(`ShadowResidualKind` / `SHADOW_RESIDUAL`)**已移到
258+
* `surfaceResidual.ts`** —— T15a 的登记制用的正是 `surfaceResidual` 那件「登记为什么」的归处
259+
* (照 `loadingBaseline.ts` 的 `RESIDUAL` 范式),而本件已贴近 300 行硬限:两者同住会顶红
260+
* `line-limits`。判据(`surfaceRatchet.test.ts` ⑧)逐条未改,只是导入来源换了一件。
261+
*/

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

Lines changed: 26 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -15,40 +15,47 @@
1515
* ⑤未登记文件 = 0 ⑥防真空对照)—— `describe.each` 让三组用例**各自具名**,失败可单点定位。
1616
*
1717
* ★ 第 ⑦ 条(计划 M3/V4 点名必须补):**调用点不得用行内 `style` 覆盖 `Surface` 的底/圆角/边框**
18-
* (ADR-033 §4:`style` 是纯透传、**视觉权威在类**)。今日域内 `<Surface` 开标签 **0 个** ⇒ 该判据
19-
* 对真实代码**空真**;故它自带正反 fixture(正例必命中 / 布局反例必不命中 / 域外样本不在域内)
20-
* 并冻结 `FROZEN_SURFACE_TAG_TOTAL`(T17-B 迁移后必须手工抬高 ⇒ 空真结束)。
18+
* (ADR-033 §4:`style` 是纯透传、**视觉权威在类**)。迁移前域内 `<Surface` 开标签 **0 个** ⇒ 该判据
19+
* 对真实代码**空真**;故它自带正反 fixture(正例必命中 / 布局反例必不命中 / 域外样本不在域内)。
20+
* ⚠️ T15a 后本件**不再**断言「标签数 == `FROZEN_SURFACE_TAG_TOTAL`」(那会让任何新视图用 `<Surface>`
21+
* 必然红)—— 改由 `surfaceTagRegistry.test.ts` ⑪ 的**新增调用点登记制**判:登记即计数、总数 ==
22+
* Σ登记值 == Σ实测。本件只保留「标签数 == Σ 登记值」这一条**交叉**自证(两件判据对同一把尺子)。
2123
*
2224
* ★ 变异体(`tmp/t17a/run-mutants.mjs`,**每个变异新解一棵导出树** · CONTROL 在冻结提交的新解树上取):
2325
* M1 恢复一处 `border: "1px solid #e5e7eb"` ⇒ ①红 · M2 调用点新增 `boxShadow:` 字面量 ⇒ 阴影组红
2426
* (**阴影红线**)· M3 行内 `style` 覆盖 `Surface` 底色 ⇒ ⑦红 · M4 新增 `borderRadius: 6` ⇒ 圆角组红 ·
2527
* M5 删基线一行 ⇒ ④/③红 · M6(反例守卫,**必须绿**)新增合法档 `borderRadius: 8` ⇒ 全绿。
28+
* (T15a 的 M1–M6 是**另一组**:`<Surface>` 调用点登记制,见 `surfaceTagRegistry.test.ts`。)
2629
*
2730
* 副作用:只读磁盘(递归遍历 `app/src`)。边界:**文本级**判据、不做 AST;切片外余量(边框 28 · 圆角 28 · 阴影 3 文件)只冻结不迁移,去向见 `surfaceBaseline.ts` 与 `tmp/t17a/slice.md`。
2831
*/
2932
import { existsSync, readFileSync } from "node:fs";
3033
import { dirname, join } from "node:path";
3134
import { fileURLToPath } from "node:url";
3235
import { describe, expect, it } from "vitest";
33-
import { relOf, readLines, stripComments, walkSources } from "./sliceScan";
3436
import {
3537
BORDER_ANCHOR, FROZEN_BORDER_BY_FILE, FROZEN_BORDER_TOTAL,
3638
FROZEN_RADIUS_OUTLIER_BY_FILE, FROZEN_RADIUS_OUTLIER_TOTAL,
37-
FROZEN_SHADOW_BY_FILE, FROZEN_SHADOW_TOTAL, FROZEN_SURFACE_TAG_TOTAL,
38-
RADIUS_OUTLIER_ANCHOR, SHADOW_ANCHOR, SHADOW_RESIDUAL, type ShadowResidualKind,
39+
FROZEN_SHADOW_BY_FILE, FROZEN_SHADOW_TOTAL,
40+
RADIUS_OUTLIER_ANCHOR, SHADOW_ANCHOR,
3941
} from "./surfaceBaseline";
40-
import { BORDER_RESIDUAL, RADIUS_RESIDUAL, SURFACE_RESIDUAL_WHY } from "./surfaceResidual";
42+
import { relOf, stripComments, walkSources } from "./sliceScan";
43+
import {
44+
absOf as absOfSrc, inDomain, overridesSurface, scanSurfaceTags, surfaceTagsIn, textOf as textOfSrc,
45+
} from "./surfaceScan";
46+
import {
47+
BORDER_RESIDUAL, RADIUS_RESIDUAL, SHADOW_RESIDUAL, SURFACE_RESIDUAL_WHY,
48+
SURFACE_TAG_REGISTRY, type ShadowResidualKind,
49+
} from "./surfaceResidual";
4150

4251
const HERE = dirname(fileURLToPath(import.meta.url));
4352
/** `app/src` —— 基线的键就是相对这个目录的正斜杠路径。 */
4453
const SRC = join(HERE, "..", "..");
4554

46-
/** 域口径(判据与 fixture 共用**同一个**谓词,防"两套口径") */
47-
const inDomain = (rel: string): boolean => !/\.test\.tsx?$/.test(rel) && !rel.startsWith("ui/primitives/");
55+
/** 域口径与读法**全部来自共享件** `surfaceScan.ts`(域谓词只有一份实现) */
56+
const absOf = (rel: string): string => absOfSrc(SRC, rel);
57+
const textOf = (rel: string): string => textOfSrc(SRC, rel);
4858
const FILES: readonly string[] = walkSources(SRC).map((abs) => relOf(SRC, abs)).filter(inDomain);
49-
const absOf = (rel: string): string => join(SRC, ...rel.split("/"));
50-
/** 剥注释后的整段文本(仪器只此一处取用,判据与自证同源) */
51-
const textOf = (rel: string): string => readLines(absOf(rel)).stripped.join("\n");
5259
/** 「处」口径:整段文本上的匹配次数(`g` 只用于统计) */
5360
const countOcc = (text: string, re: RegExp): number =>
5461
(text.match(new RegExp(re.source, re.flags.includes("g") ? re.flags : `${re.flags}g`)) ?? []).length;
@@ -144,42 +151,8 @@ describe.each(FAMILIES)("$label(T17-A 冻结基线,只许降)", (f) => {
144151
});
145152

146153
/* ───────── ⑦ ADR-033 §4 的机器判据:调用点不得用行内 `style` 覆盖 `Surface`(计划 M3/V4) ───────── */
147-
/** 被覆盖即违规的属性键(底/圆角/边框三族;**不含** `padding/margin/width` 等布局口 —— 那是 `style` 的合法用途) */
148-
const OVERRIDE_KEYS =
149-
/(?:^|[,{;\s])(background|backgroundColor|backgroundImage|border|borderColor|borderWidth|borderStyle|borderTop|borderBottom|borderLeft|borderRight|borderRadius)\s*:/;
150-
151-
/**
152-
* 单个 `<Surface …>` 开标签是否用行内 `style` 覆盖了底/圆角/边框。
153-
* 边界(**文本级启发式,不是 AST**):假阳 = JSX 文本节点里出现的字面量 `<Surface`(实测本仓 0 例);
154-
* 假阴 = 样式对象经变量间接给出(`style={S}` / `style={pick()}`)⇒ 判据只认 `style={{` 的字面对象。
155-
* `style={{ padding: 8 }}` 这类**纯布局**写法**不**算违规(ADR-033 §4:`style` 是透传,供布局用)。
156-
*/
157-
function overridesSurface(tag: string): boolean {
158-
const at = tag.search(/\bstyle\s*=\s*\{/);
159-
return at >= 0 && OVERRIDE_KEYS.test(tag.slice(at));
160-
}
161-
162-
/** 取 `<Surface …>` 的整个开标签:从 `<Surface` 起按 `{}` 深度找深度 0 的 `>`(跨行标签也能取全) */
163-
function surfaceTagsIn(text: string): { tag: string; line: number }[] {
164-
const out: { tag: string; line: number }[] = [];
165-
const re = /<Surface(?=[\s/>])/g;
166-
let m: RegExpExecArray | null;
167-
while ((m = re.exec(text)) !== null) {
168-
let i = m.index + 8;
169-
let depth = 0;
170-
while (i < text.length) {
171-
const c = text[i];
172-
if (c === "{") depth++;
173-
else if (c === "}") depth--;
174-
else if (c === ">" && depth === 0) break;
175-
i++;
176-
}
177-
out.push({ tag: text.slice(m.index, i + 1), line: text.slice(0, m.index).split("\n").length });
178-
}
179-
return out;
180-
}
181-
182-
const SURFACE_TAGS = FILES.flatMap((rel) => surfaceTagsIn(textOf(rel)).map((t) => ({ file: rel, ...t })));
154+
// 仪器在 `surfaceScan.ts`(T15a 析出:⑦ 与 ⑪ 的登记制共用同一把尺子,防两套口径漂移)
155+
const SURFACE_TAGS = scanSurfaceTags(FILES, textOf);
183156
const OVERRIDES = SURFACE_TAGS.filter((t) => overridesSurface(t.tag));
184157

185158
describe("⑦ 调用点不得用行内 `style` 覆盖 `Surface` 的底/圆角/边框(ADR-033 §4)", () => {
@@ -188,7 +161,7 @@ describe("⑦ 调用点不得用行内 `style` 覆盖 `Surface` 的底/圆角/
188161
expect(hits, `行内 style 覆盖了 Surface 的视觉语义(视觉权威必须在类上):\n${hits.join("\n")}`).toEqual([]);
189162
});
190163

191-
it("启发式双侧自证 + 非真空登记:正例必命中 · 布局反例必不命中 · 域外样本不在域内 · 标签数 == 冻结值", () => {
164+
it("启发式双侧自证 + 登记面自证:正例必命中 · 布局反例必不命中 · 域外样本不在域内 · 标签数 == Σ 登记值", () => {
192165
expect(overridesSurface('<Surface style={{ background: "#fff" }}>'), "底色覆盖没被命中").toBe(true);
193166
expect(overridesSurface('<Surface\n className="card"\n style={{ borderRadius: 8 }}\n>'), "跨行 + 圆角覆盖没被命中").toBe(true);
194167
expect(overridesSurface('<Surface style={{ border: "1px solid #e5e7eb" }}>'), "边框覆盖没被命中").toBe(true);
@@ -198,9 +171,10 @@ describe("⑦ 调用点不得用行内 `style` 覆盖 `Surface` 的底/圆角/
198171
expect(existsSync(absOf("ui/primitives/Surface.test.tsx")), "域外样本文件不存在 ⇒ 排除自证无效").toBe(true);
199172
expect(inDomain("ui/primitives/Surface.test.tsx"), "原语层/测试文件掉进了域内").toBe(false);
200173
expect(inDomain("components/GoalCard.tsx"), "域内样本被误排除").toBe(true);
201-
// 空真登记:今日 0 个 `<Surface>` ⇒ 上面那条 0 命中是**空真**;T17-B 迁移后必须手工抬高
202-
expect(SURFACE_TAGS.length, "调用点里出现了 <Surface> ⇒ 请抬高 FROZEN_SURFACE_TAG_TOTAL 并复核 ⑦ 已变为有真实输入").toBe(
203-
FROZEN_SURFACE_TAG_TOTAL,
174+
// 空真登记:迁移前 0 个 `<Surface>` ⇒ 那条 0 命中是**空真**;T17-B 迁进 14 个后 ⑦ 第一次有真实输入。
175+
// T15a 修正:标签数不再钉死常数,而须**恰等于登记值之和**(登记即计数;增长走 ⑪ 的登记制通道)
176+
expect(SURFACE_TAGS.length, "调用点里的 `<Surface>` 数 ≠ 登记表之和 ⇒ 请走 ⑪ 的登记制(登记 + 抬高总数 + 同步锚)").toBe(
177+
SURFACE_TAG_REGISTRY.reduce((a, e) => a + e.count, 0),
204178
);
205179
});
206180
});

0 commit comments

Comments
 (0)