Files
MailUI4Agents/client/electron/test/CRITERIA.md
JianFeeeee 9d40816230 docs(判据): ★★ 「重构建」是**两步** —— 只做第一步,红会从 build-stamp 搬到 packaging
**实测(2026-10-03,每步退出码取自不接管道的运行)**:
    提交 ⇒ HEAD 前进 ⇒ build-stamp 红(产物记旧 rev)
    ① npm run build                      ⇒ build-stamp 绿、**packaging 红**
    ② npx electron-builder --linux …    ⇒ 两条同时绿
★ 只做①的人会以为「修好了」(前一条确实绿了),而红只是**换了个位置**。

**为什么①会让 packaging 转红**:packaging 比的是「asar 里的 dist 文件名」
vs「当前 dist/index.html 引用的文件名」,而 Vite 输出带**内容哈希**
⇒ 重构建一换文件名,asar 立刻过期。两条判据是**同一条链的两环**,
而 package.json 里**没有** beforeBuild 钩子把两者串起来 ⇒ 必须人记得做两次。

**改了三个地方,因为「报错文案」比文档更容易被看到(§14 同一条道理)**:
· build-stamp 的报错文案**自带第二步**。原文只写「正确修法只有一个:重跑构建」——
  实测证明那句话**只完成了一半**,而只看文案的人会照做然后以为好了。
  已用变异测试确认:把产物 gitRev 改成 0000000 ⇒ 变红且两条步骤都出现,
  改回 ⇒ 绿。
· CRITERIA.md 新增 §6.0.6「同一个红会自己搬家时,要把它变成有界的」。
  它讲的是**读数的信噪比**:一条结构性长期红与真缺陷**同屏**,
  会把整屏红的价值抵消("看到了 ⇒ 当没看见")。
  明确**不能**靠加跳过名单解决(那是把它变成看不见),
  正解是到期条件可机检 + 报错文案自带下一步。
· DEBTS.json 的 build-stamp-stale-artifact-blocks-verification:
  把 due/where/note 补上「两步」的实测事实,并把**第二环 packaging**
  补进 where —— 原笔只记了 build-stamp 那一环。

**边界 / 未做**
· 没有把两步串进 npm 脚本或 beforeBuild —— 那是**构建流程**的改动,
  而本工作区正被多个会话并发使用;由人决定。
· 这笔债**仍未结算**:它会在**下一次提交**时重现(结构性的,与代码无关)。
2026-10-03 11:17:18 +08:00

97 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.0 怎么读判据的数(前半篇讲「怎么写」,这一节讲「怎么读」)

§1–§17 讲的都是「判据怎么写」。另一半——「怎么读它的数」——本节讲, 因为 2026-10-03 一天之内就撞了两起独立事件,都出在这一半,而仓库里没有成文规则。

6.0.1 接线守卫的失效形状是「全停」,不是「那一条不跑」

run-all.mjs 的自检 2(「每个 *.test.mjs 都要在清单里」)在跑任何判据之前 process.exit(1) ⇒ 实测 RESULT 行数 = 0。又因 npm test 是 && 链, vitest 与 tsc 一起不跑——而两者单独跑都是绿的。

2026-10-02 10:46  aeb1f41 加了 inbox-fallback-poll / sse-credentials(未接线)
2026-10-02 13:58  2f17f62 加了 web-comment-only(未接线)
        ↓
2026-10-03       整仓 24 小时零读数;失败只有一行中文 stderr,看起来像「环境问题」

⇒ 「判据存在」不等于「判据在跑」,而本仓的守卫把两者绑在一起。 它的价值(漏接线绝不静默)是真的,代价(一个文件漏接 = 全仓失去全部读数)也是真的。

  • 新增 *.test.mjs 之后必须做的事:跑一次 node test/run-all.mjs, 看 RESULT files=N ran=N —— ran 必须等于 SUITE 条数。 只看到「没接进套件」那一行就以为「跑过了」是最容易犯的错。
  • 别信「上次是绿的」:若那个结论来自一份报告而不是一次运行, 先确认上一次真读到数是什么时候(本笔已登记为 criteria-suite-unwired-stops-everything)。

6.0.2 退出码只能来自不接管道的运行

cmd 2>&1 | tail -N      ⇒ $? 是 tail 的,不是 cmd 的
cmd >file 2>&1; echo $? ⇒ 唯一诚实的形状

实测(同一会话内我连踩三次):

命令 报的 实际
npm test | tail -80 0 0 条判据跑过
npm test | tail -30 0 run-all red,vitest 根本没跑
tsc --noEmit | tail -20 0 tsc 的 0(碰巧也是)

第三条最危险:结论为真,但当时没有根据。tsc 静默无错与「tsc 被 tail 吃掉」 在日志里长得一样。⇒ 事实成立 ≠ 当时有根据;依据必须重取。

6.0.3 && 链里「全绿」要问:真跑到那一环了吗

run-all red ⇒ vitest/tsc 根本没跑。而日志里可以同时出现 「有 RESULT 行」与「无 vitest 输出」——只看日志会以为它跑过了。

  • 汇报门禁结果时,写清是哪几环(run-all / vitest / tsc), 以及哪些没跑。
  • 「没输出」要连退出码一起看(见 6.0.2 第三条)。

6.0.4 判据变红时,先问「判据用的工具本身可信吗」

2026-10-03 的 harmony-admin 假红:stripComments 把 main.go:78 行注释里的 /* 当成块注释开头 ⇒ 137 行 / 37 条路由注册被当注释抹掉, 含它正在断言的 r.Get("/auth/me", …)。

⇒ 读取器的缺陷是静默的(不抛错、不警告),症状却出现在被测对象上。 所以:「某条判据突然变红,而源码看起来是对的」⇒ 先验证读取器,再怀疑代码。

反过来也成立:换成更严格的读取方式之后判据变红,通常是判据错了—— 本次 inbox-fallback-poll 从裸 readFileSync 换成 code()(剥注释)之后变红, 暴露的是它本来就在判一行尾注释里的字(删掉注释照样绿、塞进真 bug 也照样绿)。

6.0.5 改共用工具时:第一假设是「我弄坏了它」,不是「代码回归了」

6.0.4 讲的是「工具坏了怎么认出来」。这一节讲它的另一半:当你就是改工具的人。

2026-10-03 为修 6.0.4 那个假红重写 lib/read.mjs 的 stripComments,连错三次, 三次都是判据没错、我错了,而且三次的第一反应都是「判据是不是过期了」:

轮 我的改动 谁红了 真因
v1 块注释改「空格填充等长」 harmony-logic / harmony-nav 下游 [\s\S]{0,300}? 窗口是按"删掉"的尺度标定的,填充把窗口撑爆
v2 状态改成「回看已输出的 out」 criteria-hygiene out 里注释已抹过,重算的上下文 ≠ 源码 ⇒ :566 真块注释没被剥
v3 只跟踪字符串、不认正则字面量 criteria-hygiene harmony-device.mjs:59 的正则里有 4 个引号(奇数)⇒ 打开的"字符串"永不闭合

★ 「判据过期了」这个反应本身就是错的,因为它默认了「我改的是正确的东西」。 三次里如果反过来先怀疑代码,就会去改本来正确的生产代码。

  • 共享读取器的「删多少 / 怎么判」是被下游按字节尺度依赖着的。 改它之前先问「谁在依赖这个性质」——本次两处依赖都是 [\s\S]{n,m}? 窗口, 而 codegraph 那类符号图看不见这类依赖(它们是行为依赖:不调用你, 但依赖你的输出形状/尺度)。
  • 每次改动都重跑全量,不要只看直接调用方:v1 就是只看了 code() 的直接使用者, 是跑全量 run-all 才看见 harmony-logic/harmony-nav/harmony-apibase 同时变红的。
  • 新写读取器要把四条性质一起钉(本次五条,逐条实测才算数): ① 行号不变 ② 字符串里的 // 与注释标记不被当注释 ③ 注释里的引号不污染字符串状态 ④ 正则字面量被当正则(漏认的方向是假绿:奇数引号 ⇒ 永不闭合 ⇒ 后面注释全跳过) ⑤ 块注释连文本一起删(不是留空白、不是只留换行 —— 后两种都会被下游窗口的标定尺度打中)。
  • 改完用变异测试自证:把旧实现放回去,确认那条判据确实变红。 本次正是这一步把「我修好了」与「我改的东西恰好没人用」区分开。

6.0.6 同一个红会自己搬家时,要把它变成有界的(2026-10-03)

6.0.1–6.0.5 讲的是「读数怎么取才可信」。这一节讲一个读数的信噪比问题: 一条结构性长期红,与真缺陷同屏时,会把整屏红的价值抵消掉。

具体形态(client/electron/build-stamp + packaging):

提交 ⇒ HEAD 前进 ⇒ build-stamp 红(产物记旧 rev)
     ↓ npm run build
build-stamp 绿、**packaging 红** ← 红从一条**搬到**了另一条
     ↓ npx electron-builder --linux
两条同时绿

★ 为什么重构建会连带打红 packaging:packaging 比的是 「asar 里的 dist 文件名」vs「当前 dist/index.html 引用的文件名」, 而 Vite 输出带内容哈希 ⇒ 重构建一换文件名,asar 立刻过期。 ⇒ 两条判据是同一条链的两环:只做一步的人会以为“修好了”(前一条绿了), 而红只是换了个位置。package.json 里没有 beforeBuild 钩子把两者串起来。

为什么不能靠“加进跳过名单”解决:那是把它变成看不见(本仓反复消的 「看不到 ⇒ 绿」,方向相反的那一族)。正解是把它变成有界的:

  • 到期条件写成可机检的动作(本笔的 due 就是那个界);
  • 报错文案自带第二步(build-stamp 的文案现在只说“重构建”, 不够 —— 要写上“然后重打包”)—— 与 §14 同一条道理: 报错是下一个人一定会看到的东西,文档不一定。
  • 实测记下哪个 commit 下什么状态,别让下一个人重新推一遍。

⚠️ 注意区分两种红: · 本节的:与被测代码无关,只由 HEAD 移动引起 ⇒ 有界、可预言; · 真缺陷:由代码引起 ⇒ 不会自己消失。 ⇒ 两者同屏时,要让人一眼分得出 —— 至少别让前者看起来像后者。

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.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 的实现改空):

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(新判据请用它)

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

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.1.1 ★★ 第四个落点:变异条目的锚点失效(hits=0)—— 守具有齿,却不在位(dsh 2026-09-19)

pi 报的第 1–3 例都修好之后,我顺着同一条线审计全部 61 个变异条目的锚点, 发现还有一个同形状的落点,而且它在真树上是活的(不是构造的):

活跃条目 61 个 · 锚点命中 ≠ 1 的:**1 个**
  hits=0  client/harmony/.../pages/SettingsPage.ets  「管理入口不做门禁」(jobs-all.json)

根因:该锚点写的是 6 空格( if (this.isAdmin) {), 而 f4b8bc1(09-17 17:43,"邮件详情与「我的」页 1:1 对齐 WebUI")把该文件重排成 4 空格 ⇒ 锚点从此命中 0 次。写进清单时(e2f117f,09-15)它是对的(当时 6 空格命中 1 次)。

为什么没人发现:summary.py 把「hits=0 … 过期条目」只打印, 完全不进严重度链 ⇒ rc=0、diag=none ⇒ 套件那边 whyLines: (note || status !== 0) ? … : [] 为假 ⇒ 整段丢掉。 端到端实测(真树、干净工作树):

修前(锚点 6 空格) 修后(锚点 4 空格)
mutants= / ran= / skipped= 48 / 47 / 1 48 / **48** / **0**
diag= none none(无此码,因为已修)
"过期条目"在套件输出 grep 0 —

★ 危害是"一个变异守具被静默关掉",不是"数字错了": ran 少 1、skipped=1 是个中性数字,读者看不出少了哪一个、也不知道少了。 而手工施加那个变异仍能让判据红(harmony-admin.test.mjs # fail 1, ★ 管理入口没有 isAdmin 门禁 ⇒ 每个普通用户都会看到一个点进去 403 的入口) ⇒ 守具有齿,只是不再被挂上。

修法(与第 3 例同一条链,summary.py 的唯一严重度链): 新增 mutant-anchor-stale ⇒ 1 档(清单/数据该改), 不是 2 档(那才是环境问题)——照 env-defaults.sh:25 的反方向:别让"清单没跟上"冒充环境。

★ 射程如实标出:判据只看 hits == 0,不看 hits == -1。 -1 是"目标文件打不开"(文件没了/权限),与"锚点写法过期"是两回事, 且 --exitcode-selftest 的迷你夹具里 jobs-one.json 指向的文件本来就不在 (夹具设计,不是缺陷)⇒ 把 -1 算进来会造假红。 -1 那一半目前没有判据守着(真树实测 0 条)。

端到端 A/B(dsh,隔离 worktree,同刻对照):

A:锚点已修(4 空格) B:锚点退回 6 空格(制造 hits=0)
summary.py rc 0 1
diag= none mutant-anchor-stale
ran= / skipped= 48 / 0 47 / 1
"锚点已失效"在套件输出 grep 0 grep 2

变异验证(dsh,4 个方向全部抓住):

变异 结果
新档关闭(elif False)=回到旧的"只打印" exitcode-selftest rc=1、2 条红(rc=0(期望 rc=1)✓
UPSTREAM_RC 删掉新码 反向覆盖点名 + 案例不符 ✓
blocksGreen: true → false 两侧口径漂移(双向比对 UPSTREAM_RC ↔ blocksGreen) ✓
判定放宽成任何 skipped_detail(含 hits=-1) rc=1、5 条红(迷你夹具假红)⇒ h == 0 这个射程是承重的 ✓

★★ 通用规则(本仓第 4 次同一形状,值得单列):

"跑了多少个"与"该跑多少个"之间,也要有一条判据。 一个变异/检查被跳过时,ran 只少 1、skipped 只多 1 —— 两个中性数字, 而"少了哪一个、为什么少"没有任何通道。 ⇒ 凡有"登记了一批东西、再逐个挂上"的结构(变异条目、判据、样本表), 都要问一句:挂不上的那一个,谁来说? 同族的第三个自查问(pi 归纳):"守着它的判据,它自己的样本由谁守"; 这一条是它的镜像:"被守的那个东西没挂上时,谁来说"。

16.1.2 ★★ 第五个落点:判据写对了,却没有任何人执行它(dsh 2026-09-19)

同一个形状的又一格,而且这一格的判据本身写得完全正确:

deploy/check-file-modes.sh = 源文件权限政策的唯一判据 ("受跟踪文件不得比 0644 更严;可执行与否按 git ls-files -s 记的那位判")。 它甚至专门修过自己那份 [ -x ] 恒真的 root 陷阱(b7dc9e9),改成直接读权限位 —— 修得对。

但它从来没有被任何东西执行过。 全仓 grep -rn "check-file-modes" 的结果:

deploy/check-deploy-drift.mjs:147,149,442   ← 都在注释里谈"分工"
client/electron/test/run-all.mjs:951        ← 注释(讲 root 陷阱)
client/electron/test/mutants/summary.py:77  ← 注释,且**说法与实现不符**
client/electron/test/mutants/summary.py:431 ← 一句 print(...) 的**文案**

⇒ 唯一一处非注释提及是一句 print 的文案。 没有任何 install.sh / redeploy-*.sh / hook / CI / cron 调用它。而它当时正红着:

[FAIL] 权限过严:docs/DSH-0.1.5-MAIL-CHANNEL-ROOTCAUSE.md 是 600
[FAIL] 权限过严:scripts/repair-legacy-spliced-ids.mjs 是 600
[FAIL] 目录不可进入:client/electron/test/mutants/jobs(drw-r--r--,缺属主 x 位)

★ 为什么这一类只能靠判据守、不能指望"顺手看见"(实测):

手段 能否发现 600 vs 644
git status 不能(两次都空)
git diff 不能
git ls-files -s 只记 100644/100755 ⇒ 组/其他读位不进版本库
套件(jobs/ 缺 x 位时) 不能(实测 diag=none、ran=48,全绿)

⇒ 这一类没有第二条通道。0600 在"跑的人恰好是属主"时不炸,一旦换身份(install.sh 自己会切身份判可写性;套件里还有以 nobody 降权跑的判据)就是 EACCES, 而那串报错看起来像代码问题。

summary.py:77 那句"那个只在部署时跑"是实现与说法不符(实际从没跑过)—— 按本仓口径:说法与实现不一致 ⇒ 以实现为准,故以实现(从没跑)为准并把它接上线。

修法:接进 deploy/install.sh(check-shared-libs.sh 之后)。 ⚠️ 接线必须走 --check 的累积通道(照既有 npm_rc/CHECK_GATE_RC): 门禁红时它 exit 1,而 install.sh 是 set -e ⇒ 直接调会在那里中止、 后面 build-stamp/钩子/收尾诊断一行都不打。 实测(我第一版就是直接调的):接线后 构建 Gateway 在日志里命中 0 次 (接线前 2 次)—— 正是 install.sh:141-157 刚修过的同一个毛病,我自己又造了一遍。 改成累积通道后 构建 Gateway 回到 2 次,门禁报 [FAIL] 权限政策没过(退出码 1)。

新增判据(criteria-hygiene.test.mjs,第 7 条):政策门禁(deploy/check-*.sh) 必须至少被一个入口脚本在可执行位置调用。

★ 射程如实标出:

  • 只管 deploy/check-*.sh(政策门禁那一族);不管 check-deploy-drift.mjs 这类按需手动工具(自带 --self-check、文档写明是"事后自查")—— 要求它也进入口是错的(把工具当门禁,然后逼人硬挂上去)。
  • 判"有没有接线",不判"接得对不对"(挂累积通道是另一条纪律)。
  • check-deploy-drift.mjs 同样不在任何入口被调用(实测 0 处), 但它是工具不是门禁 ⇒ 本判据不覆盖它,也没修它。

★★ 变异验证时我自己先假绿了一次(值得单记):

第一版用 code()(剥注释)读入口脚本 —— 而 lib/read.mjs 的 stripComments() 只认 // 与 /* */,那是 JS 的注释,install.sh 是 shell,注释是 # ⇒ 对 shell 文件它原样返回。于是判据读到了我写在它上面那段解释里的 # `check-file-modes.sh` 红时 `exit 1` ⇒ 变异验证①(把接线整段删掉)判据仍绿。

变异 第一版(用 code()) 修后(自剥 shell #)
① 接线拆掉 rc=0(假绿) rc=1 ✓
② 只在 echo 里提一句 — rc=1 ✓
③ gates 恒空(∀x∈∅) — rc=1(只找到 0 个)✓

⇒ 修法:自己剥 shell 注释(行首/空白后的 #;故意不碰 ${VAR#pat}), 并抹掉 echo/printf 开头的散文行。已知限制:引号内的 # 会被当注释起点, 方向是假红(漏掉接线),不是假绿 —— 假红当场看得见。

纪律(这一格的通用形式):判据自己也要能"被证明它真的在看代码"。 一条"在源码里找某个写法"的判据,若它读的那种语言和 stripComments 实现的那种语言不一致(shell vs JS、Python vs JS、模板 vs 源码), 那么"剥注释"是假的 ⇒ 判据会消费散文,而变异验证是唯一能戳破它的东西。 本仓 stripComments 自己那段注释早警告过"判据开始消费散文", 但警告的是字符串,没覆盖"另一种语言的注释" —— 这一格是那个洞的实例。

16.1.3 ★★ 第六个落点:判据锚在"字面相邻",而实现合法地多了一层(pi 报 + dsh 复现,2026-09-20)

harmony-nav.test.mjs:441 断言:

assert.match(body, /duration:\s*Theme\.durRise/, '★ paneRiseIn 里入场时长必须引用 Theme.durRise')

它要防的真 bug 是 2026-09-18 那笔:把"壁纸淡入的时长"(durBase) 当成"面板入场的时长"。 动机完全正确。 但它的形状是"duration: 与 Theme.durRise 字面相邻"。

而无障碍动效开关落地后,Theme.paneRiseIn() 合法地变成:

).animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise })

Motion.dur(x) = reduced() ? 0 : x(系统开"减少动效"就折成 0ms)。 ⇒ 语义上 durRise 仍被引用,只是多了一层卷绕 ⇒ 正则失配 ⇒ 假红。

实测(dsh,隔离 worktree,同刻对照):

树 harmony-nav
A 干净 HEAD 19 tests / 18 pass / 1 fail(not ok 16,设备条)
B HEAD + 未提交的无障碍改动 17 pass / 2 fail(多出来的正是 not ok 8)

修法(放宽包装,不放宽令牌):

/duration:\s*(?:Motion\.dur\()?Theme\.durRise/

★ 区分力实测未动(3 个方向):

变异 结果
Motion.dur(Theme.durBase)(换令牌) 仍红 ✓
裸 Theme.durBase(不包了,直接换) 仍红 ✓
干净 HEAD 形状但换令牌 仍红 ✓

且下面那条 !/Theme\.durBase/ 逐字仍在、扫的是整个函数体 ⇒ Motion.dur(Theme.durBase) 这种"包一层蒙混"照样红。 修后全套 red 5 → 4(harmony-nav 从红名单出去,其余 4 条红是别的会话的)。

★★ 同一次量出的更值钱一条:那个无障碍层本身"零判据",而且只落了一半

  • client/electron/test/ 里 isAnimationReduceEnabled / Motion.dur / Motion.reduced grep 各 0 次;对照 WebUI 侧有判据(animation-audit.test.mjs:112 的 prefers-reduced-motion 覆盖 .rise-in)。同一份无障碍义务,Web 侧有守、鸿蒙侧没有。
  • 变异零反应(dsh 复现 pi 的):把 Motion.dur 改成恒 return want (整个开关失效,源里 6 处受影响)⇒ harmony-nav 失败集合与变异前逐条相同(17/2)。
  • ★ 而绕过面比"6 处都包了"更大:11 个真·动画时长站点里 5 个绕过开关 —— CalendarPage.ets:373,410、MainPage.ets:2607,2845,2884 是裸 Theme.durX。 即"收成一个入口,漏掉的人写不出忘了判断的代码"这个设计意图目前只落了一半。 (另外 6 处:Theme.ets 5 处 + Surface.ets:276。)

通用纪律(这一格的抽象):判据锚在"字面相邻"时,它其实断言了"两层之间不许再有一层"。 那个隐含断言几乎从来不是作者的本意 —— 作者本意是"这个令牌要被引用"。 二者在实现多一层合法卷绕(包装函数、无障碍开关、单位换算、类型转换)时必然分叉, 而分叉的方向是假红。 ⇒ 写"源码里找写法"的判据时问一句:我要的是"这两个字面上挨着",还是"这个令牌在这条链上"? 若是后者,正则要给卷绕留位((?:Wrapper\()?),而**"令牌"那一格一个字都不放**。 ★ 与 §16.1.2 同源:那一格是"判据读的语言不对",这一格是"判据读的形状比实现更死"。 两次的教训合成一条:判据的"严格度"必须与它真正想守的那件事对齐, 而不是与"当时那版实现的写法"对齐。

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 送了什么 —— 锚点落在「实际发生的那次调用」上,这是源码文本够不着的东西:

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 列变成每次运行都可见之后,我做的第一件事就是去读它 —— 于是读到一句假话, 顺着它又挖出本仓一条同族的缝。这条缝的形状:

/** 服务端壁纸上限(`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 档徽标)。真要扩,按文件窄豁免写。