Files
MailUI4Agents/client/electron/test/CRITERIA.md
JianFeeeee d3140c213c 补充: 给"条数登记校验"加锚点(自检 5b)—— ★ 而我第一版锚点自己写成了**空真**
`788d7cc` 把条数校验挪进 `else { … }`(红绿都跑)之后,**它没有自检**:
下一个人完全可以再挪回 `else if` 后面,那时**什么都不会红**
(红文件又收不到条数回执),而缺口**只在文件恰好红时隐形** ——
正是它上次潜伏到 `c523c21` 的原因。⇒ 补自检 5b。

做法(锚点落在**实际发生的比较**上,不许落在源码文本上,§16.3):
在 `else { … }` 里每次比较都 `countCheckRan.add(file)`,
5b 从 `records`(谁真的自报了条数)**独立重算**应当被评估的集合,再要求它被覆盖。
**不读 `reds`、不看那条校验自己的输出** —— 否则就是"读数器自作证"。

★★ 而**我第一版 5b 是空真的**(自捉,如实记):
我原来比的是"凡**条数不符**的文件都必须被记录过" ——
**而这条修复本身就把 harmony-admin 的登记数对齐了 ⇒ 那个集合恒空 ⇒ 断言恒真。**
变异测试当场抓到:把 `countCheckRan.add` 挪回"只绿才走",**5b 一声不响** ——
那一刻我才发现它不是"通过",是"**没有对象**"。

改成比 **"所有自报了条数、且没崩的文件"**(红绿都含):红文件必在其中 ⇒ 非空,
且"红文件被漏记"必被抓。另加**反空转**:集合为空而并非全部 broken ⇒ 自检自己报失效。

变异验证:挪回"只绿才走" ⇒ 5b 报出 6 个红文件名
(cross-client-theme / build-stamp / align-refs / harmony-push / criteria-hygiene / harmony-admin);
基线不报 ✓。修后全套(树内):files=31 ran=31 checks=487 pass=476 fail=11 red=10
broken=0 unreported=0。

`CRITERIA.md` 补两条可复用教训:
① **"拿现有数据试一遍"要试到"数据非空"** —— `∀x∈∅` 的判据看起来和真判据一样绿;
② **修好一件事会同时消灭它自己的测试对象** ⇒ 锚点不许建立在"当前的错误状态"上,
   要建立在**恒在的集合**上("谁自报了条数",而不是"谁条数不符")。
2026-09-19 12:56:19 +08:00

1132 lines
76 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 判据规范(写判据前先读这份)
这份文件记的是**判据本身的写法**:什么样的判据能红、能红在对的地方、以及不会在"代码完全正确"时乱红。
不是"怎么用 node:test",而是这个仓库里踩出来的形状。
一句话版本:**判据要钉结构与行为,不要钉字面与邻接。**
---
## 1. 判结构必须配对/解析;看邻接或固定宽度都会被合法写法绕过
统一形式(这是同一个坑在本仓露头的**第三次**,所以升格成规则):
> **判结构要配对/解析(括号、块、AST);看"前一个字符"或"固定宽度窗口"都会被合法写法绕过。**
三次露头:
| # | 当时的写法 | 被什么合法写法绕过 |
|---|---|---|
| ① | 固定宽度窗口正则 | 往规则里加一行注释就把 `backdrop-filter` 挤出窗口 —— 断言变红而 CSS 完全正确(`background.test.mjs` 的注释里记着这件事) |
| ② | 括号配对取代窗口 | 修掉了 ①,方向对 |
| ③ | "调用点往前看一个字符是不是 `}`" | ArkUI 的**链式修饰符**:`Row(){…}.backgroundColor(x).backgroundBlurStyle(A)` 里调用点前面是 `)` 不是 `}` → 判据**静默失效**(拿到 null 就放过去了) |
③ 的修法:往回扫时**跳过成对的括号组**再找 `}`。它同时暴露了"叠"有两种形态这件事
(同一组件叠两次 / 套在另一层玻璃的子树里),所以 §2 的形状在这些规则里反复出现。
**推论**:同一条规则里出现 `[\s\S]{0,N}` 时先问一句"N 是怎么来的"。答不出数字来源的,
就是窗口式判据,换成"取出这条规则的 `{...}` 体再断言"。
**已知存量**(pi 2026-09-14 点名,**WebUI 侧归他**,别人不要顺手改):
`client/electron/test/background.test.mjs` 里还有一批 `\{[\s\S]{0,80}…`、`{0,160}` 窗口。
下次碰那个文件时按本节形状改成"取规则体",不要在那里再加一条注释算了。
### 1.5 两张表各缺一半时,必须**按 id 联接**,不能按相邻关系配对(pi 2026-09-14)
读 SDK 的系统色时踩到的:`sysResource.js` 只有**名字→id**,`resources.txt` 只有 **id→值**,
而后者把名字与值排得并不总是紧邻(我第一遍按文本邻近关系读,直接串了行,
把 `ohos_id_color_background` 读成了 `ohos_id_color_background_dark` 的值)。
正确做法:名字查 id、id 查值,**两次联接都基于标识符**;顺便用一张表校验另一张表
(例如 `_dark` 结尾的名字应当拿到深色值),对不上就说明联接方式错了。
这与第 1 条是同一件事:**邻接不是结构**。它同样适用于"我自己写的判据"
(§6.6 里那条被变异测试抓住两次的窗口式正则就是反例)。
## 2. 合成的清单 + 空 allow-list:条目要结构化
"清单从 SDK/参考实现生成 + allow-list 目前为空"是个好形状,但它**迟早被侵蚀**:
上游升版会往清单里加新条目 → 某天早上套件突然红,且红在与本次改动无关的代码上。
这时人的第一反应是把名字塞进 allow-list —— 而 allow-list 一旦这么用,
就不再是"研究过的例外",只是"红的止痛药"。
所以条目写成三元组,并断言后两项非空:
```js
const ALLOW = [ /* { name, replacement, why } */ ];
```
- `replacement` 必须非空:**"暂时不想改"不是放行理由**,"替代品要求的 API level 高于本机基线"才是;
- **不要断言"名单必须为空"**(那会挡住合理放行);要断言的是"有名字、没替代品 → 红"。
这样侵蚀发生时红的是**放行这件事本身**,而不是某天的新 SDK。
同一个形状的实例:`cross-client-theme.test.mjs` 的 `SELF_OWNED_COLORS`(手写色登记表,
每条带理由)、`GLASS_REGISTRY`(玻璃面登记,每条带"为什么这里要玻璃")。
## 3. 判据必须能红,而且红的地方要对
每条新判据配一次**变异验证**:把源码改成"错的样子",确认它红,并且红在那条上。
变异没红有两种可能,都要查清:一是判据没覆盖,二是**变异没真的生效**
(本仓真发生过:变异脚本的锚点不匹配、缩进不对,于是"变异后依然全绿"被当成判据有效)。
**还原纪律(pi 2026-09-14 纠正了我的写法,这条更好用)**:
我原来写的是"变异后不要用 `git checkout` 还原"——**那是治症不治因**。
真因是:**被还原到的那个状态还没提交**,于是 `checkout` 把未提交的改动一起抹掉了
(我丢的是一个刚加、尚未提交的 marker)。
可执行的形状是改**操作顺序**,而不是记一条禁令:
> **任何破坏性还原,都要求"将被还原到的那个状态已经在某个提交里"。**
- 所以:**变异前先把基线提交掉**(哪怕是个很小的提交)。之后 `git checkout -- <文件>`
只可能还原到已提交状态,不会连带抹掉未提交的东西;
- 要更稳就 `git worktree add` 一个干净副本,在副本里变异,主工作区完全不碰;
- 备份(`cp` 到 `/tmp/*.bak`)仍然可以,但它靠的是"人记得备份"——顺序改对了则不依赖记性。
这跟"记得打 marker"改成"计数写在 `check()` 内部"是同一招。
### 变异红了,还要看**红在哪**(pi 2026-09-14 补的一档)
只报"红了"不算证据。反过来的那一半同样成立:**变异后红了,也可能是假红** ——
比如变异把文件写坏了语法,判据红在"模块加载失败/解析不到源码",看起来像判据生效,
实际那条断言根本没执行。所以:
- 报结果要能**指名红的是哪几条**(本仓的变异表就是按这个写的:
"红 2 条(B + 裸色值)"、"红 3 条(含品牌色防线)");
- **红在解析/加载失败上不算红** —— 先让变异"语法正确、语义错",再谈判据有没有生效;
- 同理,变异**作用于注释**(被剥掉的那部分)也不算:判据读的是剥注释后的源码,
文档里的"理由"断言才读原文(见 §4)。
### 判据自己不会跑:一个家族,六种宿主
这条家族在本仓已经露头六次,共同点都是"**看起来全绿**":
| # | 宿主 | 形态 |
|---|---|---|
| ① | `&&` 链 | 前面红一条,后面全部不跑("红"不可信) |
| ② | 判据文件 | 没接进 `SUITE`(写好了但隐身) |
| ③ | 清单名字 | 文件名写错 = 静默跳过一条判据 |
| ④ | 文件末尾 | 判据写在 `process.exit()` **之后**(并发写入总往末尾追加) |
| ⑤ | 验证手法 | 用 `node --test` 去验 runner,里面的 `process.exit(1)` 被吞(见 §6) |
| ⑥ | runner 内部 | 清单 flag 与判据写法配错 |
`run-all.mjs` 现在对 ②③④⑥ 都有静态自检,⑥ 的落地方式见下(**不要**照抄"解析 pass 计数"):
⚠️ **实测过两条真实样本,结论与直觉不同**:
- `node --test <自定义 check() 的判据>`:**退出码照样传出来**(文件 exit 1 → 命令行 exit 1),
并没有被 runner 吞掉;
- 但 `node --test <什么都不做的文件>` 会报 `# tests 1 / # pass 1` ——
**计数不是"检查跑过"的证据**。所以"数 pass、0 就判红"既抓不到空判据(它报 1),
又会在汇总行没有数字的判据上误报。
改用**结构证据**:每条判据文件里必须存在"能红"的路径(`test(` / `check(` / `process.exit(1)`),
外加"跑完必须有输出"。**先看真实输出再写规则**——这一条本身就是 §1 的推论。
## 4. 读源码断言的两种模式,别混用
- 断**代码行为**:读**剥掉注释**的源码(注释里出现的调用不是调用);
- 断**"理由写清了没"**:读**原文**(理由就在注释里)。
本仓真踩过:用剥离注释的读取去断"注释里写了为什么用 Canvas",永远红。
## 5. 取片段按行/按块,别用偏移算术两头夹
`src.indexOf('build() {')` 会撞上文件里更早的同名成员;偏移差一个字符会把最后一行拦腰截断
(现象是"这行只剩 57 个字符"这种看着像文案、其实像切片的怪事)。
取"某个成员的正文"要按行扫到下一个同级成员,并加一条自检断言切片没跨到别的成员上。
## 6. 判据要接线,扫描范围要有下限
- 新增判据文件必须进 `test/run-all.mjs` 的 `SUITE`(run-all 的**自检 2** 会直接红);
- 扫目录的判据要断言"**至少扫到 N 个文件**",并列出必须包含的子目录
(`pages/` `common/` `model/` `api/` …)—— 目录改名/只扫一个子目录会让判据变成
**空判据而依然全绿**。
- **验证要按真实入口跑**:`npm test` 走的是 `node test/run-all.mjs && vitest run`。
用 `node --test test/run-all.mjs` 去验时,runner 只报"0 个测试"、**把 runner 内部的
`process.exit(1)` 吞掉**,于是"变异后依然 exit 0"看起来像判据失效(本仓刚踩过这一次:
自检 3 其实是好的,是我用错了入口去验它)。**验判据要模拟用户/CI 真正跑的那一行。**
### 6.1 判据的**存在性**也要有下限:把"必须存在哪几条"锚到判据自己的表之外(pi 2026-09-18)
§6 那条只说了"扫目录要有下限"。**同一条规矩换个对象就漏了** —— 这条是它的镜像:
`run-all.mjs` 的自检登记表 `SELFTESTS` 原来**没有任何下界**,也没有任何"必须存在哪几个"的
外部清单。于是**把一整条自检(函数体 + 登记 + CLI guard)三处同步删掉是零痕迹的**:
```
基线: RESULT … checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red 自检段:5 个自检…
删掉: RESULT … checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red 自检段:4 个自检…
```
`red` 与基线**逐字相同**、相关 RED **0 条**,只有报文里 `5` 变成 `4`(而那句读起来像正常)。
最尖的形式:删掉 `exitcodeSelfTest` —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** —— 同样零痕迹。
⇒ 我们花两轮建起来的真跑锚点,可以被一次编辑无声撤掉。
**为什么三道防线都看不见它**(它们问的都是"**出现的东西对不对**"):
| 防线 | 它比什么 | 整条删除时 |
|---|---|---|
| 名字级扫描 | 源码里的 `\w+SelfTest` ↔ `SELFTESTS` | 名字**两边都不出现** ⇒ 无从对比 |
| "写了但没接线" | `declaredSelfTests(源码)` ↔ `wired` | **两边同时缩小** ⇒ 相等 |
| 样本/见证/要求 | 样本表**内部**三字段 | 与 `SELFTESTS` 无关 ⇒ 不受影响 |
⇒ **没有一条问"该出现的东西在不在"。** 所以规矩是:
**凡"某张表里必须有哪几项",那几项必须能在**这张表之外**被读到 ——
写在别处的、有人维护的文本里(本文件就是这样),而不是由表自己说了算。**
本仓的落点是:`run-all.mjs` 读**本文件 §6.1**下方那段 `<!-- selftests: … -->`,
要求它与 `SELFTESTS` 的键**恰好相等**(两个方向都判)。
★ 为什么锚在**本文件**而不是"再写一张内部清单":本文件是**另一个文件**,
且它自己已被 run-all 的**自检 3**守着(存在性 + 关键条目在)——
⇒ 链条是"**被外部守着的文本 → flag 名 → guard → SELFTESTS**",锚点不在 `SELFTESTS` 自己身上。
★ 自查句:**"我这条判据,把它的定义、登记、调用三处一起删掉,谁会红?"**
问不出来,就说明这条判据的**存在性**没有锚点(它只被"内容正确"类判据守着)。
<!-- selftests: probe-selftest:3 exitcode-selftest:12 skip-selftest:5 verdict-selftest:12 mutants-line-selftest:9 -->
★ 每项写成 `名字:下界`。**下界 = 这条自检至少该打出多少条断言**(`^(ok|RED) ` 行数)。
语义是 §6.6 那种**棘轮**:**只增不减** —— 加断言不用改数字,**掉下来才红**。
下界是**行为**读数(自检真跑出来的行数),不是"源码里那句 `console.log` 在不在"。
★★ 为什么必须有它(**层 6**,pi 2026-09-18 报):上面的契约行只管**存在性**,
而存在性有**两种**失去方式 —— **名字没了**,和 **名字在、里面是空的**。后者零痕迹:
```
把 `exitcodeSelfTest` 的函数体(14616 字节)换成 `{ return 0; }`
(名字、登记、CLI guard、契约行一个都不动)
⇒ red 与基线逐字相同、^RED 0 条
最尖形式:掏空 + 把 `UPSTREAM_RC['baseline-residue']` 改成 99 ⇒ 0 行提到 UPSTREAM
对照(只改值、不掏空)⇒ red=10,真报「UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符」
```
⇒ **所有防线问的都是"它在不在",没有一条问"它做了没有"。**
★ 所以下界必须写在**这张表之外**(就是本文件),不能由 `SELFTESTS` 自己说了算 ——
与 §6.1 同一条理由。**数字掉下来 = 那条自检要么被掏空、要么被改得不再检查东西。**
⚠️ 下界取的是**略低于实测值**的余量(实测 3/13/5/13/9),不是"恰好等于实测":
相等的下界会让**任何**一条断言的小改动都变成"必须同步改数字",而留余量只拦
"掉到明显不对"的那种(掏空 ⇒ 0、判据被删掉一半 ⇒ 腰斩)。
★ 判据只在**该自检自称绿**时才比下界:`exitcodeSelfTest` 在**拿不到降权工具**的机器上
会 `continue` 掉两个案例(那是它**故意的**行为 —— 前提构造不出来就报红,不许静默跳过),
那种机器上条数本来就不同 ⇒ 不做这个区分就会在**别的机器上假红**。
★ 层 5 与层 6 是**两根轴**:层 5 问"**它还在吗**",层 6 问"**它做了吗**"。
★★ 而它们共同的教训是:**每根轴的"底"看起来都像整体的底。**
"到底了"永远只对**当前那一根轴**成立 —— 这条我等了三轮才学会,写在这里给下一个人。
### 6.1.1 条数下界**也**是自述 —— 必须再补一层**因果**判据(pi 2026-09-18,层 7)
上面那条下界判据(层 6)的输入是**输出行数**:**数的是它"打了几条",不是它"验了几条"。**
所以把函数体换成"循环打满 13 条空 `ok`、`return 0`"就**恰好绕过**它:
```
基线: red=9 … verdict=red
假 ok×13: red=9 … verdict=red ← 逐字相同、下界相关 RED 0 条(13 ≥ 下界 12)
假 ok×13 + UPSTREAM_RC['baseline-residue']=99 ⇒ 0 条 RED、0 次提及 UPSTREAM
对照(只改值、不掏空)⇒ red=9→10,真报「与真跑出来的 rc=1 不符」
```
⇒ 根因一句话:**下界判据的输入是"自我报告"** —— 判据自己说它做了多少,而它可以撒谎。
**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**
★ 三种"看起来更硬"的替代都被否掉了,理由都是它们仍在自述这一侧:
· **黄金输出**(逐字节比对期望输出)= 又一份**手写数据**(本文件刚证过那族可被同步改);
· **扫源码找关键词**("源码里有那个断言")= "**提到 vs 就是**"(本文件反复证过它脆);
· **毒化环境变量**(该模式下每条自检必须报红)= 把"必须红"的断言写在**自检函数体内**
⇒ **假体不打它**。实测:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题。
★★ **本仓采用的锚点是"外部因果":改坏被守的生产符号 → 那条自检必须红。**
```
· 假体(空 `ok`)在符号被改坏时仍然打 ok ⇒ 它不红 ⇒ **红**(判据抓它);
· 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
⇒ 这是"自检真的读了那个符号"的**行为**证据,不是它的自述。
```
实现要点(`run-all.mjs` 的 `CAUSAL` 表 + 快入口):
- 快入口 `--only-selftest=<名>`:实测 **52~696ms/条**
(对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s);
- 隔离用 `mkdtempSync` + `cpSync(join(HERE), …)`(**只拷 `test/`,1.6MB / 6ms**)——
照本仓"隔离要用**最小**夹具"那条;
⚠️ 但**必须**拷整个 `test/`:run-all 的**自检 1/2** 要 `readdirSync(test)` 与 `SUITE` 对齐,
缺文件会让它在跑自检**之前**就 `exit(1)` ⇒ **读到的是假红**
(我第一次少拷东西时就这么读错过:`rc=1` 看着像"改坏生效",其实是清单自检停了整套);
- 关系表(自检 ↔ 它守的符号)也是手写数据,**但它被真跑锚住了**:
表里每一对都必须实测"改坏了它真会红";把符号写错(写成它不读的)⇒ 那条自检改坏后**不红** ⇒ 红。
⇒ 表自己也被因果判据守着,这是它与"下界数字"的**关键区别**。
⚠️ **这句话上面那半句是错的**,见 §6.1.2 —— 被守的只是"表里的锚文本唯一",**"表里该有几条"当时没人问**。
- ⚠️ 锚文本的唯一性必须在**除去 `CAUSAL` 表本身**的源码里数,
否则会**匹配到表里那一行字面量**(我第一版就踩了:probe/verdict 立刻报"不唯一",
真因是表自己,不是生产里有两处)。★ 又是那句:"提到"与"就是"在文本上长得一样。
⇒ 可推广成一句:**任何"在源码里找自己写的那串字"的判据,都必须先把自己那串字挖掉** ——
否则它就是在给自己数数。
### 6.1.2 **守着锚的那张表,自己没人守**(pi 2026-09-18,层 8)
§6.1.1 我写"表本身也被真跑锚住了" —— **只对了一半**。被守的是"锚文本在源码里唯一",
而 **"表里该有几条"没有任何东西在问**。实测(`be49e07`):
```
CAUSAL = [](整表清空) ⇒ red=10,与基线**逐字相同**、因果 RED 0 条
只留 1 对(删掉另外 3 对) ⇒ red=10,那三条自检**从此失去因果锚**、无任何提示
CAUSAL = [] + 假 ok×13 ⇒ red=10、因果/空壳 RED **0 条**
⇒ **层 7 的攻击被完整放回来了,而代价从"改函数体"变成"删掉表里一行"。**
```
⇒ 根因:**锚点不能落在"被守对象的自述"上**(层 7),
而这里是 **"守着锚的那张表自己的内容"没有锚** ——
上一轮是"判据自述",这一轮是"**锚表的自述**"。
★ 新轴:**守护者自己进入了被守集合** —— 前几层问的都是"**被守的东西**怎样",
层 8 问的是"**守它的东西**怎样"。
**修法:覆盖面必须有一个不在表里的来源。** 这里用**两个**,都是已有的:
- ① `contractEntries` —— `CRITERIA.md` 契约行(**外部文件**,与层 5 **同一个锚点**;
一个外部来源同时守两件事,不必再写第二张表);
- ② `SELFTESTS` 的**键**(源码结构)—— 让"新加了一条自检却没配因果对"也能被抓。
⚠️ **空真的坑**:只写"每个 `CAUSAL` 项都对应一个真自检"(反向)**挡不住 `[]`** ——
`[]` 恰好满足那个空真("每个"在空集上恒真)。⇒ 必须**正向**要求覆盖。
★ 与本仓那句同族:**"判据存在" vs "判据在路径上"**;这里是 **"表非空" vs "表够长"**。
⚠️ **两个命名空间别混**(我第一版就混了,被自己刚写的判据当场抓住):
`wired` 装的是**函数名**(`probeSelfTest`),`CAUSAL`/契约行装的是**旗标名**(`probe-selftest`)。
拿前者比后者 ⇒ **5 条全报"幽灵名"、另 5 条全报"没有因果锚"**(一次红 2 条、方向相反)。
⇒ 判据没错,是我把两套名字当成了一套。**正确的来源是 `SELFTESTS` 的键**(旗标名)。
★ 教训:**同一个东西在两个地方有两套名字时,"名字相等"不是可传递的**。
### 6.1.3 **判断据的那个人,也可信吗**(pi 2026-09-18,层 9)
§6.1.1 我为把因果判据的成本从 4.3s 压到 52~696ms 加了快入口 `--only-selftest`,
而它当时自带**第二张分发表 `T`**(`{'probe-selftest': probeSelfTest, …}`)。
我在上一封把它如实标成"**副作用、不是防线**" —— **层 9 就长在那个副作用上**。实测:
```
T 全换成 () => 1 桩 ⇒ red=9,与基线**逐字相同**、因果 RED **0 条**
T 全桩 + 假 ok×13(层 7) ⇒ red=9、**红清单 diff 0 行**(层 7 的攻击完整放回)
```
为什么因果判据看不见:它观察孩子**只经 `T` 的分发**,而它比的"孩子退出码"
正是 `T` 提供的 ⇒ **判据的观测通道被它自己要观测的对象控制着。**
⇒ 层 7 问"**被测者**的话可信吗",层 9 问"**测量者**的话可信吗"。
★ 也是本仓老形状的又一次:`T` 与 `SELFTESTS` 是**同一事实两份实现**
(`stripStrings` 兄弟副本、`blurStyleFor`、`parseSkips` 抄副本…… 这次是**分发映射表**)。
**修法一(消除"两份实现"本身):`T` 不该单独存在。**
`SELFTESTS` 上移成**唯一的一份登记**,快入口从它派生 ⇒ "桩化 `T`"这个动作**没有落点**。
⚠️ 五个自检都是 `function` 声明(**提升**)⇒ 引用它们没有 TDZ 问题;
但**调用**它们仍必须排在 `DIAG` 等 `const` 表之后(那个 `if` 留在原位)。
**修法二(更硬,堵形状而不堵那一个洞):给因果判据加对照 —— 未改坏时必须 rc=0。**
只问"改坏 ⇒ 必须 rc=1"的话,**任何恒 rc=1 的通道**(表、wrapper、环境变量…)都能让它绿。
⇒ 加一问:**没改坏的时候它必须 rc=0**。通道坏了 ⇒ 这一问就红。实测:
把快入口改成恒 `exit 1` ⇒ **5 条 RED**(`观测通道本身有问题`);
而改坏生产符号那一半仍照常红。
★ 这是"锚点必须落在被守对象管不着的地方"的**行为**版本:
**我不需要知道通道长什么样,只要求它在两种输入下给出两种答案。**
⚠️ 代价:每对自检跑**两次**(默认跑 5.1s → **6.8s**,可接受)。
★★ **一般化(我认为这是这条线上最值得记的一句)**:
> **每加一条防线,要同时回答两问:**
> ① 它守什么?(设计意图)
> ② **它自己新增了哪些"必须被守"的东西?**(副作用)
>
> 层 9 就是第②问没问的产物 —— `--only-selftest` 是**性能上的净收益**,
> 却往被守集合里加了一个**没人守**的成员。
⇒ 这与"每根轴的底看起来都像整体的底"是同一现象的两种说法:
**防线是会长出新的被守对象的**,所以你永远能看到一个新的"底"。
★ 层级表:写法(4) → 存在性(5,它还在吗) → 行为(6,它做了吗) → 自述(7,被测者可信吗)
→ 锚表(8,锚还在吗) → **观测(9,判断据的那个人可信吗)**。
### 6.1.4 **对照能被"按内容分流"的通道骗过** —— 红必须是"报出来的",不是"退出来的"(pi 2026-09-18,层 10)
pi 报的层 10:上一版那条"对照"(未改坏必须 rc=0)能被一个**读自己源码**的通道骗过。
通道在快入口里加:读自己的源码、发现"我被改坏了"(源码里出现了改坏标记)就 `process.exit(1)`。
它在**两种输入下都答对**:原样走真实现 ⇒ 绿;被改坏 ⇒ 自己退 1 ⇒ **对照通过**,
而层 7 的假体照旧静默。实测:**红清单 diff 0 行**。
★ 根因:**对照只问"输入→输出映射是否非平凡",没问"那个输出是不是被守对象产生的"。**
通道可以**自己**产生那个"红"(靠检测自己的源码),于是它既是仪器又是被测者 ——
层 9 是"仪器归被测者管",层 10 是"**仪器可以自己造假象,而它造得让对照满意**"。
**修法:把"红"从退出码改成可归因的报告。**
· ① 干净孩子必须 **rc=0 且 0 条 `RED ` 行**(健康的自检不该喊红);
· ② 改坏后的孩子必须 **rc=1 且 ≥1 条 `RED ` 行**(它得说出**为什么**)。
· ⇒ 只会 `process.exit(1)` 的通道**什么也没说** ⇒ ①过②不过 ⇒ **红**。
★ 一般化:**"它红了吗"必须落在"它说了什么"上** ——
退出码是**任何人**都能给的(连崩了都给),而 `RED ` 行是被守对象**自己的判断**。
⚠️ **已知残留(不声称层 10 封死)**:通道打一行**假 `RED `**(`console.log('RED …')`)
⇒ 同样两问都答对。实测 `red=9`、0 条因果红。**成本只高了一行 `console.log`。**
⇒ 这与 pi §二的根因完全同形:**通道能分辨两种输入,就答对两问。**
· pi 的 ① 方向(改坏**孩子读的外部依赖**而非孩子自己的源码)能穿透这个分流 ——
但我实测:**只有 `exitcode-selftest` 读外部 `summary.py`**;另 4 条的生产符号
(`probeIsDue`、`parseSkips`、`verdictOf`、`summarizeMutants`)都在 `run-all.mjs` **自己里**。
⇒ **① 不 generalize**,它只堵 1/5。
· pi 的 ②(加第三种输入"改坏无关符号")也测了:**定向通道**(只认自己那一个改坏形态)
在无关符号被改坏时仍走真实现 ⇒ rc=0 ⇒ **② 通过** —— 被绕过。
⇒ ★ **这一串的底(层 5→10)**:每加一层,成本从"一处编辑"抬到更高,
但"多处同步删仍静默"和"通道能分辨输入就答对两问"**性质没变**。
**防线是会长出新的被守对象的,而观测通道最终都能分辨输入。**
⇒ pi 建议到此为止(层 10),我同意 —— 见 §6.1.5。
★ 层级表:写法(4) → 存在性(5) → 行为(6) → 自述(7) → 锚表(8) → 观测(9)
→ **归因(10,"红"是报出来的还是退出来的)**。
### 6.1.5 **这一串的定价:为何到层 10 为止**(pi 2026-09-18,dsh 同意)
层 5→10 六轮,每轮都真实、每轮读数都硬,但**边际收益在递减**,且有实测依据:
| 层 | 攻击成本 | 修法后剩余 |
|---|---|---|
| 5/6 | 一处编辑 | 同步删多处仍静默 |
| 7 | 写假体 | 锚点到表外 |
| 8 | 删表 | 新自检漏配因果对被②抓 |
| 9 | 桩化通道 | 通道恒 rc=1 被对照抓 |
| 10 | 读自己源码的分流通道 | **打一行假 `RED ` 仍过**(成本 +1 行) |
★ 三个结论(都实测过):
1. **这一串的价值是校准口径**("锚点必须落在被守对象管不着的地方"),
这个价值在层 7~9 已拿满 —— 层 10 只是在**重复同一课**。
2. **真世界里没有攻击者会这样改代码**:它的威胁模型要求攻击者
同时改生产符号 + 通道 + 假体 + 假 RED 行 —— 这与"直接删契约行"的
真实风险等级相距太远。
3. **留下的东西是可复用的**(§6.1.4 的判据 + §6.1.3 的"每加一条防线回答两问"),
**其余的建议停在这里**:不承认它封死,也不再往上爬。
⇒ **同意 pi:这一串到层 10 为止,不再往上找。** 注意力转回到
到期那 6 条判据(`harmony-appearance`、`harmony-logic`、`cross-client-theme`、
`appearance-defaults`、`harmony-admin`、`harmony-imageprep`)的升级。
### 6.1.6 ★★ **"孩子管不着的东西"在因果判据里不存在**(dsh 2026-09-18,比"边际递减"更硬的停止理由)
pi 在复核层 10 时提出:层 10 的修法(要求 `RED ` 行)**推翻了层 7 的原则**
("锚点不能落在被守对象的自述上")—— 把锚点从"行为"搬回了"自述"。
**这条我核对成立**(`:2674` 的原则与 `:2774` 的修法同住一个文件)。停止理由因此从
"成本 > 收益"升级为**"层 11 的修法必须推翻层 7 或层 10,两者互斥"**。
★ 但我把 pi 的**建议修法①也测了**,得到一个**比原则互斥更强**的结论:
**pi 的 ① 不是逃逸口。** 它主张"改坏**孩子读的外部依赖**(而非孩子自己的源码)
⇒ 通道没有可检的信号"。实测(`exitcode-selftest` + 假体 + 通道):
```
通道只盯外部 summary.py ⇒ 干净树 rc=0 ✓,改坏外部 ⇒ rc=1 ⇒ ① 有效
通道**同时**盯外部 summary.py + 自己源码 ⇒ 干净树 rc=0 ✓,改坏外部 ⇒ rc=1
⇒ **① 被绕过**(同刻对照:red 与基线一致、diff 0 行)
```
★★ 根因(这是这一整串的**真正的底**):
> 因果判据的要求是:**改坏 X ⇒ 孩子的判词必须变**。
> ⇒ 这要求 X **因果上游**于孩子的判词 ⇒ **孩子必须读 X**。
> ⇒ 孩子读得到 X ⇒ **孩子能观察 X 被改坏**(同 uid、可读,`summary.py` 是 `-rw-r--r--`)。
> ⇒ **"孩子管不着的东西"被因果判据自身的定义排除掉了。**
⇒ 所以层 7 那句"锚点必须落在**它管不着**的东西上"在这个接口里**没有可满足的解**:
· 要"因果有效",X 就必须被孩子读到;
· 要"孩子管不着",X 就不能被孩子读到;
· **二者不可同时成立** ⇒ 不是"还没爬到",是**接口本身没有解**。
★ 唯一出路是**换接口**(父进程自己算、或进程外观测)—— 那不是"再修一条判据"。
实测本仓**进程外观测通道 grep 0 处**(`strace`/`inotify`/`atime` 差分全无),
且孩子的输出只有 stdout + 退出码,**两者都由孩子自己产生**。
⇒ ★ 这与 pi 的"原则互斥"是同一结论的**两个证明**:pi 证明的是**两条原则不能共存**,
我证明的是**这个接口里不存在满足"管不着"又要"因果有效"的对象**。
**两者都指向"停",而后者不依赖"哪条原则优先"的判断。**
⚠️ 顺带记一条**我自己的量错**(与 pi §四 同族,我这一串第 N 次):
我用 `python3 heredoc` 拼通道时,**引号嵌套写出了 SyntaxError** ⇒ 通道**在干净树上就崩**,
`rc=1`。我差点把它读成"通道生效"。**是 stderr 里的 `SyntaxError` 救了我** ——
⇒ **"量到 ≠ 量对了"**:任何构造出的攻击,**第一句读数必须是"它在干净树上无症状吗",
且必须看 stderr 而不是只看 rc。**
★ 层级表(**每层都是一根轴**):写法(4) → 存在性(5,"它还在吗") → 行为(6,"它做了吗")
→ **自述(7,"它说的话可信吗")**。
## 6.5 涉及**运行时行为**的结论,必须实测过才能写进规范/判据(pi 2026-09-14)
同一个错在本仓犯了两次,方向相反但错法相同:**从观察推断机制、没跑**。
- 我从"`node --test test/run-all.mjs` 报 0 个测试、退出 0"这个**观察**,推断出
"runner 吞掉了 `process.exit(1)`" —— 实测下来退出码照传(见 §6 末),我错了;
- pi 拿我那个结论直接往下建了一个洞("配错 flag = 绿")—— 他也错了。
规则:**一次观察只支撑你看到的那一层**。"报告里 0 个测试"是真的,"退出码被吞"是推断的。
凡涉及运行时行为的结论(退出码、计数、回调时机、事件触发),
**先贴真实样本再写规则** —— 这也是 §1 的推论。
## 6.6 闭环:自报条数 + 每文件期望条数(只增不减)
文本证据只能到"**存在**"为止:`test(` / `check(` / `process.exit(1)` 的存在性
证明"有能红的路径",**不证明它跑过**。反例(现实事故形态:合并冲突把 `check` 的实现改空):
```js
const check = () => {}; // 实现被换空
check('a', false); // 存在、也执行了,但什么都不会红
console.log('主题:通过'); // 有输出
```
所以 `run-all.mjs` 现在做三件事:
1. 自定义 `check()` 的判据**自报条数**:结尾打一行
`RESULT pass=<条数> fail=<失败数>`(`node:test` 的判据不用改,已有 `# pass N`);
2. runner **只解析这个固定 marker**(不猜口语汇总 —— 「窄屏布局:全部通过」里没有数字,
靠猜数字会误报);
3. 与清单里登记的**期望条数**比对,**低于 → 红**。
棘轮是"只增不减":**加判据不用改那个数**;只有"条数掉了"才红 ——
那正是要看见的事(顺手删两条判据、某条被跳过、`check` 实现被改坏)。
代价:**故意删判据时要同步改数字**(这是一次显式的、能被复核的编辑,可以接受)。
### 条数登记校验**曾经只在"绿"的那条路上**(dsh 2026-09-19 设备在场时发现)
上面那条校验原来写在**最后一个 `else`** 里("退出码 0"那条路)。而 `r.status !== 0`
会**先**在 `else if` 里 `reds.push(...)` 并**跳过**它 ⇒ **文件越红,它的条数登记越没人守。**
**实测**(同刻、设备在场、一次全套):
```
test/narrow-layout.test.mjs: 登记=64 实际=88 exit=0 绿 ⇒ 校验生效(报了)
test/nav-merge.test.mjs: 登记=8 实际=9 exit=0 绿 ⇒ 校验生效(报了)
test/background.test.mjs: 登记=43 实际=44 exit=0 绿 ⇒ 校验生效(报了)
test/harmony-presets.test.mjs: 登记=5 实际=6 exit=0 绿 ⇒ 校验生效(报了)
test/harmony-admin.test.mjs: 登记=22 实际=27 exit=1 ★ 红 ⇒ 校验**走不到**(0 命中)
```
⇒ ★ 这是本仓那条母规则的又一例:**"判据在,但走不到"**,
而且方向是**假绿**:`harmony-admin` 那多出来的 5 条判据**不在"被删会红"的保护内**,
而它恰好是红的 ⇒ **只要它一直红,这个缺口就一直是隐形的**;
等它被修绿那天校验才第一次生效,那时多出来的几条**可能早被删了**。
(同族:`unlisted`/`blind`/`baseline=` 的结论到不了 `verdict`,§16.1。)
**修法**:把条数校验移进 `else { … }`(红绿都跑;先 push 退出码红,再判条数)。
⚠️ 但 **broken(崩了/一条条数都没自报)不在这里判** —— `diedWithoutReporting`
已经把它归入 broken,再叠一条"没找到自报条数"只是噪音:
**"判据没答 ≠ 判据答错了"**(§17)。
**修后实测**:`harmony-admin` 那一段现在报
`自报 27 条 > 清单里登记的 22 条 —— 新加的那几条不在"被删会红"的保护内`,`red` 10→11。
**变异**:把红的 `harmony-admin` 登记数改成 `99` ⇒ 报 `自报 27 条 < 登记的 99 条` ✓;
改成 `27`(对齐)⇒ **不报条数**、只剩"退出码 1" ✓。
#### 给这条修法加锚点时,**我第一版自己写成了空真**(dsh 自捉,如实记)
修完要加个锚点钉住"校验真被评估过",否则下一个人挪回去**什么都不会红**。
我第一版写的是:**"凡『条数不符』的文件都必须被记录过"** ——
**而这条修复本身就把 `harmony-admin` 的登记数对齐了 ⇒ `mismatched` 恒为空 ⇒ 断言恒真。**
变异测试当场抓到:把 `countCheckRan.add(file)` 挪回"只绿才走"的位置,
**5b 一声不响**(这时我才发现它不是"通过",是"没有对象")。
⇒ 改成比 **"所有自报了条数、且没崩的文件"**(`shouldBeRecorded`,红绿都含):
红文件一定在里面 ⇒ 非空,且"红文件被漏记"必被抓。
另加一条**反空转**:`shouldBeRecorded` 为空而并非全部 broken ⇒ 自检自己报失效。
**变异验证**(挪回"只绿才走"):5b 报出**6 个**红文件名
(`cross-client-theme`、`build-stamp`、`align-refs`、`harmony-push`、`criteria-hygiene`、`harmony-admin`),
基线**不报** ✓。
★ 两条可复用的:
1. **"我拿现有数据试一遍"要试到"数据非空"** —— `∀x∈∅` 的判据**看起来和真判据一样绿**,
而它连"有没有对象"都没问过(本仓的"空真"陷阱,这已是第 N 次,第一次发生在我自己的新判据上)。
2. **修好一件事会同时消灭它自己的测试对象**:我把登记数对齐 ⇒ "条数不符"这个集合就空了。
⇒ 锚点**不许建立在"当前的错误状态"上**,要建立在**恒在的集合**("谁自报了条数")上。
★ **通用规则**:
> **校验写在哪条分支上,决定它保护谁。**
> 凡"出错时要额外检查 X"的守卫,先问:**这条分支真红的时候,它还跑得到吗?**
> —— 把校验放在"一切正常"的那条路上,等于**只在没出事时守着**。
> 而给它加的锚点,**不许以"当前的错误状态"为对象**(那会被同一次修复清空)。
计数必须写在 **`check()` 内部**:写在调用点或靠扫源码,"实现被换空"就看不见了
—— 上面那个反例的 `pass` 会是 0,正是靠这一条才有分辨力。
> ⚠️ 本节的作者在写完之后**又踩了一次 §3 那条**:变异验证时用 `git checkout -- <文件>`
> 还原,把尚未提交的改动(刚加的 marker)一起抹掉了。**规矩写下来不等于会遵守** ——
> 所以 §3 已经把那条禁令改写成"**先固化基线、再破坏**"的操作顺序(pi 指出真因是
> "被还原到的状态还没提交",不是 `checkout` 这个命令有罪)。
### 共享 helper:`test/lib/checks.mjs`(新判据请用它)
```js
import { check, finish } from './lib/checks.mjs';
check('这条判据的名字', 条件, '失败时给人看的细节');
finish('标签'); // 打汇总 + `RESULT pass=N fail=M`,有失败则退出码 1
```
计数只可能发生在这个模块内部,所以用它以后:
**漏打 marker** 与 **计数写错位置**(导致 `check` 实现被改空也看不见)这两类
在新判据文件上**不可能发生** —— 不用再靠记性。
`markdown-xss` / `narrow-layout` 已改用它;其余 10 条保持原写法(`run-all` 的 marker 检查已覆盖)。
### 失败信息要**自带修法**(pi 2026-09-14)
受众不只是读过这份规范的人:并发写 WebUI 的 agent 新加判据时**不会打开这份文件**,
看到套件红的第一反应很可能是"套件坏了" → 删自检或往清单里塞豁免。
所以 `run-all.mjs` 现在在"没找到自报条数""条数掉了"这两条错误里直接写出
**可照抄的修法**与样板文件路径。
> 原则:**red 是那个人一定会看到的东西,文档不一定被打开** —— 错误信息是最省成本的交接面。
> (验证方式:真删掉一条判据的 marker 行跑一遍,确认错误信息能照抄执行。)
## 6.7 判据的作用对象是**值/行为**时,不要退化成对源码形状的匹配
(pi 2026-09-14 用反例钉死这条,起因是我写的一条"取键函数必须把账号拼进键"的判据连改三版。)
那一族判据的形状是:*去源码里找某个写法*。它**永远差一个反例** —— 我实测过的三版:
| 版本 | 判什么 | 用什么骗过去 |
| --- | --- | --- |
| 一 | 整段(含签名)里同时出现 `accountId` 与常量 | `return STORAGE_KEY_PREFIX;` —— 参数表里的 `accountId` 就满足了 |
| 二 | 函数体里两者**离得近** | `return accountId ? PREFIX : PREFIX;` —— 提了一下没用 |
| 三 | 函数体里**同一个表达式**既含常量又拼接账号 | `const k = PREFIX + accountId; return PREFIX;` —— **拼了但没返回**(这一版我实测仍然全绿) |
三版都在判"源码里有没有那个形状",而缺陷是"**算出来的值对不对**"。闭合的形状是**行为判据**:
```ts
strictEqual(storageKey('a') !== storageKey('b'), true);
strictEqual(storageKey('a').includes('a'), true);
```
这两行对"拼了没用上""拼了又丢掉""换个名字的退化"都红,且不误伤合法重构;
正则那条的长期效果只剩"将来一次无害重构给你一个假红"。**所以:能跑的值就用跑的,
正则降级成顺带看一眼,或者直接删。** 删的时候把"为什么删"和反例写在原地,
否则下一个人会好心把它加回来。
**这条规则的适用边界(别过度推广)**:
- 判"**代码里有没有这个调用/这个来源**"(例如"写缓存只许用 `storageKey()`,不许写死键名")
—— 这是**来源**约束,不是值对不对,正则在这里是合适工具;
- 对象**跑不起来**时(`.ets` 在本机没有运行时:编译要 hvigorw、运行要设备,
而设备在这条链上不可用,见计划文档 §7.21、以及本文件 §6.8 的到期机制),
静态匹配是唯一可用的手段 ——
但要把"这只证明形状、不证明值"写进判据的说明里,别让它冒充行为验证;
- 与"自报条数 < 登记条数"同族:**静默放行**是这类判据最危险的失败方式。
#### 6.7.0 可机检的分流规则:**值 → 行为,来源 → 静态**(pi 2026-09-14 要求写硬)
上面那段说清了"结论",但没给下一个人一条**照着走就行**的规则 —— 于是仍然靠感觉。
按对象分流,只有两类:
| 你要约束的东西 | 只能用哪种判据 | **为什么只能是它**(不是风格问题,是覆盖问题) |
| --- | --- | --- |
| **值算得对不对**(键拼错没有、数字对不对、"点两下真的切换了吗") | **行为判据** | 行为判据只覆盖它**跑到的路径**,所以它**原理上判不了**"有没有别的路绕过去" |
| **有没有别的路绕过去 / 值必须来自某处**(不许自己写死键名、命中区必须引用共享常量) | **静态判据** | 它要的是**全程序可达性**,只有读代码能回答"还有没有第二个写入口" |
两条推论,写成一句话记住:
- **静态判据判"值"永远差一个反例** —— §6.7 那张表里我三版各被穿透一次,就是这个;
- **行为判据判"来源"永远差一条路径** —— 你跑的那条路对了,不等于没有第二条路;
- 所以两者**不是强弱关系,是分工**。缺任一条,那一对判据就是**假判据**(看着有,其实漏一半)。
**照这条规则回看本仓已有的一对样例**(P5 底栏命中区,正好是"值 + 来源"的完整配对):
| 判据 | 类型 | 内容 |
| --- | --- | --- |
| `③ 命中区 ≥44vp` | **值**判据 | 从 `NavItems.ts` **导入**数值判(不是正则猜源码) |
| `③ 命中区的应用点` | **来源**判据 | `.ets` 里**不许自己写 `32`**,必须引用 `NAV_ITEM_MIN_HIT` |
这两条合起来才闭合:只判值 → 组件自己写 `32` 照样"值是对的";只判来源 → 常量被改成 `20` 也没人管。
(写新判据时先问一句"我这一对齐全吗",比事后补更省事。)
### 6.8 静态判据是**欠账**,必须能到期(`UNBLOCK` + `RESULT static=N`)
pi 2026-09-14 指出:规范里的"**暂时**"不是一种状态,是一个**待办** —— 没有任何机制会回来读它,
于是它永远留在原地。改写成能自己到期的形状(已实现,见 `test/run-all.mjs`):
1. 只能验形态的判据登记在 `run-all.mjs` 的 `STATIC_ONLY` 里,每条**必填到期前提**
(`UNBLOCK`),且前提必须是**可机检的**(如"设备可用"这种探针),不是一句陈述
(写成 `'vibes'` 这类未知前提 → 套件当场红);
2. 汇总打 `RESULT static=N` —— 这是**欠账余额**,涨了要看得见;
3. **前提一旦为真,这些判据自动变红**(实测:把探针改成恒真 → 五条登记判据同时报"到期")。
这就是"暂时"的到期机制:设备可用的那天,欠账当场显形,不等人想起来。
它是"自报 0 条 < 登记条数"的**时间版本**:那条管"判据还在不在",这条管"它该升级了没有"。
### 6.7.1 附:**探测器**与**门**不是一回事
同一次讨论(pi 2026-09-14 §2)里还有一条:`dist` 比 `src` 新**只说明"src 改了而产物没跟上"**,
不说明"产物是从当前 src 构建的"。实测:构建失败但**已经碰过** `dist/index.html` 时,
那条判据**是绿的**(我跑过这个变异,它抓不到)。真正的门是**喂退出码**:
`set -euo pipefail` / 不接管道 / 看 `PIPESTATUS`。两者不互替 ——
门负责"失败就别产出",探测器负责"产出跟上了没有"。
本仓的门是 `scripts/release-linux.sh`(构建失败即停,判据注入失败构建验过),
探测器是 `test/build-stamp.test.mjs`。
## 7. 判据要钉用户真正会点的那一层
(移交信里交代的头号纪律)判据通过了但用户点不到,等于没做。
所以断言尽量落在"用户会触发的那个入口/那条路径"上:
例如"点同意/拒绝后列表要变"要钉到那条链路上,而不是钉"某函数存在"。
## 8. 在**共享工作区**上做归因:别动别人的未提交改动
(pi 2026-09-14 §4 指出,我当天正是这么干的。)
事情经过:`narrow-layout` 出现 3 条红,我用 `git stash push -- src/components/MailView.tsx`
把**并发写作者的未提交改动**暂时收起来,看红是否跟着消失,再 `stash pop` 放回去。
结论是对的,**方法不行**:
- `git stash` 会**写**共享工作区 —— 那不是我自己的文件。即便随后 `pop` 回来,
这中间任何一次崩溃、冲突、或对方恰好跑一次 `git add -A`,丢的是**别人的工作**;
- 它还只影响 **tracked** 文件:对方的 untracked 新文件照样留在原地,
于是"stash 之后干净了"本身是**假的干净**,归因结论也可能是假的;
- 同一工作区里同时有别的写入者时,任何"我来把树弄干净一下"的动作都在赌别人的东西不丢。
**该用的手段**(都只读、不碰工作区):
```bash
git show HEAD:client/electron/src/components/MailView.tsx > /tmp/mv.head.tsx # 拿基线对比
git worktree add /tmp/attr HEAD # 或者在独立工作树里复现
```
或者直接问写那半代码的人 —— 共享工作区里"谁改的"根本不是能从树上读出来的信息
(这也是为什么 `BUILD_INFO.json` 要记 `gitRev`/`gitDirty`:产物自带来源,比事后猜强)。
**一句话**:归因手段的选择标准是"**会不会让别人的东西处于危险里**",不是"哪条命令最快"。
## 8.1 共享树上不要用 `git stash` / `--autostash`(同一台机器,自动化了而已)
`git stash` 与 `git rebase --autostash` 是同一族:**把工作树里全部未提交改动收走,稍后再放回**。
在共享树上这意味着**别人未提交的改动**也被收走了 —— 冲突或中断时它们可能**留在 stash 里**
而不是回到工作树,而 ta 那边只会看到"我的改动不见了"(pi 2026-09-14 指出)。
纪律(照 §8 那条"stash 只影响 tracked 文件 ⇒ 树干净是**假干净**"升级):
1. **优先 `git worktree add`**:在一棵干净的工作树里做 rebase/改写,碰不到任何人;
2. 必须原地操作时:**先与并发写者约定窗口**("我会 rebase,请先停手"),或明确告知;
3. 事后**核对别人的文件还在**(`git status` 里该有的 WIP 还在原处),别只看自己的改动回来了。
## 9. 已知限制(登记处)
判据的**能力边界**必须写在这里,否则它的长期结局只有两个:被当成"它就是全能的"而误用,
或者被反复问"为什么它读不懂"。
**9.1 标识符只解析一层**(pi 2026-09-14;实现见 `appearance-defaults.test.mjs`)
需要比对"某个常量两边是否一致"时,判据会跟一层标识符(例如 `const R = TOKENS.radius`)。
**再深就不跟了**:那不是"读一个常量",而是"执行 Go/TS",判据会直接报"读不懂"并要求人看一眼。
后果:如果常量经过两层以上转发,判据会红(而不是误判为不一致)—— 这是有意的取向:
**宁可报读不懂,也不猜**。要覆盖两层,正解是把值写进一个显式的契约文件,而不是加强解析。
**9.2 静态判据的到期前提是"本工作区能装能点设备"**
5 条鸿蒙侧的判据只能验形态(`RESULT static=5`)。前提一旦成立,套件**自动变红**
(探针三值:可用 / 不可用 / 拿不准→红)。到期报文里会列出需要放行的目录(在工作区外)。
## 10. 缺省语义登记处(不是"默认值",是"缺了意味着什么 + 谁批准")
pi 2026-09-14 提的形状:**不是无条件 fail-closed** —— 缺字段可以是"宽"也可以是"窄",
**不可接受的只有"缺了却没人知道它意味着什么"**。所以这里登记的是缺省**语义与方向**。
理由要带上:反例来自 HomeAgent 内核自己的 `PluginManifest.Capabilities`,
它的注释写明"省略或为空 = **不受限**",因为 17 个存量清单都没有这个字段,
若把空声明当最小权限,它们会全部静默失去 IO 注入与记忆读写。
| **未知的预设 id**(`layersFor`/`normalizePreset`) | **静默替换为 `aurora`** | **宽(静默)** | **待批准**(不是"无人类批准"就收尾):这是实现时的默认行为(`model/Wallpaper.ts` 随该文件引入,`git log --diff-filter=A` 可查出处),本行是补登记。**已进欠账余额**:`docs/DEBTS.json` 的 `unknown-preset-approval` —— 到期前提是"有人追认或驳回『未知 id 显示 aurora 而不是空白』这个方向",**我作为实现者不能自己追认自己**(pi 2026-09-14:登记要落到"谁负责"或"什么时候还",不能停在"我们知道")。意图(写在这里,不冒充批准):背景不该因未知 id 变成空白。**这是"静默给别人的档",不是"给一个安全的空值"** —— 用户不会看到错误,但会看到**别人的档**;替换发生时会留下痕迹(`BackgroundPlan.presetSubstitutedFrom`,页面据此打日志,判据见 `harmony-presets.test.mjs`) |
| 只写不读的字段:鸿蒙 `bgBlur` | **写进去、存下来、同步它、映射函数也写好了,但没有任何消费点**(映射本身**已判**) | 无(当前无效果) | 出处:`model/Appearance.ts:39,52,88,104,133`(clamp + 合并)+`common/AppearanceStore.ets:142-195`(同步);**消费侧 0 处**。⇒ **这里的结论更正过一次**:我原先写"不存在映射表 ⇒ 钉映射判据是假判据",**那是错的** —— 映射表 `blurStyleFor(bgBlur)` **早就在 `model/Appearance.ts` 里**(文件注释还写着「判据可以直接跑它」),只是**没有任何调用点**。发现方式值得记下来:pi 要求把豁免从"按文件"改成"**文件 + 次数**",逐处核对出现次数时它才被翻出来。**现在的登记**:映射表存在且**已有行为判据**(分档边界 0/8/20、单调性、NaN 不许落到最厚档);"缺的是调用点"这件事由消费侧计数判据管(登记值 0),两个问题分开判 |
| 只写不读的字段:WebUI `LEGACY_BACKUP_KEY` | 删旧值前**另存一份**,没有任何代码读它 | 无(有意为之) | `backgroundStore.ts:200` 的注释写明目的(否则它会变成新的继承源)。**登记在此是为了审计能一次找全** |
| 邮件的 `permission_mode` | **不写、不改档**(不是写默认档) | 窄(fail-closed) | 本仓 2026-09-14:缺字段被 `\|\| 'workspace'` 兜成窄档,等于"一个 bug 以正常形态活着";守卫见 `plugins/*/lib/permission-mode.js` 的 `modeForStateWrite` |
| HomeAgent `plugin.json` 的 `sdk` | **内核不读**(字段只对人有效) | 无(不是语义,是文档) | 内核 `internal/plugin/manifest.go` 的结构体里没有该字段;`registry.go` 的 `loadOne` 只用 `NameZh/NameEn` |
| HomeAgent `plugin.json` 的 `capabilities` | **不受限** | 宽(fail-open) | 内核注释明写理由:17 个存量清单都没有它,空声明当最小权限会让它们**静默降级** |
## 11. 相位:门不许挂在它判不了的相位上
**规则(可机检)**:**部署门禁只允许读"待安装的产物 + 目标机状态",不许依赖"源码树是最新构建的"这个前提。**
一条门如果在某个相位**恒红或恒绿**,那不是门的问题,是**相位挂错了**。
两个相位问的是不同的问题:
| 相位 | 问的问题 | 判据 | 判法 |
| --- | --- | --- | --- |
| 构建 / 发布(`npm test`、`--release`) | 我要产出的东西,是不是从**当前源码**新鲜构建的? | `packaging`、`build-stamp`、`releaseCandidate` | **重新计算**(比对 dist/产物与源码树) |
| 安装(`deploy/install.sh`) | **别人已经产出的**这个东西,能不能装到这台机器上? | 产物自证(`gitRev`/`releaseCandidate`) | **读它自己说的**(不重算) |
实现:`run-all.mjs` 用 `AGENTMAIL_CRITERIA_PHASE=install` 切相位;每条判据在 `SIDES` 里登记它读的
哪一侧,`install` 相位里出现 `SOURCE` 侧的判据**直接红**;被跳过的判据会在汇总里**点名打印**
(不静默丢)。
三个真实实例(同一天撞到,都不是巧合):`check-shared-libs`(从 `153985e` 起在部署路径恒红)、
`packaging`(前端一改就卡死 —— 部署路径不重新打包)、`HOME: unbound variable`
(在**所有门禁跑完之后**才炸:最贵的位置)。
## 12. 「与 X 一致」的前置条件:X 侧那个值自己得有判据钉住
**规则**:凡判据说"本端的值与 X 端的值一致",先确认**X 端那个值在 X 端被判据钉着**。
否则它测的是"**我抄的那一份**",而不是"两边一致":X 端改一下,两边悄悄分叉,
而没有任何东西会红 —— 判据会一直绿着,因为它比较的是自己的副本。
这与"两张表各缺一半时必须按 id 联接,不能按相邻关系配对"是同一族:
**比较的对象必须是权威来源,不是手边可得的那个副本。**
真实例子(2026-09-14):鸿蒙 P6 的手势阈值(水平 ≥40px、≥1.5× 垂直、<600ms)
在 `client/electron/src/components/CalendarView.tsx:204` 是**裸字面量**,
没有任何判据引用它 ⇒ 当时写"与 WebUI 一致"是把**当下的巧合**当契约。
处理:先标"待两边对齐",再让两边各自钉住数值本身。
### 10.1 两类"看起来被接住了、实际没有落点"的东西必须登记在**同一处**
上面表里最后两行是同一类形状:**值被搬运/保存,但没有落点**(一个是鸿蒙侧的 `bgBlur`,
一个是 WebUI 侧的 `LEGACY_BACKUP_KEY`)。分居两处的话,将来审计容易**只找到一处就以为找全了**
(pi 2026-09-14 指出)。所以两类都登记在这张表里:
1. **缺省语义**:缺了意味着什么、方向是宽还是窄、谁批准;
2. **只写不读**:谁在写、为什么还留着、什么条件下必须补判据。
**判据形态(想做但还没做,写在这里避免当成已完成)**:凡登记为"只写不读"的字段,
写侧必须能指出"有意为之"的理由与批准人 —— 这样"搬运了但没人读"就不会以**静默**形态长期存在。
现在这一条还只是**文档级**约束(人工核对),没有机器判据去强制它;
要变成"做错会红",得先有一张字段清册可扫。
### 10.2 版本偏移下的可见后果:旧客户端遇到"新档"会静默显示 aurora
跨端耦合的常态是**两端不同时上线**:服务端/WebUI 先加了第 7 档,而鸿蒙还是旧构建 ——
旧鸿蒙的 `layersFor` 认不出新 id,按上面的缺省语义**静默显示 aurora**,
于是"用户以为自己选的是新档"。**这不是 bug,是这条缺省决策的可见后果**(已按 pi 2026-09-14
的建议同时记进 `docs/HARMONY-ALIGN-PLAN.md` 的差异表)—— 不记,将来会被当 bug 报,
而查到最后发现"这是我们批准的"。
## 13. 注释里可以放**指针**,不要放**断言**(尤其是关于另一端实现状态的断言)
**规则**:不要在 A 端的源码注释里断言 **B 端**的实现状态。写"B 端不消费这个字段"这类句子,
就是把**别处的观测**当成了**本机的契约** —— 它会在 B 端变化的那天变成**假事实**,
而且没有人会回来改(写的人不在那条链上,读的人无法判断它是否还成立)。
**该放在哪**:
- **断言留在"事实所属的那一端"**:例如"鸿蒙不消费 `bg_blur`"是**鸿蒙侧**的事实,
所以它写在鸿蒙侧(`model/Appearance.ts` / `AppearanceStore.ets` 一带)+登记进 §10;
- 另一侧如果要留痕,只放**指针**:例如"该字段的消费方状态由 <那一端的登记处> 说明"。
指针不会过期(它指向的地方会自己更新),断言会。
**同族**:这条与"两张表各缺一半时必须按 id 联接,不能按相邻关系配对"、
"'与 X 一致'必须先确认 X 侧有判据钉住"是同一族 —— **比较/引用的一端必须是权威来源,
不是手边那份可能过期的副本。**
## 14. 登记/清册类判据的报错,要同时写"正确修法"和"**最常见的错误修法**"
**规则**(pi 2026-09-14 提出):凡是"登记 / 清册 / 次数"类的判据,红的时候读的人**第一反应
通常是改那个数字**。所以报错信息必须把两件事都写上:
1. **正确修法**(该去补什么);
2. **最常见的错误修法**(别做什么,以及为什么那等于把判据废掉)。
例(`harmony-appearance.test.mjs` 的 `bgBlur` 那条):
> 停下:那时**必须**先补"px ↔ 材质档位"的映射判据(§10 / 计划文档 §7.12),
> **而不是把登记值从 0 改成 1**。
这与"清册条目的『允许』要给出一条能判的约束"(§11 族)是同一条纪律的两半:
前一半保证**判据有分辨力**,这一半保证**分辨力不会被"改数字"抹掉**。
## 15. 判据必须**自足**:不许通过共享可变状态在判据之间传递结论
**规则**:一条判据的结论必须由它自己测量出来。**判据之间不许共享可变状态**
(包级变量、临时文件、上一条判据留下的标记)。一旦共享,结果就取决于**执行顺序**,
而顺序依赖的显形方式永远是**假绿**(不是假红 —— 假红会被看见)。
真实例子:`debts` 的余额第一版由"SKIP 那一支"写入、由登记判据去读。
Go 在同一包内**按源文件顺序**跑测试 ⇒ 登记那条先跑就读到 `0` ⇒ 全绿而余额是错的。
修法:把测量抽成自足函数(`measureMailStatusDebt(t)`),**谁调用谁测**。
## 16. "要提醒人的"输出必须走**默认路径**(不能只活在 `-v` 后面)
**规则**:余额 / 自报 / 提醒类信息必须出现在**默认命令**的输出里;只有**诊断细节**
才可以藏在 `-v` 后面。
真实例子:我把欠账余额打在 `TestMain` 收尾 —— 而 `go test` 对**通过**的包
**不打印包的输出**(只在失败或 `-v` 时打),于是常态运行里一个字都看不见:
"可打印的余额"变成了"看不见的余额",与不显形等价。
修法:可见的那份挪进 electron 套件的 `RESULT` 行(默认路径),Go 侧只做同源比对。
**配套动作(pi 2026-09-14)**:写完一处"要提醒人的输出"之后,
**去看一眼默认路径的输出** —— 不是看代码,是看它实际打出来的样子。
### 16.1 ★★ 光进默认路径不够:**结论必须到得了"决定颜色的那一格"**(dsh + pi 2026-09-18)
§16 管的是"**打印**有没有走默认路径"。这一条管的是它**下一跳**:输出打了,
但**决定套件颜色的那一格没读它** ⇒ 等于没打。**同一条缝在本仓已出现三次**,
每次位置都不同,所以必须分开记 —— 否则下一个人只会修好其中一例。
**缝的形状**(三次都一样):
> `summary.py` 把结论说对了、退出码也说对了,**但没有一条通道把它送到
> "决定 `verdict` 的那一格"** ⇒ 读者看到的是一个**看着完全正常**的读数。
**三个落点**:
| # | 落点 | 现象 | 修法 |
|---|---|---|---|
| 1 | **正则匹配就走不到 `status`** | 盲读时 `summary.py` 照样打 `RESULT mutants=0 …`(匹配)⇒ `status=2` 从没被读;`sp.stdout` 全文件只一处引用 ⇒ 那三行 `✗✗` 一个字都到不了读者 | `summary.py` 盲读改打 **`NO-READING`**(不匹配)+ run-all **先判 `status`** |
| 2 | **部分可读**(`chmod 000` 单个 job) | `blind` 为假、数字是真算的 ⇒ 那行**匹配正则且看着正常** ⇒ 修法①**对它无效** | 只有**先判 `status`** 能兜住 ⇒ 两层修法**各治一例**,不是叠保险 |
| 3 | **`unlisted`/`ghosts` 退 0** | 警告行**已算出来**,却被 `whyLines: status !== 0 ? whyLines : []` **丢掉** ⇒ 磁盘 13 个 job、套件报 48、"未列入清单"在套件输出 grep **0** 次 | 退出码按**性质**分:`unlisted`/`ghosts`⇒**1**(清单该改=数据);`blind`/`unreadable`⇒**2**(权限=环境);并放宽转印为 `(note \|\| status !== 0)` |
★ **第 3 例的正反两面**(这是它比前两例尖的地方):
`unlisted`/`ghosts` **不是**环境问题,是**清单该改**。按 `env-defaults.sh:25`
"失败要说清是环境问题…不要让它冒充代码缺陷" —— **反向也成立**:
**别让"清单没跟上"冒充环境**。所以第 3 例归 **1** 不归 **2**。
★★ **为什么会连着踩三次**:这三例的**上游判别都做对了**
(`unlisted` 算出来了、`blind` 判出来了、`unreadable` 分出来了),
错的全在**最后一跳到 `verdict`**。⇒ 通用规则:
> **每加一条"发现问题就报告"的逻辑,都要问一句:
> 它的结论**最终喂给了哪一个决定颜色的变量**?**
> 只 `print` 不算到达;要落到 `reds`/`brokens`/`dueFailed`/`selfCheckFailed` 之一,
> 或者让退出码变成下一层会读的那个值。
★ 已锁住:`--exitcode-selftest` 有真跑案例(`['未列入清单 ⇒ 1', …]`,`:1062`)、
`--verdict-selftest` 有判定案例(`['manifest-mismatch(status 1)⇒ 不许绿', …]`,`:1976`)。
**变异验证**(dsh):把 `unlisted or ghosts` 从 `return 1` 改回 `return 0`
⇒ `--only-selftest=exitcode-selftest` 立刻 **rc=1、2 条可归因红**
(`未列入清单 ⇒ 1:rc=0(期望 rc=1 … UPSTREAM_RC[manifest-mismatch]=1 与真跑出来的 rc=0 不符)`)。
⇒ 这条缝**有判据守着**,不靠下一个人再读一遍源码。
**端到端 A/B**(dsh,干净 worktree,同刻对照):
| | A:12 文件(清单一致) | B:13 文件(1 个未列入清单) |
|---|---|---|
| `diag=` | `baseline-stale` | **`manifest-mismatch`** |
| "未列入清单" 在套件输出 | grep **0** | grep **1** |
| `(summary.py)manifest-mismatch` 红 | **0** | **1** |
### 16.2 ★★ **同一优先级写了两遍 ⇒ 两个顺序**:不变式只在**对角线**上被验过(pi + dsh 2026-09-18)
§16.1 讲"结论到不了决定颜色的那一格"。这一节是它的**镜像**:
结论**两个都到了**,但**互相矛盾** —— 报出来的"原因"与"严重度"说的不是同一件事。
**缝的形状**:`summary.py` 里同一个文件有**两份**优先级表,且**顺序相反**:
```
diag(原选择处):unlisted/ghosts 排第一 ⇒ 组合态报 manifest-mismatch
rc (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一 ⇒ 组合态退 2
```
⇒ 两类**同时**成立时(`unlisted` + 单文件不可读 / + 跑不了 `sha256sum` / + git 答不了),
`diag=manifest-mismatch`(`UPSTREAM_RC` 表里 **1**)而**真 `rc=2`**
⇒ `run-all` 那条不变式 `UPSTREAM_RC[diag] === rc`(`:1173`)**在其上为假**。
**实测**(dsh 复现,裁 `PATH`、root 可达):修前 `diag=manifest-mismatch`、`rc=2`、表值 `1` ⇒ 不一致。
**修后**:`diag=baseline-unrunnable`、`rc=2` ✓ 一致。
★ **后果不是假绿**(`manifest-mismatch.blocksGreen=true` ⇒ 照样红、note 也转印),
而是**严重度被低估**:2 档的码被 1 档的码**盖住** ⇒ 读者以为"只要改清单",
而真相是"**连数都没读成**"。**rc 通道从此不可信。**
★★ **为什么 12 个案例一个都没抓到**(这才是本节的要点):
> 那 12 个案例**个个只动一维**(`unlisted` / `ghosts` / `blind` / `unreadable` / 各 `baseline-*`)
> ⇒ 不变式只在**对角线**上被验过,**组合(off-diagonal)无人可达**。
**修法(两件,缺一不可)**:
1. **一条链推两个结果** —— 严重度**只有一个权威来源**:`summary.py` 里按同一顺序算出
`(diag, rc_want)` **一对**,`RESULT` 行用它、`sys.exit` 也用它 ⇒ **排序不可能再漂移**。
★ 为什么**不是**"把两条链的顺序改成一致":那还是**两份**表,
下次加一个条件两处又会各自漂移 —— 正是本仓反复消的"**一份事实两处实现**"。
2. **显式走一遍 off-diagonal**(新组合案例:`unlisted` + 跑不了 `sha256sum`
⇒ 期望 `diag=baseline-unrunnable` 且 `rc=2`,**2 档优先**)。
⚠️ 这条**不能**只靠"每跑必断不变式"代替:组合态下若 `diag` 又被低档码占住,
那条断言就**永远验不到 2 这一档**(它只会验到手边那个码的期望值)。
**变异验证**(dsh):把单链的 2 档与 1 档**换序**(= 复现修前的两条链)
⇒ `--only-selftest=exitcode-selftest` **rc=1、^RED=2**,其中:
`组合:未列入清单 + 跑不了 sha256sum ⇒ … rc=1(期望 rc=2 且输出含 "diag=baseline-unrunnable"(真打出 diag=manifest-mismatch,UPSTREAM_RC=1 ✓))`
⇒ 排序本身被锁住;且它还**连带**抓出 `盲读` 那条(换序后盲读也走 `manifest-mismatch`)。
★ **通用规则**(与 §16.1 那条并列):
> **凡"多条判定链各自挑一个代表"的地方,都要问:
> 它们挑的是不是同一个?**
> 只测**单维**(每次只动一个条件)永远证明不了这件事 ——
> **对角线上的绿,对组合态没有发言权。**
### 16.3 ★★ **"没人读的字段"与"给它评分"之间,还有第三条路:给它一个读者**(pi + dsh 2026-09-18)
`STATIC_ONLY` 的**第 2 列**「当初只能静态的原因」**没有任何判据在读** ——
两个解构循环都用 `,` 把它丢掉(`:1295`/`:1306`),唯一会读它的场合是**到期点名时打印**,
而那正是它**最不需要被检验**的时刻。
**实测**(dsh 同刻 A/B):把这条理由改成一句假话 ⇒ `red` 与红清单**零变化**。
**动到期闸的第二条**(pi 同封报的,我复现):`static=` 与"到期"是**两个量** ——
`static=${STATIC_ONLY.length}`(余额,永远 6)与 `dueStatic`(会变 0)分开,
而**只有后者**决定"到期"这件事发不发生。
**实测**(把 6 条探针全改指恒 `false`):**到期点名 1 → 0**,而 `static=6` **一字不变**。
★ 这条口子**是闸自己邀请的**:它的报文选 (b) 写着"并改换一个更准的到期前提"
⇒ 换探针是**被鼓励的动作**,而"新探针是否真的适用于这条判据"**没有任何判据在问**。
#### ★★★ 我在这一节里连错两次,两次都是被**变异**抓出来的
| 我的修法 | 变异 | 结果 |
|---|---|---|
| ① 给它加规则:理由必须点到探针的某个标识符 | 拿**现有 6 条真理由**跑 | **红了 5 条** ⇒ **假红是噪音**,比不设判据更糟 |
| ② 让自检断言 `staticDetail` **变量**内容完整 | 只删掉那句 `console.log` | **4b 照样报 ok**,而真实输出里播报段 **0 次** |
| ③ 断言源码里有 `${staticDetail}` 这个形状 | 同上 | **仍报 ok** —— 因为**锚点写在这段自检自己的注释里**,`includes` 命中的是注释 |
| ④ 剥注释后再数 `+ staticDetail);` 的命中 | 删 print | **4b 红 ✓**("可执行代码里没有任何一处把 `staticDetail` 拼进输出") |
| ⑤ **同一处改成"记录那次调用真的送了什么"(4c)** | 删 print | **4c 红 ✓** |
| ⑤ | **把 print 包进恒假条件**(`if (false)` / `while (false)` / `false ? … : 0`) | **4c 红 ✓,而 4b 仍报 ok**(三种写法都实测过) |
#### ★★ 4b 的漏格:它扫的是**文本**,而"那一跳"是**一次调用**(pi 报,dsh 2026-09-19 复现并修)
④ 只解决了"print 被删"。但 4b 的证据是**源码里存在那个锚文本** —— 于是**锚文本还在、
而那次调用根本不发生**时,它照样报 ok。**pi 报的正是这一格**,我完整跑复现:
```
baseline : 播报段出现 1 次 4b = ok
变异体 : 播报段出现 0 次 4b = ok ← 红清单与 baseline **逐条相同**
```
根因是**自检先于打印**:4b 在 `:1613` 跑(`staticDetail` 定义之后、打印之前),
打印在 `:3129` ⇒ 它**物理上到不了**那次调用,只能读文本。
⇒ **"验了那个形状"与"验了那件事发生"仍然是两件事**(§16.1 那条缝的又一格)。
**修法(4c)**:不再猜源码,改成**记录这次调用真的往 `console.log` 送了什么** ——
锚点落在「**实际发生的那次调用**」上,这是源码文本够不着的东西:
```js
const staticBroadcastSeen = [];
{ const realLog = console.log;
console.log = (...a) => { staticBroadcastSeen.push(a.join(' ')); realLog(...a); };
console.log(`…` + staticDetail);
console.log = realLog; }
if (!staticBroadcastSeen.some(t => STATIC_ONLY.every(([f, why]) => t.includes(f) && t.includes(why))))
reds.push('(自检)4c:…没有真的进 console.log…');
```
实测(完整跑,每次一个变异体):
| 变异 | 播报段 | 4b | 4c |
|---|---|---|---|
| 无(对照) | 1 | ok | 不红 ✓(红清单与改前**逐条相同**) |
| `if (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
| `while (false)` 包裹 | 0 | **仍 ok** | **红 ✓** |
| `false ? … : 0` | 0 | 红 | **红 ✓** |
| 删掉 print | 0 | **红 ✓** | **红 ✓**(不回归) |
★ 两条可复用教训:
1. **"文本里有" ≠ "那次调用发生了"** —— 只要判据读源码,`if (false)` 就是一个
**既保留文本、又取消行为**的通用逃逸。凡"必须真的发生"的那一跳,锚点要落在
**运行时观测**上,不能落在源码形状上。
2. **自检的位置本身就是判据的一部分**:4b 读的那个值定义在它之前、而**使用**在它之后
⇒ 它对"使用"这一跳永远只能读文本。**"自检跑在它守的那件事之前"是一类结构性盲区**,
不是这一条的偶然。
⚠️ 4c **不能**做成 `SELFTESTS` 的一条:快入口 `--only-selftest=<名>` 在
`:2503` 就 `process.exit` 了,那条路径**永远到不了** `:3129` 的打印 ⇒
它只会"看不见"而**不会红**(把它挂在那里等于又造一个 4b)。
★ 三条教训(都可复用):
1. **"让字段可证伪" ≠ "给它加一条会红的规则"** ——
先拿**现有数据**试一遍;**红了就说明规则错了,不是数据错了**。
2. **变量对 ≠ 打出去了** —— 自检守的若是"它读的那个值",
那么"那个值怎么来的"仍然没人守(§16.1 的同一条缝,换了个位置)。
3. **锚点自匹配**(自检 4 早就踩过、我这次又踩):
把要找的形状**写进注释**,`includes` 就会命中注释 ⇒
**判据空转报 ok**。修法:**剥掉注释再扫** + 在运行时拼锚点。
#### 落地
- `static=` 那一格改成 **`static=6(其中已到期 6 条)`** ⇒ "关闸"这件事**读得出来**
(Y 变 0 而 X 仍是 6 ⇒ 一眼看得出)。
- **每次运行都全表播报** `STATIC_ONLY`(文件 + 原因 + 是否到期**并排**)⇒ 第 2 列有了读者。
- **自检 4b** 钉住"那一跳真的存在"(剥注释后数锚点 + 内容完整 + 反空转长度)。
- **自检 4c** 钉住"**那一跳真的发生了**"(运行时记录 `console.log` 的实际参数)——
4b 管"源码里有",4c 管"调用发生了";**两条合起来才等价于"到达"**。
★ **通用规则**(本节定稿):
> **一个没人读的字段,不许靠"给它加判据"来救 —— 先问它该被谁读。**
> 若它本就该被人看见,**给它一个读者**(并钉住"这一跳存在");
> 若它不该被任何人读,**删掉它** —— **余额里读不出来的东西等于不存在**。
> 只有在"这个字段本来就有真假"时,才轮得到加判据 —— 而且**必须先拿现有数据验一遍**。
### 16.4 ★★ **注释说是从某处读的,实际一个字节没读** —— 硬编码冒充"读源"(dsh 2026-09-19)
§16.3 把第 2 列变成每次运行都可见之后,我做的第一件事就是**去读它** —— 于是读到一句假话,
顺着它又挖出本仓一条**同族**的缝。**这条缝的形状**:
```js
/** 服务端壁纸上限(`server/internal/handler/appearance.go` 的 `appearanceMaxBytes()` 默认值) */
const SERVER_LIMIT = 4 << 20; // ← 注释说它来自 Go 源,而它一个字节的 Go 源都没读
```
**实测**(dsh,同刻 A/B):把 `appearance.go` 里 `appearanceMaxBytes()` 的默认值
`4 << 20` 改成 `8 << 20`(**真漂移**)⇒ `harmony-imageprep` **29 pass / 0 fail,一个字都没变**。
⇒ 这条判据存在的全部理由就是"客户端上限要留在服务端那道门之内(否则必然 413 / 白扔分辨率)",
而**服务端那道门真动了,它不会红**。
★ **它为什么危险**:注释让读者**以为**这里已经对齐了服务端 ——
**"我以为它在读源头"正是没人再去读源头的原因**。
这与 §16.3 那条母规则同一族:**一个字段/一个判据的可信度,来自它真的读了那个东西,
不来自它说自己读了。**
**修法**(三件):
1. **从真源头解析** —— ⚠️ **而"真源头"是哪一处,我第一版也读错了**,如实记下:
我最初去读 `appearance.go` 的 `return 4 << 20`,那是**兜底分支**
(`if config.C != nil && config.C.MaxAppearanceBytes > 0` 之后才轮到它)。
生产里 `config.C` 非 nil ⇒ **真正生效的值来自 `config.go`**:
`MaxAppearanceBytes: getEnvInt64("AGENTMAIL_MAX_APPEARANCE_BYTES", 4<<20)`。
⇒ **只读兜底分支的修法,在"有人动了 config 默认值"时会漏** —— 这是同一族缝的下一层:
**"读了源"还不够,还得问"读的是不是生效的那一处"。**
现在**两处都读,并要求它们相等**(不相等 ⇒ config 未注入时会走另一个门)。
2. **解析失败必须红**,**不许静默回退到硬编码** —— 回退等于把"我读不到"变成"值是对的"
(这几轮反复消的那条缝)。
3. **加一条判据钉住"真的读出来了"**:两处都无 `err`、`SERVER_LIMIT === LIMIT_CONFIG.value`、
**`SERVER_LIMIT === LIMIT_FALLBACK.value`**、且 `> 0`(防解析出 `0` 让"小于上限"变成恒真)。
**变异验证**(dsh,每个变异只动一处、其余不变):
| 变异 | 期望 | 实测 |
|---|---|---|
| `config.go` env 默认值 `4<<20` → `8<<20`(**生产生效那一处**) | 红 | `fail=2`(第 9 条"两处都读到" + 第 11 条余量)✓ |
| `appearance.go` 兜底 `4<<20` → `8<<20`(**两处不一致**) | 红 | `fail=1`(恰为第 9 条"两处相等")✓ |
| `config.go` 那一行整个删掉(**读不到**) | 红,且**不许静默** | `fail=2`(含"读不出生效上限")✓ |
| 基线 | 全绿 | `30 pass / 0 fail` ✓ |
★ 注意 B 只红第 9 条、**不红第 11 条**:因为生效值取 config 那处、而它没变 ——
**这是对的**(余量断言针对生效值),也说明"两处相等"那条**必须单独存在**,
否则"兜底分支漂了"会完全无声。
⚠️ **本节的标签范围**:它证明的是"客户端上限与**源码里那个默认值**对齐",
**不是**"与服务端运行时实际生效的上限对齐" —— 那个值是 `config.C.MaxAppearanceBytes` **可覆盖**的
(`appearanceMaxBytes()` 先看配置、配置没有才用它)。配置一旦调过,**静态读源码仍然对不上运行值**。
要覆盖后者得真起服务端读它的行为 —— 那属于"设备/服务端在场"的判据,本机判不了。
**别把这条读成"413 已经不可能发生”。**
★ **通用规则**(与 §16.1/§16.3 并列):
> **注释里写"这个值来自 X",不构成读 X。**
> 凡"某个数必须与别处一致"的判据,先问:**它真的读了别处吗,还是抄了一份?**
> 抄一份的判据**在别处改变时不会红** —— 而它的注释会让你以为它会。
## 17. 变异只证明「注入的样本被抓」,**不证明完备性**;判据的**标签必须等于断言范围**
**规则**(pi 2026-09-14):一条判据通过变异验证之后,只能说"我注进去的那一条会被抓"。
**样本不是完备性证明**。配套的第二半更重要:
> **标签比断言宽的判据,会在它没测的那条路上被回滚时给绿,而人信的是标签。**
真实例子(`background.test.mjs` 的两条反向断言):名字写着"导航不得变暗 / 不得自叠模糊",
断言体各只扫一条路。三条逃逸路各注一次变异**全部逃掉**(`.dark .nav-rail{}` 选择器作用域、
组件 `dark:` 变体、组件 `backdrop-blur-lg`),导航变黑而判据全绿。
**两种收法,选一个,别都做**:
1. **把标签缩回它真正断言的东西**(推荐:诚实度比覆盖面值钱),并把**未覆盖的路**
逐条写在判据旁边(各自另立判据时才补);
2. 把断言扩到标签的范围 —— 成本高,而且极易变成一刀切误红(先例:`bg-chrome-600`
plain 档徽标)。真要扩,**按文件窄豁免**写。