pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。
## 1 判据③:形状正则已删(它永远差一个反例)
实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。
## 2 真正的门:`scripts/release-linux.sh`
- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
`npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
**且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。
## 3 typecheck 进 `npm test` 链
`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。
## 4 Go 源码判据:零匹配 / 读不懂 都要红
改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。
## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"
查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。
## 验证
`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
16 KiB
判据规范(写判据前先读这份)
这份文件记的是判据本身的写法:什么样的判据能红、能红在对的地方、以及不会在"代码完全正确"时乱红。 不是"怎么用 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.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 现在做三件事:
- 自定义
check()的判据自报条数:结尾打一行RESULT pass=<条数> fail=<失败数>(node:test的判据不用改,已有# pass N); - runner 只解析这个固定 marker(不猜口语汇总 —— 「窄屏布局:全部通过」里没有数字, 靠猜数字会误报);
- 与清单里登记的期望条数比对,低于 → 红。
棘轮是"只增不减":加判据不用改那个数;只有"条数掉了"才红 ——
那正是要看见的事(顺手删两条判据、某条被跳过、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.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. 判据要钉用户真正会点的那一层
(移交信里交代的头号纪律)判据通过了但用户点不到,等于没做。 所以断言尽量落在"用户会触发的那个入口/那条路径"上: 例如"点同意/拒绝后列表要变"要钉到那条链路上,而不是钉"某函数存在"。