Files
MailUI4Agents/client/electron/test/CRITERIA.md
JianFeeeee 3175ee7267 修复: **函数体可以被掏空** —— 名字/登记/guard/契约行全在,里面不检查任何东西(层 6)
pi 2026-09-18 报的层 6。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:掏空是零痕迹的

把 `exitcodeSelfTest` 的**函数体**(14616 字节)换成 `{ return 0; }`
(名字、`SELFTESTS` 登记、CLI guard、`CRITERIA.md` 契约行**一个都不动**):

```
基线:   checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red
掏空:   checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red   ← 逐字相同
^RED:   0 条
```

**最尖的形式**:掏空 **+ 把 `UPSTREAM_RC['baseline-residue']` 改成 99**(= 它本该抓的那个)
⇒ `red=9`、**0 行提到 UPSTREAM**。
**对照(证明掏空是唯一原因)**:不掏空、只把值改成 99 ⇒ **`red=10`**,且真报出
`UPSTREAM_RC[baseline-residue]=99 与真跑出来的 rc=1 不符`。
⇒ 我们花三轮把 `UPSTREAM_RC` 锚到"真脚本真跑"上,**这个锚点可以被一次"清空函数体"无声撤掉**。

★ 根因:**层 5 及之前所有防线问的都是"它**在不在**",没有一条问"它**做了没有**"。**
层 5 修的是存在性,而存在性有**两种**失去方式:**名字没了**,和 **名字在、里面是空的**。
(★ 层 5 与层 6 是**两根轴**:层 5 问"还在吗",层 6 问"做了吗"。
它们共同的教训:**每根轴的"底"看起来都像整体的底** —— "到底了"只对当前那根轴成立。)

## 二、修法:给"判据在工作"一个**行为**下界

每跑完一条自检,记下它**真跑出来**的断言条数(`^(ok|RED) ` 行数),
与 `CRITERIA.md` 契约行里声明的**下界**比 ⇒ 掏空 ⇒ 条数掉到 0 ⇒ **红**。
契约项写成 `名字:下界`(棘轮语义:只增不减,掉下来才红)。

**实测条数(确定性:连测 3 次 + 两个相位都相同)**:probe=3 exitcode=13 skip=5 verdict=13 mutants-line=9。
**下界取略低于实测的余量**:`3 / 12 / 5 / 12 / 9`。

★ 下界写在 **`CRITERIA.md`**(外部文件、被 4 个文件引用、自己已被自检 3 守着),
不在 `SELFTESTS` 自己身上 —— 与 §6.1 同一条理由。

★ **为什么不用"恰好等于实测"**:相等的下界会让**任何**一条断言的小改动都变成
"必须同步改数字"(噪音),而留余量只拦"掉到明显不对"的那种(掏空 ⇒ 0、删一半 ⇒ 腰斩)。
诚实说:**这也意味着"改小下界"本身就是一条绕过路径**(见 §四)。

## 三、⚠️ 一个我实测出来的真实约束(不做区分就会在别的机器上假红)

判据**只在"该自检自称绿"时**才比下界。理由:`exitcodeSelfTest` 在**拿不到降权工具**
(`runuser`/`setpriv`)的机器上会 `continue` 掉两个案例、多打一条说明 ——
那是它**故意的**行为(前提构造不出来就报红,不许静默跳过),那种机器上条数本来就不同。
⇒ 若不管"红不红"都比下界,就会在无降权工具的机器上**假红**。
(掏空仍必被抓:掏空后 `bad=0` ⇒ 自称绿 ⇒ 条数 0 ⇒ 红。)

## 四、⚠️ 残留(如实登记):**"把下界改小"是一条绕过路径**

我实测:把五条下界都改成 `1`,再把 `exitcodeSelfTest` 掏空成只打一条假 `ok`
⇒ **`^RED ` 0 条**(下界判据不响)。
⇒ 这是"**数字可以被改小**"那个老形状在**新落点**上的复现 —— 我把下界放到表外,
但**下界本身仍是一个可编辑的数字**。
★ 它比原来**贵一点**(要同时改 `CRITERIA.md` 的数字 **和** 掏空函数体,是跨文件的两处编辑),
但**性质没变**。所以**我不声称层 6 封死了**,与层 5 一样。
真要把下界也锚到行为上,得让"该打多少条"由**真跑对照**决定(例如拿一份已知坏输入要求它红),
那是独立工作。★ pi 也提过"要求每条自检必须能被某个坏输入弄红"(更硬),
我没选它,因为它要为 5 条自检各造一份坏输入,而**"坏输入"自己又成了手写数据**(刚被证过的那族)。

## 五、我写这条判据时自己踩的坑(第 N 次同一形状)

第一版我把 `entries` 声明在 `if (contractMs.length === 1) { … }` 块**内部**,
然后在块外用 `typeof entries === 'undefined' ? [] : entries` 兜 ——
那**永远取到 `[]`** ⇒ 下界判据**静默不跑**。
★ **一个"以防万一"的兜底写法,把判据本身变成了空判据** ——
比不写更坏,因为它**看起来在**。已改成同作用域声明 + 不留兜底。
⇒ 教训与 §6.1 那句"存在性锚点"合起来是同一句:**判据不跑时,谁来喊?**

## 六、变异验证(都已还原)

| 变异 | 结果 |
|---|---|
| **掏空 `exitcodeSelfTest`** | **红**:`自检 --exitcode-selftest **自称绿,却只打出 0 条断言**(契约下界 12)⇒ 它可能被**掏空**了`,`red=9→10` |
| **掏空 + `UPSTREAM_RC` 改 99** | **红**(同上;修复前 0 条) |
| 契约行整体删掉 | **红**(`契约行有 0 条,要求恰好 1 条`) |
| 下界写成非数字 `x` | **红**(`没写下界(或下界不是 ≥1 的整数)`) |
| **下界改 1 + 掏空成假 ok** | **不红** ⇒ §四 的残留,如实登记 |
| 回归 **层5** 删整条 `skipSelfTest` | 1 条(契约要求而 `SELFTESTS` 缺) |
| 回归 **M43** 窄正则 / **M45** 只删样本 / **M47** 标签撒谎 / **M48** 空见证 / 名字级未接线 | 各 1 条 |

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· 本提交含 `client/electron/test/CRITERIA.md`(§6.1 补层 6 说明 + 契约行加下界)与 `client/electron/test/run-all.mjs`。
· 提交前 `HEAD=ecefd50`。
2026-09-18 08:12:44 +08:00

39 KiB
Raw Blame History

判据规范(写判据前先读这份)

这份文件记的是判据本身的写法:什么样的判据能红、能红在对的地方、以及不会在"代码完全正确"时乱红。 不是"怎么用 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 一旦这么用, 就不再是"研究过的例外",只是"红的止痛药"。

所以条目写成三元组,并断言后两项非空:

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 自己身上。

★ 自查句:"我这条判据,把它的定义、登记、调用三处一起删掉,谁会红?" 问不出来,就说明这条判据的存在性没有锚点(它只被"内容正确"类判据守着)。

★ 每项写成 名字:下界。下界 = 这条自检至少该打出多少条断言(^(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.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 的实现改空):

const check = () => {};     // 实现被换空
check('a', false);          // 存在、也执行了,但什么都不会红
console.log('主题:通过');   // 有输出

所以 run-all.mjs 现在做三件事:

  1. 自定义 check() 的判据自报条数:结尾打一行 RESULT pass=<条数> fail=<失败数>(node:test 的判据不用改,已有 # pass N);
  2. runner 只解析这个固定 marker(不猜口语汇总 —— 「窄屏布局:全部通过」里没有数字, 靠猜数字会误报);
  3. 与清单里登记的期望条数比对,低于 → 红。

棘轮是"只增不减":加判据不用改那个数;只有"条数掉了"才红 —— 那正是要看见的事(顺手删两条判据、某条被跳过、check 实现被改坏)。 代价:故意删判据时要同步改数字(这是一次显式的、能被复核的编辑,可以接受)。

计数必须写在 check() 内部:写在调用点或靠扫源码,"实现被换空"就看不见了 —— 上面那个反例的 pass 会是 0,正是靠这一条才有分辨力。

⚠️ 本节的作者在写完之后又踩了一次 §3 那条:变异验证时用 git checkout -- <文件> 还原,把尚未提交的改动(刚加的 marker)一起抹掉了。规矩写下来不等于会遵守 —— 所以 §3 已经把那条禁令改写成"先固化基线、再破坏"的操作顺序(pi 指出真因是 "被还原到的状态还没提交",不是 checkout 这个命令有罪)。

共享 helper:test/lib/checks.mjs(新判据请用它)

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; —— 拼了但没返回(这一版我实测仍然全绿)

三版都在判"源码里有没有那个形状",而缺陷是"算出来的值对不对"。闭合的形状是行为判据:

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 之后干净了"本身是假的干净,归因结论也可能是假的;
  • 同一工作区里同时有别的写入者时,任何"我来把树弄干净一下"的动作都在赌别人的东西不丢。

该用的手段(都只读、不碰工作区):

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):写完一处"要提醒人的输出"之后, 去看一眼默认路径的输出 —— 不是看代码,是看它实际打出来的样子。

17. 变异只证明「注入的样本被抓」,不证明完备性;判据的标签必须等于断言范围

规则(pi 2026-09-14):一条判据通过变异验证之后,只能说"我注进去的那一条会被抓"。 样本不是完备性证明。配套的第二半更重要:

标签比断言宽的判据,会在它没测的那条路上被回滚时给绿,而人信的是标签。

真实例子(background.test.mjs 的两条反向断言):名字写着"导航不得变暗 / 不得自叠模糊", 断言体各只扫一条路。三条逃逸路各注一次变异全部逃掉(.dark .nav-rail{} 选择器作用域、 组件 dark: 变体、组件 backdrop-blur-lg),导航变黑而判据全绿。

两种收法,选一个,别都做:

  1. 把标签缩回它真正断言的东西(推荐:诚实度比覆盖面值钱),并把未覆盖的路 逐条写在判据旁边(各自另立判据时才补);
  2. 把断言扩到标签的范围 —— 成本高,而且极易变成一刀切误红(先例:bg-chrome-600 plain 档徽标)。真要扩,按文件窄豁免写。