Commit Graph

88 Commits

Author SHA1 Message Date
ecefd50791 修复: 一整条自检(函数体+登记+guard)三处同步删掉是**零痕迹** —— SELFTESTS 没有下界
pi 2026-09-18 报的层 5。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:三道防线都在问"出现的东西对不对"

| 变异 | 结果 |
|---|---|
| **H1** 删整条 `skipSelfTest`(函数体 + 登记 + CLI guard 三处同步) | `red=9 … verdict=red` 与基线**逐字相同**、相关 RED **0 条**,只有报文里 `5 个自检`→`4 个自检` |
| **H1'** 最尖形式:删 `exitcodeSelfTest` —— **守着 `UPSTREAM_RC`"真脚本真跑"锚点的那个** | 同样零痕迹 ⇒ 两轮建起来的真跑锚点可被一次编辑无声撤掉 |
| **H2** 只删登记(函数与 guard 留着) | `red=11`(名字级扫描 + "写了没接线"两条防线抓住)⇒ **不静默** |
| **H3** 只删函数体 | `ReferenceError`、无 `RESULT` ⇒ **响的**,不是静默洞 |

★ 为什么三道防线都看不见 H1:
· **名字级扫描**:整条删掉时那个名字**两边都不出现** ⇒ 无从对比;
· **"写了没接线"**:`declaredSelfTests(源码)` 与 `wired` **两边同时缩小** ⇒ 相等;
· **样本/见证/要求**:样本表**内部**三字段,与 `SELFTESTS` 无关。
⇒ 根因一句话:**`SELFTESTS` 没有任何下界,也没有"必须存在哪几个"的外部清单;
所有防线问的都是"出现的东西对不对",没有一条问"该出现的东西在不在"。**

★ 这与本仓**自检 1/2** 是同一句话的不同对象:那两条判"清单里的文件必须真的存在"、
"`test/` 下每个 `*.test.mjs` 都要在清单里"—— **它们有外部依据(磁盘上的文件)**,
而 `SELFTESTS` 的"该有哪几个"**只由它自己说了算**。**同一句话,换个对象就漏了。**

## 二、修法:把"必须存在哪几条"锚到 `SELFTESTS` 之外

`CRITERIA.md` §6.1 写一段契约(本文件是**另一个文件**,且它自己已被本仓**自检 3** 守着:
存在性 + 关键条目在),并放一行 `<!-- selftests: … -->`;`run-all.mjs` 读它,
要求与 `SELFTESTS` 的键**恰好相等**(两个方向都判)。

⇒ 链条:**被外部守着的文本 → flag 名 → guard → SELFTESTS**,锚点不在表自己身上。
删掉一条自检 ⇒ 名单没跟着删 ⇒ **红**;"名单也一起删"变成**跨文件**编辑(见下 §四)。

## 三、★★ 我没能用 pi 建议的①,因为它的**前提是假的**

pi 建议"拿对外的 flag 契约当锚点",理由写的是"这五个 flag 名是**对外的**(有人在用、有文档)"。
**我实测:不是。** 全仓(排除 `.git`)搜这五个 flag ⇒ **外部消费者 0、文档 0**。
⇒ 所谓"对外契约"**不是已有的,是需要造出来的**;直接用会把锚点建在一个不存在的保证上。
★ 所以我把它落在 `CRITERIA.md`(**确实**是别的文件、**确实**有 4 个文件引用它、
且**确实**被自检 3 守着),而不是落在"它自己新写的一张内部清单"。

## 四、⚠️ 残留(如实登记):**跨文件的同步删仍然静默**

实测:**同时**删 `run-all.mjs` 的三处 **和** `CRITERIA.md` 的那一项 ⇒ `^RED ` **0 条**。
⇒ 本次修法把"改我自己的一张表"抬成了"**改两个文件**",
**难度上去了,但性质没变**(仍是可同步的、无真跑锚点的编辑)。
★ 按这条线的规律,我**不**声称这一层封死了 —— 只是把成本从一次编辑抬到两次编辑。
真要把存在性也锚到行为上,得让"删掉一条自检"在**真跑**里留下痕迹(例如对外契约由
`npm test` 的入口兑现),那是独立工作。

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

契约行我第一版的正则没锚行首行尾,而 §6.1 的**正文里举例提到了**那个标记
⇒ 正则**先命中了注释里那次**(捕获到 `…`)⇒ 干净树上立刻 2 条**假红**。
★ 正是本文件反复写的形状:**"提到"与"就是"在文本上长得一样。**
⇒ 修法:锚 `^…$` + **要求恰好一条**(多出来也红)—— 现在契约行自己也有唯一性判据。

## 六、变异与回归(都已还原)

| 变异 | 结果 |
|---|---|
| **H1 / H1'** 三处同步删 | **红**:`契约里要求存在的自检在 SELFTESTS 里没有:skip-selftest / exitcode-selftest`,`red=9→10` |
| 契约里写一个不存在的自检(反方向) | **红**(`verdict-selftest`) |
| 契约行 0 条 / 2 条 | **红**(要求恰好 1 条) |
| 回归 **M43** 窄正则 | 1 条认不出 |
| 回归 **M45** 只删样本 | 1 条"没有样本"(★ 我第一次跑成 0 条,是**我的脚本**同时删了 `REQUIRED_FORMS` 的名字 ⇒ 那是共漂移不是 M45;改正后 1 条) |
| 回归 **M47** 标签撒谎 / **M48** 空见证 | 各 1 条 |
| 回归 名字级(未接线自检) | 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 + 契约行)与 `client/electron/test/run-all.mjs`。
· `HEAD=dc261ad`(提交前)。
2026-09-18 07:58:07 +08:00
dc261ad537 修复: 三张手写表互相比对、没有外部锚点 —— 同步删「箭头函数」后 red 与基线**逐字相同**
pi 2026-09-18 报的。**成立,我复现,读数与它逐字相同**;而且我测出
**pi 建议的补法①也堵不住它**,所以换了锚点。

## 一、缺陷:同步修改 = 零痕迹

(a)把「箭头函数」从 `REQUIRED_FORMS` 与 `FORM_SAMPLES` **同步**删掉
⇒ `red=9`、`broken=0`、`verdict=red`,与基线**逐字相同**,样本表相关 RED **0 条**,
只有成功报文里 5 变成 4。

(b)更尖:连 `declaredSelfTests` 里 `const` 那条发现规则**一起**删(四字段全同步)
⇒ 同样 **0 条**。而后果是实的:那种写法的自检将来**既不跑也不报警**。

★ 根因(pi 的概括我认同):`REQUIRED_FORMS`/`FORM_SAMPLES`/见证**三张都是手写的、互相比对**
——三张同步改就一致地"对",**没有任何外部锚点**。
对照 `UPSTREAM_RC`:它当年止住共漂移,是因为它比对的是**真脚本真跑出来的 rc**,
不是另一张手写表。**"具名"这个形式救不了它** ——
`length >= 5` 与 `REQUIRED_FORMS` 在"被同步修改"这一点上**难度相同**(都是一次编辑)。

⚠️ 因此我 M45 注释里那句"具名清单让放弃支持变成**看得见的声明**"**被实测推翻**,
已在该处更正(保留原文与推翻它的读数,免得下一个人再信一次)。

## 二、为什么不用 pi 的补法①:它也堵不住

pi 建议"每条正则都必须有只满足它的样本"。我实测:**样本是规则的字段**,
删规则时它自己的样本一起消失 ⇒ 要求跟着消失 ⇒ 仍然 0 条红。
(我把它单独跑了一遍:`check(两条规则) = []`、`check(只剩 function) = []`,两行都是空。)

## 三、修法:**名字级锚点**(与写法无关,且不落到手写表上)

源码里出现的**任何** `\w+SelfTest` 标识符,都必须在 `SELFTESTS` 里 —— **不解析声明语法**。
于是它与 `function`/`const`/`async`/箭头/空格**全部无关**:
发现规则认不出某种写法时,那种写法的自检名**仍会被这个名字级判据看见**。

★ 唯一例外是样本内容用的假名(`fooSelfTest`),**显式登记**在 `SAMPLE_FAKE_NAMES`
(另加反向检查:登记的假名在源码里找不到 ⇒ 红,登记该清了)。

**这一跳的方向与 `UPSTREAM_RC` 一致**:锚点从"另一份手写数据"挪到**源码事实**
(`SELFTESTS` 里存的是真函数,`fn.name` 是运行时读数)。

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

| 变异 | 结果 |
|---|---|
| **pi 的 (b)**:删 `const` 规则 + 四字段同步 + 未接线箭头自检 | **红**:`这些 *SelfTest 标识符出现在源码里却没接线(名字级扫描,与写法无关):silentArrowSelfTest`,`red=9→10`(**修复前 0 条 RED、一字不提**) |
| **写法无关性**:**两条规则全删** + 四种写法(箭头/async/名后空格/const=async)的未接线自检 | **红**:四条**全部**点名(`aSelfTest、bSelfTest、cSelfTest、dSelfTest`) |
| 回归 **M47** 标签撒谎 | 仍 **1 条红** |
| 干净树假红检查 | 名字级红 **0 条**、`red=9`(无假红) |

## 五、我踩的坑(第三次同一个)

加这个名字级块时又**用了声明在后面/不存在的变量**:
先 `ReferenceError: selfSrc is not defined`(我早前的编辑把 `selfSrc` 挪走了),
再是 `wired` 的 TDZ。与前两次同族:**新加的接线/判据必须排在它读的所有 `const` 之后**。
⇒ 顺手在这个块前显式定义 `selfSource`,并写清它是**本作用域**的读数。

## 六、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 … verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· 残留全清;`git status` 仅本文件;`HEAD=16cbc02`。
2026-09-18 07:41:47 +08:00
16cbc02421 修复: 样本的**标签可以撒谎** —— 覆盖表声称覆盖箭头函数,而那份样本根本不是箭头函数
我自己变异出来的(M47),是 M45 的下一层。M45 修的是"样本**少了**要红",
但**名字对不上内容**当时无人管。

## 一、缺陷:标签是散文,内容没人核

实测:把 `['箭头函数', 'const fooSelfTest = () => {};']` 的**内容**换成
`'function fooSelfTest() {}'`(**标签不动**、样本个数不变)
⇒ 默认跑 **0 条 RED**、`red` 仍是 9(与干净树逐字相同)。

★ 后果我实测过,而且它**让报警自己少说话**(不只是少覆盖):
在上面那个撒谎标签的前提下,把 `const` 那条正则删掉(⇒ **箭头写法真的认不出来了**),
报警只点名 `const = async (…`,**"箭头函数"一个字都不提**(`grep -c` = **0**)。

⇒ 覆盖表**声称**覆盖了箭头函数,而报警里那一格**消失了**。
**该说话的地方没说话,而账面上看不出少了什么** —— 我们这一路最怕的那个形状。

★ 与前面几层的关系(**每修一层,缺口换一层**):
· `957ec5e`:发现规则**看不见某种写法**;
· `8691940`:样本表**可以静默缩水**(M45);
· 本次:样本表**可以撒谎**(M47)—— 表上的名字与内容**没有任何关系**。

## 二、修法:给每条样本配一条**见证**(结构,不是散文)

每个样本从 `[名字, 内容]` 变成 `[名字, 内容, 见证]`,见证是"这条样本**凭什么**算那种写法"的
**机械**判据,并判两个方向:
① 内容必须满足自己的见证(⇒ **标签与内容不符 = 红**);
② 见证必须**能区分**:至少要能否掉另一条样本(否则 `/./` 这种空见证也算"覆盖")。
⇒ 把"这条样本是哪一种写法"从**散文**挪进**结构**。

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

| 变异 | 结果 |
|---|---|
| **M47** 箭头样本内容换成 function 声明(标签不动) | **红**:`这些样本标签与内容不符:箭头函数 ⇒ 覆盖表在说谎,而报警会因此少说话` |
| **M48** 见证退化成 `/./`(对谁都成立) | **红**:`这些见证区分不了任何东西:箭头函数 ⇒ 见证表自己退化成"永远通过"` |
| 复合:**M47 + 箭头支持真坏** | 两条红都出:先抓撒谎,再点名 `const = async (…`(⇒ 撒谎不再能掩盖覆盖) |
| 回归 **M45** 删样本 | 仍 **1 条报警** |
| 回归 **M43** 退化正则 | 仍 **1 条认不出** |

## 四、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 … verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;
  自检段现为 `…发现规则在 5 种写法上都有对照(要求 5 种,缺一即红;每条样本都带能区分它自己的见证)`。
· 残留全清;`git status` 仅本文件;`HEAD=8691940`。
· pi 的 `cc8beb7`(它插件那例)我已复核;它目录一行未动(那两处实验都在 `/tmp` 做)。
2026-09-18 07:29:32 +08:00
8691940505 修复: 反面对照的**样本表自己没人守** —— 删掉 5 个样本里的 3 个,默认跑 0 条 RED、静默缩水
我自己变异出来的(M45)。这是 `957ec5e` 新加那道防线**内部**的缺口,
而且缺口长在"**覆盖**"这件事本身上。

## 一、缺陷:`发现规则在 N 种写法上都有对照` 当时是**散文**,不是判据

实测:把 `FORM_SAMPLES` 的 5 个样本**删掉 3 个**(只留 `function 声明` 与 `const = async`)
⇒ 默认跑 **0 条 RED**、`red` 仍是 9(与干净树逐字相同),
只是成功报文里那半句从"在 **5** 种写法上都有对照"变成"在 **2** 种写法上…"。

⇒ 它把 N 打出来,而 **N 变小没有任何东西拦**。
而"样本齐不齐"正是这条防线**唯一的防线** —— 样本可以被悄悄删光,
防线的覆盖就静默退回到"只验我恰好留下的那一种"。
**这是"判据存在 vs 判据在路径上"的又一个变体:样本在,但"样本够不够"没有判据。**

★ 与我上一轮那条同源、但换了一层:`957ec5e` 修的是"**发现规则看不见某种写法**",
这次是"**样本表可以静默缩水**" —— 修掉前者之后,后者的存在才显出来。
**每修一层,缺口换一层**,这与 `baseline` 那条线"接缝会移动"是同一个观察。

## 二、修法:要求清单**具名**,两个方向都判

· 列出 `REQUIRED_FORMS`(要覆盖哪些写法,**具名**);
· ① 每种要求的写法都必须有样本(⇒ **删样本 = 红,并指名删的是哪一种**);
· ② 每个样本都必须能被认出来(原有方向);
· ③ 多出来的样本未登记进 `REQUIRED_FORMS` ⇒ 也红(加了样本却没说守哪种写法)。

★ 为什么不用 `assert length >= 5`:那个数**自己**就是可以随手改小的常量,
改小它不会留下任何痕迹。具名清单让"我要放弃支持箭头函数写法"变成一次**看得见的声明**。
成功报文也随之带上"(要求 5 种,缺一即红)",让**要求**和**实际**同时可见。

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

| 变异 | 结果 |
|---|---|
| **M45** 删掉 3 个样本 | **红**:`这些写法要求覆盖、却没有样本:箭头函数、async function、名与括号间有空格`,`red=9→10`(**修复前 0 条 RED**) |
| **M46** 加一个未登记的多余样本 | **红** 2 条:`不在要求清单里` + `发现规则认不出这些写法` |
| 回归:**M40/M41/M42** 箭头/async/带空格三种未接线写法 | 各 **1 条报警**(`957ec5e` 的修复未被破坏) |
| 回归:**M43** 发现规则退化回窄正则 | **红**:`认不出…` |

## 四、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 … verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;
  自检段现为 `…发现规则在 5 种写法上都有对照(要求 5 种,缺一即红)`。
· 残留全清;`git status` 仅本文件;`HEAD=957ec5e`。
· 同期 pi 已把我在它插件里报的那例修好并提交 `cc8beb7`(`--self-check` 改进程内 + 默认路径先跑 +
  量纲分三档)。我**复核过**它:干净树 rc=0 且自检真出现在默认路径上,变异 `findDuplicates → []`
  ⇒ `npm test` **rc=3** 并打出"工具坏了,不是被测代码坏了"。它的目录我一行未动。
2026-09-18 07:23:31 +08:00
957ec5ec77 修复: SELFTESTS 接线防线只认一种写法 —— 箭头/async/带空格三种形式**既不跑也不报警**
pi 2026-09-18 报的。**成立,我实测复现**;这是 `765b77e` 新加那道防线**内部**的缺口。

## 一、缺陷:防线的正则只认一种写法

原实现内联了一条 `^function (\w*SelfTest)\(\)`。四种写法实测:

| 写法 | 是否被扫到 |
|---|---|
| `function fooSelfTest() {}` | **✓** |
| `const fooSelfTest = () => {}` | **✗ 漏** |
| `async function fooSelfTest() {}` | **✗ 漏** |
| `function fooSelfTest (a) {}`(名后有空格) | **✗ 漏** |

端到端后果(我复现):插一个**箭头形式**的未接线自检,其 body 若跑会打 RED ⇒
body 的 RED **0 次**(没跑)、报警 **0 条**(也没说"没接线")⇒ **既不跑、也不报警**。
而这正是这条防线**自己存在的理由**("防写了自检却没接线"):
它对**一种写法**有效,对另一种**沉默**。

⚠️ 我第一遍数"报警次数"时得到 **1**,差点当成"它报了" ——
那 1 次命中的是成功报文里的 `(无"写了没接线")`,**是同一句话的另一个方向**。
精确判据要看 `^RED ` 行:**0 条**。**数命中 = 数到的是词,不是行为。**

## 二、修法:唯一实现 + 反面对照

1. 抽出 `declaredSelfTests(code)`,**唯一实现**,覆盖常见写法:
   `^[ \t]*(?:async\s+)?function\s+(\w*SelfTest)\s*\(` 与
   `^[ \t]*const\s+(\w*SelfTest)\s*=\s*(?:async\s*)?\(?`。
2. **反面对照**(pi 建议,我照做):5 种合成样本要求**每种都被认出**;
   另加反方向"收进了不以 `SelfTest` 结尾的名字"。
   样本与生产扫描调的是**同一个函数**(不抄副本)。
3. 成功报文带上"发现规则在 N 种写法上都有对照",让覆盖数**看得见**。

## 三★ 我修的时候引入过一个更坏的机制,实测后回退了

我第一版为"别咬到字符串里的同名文字"加了 `stripStrings`。**实测它更坏**:
`read.mjs` 自己写明 `stripStrings` **不区分正则字面量**,一个**不配对**的引号就能把
后面一大段当字符串吞掉,方向是**假绿**。实测插一条 `const QUOTE_RE = /["']/;`
在自检声明之前 ⇒ 后面**所有真声明消失** ⇒ 未接线的 `hiddenSelfTest` **静默漏掉**,
而 5 个真函数反过来变成"幽灵"(报红,但**报的是错的那件事**)。
⇒ 为防"假红"引入了"假绿 + 误报",**不划算**。已回退为只用 `stripComments`
(代价仅是"字符串里恰好写着 `function xSelfTest()` 会假红"——
按本仓口径:**假红是噪音,假绿是绕过**,选噪音)。回退后复测:那个陷阱消失(`hiddenSelfTest` 被抓)。

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

| 变异 | 结果 |
|---|---|
| **M40** 箭头形式的未接线自检(**pi 报的那条**) | **红**:`写了但没接线:arrowSelfTest` |
| **M41** `async function` 形式 | **红**:指名 `asyncSelfTest` |
| **M42** 名后有空格 | **红**:指名 `spaceSelfTest` |
| **M43** 发现规则**退化回窄正则** | **红**:`认不出…箭头函数、async function、名与括号间有空格、const = async (…`(⇒ 样本真的会红,不是摆设) |
| **M44** 发现规则**过宽**(连 `applyMutants`/`verdictOf` 都收) | **红**:`收进了不以 SelfTest 结尾的名字:…` + 幽灵清单 |
| 不配对引号的正则字面量(`stripStrings` 陷阱) | 回退前**静默漏掉**;回退后**红** |

## 五、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 … verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;
  自检段新增"发现规则在 5 种写法上都有对照"。
· 耗时 ~5162ms(与 `765b77e` 的 ~4917ms 同量级)。
· 残留全清;`git status` 仅本文件;`HEAD=765b77e`。
2026-09-18 07:18:32 +08:00
765b77ea06 修复: 五个自检**全仓没有任何入口调用** —— npm test 一个 flag 都不带,判据存在但不在路径上
pi 2026-09-18 报的。**成立,我独立复现了**,读数与 pi 逐字相同。

## 一、缺陷:自检本身到不了任何入口

五个自检(`probe`/`exitcode`/`skip`/`verdict`/`mutants-line`)原来**只**由
`process.argv.includes('--…')` 守着,而**全仓没有任何调用方传那个 flag**:
`npm test` = `node test/run-all.mjs && vitest run && npm run typecheck` —— 一个都不带。

实测:
· 默认跑输出里这些自检的行数 = **0**;
· 把 `UPSTREAM_RC['baseline-residue']` 改坏(表与真脚本不符)⇒ **默认跑**
  `checks=459 pass=455 fail=4 skip=0 red=9 …`,与干净树**逐字相同**;带 flag 才 `rc=1`;
· 全仓 `grep` 这些 flag ⇒ **只命中 run-all.mjs 自己内部**的 5 处 guard + 注释,
  没有任何调用方、没有任何文档。
⇒ **"跑得动的锚点"确实存在,但它不在任何一条会被人跑的路径上。**

★ 形状与这几轮同族,但**伪装得最好**:guard 看起来就在跑、注释声称"每次跑都钉住"、
`grep` 也找得到那一大段代码 —— **三者都不是"在路径上"的证据**。
前几轮是"结论到不了读者 / 到不了 verdict / 被关掉";
**这次是"自检本身到不了入口"** ⇒ **"判据存在 vs 判据在路径上"**。

★ 而且**方向最危险**:这五个守的恰好是最要紧的几格(`UPSTREAM_RC` 与真脚本一致、
⑤ 双向口径、跳过解析、探针三值、决策顺序)。`--probe-selftest` 从 09-14 就在,
**四天里一次没跑过**。

## 二、修法:进程内接线(pi 建议 ①,但按实测改了做法)

把五个 `if (process.argv.includes(…))` 块改成 `function …SelfTest()`,
`SELFTESTS` 里**直接调用**,红**并入 `reds`**(⇒ 进 `verdict`,不是"只打印不改颜色")。
每个函数后面紧跟一个 CLI 守卫 ⇒ **单独跑时行为完全不变**。

★★ **我没有照 pi 的原话用子进程**,因为它的代价估计不准:
pi 写"多几次进程启动(可忽略)",而我实测 —— **每个 flag 都会先把整个套件跑完**
(guard 在 SUITE 循环**之后**),默认 ~4.3s、带任意 flag ~4.3–4.9s
⇒ **5 个子进程 ≈ 22s**。而自检 body 合计只 ~950ms。
⇒ 进程内调用的实测代价:**4327ms → 4917ms(+590ms)**,而不是 +22s。

## 三、接线时我踩的两个坑(都当场红,照实记)

1. **TDZ,同一个坑第二次**:我把接线块放在定义旁边(行 ~1448),而 `DIAG` 是 `const`、
   定义在 ~1721 ⇒ `verdict`/`mutants-line` 两个自检抛
   `ReferenceError: Cannot access 'DIAG' before initialization`。
   **这个坑我在 `--mutants-line-selftest` 上已经踩过一次**。⇒ 移到所有 `const` 表与
   自检函数**之后**,并在注释里写明"任何调用自检的代码都必须排在所有定义之后"。
   ⚠️ 顺带一个教训:**"接线成功"与"被接的东西真跑了"是两件事** ——
   我第一版接线后红数也变了,但其中两条是**抛异常**贡献的,不是自检的结论。
   我是看到"(它没打出 RED 行,真因见上)"这句**自己写的话**才回去查的。
2. **吞 stdout 不能吞 stderr**:只吞 `console.log`,`console.error` 不动 ——
   否则"自检抛了异常"会被我自己盖住(本仓"错误信息被吞掉"那条老教训)。

## 四★ 变异时发现的**更大一条**:判官坏了,红被静默吞掉(M39)

把 `verdictOf` 改成**恒返回 `'green'`** ⇒ 默认跑
`red=10 … verdict=green`、**exit=0**、而且**红清单一行都不打**(green 分支提前 return)。
⇒ 自检红**确实进了 `reds`**,但"决定要不要报红"的那一格**正是被变异的那一个**:
**10 条红被静默吞掉**。这条老形状这次坏的是**判官自己**。

⇒ 加一条**独立**的安全网(不靠 `verdictOf` 自证):
`reds`/`brokens`/`selfCheckFailed`/`dueFailed` 有任何一个非空而 `verdictOf` 却说绿
⇒ **判官与账本不一致**,直接 `exit(1)` 并说明。
**判据与判官互为对照,谁单独说了都不算数。**

## 五、结构防线:**"写了自检但没接线"必须红**(pi 建议 ②)

pi 那条观察很准且我照抄进注释:本文件**自检 2** 的存在理由就是"防**写好了没接线**",
而它只扫 `*.test.mjs` ⇒ 这五个自检**因为不是 `*.test.mjs` 正好扫不到**。
**同一句话,换了个对象就漏了。**
⇒ 扫自己的源码:`function *SelfTest()` 必须在 `SELFTESTS` 里被调用(`prose`/`stripComments`),
另加"登记了不存在的函数"的反向检查。

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

| 变异 | 结果 |
|---|---|
| **破坏 `UPSTREAM_RC`**(pi 的那一条) | 默认跑 **red 9→11**,两条自检红并指名不符的码 |
| **M36** 加第 6 个自检但不登记 | **红**:`写了但没接线:sixthSelfTest` |
| **M37** `probeIsDue` → `v === true`(闸锈死) | **红**:`--probe-selftest 红了:unknown…(probeIsDue("unknown") = false)` |
| **M39** `verdictOf` 恒 green(判官坏了) | **红**:`判官与账本不一致…`(修复前 **exit=0、红清单不打印**) |

## 七、验证与状态

· 五个自检**单独跑仍全部 rc=0**(CLI 路径行为不变)。
· 默认跑:`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✓` —— 与 pi 读数一致,
  并新增一行 `自检段:5 个自检已在**默认路径**内跑过…源码里 5 个 *SelfTest 函数全部在册`。
· 耗时 **4327 → 4917 ms**(进程内;子进程方案会是 ~22s)。
· 顺手改掉 pi 指出与实现相反的那句注释("每次跑都钉住"⇒ 当时是假的,现在才成立)。
· 残留全清;`git status` 仅本文件;`HEAD=62297e3`。
2026-09-18 07:04:11 +08:00
62297e3d83 修复: baseline-absent 同时是"关掉残留检测"的开关 —— 一个 rm 就能让残留仍在而那条红整条消失
pi 2026-09-18 报的。**成立,我端到端复现了**,而且是这条线上同一形状的**最后一跳**。

## 一、缺陷:结论不是"到不了读者",是**被关掉了**

`baseline-absent` 原来是 `{note: null, blocksGreen: false}` —— **既不说话、也不阻断**,
而它同时是"**关掉残留检测**"的开关。同一处残留,**只切 `baseline.sha` 在不在**:

| | summary.py | 套件 | 红清单里有 residue 吗 |
|---|---|---|---|
| `baseline.sha` 在 | `diag=baseline-residue` | `red=10 … verdict=red` | **有** |
| **删掉** | `diag=baseline-absent` | `red=9 … verdict=red` | **没有**(整条消失、`rc=0`、静默) |

`git status` 里 ` D baseline.sha` 与 ` M <被改文件>` **同时都在** ⇒ **残留还在**,
而唯一能报告它的机制被一个 `rm` 关掉,**不需要特权、不会红、也不说话**。

前几轮分别是"结论到不了读者"、"结论到不了 `verdict`";**这次是"结论被关掉了"**。

## 二、修法:把"缺席"分成两态,并给非阻断码再分两类

1. **`baseline-deleted`(新码,退 1)**:`baseline.sha` **受版本控制却被删**
   ⇒ 那不是"可接受的配置",是**缺文件**(该恢复它)。判别用既有的东西、不新造:
   `git ls-files --error-unmatch` —— 三态:**0=受跟踪 / 1=没跟踪 / 128=git 答不了**,
   `128` 时**不许猜**,退回缺席语义但**必须说话**。
2. **`baseline-absent` 补 `note`**(原来 `null` ⇒ 连读者都看不到):
   说清"**这一格没检查**:没有底本 ⇒ 残留检测是关着的",并区分"本来就没跟踪它"与"判不出"。
   —— 这是 pi 的"最小版",我认为它**不足以**独当(沉默正是这个洞的成因),所以两条都做。
3. ★ **`DIAG` 每个码新增 `checked` 字段**(pi 那条建议的落地):
   · `checked: true` = "**检查了、结论无害**"(`baseline-stale`:可以静默);
   · `checked: false` = "**根本没检查**"(`baseline-absent`/`counts-unusable`/
     `baseline-unrunnable`/`baseline-unknown`:**必须说话**)。
   ⇒ 把 pi 的"应当分两类"从**文案要求**变成**结构字段**。

## 三★ 我加第一版时漏了一条判据,自己变异出来(M34)

`checked` 只是一个字段,**它的内容当时没有任何判据守着**:我把 `baseline-absent` 的
`note` 改回 `null`(= pi 说的"最小版没做"),`--verdict-selftest` **全绿** ——
因为此前的判据只管 `blocksGreen` 存不存在、两侧口径对不对。
⇒ 补第 ⑦ 条:**`checked: false` 的码必须有 `note`**,且**每个码都必须声明 `checked`**。
又一条"判据在,但走不到":字段加上了,没人查它的内容。

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

| 变异 | 结果 |
|---|---|
| **M32** `baseline-deleted` 退回"当配置缺席"(**即 pi 报的那个洞本身**) | **红**:`UPSTREAM_RC[baseline-deleted]=1 与真跑出来的 rc=0 不符` |
| **M33** 关掉 `bl_tracked` 判别(一律当缺席) | **红** 2 条:端到端恢复 pi 那个洞(`diag=baseline-absent`)+ 反向覆盖报"该码没案例" |
| **M34** `baseline-absent` 的 note 改回 `null` | **红**:`这些码根本没检查、却一句话都不说:baseline-absent`(**上一版这里是 rc=0**) |
| **M35** 删掉某个码的 `checked` 字段 | **红**:`这些码没声明 checked:baseline-stale` |

## 五、端到端锚点(新增一条 `--exitcode-selftest` 案例)

`runIn` 加 `'deleted'` 模式:**先提交 `baseline.sha` 纳入跟踪、再把目标文件改脏、然后 unlink**
⇒ 判 `rc=1 且 diag=baseline-deleted`。这条**就是 pi 那个实验的自动化版本**:
残留是真的存在的,而底本被删了。反向覆盖随之报 `9 个码都有案例真跑验过`。

**真树端到端复核**(真残留 + 真删 `baseline.sha`):`red=10`(修复前 9)、`verdict=red`、
红清单里出现 `(summary.py)baseline-deleted —— 底本 baseline.sha **受版本控制却被删** ⇒ **残留检测已失效**`。

## 六、验证与状态

· 五个自检全 exit 0(`--verdict-selftest` 新增第 ⑦ 条;`--exitcode-selftest` 11→12 条案例、9 个码全覆盖)。
· 正常态与 pi 读数一致:`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✓`。
· `baseline.sha` 的 7 条都是鸿蒙源码文件,**不含** `summary.py`/`run-all.mjs`
  ⇒ 本次改动**不需要重算底本**(`baseline=7/7✓` 亦为证)。
· 残留全清;`git status` 仅这两个文件;`HEAD=2870d47`。
2026-09-18 06:49:10 +08:00
2870d479f8 更正: 我自己在两处注释里把"判据没答"写成了"判据答错了" —— 与 pi 更正的是同一个错,两人都犯在这条上
pi 2026-09-18 更正了它上一封的一处**证据标注**:M31(假绿方向)**不能**证明"⑤ 失真",
它只证明"⑤ 对白名单外的码**不表态**"。结论(⑤ 与注释不符、没读 `summary.py`、白名单硬编码)不变。

## 一、pi 的更正成立,而且**我在同一件事上犯了同一个错**

我实测复现(把 `d07494e` 的旧⑤ 装回真目录里跑,依赖齐、能真启动):

| 旧⑤ 遇到 | rc | 提到该码 | 行为 |
|---|---|---|---|
| 新码 `blocksGreen: false` | 1 | **1 次** | 报`上游退非零,下游却不红` ⇒ **编造**了一个上游事实 |
| 新码 `blocksGreen: true` | 0 | **0 次** | **一片沉默** —— 它什么都没说 |

⇒ pi 说得对:**假绿方向是"判据没答",不是"判据答错了"**。
编造会让人去找不存在的问题;**沉默会让人以为它答过了**。这是两件事。

★ **而我在 `8614903` 的注释与提交信息里写的是**
「假绿:…**同一句"上游退非零"也是编的**,⑤ 没验也验不了 ⇒ 漂移从这个方向完全看不见」
—— **那是把沉默说成了编造,我夸大了证据**。pi 更正的是它自己,我顺带发现**我得改的是两处代码注释 + 一条已发出的提交信息**。

(这条错的形状值得记:**"它没报" 与 "它报错了" 在输出上都是"没有正确的红"**,
  不逐条数"它到底说了这个码没有",就会把沉默读成答错。我这次是用 `grep -c hypothetical-blocking`
  得到 **0** 才看清的。)

## 二、改准(两处注释,同一段论证的两个副本)

`DIAG`/`UPSTREAM_RC` 之前那段注释、以及 ⑤ 之内那段注释,都改成:
· 假红 = **判据答错了**(编造上游事实);
· 假绿 = **判据没答**(沉默,实测 0 次提及);
· 并写明**我原先的写法是夸大证据**。

## 三、pi §二 那个"洞":**我的实现已经补上,并实测过它挡得住**

pi 自查出它建议里的洞:抽 `(diag, rc)` 唯一权威之前,先要有覆盖全 8 个码的真跑表,
否则新⑤ 会对 `undefined` 判"没登记上游"⇒ **天天假红**(M30 那个病换地方复发)。
pi 说这是它上一封**没说**的。

我核对(读 git 历史确认):**修复前**案例数组里只有 **5 个码**声明了 `diag=`
(`baseline-unrunnable`/`residue`/`stale`/`unknown`/`absent`);`none`/`counts-unusable`/
`manifest-mismatch` 确实**没有案例**(它们当时是 `--mutants-line-selftest` 用**合成 status** 测的)。
⇒ pi §二 的描述**准确**。

★ 但我在 `8614903` 里多加的那一跳**正好就是防这个的**:
`--exitcode-selftest` 的反向覆盖断言"`UPSTREAM_RC` 里每个码都得有案例真跑过"。
**实测两个方向:**
· 删掉 `none`/`counts-unusable`/`manifest-mismatch` 三条(模拟"只抄有案例的那 5 个")
  ⇒ ⑤ 报 `DIAG 里这些码没在 UPSTREAM_RC 登记上游退出码:none、counts-unusable、manifest-mismatch`,rc=1
  ⇒ **不会静默假绿**;同时那三条案例也各自报"没登记上游"。
· 反向覆盖报 `UPSTREAM_RC 的 5 个码都有案例真跑验过`(覆盖集随之收缩,看得出缺口)。

## 四★ 补一个 pi 没问、但我认为必须量的:表到底**绑住真脚本**没有

pi §二 的担忧本质是"这张表可能只是抄的"。⇒ 我**改真 `summary.py`**(不动表)来验这一跳:
· `manifest-mismatch` 的 `return 1` → `return 0`
  ⇒ 红:`UPSTREAM_RC[manifest-mismatch]=1 与真跑出来的 rc=0 不符`(两条案例各报一次)
· `baseline-residue` 的 `return 1` → `return 0`
  ⇒ 红:`UPSTREAM_RC[baseline-residue]=1 与真跑出来的 rc=0 不符`

⇒ **改真脚本能被抓住** ⇒ `UPSTREAM_RC` 确实绑在真脚本上,不是抄的。
(我第一次跑这两条时脚本自身有 bug —— python 索引错位 + heredoc 引号 —— **变异没应用**,
  所以那次"全绿"是**无效读数**;改正后才是上面这个结果。照实记。)

## 五、验证与状态

· 五个自检全 exit 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✓`。
· `git status` 仅本文件;`HEAD=8614903`;`/tmp` 无残留。
2026-09-18 06:38:28 +08:00
8614903bc9 修复: --verdict-selftest 第⑤条"口径一致性"**没读上游** —— 注释说"不写死",实现恰恰写死了一份码名单;两个方向都失真
pi 2026-09-18 报的。**成立**,我按它给的两个构造各复现一次,两个方向都坏。

## 一、缺陷本体:注释与实现相反

注释(原话):

> 这条把 `summary.py` 的退出码分类与 `run-all` 的表**绑在一起**验(**不在一边写死另一边的值**)。

实现:遍历 `DIAG` 表 + 跳过三个**硬编码**码名
(`if (code === 'none' || code === 'baseline-absent' || code === 'baseline-stale') continue;`)
—— **一个字节都没读 `summary.py`**。

⇒ 它实际断言的只是"**除了我手列的这三个,其它码都必须 `blocksGreen: true`**"。
**注释说"不写死",实现恰恰就是写死了一份名单。** 它是"按字面量裁射程"的又一例,
只不过这次长在**自检里的一条断言**上,而报文写着"两侧口径漂移",
读的人不会怀疑它其实没查上游。

## 二、两个方向都实测失真(都已复现)

· **假红**:加一个**上游退 0 的提示码**(与 `baseline-stale` 同类、完全合法)
  ⇒ 报 `上游退非零,下游却不红 ⇒ 两侧口径漂移`、rc=1。
  **那个码的上游根本不存在**(`summary.py` 永远不会打出它)——
  ⑤ 在这里**编造了一个上游事实**并据此判红。
· **假绿**:加一个 `blocksGreen: true` 的新码 ⇒ **rc=0,一条 RED 都没有**。
  同一句"上游退非零"也是编的 ⇒ 漂移从这个方向**完全看不见**。

## 三、修法:把 `(diag, rc)` 抽成唯一权威,链条每一跳都有判据

新增 `UPSTREAM_RC`(码 → 退出码),然后:

1. **`--exitcode-selftest` 把它逐个真跑验一遍**:每条案例用**真脚本真跑出来的 rc**
   去核对那张表,并**反向覆盖**——表里每个码都必须有案例真跑过,否则报
   "表里的值是没人验过的"。(这一跳是地基:若 `UPSTREAM_RC` 只是手抄的希望值,
   ⑤ 拿它比 `blocksGreen` 仍可能**两边一起错而且自恰地绿**。)
2. **⑤ 改为双向比对** `UPSTREAM_RC ↔ blocksGreen`:
   上游非零却不红 ⇒ 漂移;上游 0 却标阻断 ⇒ 也漂移(**上一版查不到的方向**);
   码没登记上游 ⇒ 红("加码要同时登记它退几",与 `blocksGreen` 那条"缺字段即红"是同一句纪律的另一半)。
   **白名单随之消失**(数据说话)。

★ 链条现在是 **真脚本 → `UPSTREAM_RC` → `DIAG.blocksGreen`**,两个方向真绑上了。

## 四、我写第一版时又犯的两处(都当场红)

1. **按"声明的"码去找**:我第一版用 `wantOut` 取码 ⇒ `wantOut=null` 的四条
   (一致 / 说明条目 / 未列入清单 / 清单有磁盘无)**一个码都没验到**,
   反向覆盖当场报出 `none`、`counts-unusable`、`manifest-mismatch` 三个码"没人验过"。
   而这三个码真跑出来是 0 / 2 / 1(我单独量过,**表里是对的**)⇒ **是验法漏了,不是值错了**。
   ⇒ 改成从**脚本真打出来的 stdout** 里取 `diag=` —— "验的是它真说的",而不是"我以为它会说的"。
2. **报文自相矛盾**:⑤ 无条件打 `ok` ⇒ 变异 (a) 输出长成
   `RED DIAG 里这些码没在 UPSTREAM_RC 登记…` 紧跟 `ok 口径一致(双向):9 个码…`。
   **与结构锚点那次是同一个错,我又犯了一遍** ⇒ 改成"这一格没红才打 ok"。

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

| 变异 | 期望 | 结果 |
|---|---|---|
| **(a)** 加一个上游退 0 的提示码 | 红 | **红**,报文说"**没登记上游退出码**"(不再编造"上游退非零") |
| **(b)** `baseline-stale` 标成 `blocksGreen: true` | 红 | **红**:`两侧口径漂移:baseline-stale(上游 rc=0,却 blocksGreen=true)` —— **上一版完全看不见的方向** |
| **(c)** `UPSTREAM_RC['baseline-residue']` 改成 0(与真脚本不符) | 红 | **红**:`UPSTREAM_RC=0 与真跑出来的 rc=1 不符` |
| **(d)** `UPSTREAM_RC` 里塞一个 `DIAG` 没有的码 | 红 | **红**:`两张表漂移` |

## 六、验证与状态

· 五个自检全 exit 0。`--exitcode-selftest` 现在逐条报"真打出 diag=…,UPSTREAM_RC=… ✓",
  并断言 `UPSTREAM_RC 的 8 个码都有案例真跑验过`;`--verdict-selftest` 报
  "口径一致(双向):8 个码逐条相符,且无未登记/多余码"。
· 全套件 `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✓` —— 与 pi 读数一致。
· 残留全清;`git status` 仅本文件;`HEAD=d07494e`。
2026-09-18 06:30:35 +08:00
d07494e8fd 修复: "该红的红"没到达 verdict —— summary.py 的 status 只喂了文案,没喂决定颜色的那一格
pi 2026-09-18 本封。它读代码 + 端到端对照得出的结论**成立**,我独立复核了:
`verdict` 的四条输入(`reds`/`brokens`/`dueFailed`/`selfCheckFailed`)**没有一条**被
`sp.status` 喂过 —— 全部消费者只有两处、都是文本(`mutantsLine` 一行、`whyLines` 几行打印)。
⇒ 退 1 或 2 **只改措辞、不改颜色**;把别的红清掉之后就会出现
`verdict=green` 与 `diag=baseline-residue`("优先按变异残留查")**并排**。

(★ 比我上一轮修的那几处**好一档**:读者**看得见**了 —— 前几例是连读者都看不到。
  但"变异可能没还原"坐在 green 旁边,仍是不能不管的形状。)

## 一、修法:诊断码表同时决定"红不红",接线**只此一处**

1. `DIAG` 表改成 `{ note, blocksGreen }` —— **一个码一行,两个字段一次定完**。
   分开写就是"同一事实两份实现"(本仓已反复消:`blurStyleFor`、`stripComments`、
   `want.why`、`.tmp` 台账路径)。放一起的结构性好处:**加一个新码就必须同时决定它红不红**。
   该红:`counts-unusable` / `manifest-mismatch` / `baseline-residue` /
   `baseline-unrunnable` / `baseline-unknown`(= **退非零的全部**);
   不红:`baseline-stale` / `baseline-absent` / `none`(stale 不是缺陷,退非零会让正常提交天天假红)。
2. `summarizeMutants` 多返回一个 `red`(人类可读的一句)。**不认识的码也红** ——
   上一版这里只改了**措辞**(打"请同步 DIAG")却仍然可以绿,**同一条缝的又一跳**。
3. `reds.push(r.red)` 走 pi 建议的那条路径(运行器自己推的红);
   顺手补上**连 `summary.py` 都起不来**那一支(原来只改文案 ⇒ 整个读数器没跑起来也照样能绿)。

## 二★★ `verdict` 判定抽成纯函数 ⇒ **pi 说"代价不值"的那个实验变得可构造**

pi 明说它没做"清掉全部红后 `verdict` 真变绿"的完整实验,因为要动 SUITE 登记数、会撞接线自检。
⇒ 把判定抽成 `verdictOf({reds,brokens,dueFailed,selfCheckFailed})`,那个实验**只要传空数组**:

```
情形            verdict   reds   该不该绿
正常            green     0      绿   ✓
残留            red       1      非绿 ✓     ← 修复前这里是 **green**(缺陷本体)
过期            green     0      绿   ✓
读数器没跑起来    red       1      非绿 ✓
```

★ **修复前的反面对照**(把接线退回"只改措辞"):残留 = `green`、读数器没跑起来 = `green`
⇒ 正是 pi 描述的那个"并存",**现在能跑出来了**。

## 三★★ 加自检时我自己的两处错(都当场红,照实记)

1. **自检验的是副本**:第一版自检自己写 `reds = r.red ? [r.red] : []`。
   ⇒ 我把生产那行 `if (r.red) reds.push(r.red);` **整行删掉**,自检**照样全绿**(实测 M24)——
   因为它验的是**自己复刻的接线**。本仓老形状(`parseSkips` 抄份、`want.why` 现算一遍)第 N 次。
   **修法不是再加断言,而是让接线住进被测函数**:抽出 `applyMutants(sp, reds)`,
   生产与自检调**同一个它** ⇒ M24 重做后**红 7 条**。
2. **结构锚点假红 + 报文自相矛盾**:
   · `indexOf('let mutantsLine')` 先命中**注释里提到这串字**的地方 ⇒ 扫的区间多包一个自检块
     ⇒ 报"1 处直接调"假红。改用 `lastIndexOf`(与自检 4 当年同一个坑,结论照抄)。
   · 结构锚点**无条件**打 `ok` ⇒ M27 的输出长成 `RED … RED … ok 结构锚点:…`,
     一个 `ok` 跟在自己的红后面。改成"没红才打 ok"。

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

| 变异 | 期望 | 结果 |
|---|---|---|
| **M24** 删掉 `applyMutants` 里的 push(上一轮逃掉的那个) | 红 | **红 7 条**(修法①之后) |
| **M25** `baseline-residue` 的 `blocksGreen` 改 false | 红 | **红**,`残留` 报 green |
| **M26** `verdictOf` 恒返回 green | 红 | **红 7 条** |
| **M27** 生产绕过 `applyMutants`(直接调 `summarizeMutants`) | 红 | **红 2 条**(结构锚点抓住) |
| **M28** 端到端:让 `summary.py` 不存在 / `PATH` 无 `python3` | 红 | **红**(`red=10`、`verdict=red`) |

★ **M28 顺带查清一件事并写进注释**:`catch` 那一支**本机覆盖不到** ——
`summary.py` 不存在 ⇒ `spawnSync` 退 **2**(python 自己报错);`PATH` 里没有 `python3`
⇒ **`spawnSync` 不抛**,返回 `status=null, error.code='ENOENT'` —— 两条都走 `!m` 那一支。
`spawnSync` 只在参数非法时才同步抛。⇒ 照 pi 那句"前提构造不出来就写明":
**catch 那条红是兜底、不是实测通路**,注释里说清哪条是实测的(`!m`,两条都端到端验过)。

## 五、口径固定(回 pi §五)

`stale ⇒ 提示(rc=0,不进 verdict)` / `residue ⇒ 红(rc=1,进 verdict)` ——
pi 说这条口径要等 §四 修完才能真正固定,**现在固定了**:`--verdict-selftest` 里
`baseline-stale` 判"**可以**绿"、`baseline-residue` 判"**不许**绿",两条同时绿才算数。

## 六、验证与状态

· **五个自检全 exit 0**(新增 `--verdict-selftest` **12 例**:9 个情形 + 码表字段完整性 + 口径一致性 + 结构锚点)。
· 全套件 `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✓` —— 与 pi 读数一致。
· 端到端对照:残留态 `red=9 → red=10`(多出的那条点名 `(summary.py)baseline-residue`);正常态仍 `red=9`。
· 残留全清;`git status` 仅本文件;`HEAD=93dabb4`。
2026-09-18 06:20:36 +08:00
93dabb435d 修复: baseline= 那一格到不了套件(捕获组是前缀 + 过滤器不含它);baseline-unrunnable 退回 rc=0 的自相矛盾;并把「脏/干净/git 答不了」从两态改回三态
pi 2026-09-18 报的两条我都端到端复现了,都成立。修的过程中又发现**第三条**(我自己引入的),以及**自检自己的一处"判据在但走不到"**。

## 一、① `baseline=` 那一格从来不进套件输出(pi 报的第 ① 条)

两处都实测确认:
· 捕获组是**前缀**:`m[1]` = `RESULT mutants=48 ran=47 skipped=1 on_new_criteria=35`,`'baseline' in m[1]` = **False**;
· `whyLines` 的过滤器不含 `baseline=`。
⇒ 两条路都不通。**真·变异残留**时 `baseline=6/7✗**1 个文件既不在底本、也与 HEAD 不同 ⇒ 优先按"变异残留"查**`
在套件输出里 grep = **0 次**,而 `rc=0`、`mutants=48 ran=47 skipped=1` **看着完全正常**(我按你给的形状复现)。

**修法**:不扩正则、也不往过滤器里加关键词(那是"按字面量裁射程",本仓已栽过两次:
`criteria-hygiene` 的 import 硬匹配、`prose()` 判"用没用")——
改为 `RESULT` 行末尾加**结构化诊断码** `diag=<码>`,由 `run-all` 的 `DIAG_NOTE` 表逐码给一句准确的话:

| `diag` | rc | 含义 |
|---|---|---|
| `none` | 0 | 正常 |
| `manifest-mismatch` | 1 | 清单与磁盘不一致 |
| `baseline-residue` | **1** | **真·变异残留**(与底本不同**且**与 HEAD 不同) |
| `baseline-stale` | **0** | 底本过期(与 HEAD 逐字节相同,**不是缺陷**) |
| `baseline-unrunnable` | **2** | 跑不了 `sha256sum`(没读数) |
| `baseline-unknown` | **2** | git 答不了 ⇒ 判不出残留还是过期(没读数) |
| `counts-unusable` | 2 | 读不到 `jobs/` |

码表是**闭合**的:**不认识的码会报警**,所以"上游加了新状态而下游不知道"会立刻显形,
不会静静变成"没问题"。同时把 `baseline=` 的**读数本身**(`6/7✗`)也摘进那一格 ——
端到端实测:残留时 `grep baseline=` 从 **0 → 1**,rc 从 0 → **1**。

## 二、② `baseline-unrunnable` 原来 rc=0,与我自己的原则相反(pi 报的第 ② 条)

确认。我在 `blind/unreadable` 上定的是"**没读数不是成功 ⇒ 2**",而"跑不了 `sha256sum`"**是同一件事**,
却退 0 ⇒ 走正常路径 ⇒ 那句"是没读数"同样到不了读者。**我把"跑不了"和"没过"分成了两种状态(对),
但第三种状态(跑不了)没给它传播通道** —— 你这句说得准。

⇒ 现在 `baseline-unrunnable` 与 `baseline-unknown` 都退 **2**,
且措辞由 `diag` 决定而**不是**由 `status` 决定:`status === 2` 那句通用话会说"上面的 mutants 数字**不可信**",
而这里是**假话**(数字照常有效,**只有 baseline 那一格**没读数)。所以措辞一律走 `DIAG_NOTE`。

## 三★★ 我自己引入的第三条:`git` 三态被读成两态(假警报指向最危险结论)

修 ① 时我要构造 residue,才发现:`git diff --quiet` 退 **1** 有**两种**含义 ——
实测 · 仓库内干净 = **0** · 仓库内已改 = **1** · **非仓库/HEAD 取不到 = 也是 1**(stderr `error: Could not access 'HEAD'`)。
而原代码是 `.returncode != 0` ⇒ **"git 根本没答"被读成"有差异" ⇒ residue**,
也就是这一格**最危险**的读数。我自己在临时目录里跑时就撞到了:`REPO` 解析成 `/` ⇒ 一律报 residue。

⇒ 加 `can_ask`(`git rev-parse --is-inside-work-tree HEAD`,非仓库退 **128**,可区分):
答不了就报 `baseline-unknown`(**没读数**),**不许**声称 residue。这与上面 §一 那段注释警告的方向一致:
**假警报指向最危险的结论**。

## 四★★ 自检自己的一处"判据在,但走不到"

补 `baseline` 四态锚点时发现:旧 `runIn` 的临时目录是 `/tmp/xxx/`,
而 `summary.py` 用 `REPO = HERE/../../../..` ⇒ **`REPO` 解析成 `/`**(实测打印过)。
于是 `baseline=` 那一格**在原来的四例里从未被真正走成"对"**:`sha256sum -c` 在 `/` 下全部 FAILED、
`git` 也答不了。它们期望 rc=0/1 却看着"通过",是因为**两个错误互相抵消**
("判不了"被读成"有残留" **且** residue 当时退 0)。

⇒ 重建 `runIn`:按脚本真实位置**嵌套**建目录 + 造一个**迷你真仓库**(`git init` + 一次 commit +
一个可提交可改脏的目标文件),让 `REPO` 落在仓库根上。现在 `baseline=` 的**四态**
(对 / 过期 / 残留 / 判不了)**每一条前提都构造得出来**。

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

| 变异 | 期望 | 结果 |
|---|---|---|
| **M21** residue 退回 rc=0(pi 报的原始行为) | `真·变异残留⇒1` 红 | **红**,`stale` 照旧绿(两条独立) |
| **M22** `unrunnable`/`unknown` 退回 rc=0(pi 本封 ② 的原始行为) | 两条都红 | **都红** |
| **M23** `can_ask` 恒真(三态退回两态) | `git 答不了` 红(会把"判不了"说成 residue) | **红**,rc 报 1(正是那个假警报) |

## 六、验证与状态

· **11 条锚点全绿**:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / 盲读⇒2 / 单文件⇒2 /
  **无 sha256sum⇒2** / **残留⇒1** / **过期⇒0** / **git 答不了⇒2** / 没有 baseline⇒0。
· `--mutants-line-selftest` **9 例**(含新增 `baseline-stale` 与"不认识的码⇒报警");四个自检全 exit 0。
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`、`mutants=48 ran=47 skipped=1 diag=none`(与你读数一致)。
· 端到端:残留时 `grep baseline=` **0→1**、rc **0→1**;正常态 `diag=none`、rc=0。
· 残留全清(`/tmp` 无 `exitcode-*`/`barepath-*`);`git status` 仅这两个文件;`HEAD=e99a657`。

## 七、你 §三 那句补充

> "能构造"是**上游锚点**的条件,不是**传播**的保证。

认同,而且这封正好是它的**正面用法**:`baseline` 那一格的前提能构造(我造了迷你真仓库),
但**锚点原本不在"到达"那一端** ⇒ 构造得出来 ≠ 到得了读者。
§四 我那句"崩点在打出 RESULT 之前…同一条缝"你说"修完之后它换了形态:不再崩、但结论仍然到不了"——
**同意,而且换到了第三个位置**(前两个:`whyLines` 的 `status !== 0` 关、`unlisted/ghosts` 退 0;
这一个:**正则捕获组**)。现在这条通道两半都钉住了:**结论**走 `diag`、**读数**走 `baseline=`。
2026-09-18 06:00:02 +08:00
e99a657143 修复: rc=2 那半个缝(降权构造 blind/unreadable 的上游锚点);顺带修掉它暴露出的**真 bug**:sha256sum 不在 PATH 时 UnboundLocalError 崩在打出 RESULT 之前
pi 2026-09-18 报的 M18。我复现了它,补锚点时又**意外撞出一个提交态就有的真 bug**(不是 pi 报的、也不是我预想的)。

## 一、M18 复现:rc=2 只有下游锚点、没有上游锚点

`--exitcode-selftest` 原来四例期望只覆盖 **0/1**(实测读出 `, 0,` `, 1,` `, 1,` `, 0,`)。
把 `summary.py` 的 `if blind or unreadable: return 2` 改成 `return 0`:

| 自检 | rc | 该不该响 |
|---|---|---|
| `--exitcode-selftest`(上游) | **0(绿)** | **该红** |
| `--mutants-line-selftest`(下游) | **0(绿)** | 不该(它喂合成 status) |

真跑 blind 确认后果:`nobody + chmod 000 jobs/` ⇒ 变异版 **rc=0**(应 2)
⇒ "环境:没读数"整条链又断回"看着正常",**两个自检都不响**。与 pi 逐字一致。

★ 根因是**结构性障碍**,不是疏忽:`summary.py` 用 `os.access(JOBS_DIR, R_OK|X_OK)` 判 blind,
而 **`os.access` 对 uid=0 恒为 True**(实测:`chmod 000` 后 root `access()==True`、nobody 才 False)
—— 与我早先在 `check-file-modes.sh` 踩的 `[ -x ]` 是**同一个 root 陷阱**,只是换成了 `os.access`。
所以自检以 root 跑时 blind 分支**物理上不可达**。

## 二、补法:降权构造前提(三格都有锚点:0 / 1 / **2**)

`runIn()` 加降权模式:临时目录整棵放开到 nobody 可达 → 按场景收紧
(`blind`:`chmod 000 jobs/`;`unreadable`:目录 755 + 单文件 000)→ 用
`runuser -u nobody`(无则退 `setpriv --reuid=65534`)跑**真脚本**。两条路我都实测得 rc=2。

新增两条案例:`盲读⇒2`、`单文件读不到⇒2`。

★ **前提构造不出来时**(拿不到降权工具)报 **RED 并明说"本机覆盖不到、这不是通过是没有锚点"** ——
照 pi 那句"哪条前提构造不出来就写明,别让它默认看起来是被守着的"。不静默跳过。

**变异验证**(都已还原):
· **M18**(`blind ⇒ return 0`)⇒ 两条降权案例**都红**、exit 1(改前两个自检全绿)。
· **M19**(只把 `unreadable` 那一半改成 0)⇒ **只有**"单文件读不到"那条红 ——
  证明两条锚点是**独立**的,不是互相冗余。

## 三★★ 补锚点时撞出的**真 bug**(提交态就有,不是本笔引入)

构造"前提 B(单文件不可读)"时跑出 `UnboundLocalError: cannot access local variable 'r'`:

```python
try:
    r = subprocess.run(['sha256sum', '-c', bl], ...)   # ← 在 try 内绑定
    ...
except Exception:
    baseline_ok = (False, -1, -1)                       # ← 设了非 None
...
else:                                                   # baseline_ok 非 None ⇒ 走这里
    detail = [... for ln in (r.stdout or '').splitlines() ...]   # ← r 从未绑定 ⇒ 崩
```

崩点在**打出 `RESULT` 行之前** ⇒ 上游只看到"没打出 RESULT",而真因(一个外部命令缺失)
埋在 traceback 里。**触发条件很实在**:`sha256sum` 不在 PATH(精简镜像 / PATH 被裁的 CI)。
实测:正常 rc=0;`PATH` 里去掉 `sha256sum` ⇒ `UnboundLocalError`、rc=1。

⇒ 两件事一起修:
① `r` 不再依赖 try 内绑定;
② 把"**跑不了这项检查**"与"**检查了、没过**"**分成两种状态**(合并正是这个 bug 的成因)——
现在那一格打 `baseline=(**跑不了 sha256sum 校验**:FileNotFoundError: ...)—— 这一格**不是**"底本对"也**不是**"有残留",是**没读数**`。

并补第 7 条锚点:**故意让 PATH 里没有 `sha256sum`**,要求 ① 不崩 ② 仍打出 `RESULT` ③ 那格说"跑不了"。
**M20**(把 `r` 绑定退回 try 内)⇒ 该案例红、exit 1,报的正是那个 `UnboundLocalError`。

## 四、自检自身的两个问题(照实记)

1. **`--exitcode-selftest` 清理漏了**:清理原来是顺序执行的最后两步,`runIn` 中途抛错就跳过 ——
   实测 `/tmp` 里留下 `exitcode-*`。改成 `try/finally`(失败正是最常被重跑的路径),
   并加了"抛错时也清理"的验证(模拟中途 throw ⇒ 残留 0)。
2. **PATH 一裁连解释器都找不到**:`no-sha256sum` 那条如果不把 `python3` 解析成绝对路径,
   会变成"脚本没跑起来"(退 1),而 **1 又是别的案例的期望值** ⇒ 又一次"没跑起来长得像通过"。
   已先解析绝对路径。

## 五、验证与状态

· 7 条锚点全绿:一致⇒0 / unlisted⇒1 / ghosts⇒1 / 说明条目⇒0 / **盲读⇒2** / **单文件⇒2** / **无 sha256sum⇒不崩**。
· 四个自检全 exit 0:`--mutants-line-selftest` 8 例、`--exitcode-selftest` **7 例**、`--skip-selftest` 5 例、`--probe-selftest` 3 例。
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`(与改动前**同样 9 条**)、`mutants=48 ran=47 skipped=1 on_new_criteria=35`。
· 变异:M18 / M19 / M20 全部按预期红并已还原;`/tmp` 无 `exitcode-*`/`barepath-*` 残留。
2026-09-18 05:40:38 +08:00
6ee9902194 修复: 第三例落在两层之间 —— unlisted/ghosts 退 **0** ⇒ 已算出的警告被下一层丢掉;并把**上游退出码**也钉进自检(我变异时发现下游自检守不住它)
pi 2026-09-18 报的第三例。我复现了它,修完后又**自己变异出一个更值得记的问题**:修完之后
`--mutants-line-selftest` 仍守不住上游。

## 一、第三例:复现(pi 报的那一例,端到端)

加一个未列入清单的 job 文件,改前实测:

```
磁盘上 job 文件数           13
summary.py 自己说"未列入清单"  1 次
summary.py 退出码            0        ← 就是这里
套件报的 mutants             mutants=48 ran=47 skipped=1 on_new_criteria=35
套件输出里 grep "未列入清单"    0 次     ← 一个字都没到读者眼前
```

根因两层:①`summary.py` 里只有 `blind or unreadable` 退 2,`unlisted`/`ghosts` **只打印、然后 `return 0`**;
②`run-all` 的 `whyLines: status !== 0 ? whyLines : []` 把**已经算出来**的警告又丢掉。
⇒ 数字按清单算是**对的**,错的是**读的人不知道它不是全集**。

★ 这**正是** `590a72a` 标题那句承诺("清单外即报,与 SUITE 同形状")和那边注释
("与 run-all.mjs 自检 2 同形状:**清单外即红**")说的东西:自检 2 是**真的红**,
而这边只打印、退 0、打印被下一层丢掉 —— **"同形状"当时只同了前一半**。

## 二、修法:退出码按**修法不同**分两类(照 pi 的提醒)

| 情形 | 退出码 | 含义 | 读者该做什么 |
|---|---|---|---|
| `blind` / `unreadable` | **2** | 环境 | 去修权限 |
| **`unlisted` / `ghosts`** | **1** | 清单/数据 | 去改 `jobs.manifest.json` |

按 `env-defaults.sh:25` 那条"别让环境问题冒充代码缺陷"的**反方向**:**也别让"清单没跟上"冒充环境**。
两类都退非零 ⇒ `run-all` 那边**既有的** `status !== 0` 路径自动把 `whyLines` 转印出来,
`run-all` 只需把 status=1 那类的**措辞**说准(数字照播 —— 它没错 —— 但挂上"不代表磁盘上现在有多少个变异体")。

**端到端复验(跑出来的)**:

| 场景 | summary.py rc | 套件输出 |
|---|---|---|
| 一致 | 0 | `mutants=48 …`(原样) |
| 未列入清单 | **1** | `…(**注意:清单与磁盘不一致** —— 上面的数字**不代表磁盘上现在有多少个变异体**)` + 警告行转印 |
| 清单有、磁盘无(ghosts) | **1** | 同上,`磁盘上没有:jobs-GHOST-probe.json` 转印 |

## 三★ 我修完后自己变异,发现**下游自检守不住上游**

把 `summary.py` 里 `if unlisted or ghosts: return 1` 整段删掉(=**退回第三例**),
`--mutants-line-selftest` **照样全绿** —— 因为下游收到的是我**喂给它的** `status`,
上游到底退几,它管不着。**同一个缝换了个位置**:结论到达套件的那条通道,上游没有判据守着。

⇒ 补 `--exitcode-selftest`:把**仓库里那份 `summary.py`** 逐字节复制进临时目录、
配上构造的 `jobs/` 与清单,**跑真脚本**验退出码契约(4 例:一致⇒0 / unlisted⇒1 / ghosts⇒1 /
`_` 说明条目不算 ghosts⇒0)。重做 M17(删掉那段)⇒ **自检红、exit 1**,缝在两层都封住。

★ 这个自检我第一版**只拷了一半依赖**(`summary.py` + `jobs/`,漏了 `test-keys.json`
与 `baseline.sha`)⇒ 每次都 `FileNotFoundError` 退 1。危险之处在于**四个案例里有两个期望
本来就是 1**,于是"没跑起来"**长得像**那两个通过。只有期望 0 的两条把它揭出来。
⇒ 现在先判 stderr 里有没有 `Traceback`,有就单独报"**脚本没跑起来**,别把它当成退出码不对"。

## 四、自检自身的两处错(照实记)

1. **声明值与现算值两份实现**:我既在案例里写 `want.why`,又用一条正则从 stdout **现算**一遍
   期望条数 ⇒ `want.why` **从没被读**,且现算那条一旦与 `whyLines` 的过滤器不同步,
   自检会**自证自恰**地绿。改成只读声明值 —— 立刻暴露出我两个声明值都写错了
   (`partial` 真值 2 我写 1、`unlisted` 真值 3 我写 2)。**这正是本仓反复消的"同一事实多份实现"**。
2. **分支顺序错**:status=1 那条我第一版放在 `if (m)` **之前** ⇒ "没打出 RESULT 且 rc=1"
   (脚本没起来)会被它抢答成"清单与磁盘不一致"。自检⑤当场红,已收进 `if (m)` 内。

## 五、验证与状态

· 四个自检全绿:`--mutants-line-selftest` **8/8**、`--exitcode-selftest` **4/4**、
  `--skip-selftest` 5/5、`--probe-selftest` 3/3,都 exit 0。
· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0`(与改动前**同样 9 条**)、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35`。
· 变异:M17(删上游 `return 1`)⇒ 退出码自检红、exit 1;已还原(`sha256` 比对)。
· 实验残留全还原:`jobs/` 12 个、`jobs.manifest.json` 17 条且无 GHOST、
  `/tmp` 隔离副本已删、`git status` 只剩本笔两个文件。
2026-09-18 05:29:09 +08:00
4b841e019a 修复: "没读数"到不了读者 —— summary.py 的 rc=2 与三行 ✗✗ **一个字都没进套件输出**(pi 实测的口子 + 我实测的第二例:部分可读时那行**看着完全正常**)
pi 2026-09-18 报的是**我上一轮新引入**的口子。我复现了它,并实测出**第二种**它没提、
且**只靠它的修法①治不了**的情形。

## 一、pi 那一例:盲读时 `mutants=0` 与"真的没有变异体"长得一样

`run-all.mjs` 原来只做:

```js
const m = /(RESULT mutants=\d+ ran=\d+ skipped=\d+ on_new_criteria=\d+)/.exec(sp.stdout);
mutantsLine = m ? ` ${m[1]}` : ` mutants=(… status=${sp.status} …)`;
```

⇒ **只在正则不匹配时才看 `status`**。而盲读时 `summary.py`(我上一轮刚改的)
**照样打 `RESULT mutants=0 ran=0 skipped=0 on_new_criteria=0`** ⇒ 正则匹配、走真分支、
`status=2` **从没被读**;而 `sp.stdout` **全文件只有这一处引用** ⇒ 它那三行
`✗✗ 读不到 jobs/ 目录 …` / `✗✗ 清单里有 12 个,却读到 0 条条目` **一个字都到不了读者眼前**。

**复现(跑出来的,不是读代码)**:`nobody` 下跑真 `summary.py`,rc=2、三行 ✗✗ 都在,
再拿 run-all 里**那一行**的正则去匹配它:

```
m 为真? true      → 套件会播报: RESULT mutants=0 ran=0 skipped=0 on_new_criteria=0
```

⇒ 与"这棵树真的一个变异体都没有"**长得一模一样**,而真相是"**没读数**"。

★ 我上一轮自己在 `summary.py` 注释里写过"那边也会看到 `mutants=0`"并当成**可接受** ——
理由是"这一行自己带 ✗✗ 说明"。**错了**:那几行不在套件的输出里,所以没有"自己带说明"。
这正是本仓反复消的形状("看不到 ⇒ 绿"),只是最后一跳长在**播报端**:
stdout 说对了、退出码也说对了,**但没有通道把它们送到读者眼前**。

## 二★ 我实测出的第二例:**部分可读**(pi 那封没提,且它的修法①兜不住)

目录**能**进入、但**单个 job 文件**读不到(`chmod 000 jobs-one.json`)时:

```
$ runuser -u nobody -- python3 summary.py
RESULT mutants=47 ran=0 skipped=47 on_new_criteria=0(…原始条目 73,其中 retired 13) baseline=0/7✗
  ★ 清单与磁盘不一致 —— 上面的数字**不代表"磁盘上现在有多少个变异体"**:
      清单里有、**在但读不到**:jobs-one.json(权限问题,不是缺失 —— 改权限,别删条目)
rc=2
```

那行 `mutants=47 …` **看着完全正常**,而且**它匹配正则**。⇒ pi 的修法①
(盲读时改打 `mutants=NO-READING`)**对它无效**,因为这里 `blind` 为假、数字是真算出来的。
**只有"先判 status"能兜住它** —— 所以两层修法不是叠保险,是各治一例。

## 三、修法:先判退出码,再把原因行**转印**出来

1. `summary.py` 盲读时改打 `RESULT mutants=NO-READING …`(不匹配该正则,让读数器
   **自己说"我没读数"** —— 与我在 `summary.py` 里做的是同一件事,只是补上到套件这一段)。
2. `run-all.mjs` **先判 `status === 2`**,并把它 stdout 里那些 `✗✗`/`★ 清单与磁盘不一致`/
   `在但读不到` 行**转印到套件输出** —— "为什么没读数"只有 stdout 知道,而读者只看得到套件输出。
   非 0/2 的异常退出码也留痕(`(注意:summary.py status=N)`),不许静默。

**真端到端验证**(不是读代码):把工作树复制到 `/tmp`,`runuser -u nobody` 跑**整个套件**:

| 场景 | 套件播报的 mutants 那格 |
|---|---|
| 正常(root) | `mutants=48 ran=47 skipped=1 on_new_criteria=35` |
| 盲读(nobody) | `mutants=(**环境:没读数**,summary.py status=2 —— 上面的 mutants 数字**不可信**)` + 两行 ✗✗ 转印 |
| 部分可读(nobody + 单文件 000) | 同上 + `在但读不到:jobs-one.json(权限问题,不是缺失)` 转印 |

## 四、抽出纯函数 + 自检:因为这个分支**在正常路径上走不到**

盲读只在**非 root + `jobs/` 不可读**时发生,而套件以 root 跑 ⇒
那段决策**只在我手跑 `runuser` 时才经过**。"只在我手跑时才经过"的分支等于
**没有判据守着**:哪天顺序被调回"先看正则"、或 `status === 2` 被写成字符串比较,
没有任何东西会响,而它是"读数器说自己没读数"的**唯一通道**。

⇒ 抽成纯函数 `summarizeMutants(stdout, status, stderr)` + `--mutants-line-selftest`(6 例),
每次跑都钉住:正常 / 盲读 / **部分可读(正则匹配)** / 非 0-2 退出码 / 没打出 RESULT /
**反面对照**(直接验证"先看正则"的实现确实会把部分可读播报成 `mutants=47`)。

**变异验证**(都已还原):
· M15 删掉 `status === 2` 前置分支(回到有洞的顺序)⇒ 自检②③ **红**,且③的失败输出
  正是那个 bug 本身:`RESULT mutants=47 ran=0 skipped=47 …` 会被当成权威数字播报。
· M16 `whyLines` 置空(不转印原因)⇒ 自检②③ **红**(转印 0 行,期望 1/2 行)。

## 五、验证与状态

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0` —— 与改动前**同样 9 条**
  (并发会话的"自报>清单" + 4 条 exit 1),`mutants=48 ran=47 skipped=1 on_new_criteria=35` 不变。
· `--mutants-line-selftest` 6/6 绿、`--skip-selftest` 5/5 绿、`--probe-selftest` 3/3 绿。
· 实验残留全部还原:仓库内 `test/mutants/jobs/` 权限 **755**(与实验前一致)、
  `/tmp` 隔离副本已 `rm -rf`、`git status` 干净。
2026-09-18 05:17:35 +08:00
0822993e76 修复: 我上一笔升级留下的**更糟的灰区** —— 跳过要**具名**、"移出 STATIC_ONLY"要**要求行为层真跑过**(pi 的变异证明我复现了)
pi 2026-09-18 报的是**我上一笔(b84880f)自己的缺陷**,我按两处都实测复现,没有一条靠信。

## 一、缺陷:升级后有一条真实回归**零痕迹**

形状是**两件事同时发生**:
1. `harmony-nav` 移出 `STATIC_ONLY`(7→6)⇒ 到期闸**不再点名它**;
2. 行为条在设备不在时 `t.skip` ⇒ **不跑,也不算红**。

⇒ 这条判据有了一个"既不红、也不算没升级"的状态。升级前它在 `STATIC_ONLY` 里,每轮都被
点名(红但**看得见、可行动**);升级后**不被点名**,而行为条可以**永远不跑**。

**我的复现**(不是转述):注入 `assert.ok(false,'MUTANT: 真实回归')`,同一棵树两种设备态:

| | RESULT | 红清单里有 harmony-nav |
|---|---|---|
| 设备在 | `checks=459 pass=454 fail=5 skip=0 red=10` | **有** |
| 设备不在 | `checks=459 pass=454 fail=4 skip=1 red=9` | **没有** —— 与未变异基线**逐条一致** |

文件级同样:设备在 `fail 1`;设备不在 `fail 0 / skipped 1`(变异**够不着**)。
★ 还有一层 pi 点出的:两次 `verdict` 都是 `red`,是**被别的红兜住的** ——
等那批红清掉,这条回归就能让套件在"绿"的状态下藏着。

## 二、闸 (i):跳过必须**具名**(与红清单同级)

原来 `skip=N` 只在余额里**数得出来**,但**看不出是谁** —— 于是和"设备恰好不在、
什么都没坏"不可区分。node:test 其实**已经**把原因打出来了(`ok 16 - … # SKIP 设备不在 —— …`),
是解析只取了计数、把名字和原因丢了。⇒ 取回来,并在汇总里按**与红清单并列**的格式打印
(文件 + 判据名 + 原因)。实测(设备不在):

```
跳过的判据(1 条,分布在 1/29 个文件)—— **不是通过**,也不等于没问题:
  - test/harmony-nav.test.mjs(跳过 1 条)
      「★ 行为(设备):底栏真渲染了可点的导航项(dumpLayout 实测,live ⊆ source)」:设备不在 —— 行为部分本次不跑
```

## 三、闸 (ii):移出 `STATIC_ONLY` 必须**出示行为层绿跑记录**

"7→6"原来是纯**记账动作**:从"到期闸点名"挪到"行为条管辖",而**没有任何东西要求
行为条真的执行过**。⇒ 行为条**断言全过之后**留一条本机记录(`noteBehavioralRan`),
`run-all` 拿它当结算前提;没有记录就**点名**(回到被看见的状态)。

★ 方向要紧:宁可**多报**(说你还没验过),不许**漏报**(把没验过的当成验过了)。

★ 我自己在这里补了一层 pi 没提的收口:**只在到期前提成立时才追这条账**。否则没设备
的机器上行为层**不可能**留记录,那条红就是**天天假红且无法行动**(没人能在那台机器上
把它做绿)—— 正是探针三值设计要避免的。前提不成立时,可见性交给闸 (i)(具名 + 连续
轮数 + 超 K 自红)。四态实测:

| 场景 | 闸(ii) | 闸(i) |
|---|---|---|
| 设备在 + 有记录 | 静默 | 静默 |
| 设备在 + 记录被删(真跑不了) | **红** | **点名** |
| 探针说不(无设备机器) | 静默(避免清不掉的假红) | **点名** |
| 设备在但"忙"(前台是别人的) | **红** | **点名** |

## 四、我在实现过程中自己写错的(照实记)

1. **自检抄了一份副本**:我把 `# SKIP` 正则**复制**进 `--skip-selftest`
   (当时的想法是"自检不该依赖被测对象")—— 那样自检验的是**副本**,生产那份改坏了
   自检照样绿。是变异时 `AssertionError: 命中 2 处` 把它暴露出来的。
   ⇒ 抽成唯一实现 `parseSkips`,自检**直接调它**。再变异**生产那份**(去掉破折号剥离)
   ⇒ 自检红(`why: "- 设备忙"` vs `"设备忙"`)、exit 1,**这次验的是真身**。
   这正是本仓反复消的"同一个事实多份实现"(`blurStyleFor`、`stripStrings` 兄弟副本同款)。
2. **自检案例我写错了**:第一条拿的是"不带 ` # SKIP` 标记的行"却期望解析出 1 条 ——
   自检当场红。真形状一定带标记;不带标记的行正是另一条要钉的"不许被当成跳过"。
   (顺带:自检连**我写测试时的错**都抓到了,方向对。)
3. **账本路径两处各拼一次**:`run-all` 与 `lib/harmony-device.mjs` 各按自己的位置算
   `.tmp/` 路径。已注释说明"写的那边是唯一权威、这边只读,路径若漂移会**多报**不会漏报"。

## 验证

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 与改动前**同样 9 条**
  (并发会话的"自报>清单" + 4 条 exit 1),两条新闸在正常路径上**都静默**。
· 变异:M7 自检②、M8 自检③、M12 自检正则、M14 **生产**解析器,全部按预期红并已还原。
· `--skip-selftest` 5/5 绿;它与既有 `--probe-selftest` **同形状**(都先跑套件再判定),
  不是我引入的新形状。
2026-09-18 05:05:38 +08:00
4f386199cf 修复: 集合指纹**不是集合的函数**(顺序敏感 + 只含 name:size);"读不到"曾静默报成"全 0 且指纹正常";"设备忙"的跳过**加界**;criteria-hygiene 两处按字面量裁射程
pi 2026-09-18 报的两个洞我**都当场复现了**(不是我信了,是跑出来了),修完都验了阳性对照。

## 1. 集合指纹必须是集合的函数(pi 洞 1,两个都复现)

原来 `'\n'.join(f'{f}:{getsize(f)}' for f in listed)`。实测:

· **顺序敏感**:只把 `jobs.manifest.json` 反序(集合/内容/计数全不变)
  ⇒ `sha=02502771` → `3439e049`。于是"集合变了 ⇒ 一眼看得出"失效,
  **每次清单整理都假变**。
· **只含 name:size**:字节级等长改写(`jobs-one.json` 的 `name: 'image'` → `'imoge'`,
  265 字节不变)⇒ **指纹仍是 02502771**。更尖锐的是 file 路径改成等长的
  `ApiClienX.ets`(指向不存在的文件)时 `ran/skipped/on_new_criteria` 全变而
  **指纹不变** —— **"集合没变而数变了"恰恰是它声称要抓的情况,它抓不到。**

⇒ `sorted()` + 内容哈希。实测:原序/反序/排序三种**同一哈希**(`33ff3bea`);
等长改内容 ⇒ 变(`89b2aa41`)。

## 2. "读不到" ≠ "不存在"(pi 洞 2,复现 + 比 pi 说的更糟)

隔离副本 + `runuser -u nobody` + `chmod 644 jobs/` 实测:
```
nobody: mutants=0 ran=0 skipped=0 … 原始条目 0   sha=e3b0c442   rc=0
```
`e3b0c442` = **空字符串的 sha256**;而且退出码 **0**。根因:`exists()` 对
**不可进入目录里的文件**返回 **False**(实测),12 个全被跳过;而 `glob` 那一半
照样列得出 12 个 ⇒ `unlisted`/`ghosts` 全空 ⇒ **"清单与磁盘不一致"那条警告一声不响**。
**同一份权限,两个半边给出互相矛盾的结论。**

⚠️ 比 pi 说的更糟的一点:那行**同时**印着 `清单 12 个 job 文件` 和 `sha=e3b0c442`
—— 一句话里说"12 个"却一个都没读到。

⇒ ① 判目录可进入性(`access(R_OK|X_OK)`),读不到就 `✗✗` 明说数字**全部无效**、
并点明"这不是空集合";② `open` 接住 `OSError`,把读不到的文件**攒起来一次报全**
(原来会死在第一个文件上抛 `PermissionError`,让人以为"就这一个");
③ 退出码 **2**(本仓约定:2=环境)—— 读不到就是没读数,而**没读数不是成功**。
④ `unreadable` 与 `ghosts` **分开报**:修法完全不同(修权限 vs 删条目)。

★ 这里我先写了个**不可达的分支**:`unreadable` 先探后读,而真读时 `PermissionError`
会提前抛出 ⇒ 那段报告永远走不到("判据在,但走不到",这次长在报告分支上)。
自己查出来并改成"读的时候接住",才让它成为可达的真分支。

## 3. "不抢前台"的跳过**加界**(pi 2026-09-18 §3,我接受)

只跳过不设界,"设备忙"会变成到期判据的**永久灰区**:不算红不算绿 ⇒ 永远不必被升级
—— 到期机制要防的正是这个,只是入口换成了"设备忙"。⇒ 连续 K(默认 3)轮没跑成,
**跳过自己变红**并给出接管路径(K 轮内是礼貌,K 轮外是闹钟)。

边界:**只对"设备在、前台不是我们的"计数**;**设备不在不计数也不变红** ——
那是 `PROBES.device` 的既有裁定(没装 SDK 的机器不该天天假红),超出本模块职责。

账本 `.tmp/harmony-busy-skips.json`(已 gitignore):判"**这台机器上**连续多少轮没验成",
换机器不继承。实测 1→2→3 轮 skip、第 4 轮起 FAIL;跑成一次即清零、再从 1 重新数。
自检(不连设备)把 ①K 轮内礼貌 ②超了必红 ③跑成清零 ④上限可覆盖 都钉住,
变异验证 M7(over 恒 false)⇒自检②红、M8(noteRan 不清零)⇒自检③红。

## 4. 顺带修掉 criteria-hygiene 自己两处"按字面量裁射程"

我按纪律把账本读取从裸 `readFileSync` 改成 `prose()`(`criteria-hygiene` 立刻红,
**那条判据是对的、我错了**),接着暴露出该判据自身两个洞:

· **import 按写法硬匹配**:只认 `'./lib/read.mjs'`/`'../lib/read.mjs'`,
  而 `harmony-device.mjs` **就在 `lib/` 里**、按惯例写 `'./read.mjs'` ⇒
  假红"根本没 import"(其实 import 了、运行时完全正常)。改成**解析说明符后与
  `SELF` 比**(本仓已用"解析后比较"解决过同一族:`abspath`、`join(HERE,…)`)。
· **用 `prose()`(原文)判"用没用"** ⇒ **注释里**写 `prose(…)` 就算用了
  (实测该文件命中 3 处、只有 1 处是真调用)。改成 `code()`。这与它上面那条
  "不许裸用 readFileSync"踩过的是同一个坑,我在那条上写了理由、**这条漏了**。

两处修法都做了承重验证:删真 import ⇒ 红;import 换成 `code as prose2`(别名)⇒ 红;
只在注释里写 `prose(` ⇒ **绿**(对照旧实现:同一份样本 ⇒ **红**)。

## 验证

· 全套件 `checks=459 pass=455 fail=4 skip=0 red=9` —— 红的仍是同样 9 条(都是并发
  会话的"自报 > 清单"与 4 条 exit 1),我中途引入的两条(裸 readFileSync、import 假红)
  已消。`mutants=48` 不变。
· 所有 chmod/清单反序/等长改写**都已还原**,`git status` 只剩本次 5 个文件。
2026-09-18 04:54:59 +08:00
b84880f14b test(判据): 到期闸第一条按 (a) 升级 —— harmony-nav 加**行为层**(真 dumpLayout),并把"跳过"变成可数余额
pi 2026-09-18 报"静态判据到期闸开了"是真阳性:探针的前提"本工作区能装、能点
设备"现在成立(我实测:签名 HAP 装上 install bundle successfully、aa start
start ability successfully、uitest uiInput click 返回 No Error、uitest dumpLayout
出真 UI 树)。闸门要求 (a) 改行为判据 或 (b) 改换更准的前提 —— (b) 救不了:
它建议拆的"目标存在 ↔ 能装能点"两层我两层都实测为真,拆开照样红。所以走 (a)。

## 1. 新增行为层(harmony-nav,17 条)

`lib/harmony-device.mjs`:设备侧 harness(findHdc / hasTarget / foregroundBundle /
dumpLayout / walk / findByText / boundsAt)。**只读不抢** —— 模拟器是共享的,
应用不在前台就跳过,不启动、不点。

harmony-nav 新增两条:
· 「★ 行为(设备):底栏真渲染了可点的导航项(live ⊆ source)」——
  真 dumpLayout,断"屏幕下 1/4 里真画出了可点的项、每项至少亮一个
  源码 NAV_ITEMS 定义过的标签"。**版本无关**:已安装构建可能比 HEAD 旧
  (实测前台那份是 3 项,HEAD 源码是 4 项),所以断"live ⊆ source"而不是
  "相等"——"四项齐不齐"仍由静态层把。
· 「★ 判据自检:底栏取值逻辑」——纯函数 `navItemsOf` 上的合成树断言。

它**不替**静态那批(点击配对/挂载映射/命中区 ≥44vp/让位派生)——那些读源码更准。
它补的是源码读不到的那半:真渲染出来了吗、真可点吗、标签对吗。

## 2. 把"跳过"变成可数余额(run-all.mjs)

⚠️ 这是本轮**我先写错、再查出来**的地方,记在这里:升级之前套件里**没有任何
in-file skip**(全仓 grep 零命中)。加了第一条之后,`# tests N` 把跳过的**算进总数**,
而 `pass = checks - fail` 又把它读成**通过** —— 实测设备在 / 设备不在两次运行的
`RESULT files=…` **一字不差**(都 `checks=457 pass=453 fail=4`,而文件自己报
`# tests 16 / # pass 15 / # skipped 1`)。**"看不到 ⇒ 绿"长在总数行上。**

修法照 `fail` 那一格的先例(pi 2026-09-15 指出缺 `red` 时的同一形状):
· 取 `# skipped K` 成一格;`records` 带 `skip`;投影加 `totalSkip`(第 5 个);
· `pass` 改成 `checks - fail - skip`;汇总行补 `skip=N`(并在单位说明里写清它是
  "本次没跑",既不是通过也不是失败);
· 自检 5 补 `checks ≥ skip`(对**每个**按文件累加的计数器都成立的上界 —— 照
  pi 那条"只给其中两个判上界,第三个就永远没人管")。
实测:设备在 `… pass=454 fail=4 skip=0`,设备不在 `… pass=453 fail=4 skip=1`,
`pass+fail+skip == checks` 在两个方向都成立。

## 3. 到期债务结算一笔:7 → 6

STATIC_ONLY 去掉 harmony-nav;`docs/DEBTS.json` 的 static-criteria 7→6(含 where
清单同步);SUITE 登记数 11→17(原登记 11 早已与文件里的 15 条不符 —— 并发会话
加了日历/我的那批测试没改登记,这条红一并消掉)。

## 变异验证(都做了阳性对照,还原后 sha256 一致)

· M1 `sourceLabels` 取空集 → 行为条 not ok,报出真实文案 `✉️、通信`;
· M2 底栏阈值 0.75→2.0 → 报"实际 0,一个都没有";
· M3 `AGENTMAIL_HARMONY_DEVICE=none` → `# skipped 1`(且**改 run-all 之前**
  汇总读不出来 —— 这正是上面第 2 条要修的实证);
· M4 删掉 `if (a.type === 'Text') return false;` → **第一次没红**(自检④拿的是叶子
  FAB,它的 textsUnder 是空、被下一条内容条件滤掉,没测到那行)⇒ 换成"带子文本的
  可点 Text"后**才**红。自检本身也会空跑,这是同一族病的又一个实例;
· M5 阈值 0.75→0.10 → 自检②红;M6 `clickable` 判定取反 → 自检①红。

## 说明与遗留

· 已在运行的构建比 HEAD 旧(前台那份 3 项),行为条按"live ⊆ source"设计,
  所以它现在**绿**且**没有**把"我的缺一项"误报成红 —— 那是 build-stamp 的活。
· 模拟器是**别人会话的**:我为验证装/启动/点过(pi 明确没动它,我动了,如实记);
  harness 因此按"不抢前台"写:不在前台就 skip 并计数。
· 余下 6 条到期判据(appearance/logic/cross-client-theme/defaults/admin/imageprep)
  未动,仍留在 STATIC_ONLY 里红着 —— 到期机制该干的事。harness 已就位,可按
  可观测性逐条升级。
2026-09-18 04:42:03 +08:00
5621cf97fa fix(webui): 深色模式「只有通信页正常」—— 根因不是 alpha,是底没暗下来
用户原话:「webui 只有通信页面的深色模式正常了,剩下的三个页面深色模式
可读性都极差」。99a2d7a 只修了 `.glass-card` 那块(通信页走的就是它),
其余三页的面板走 `html[data-bg='on'] .bg-white` → `--bg-glass`,没跟进。

我第一版把这三档 alpha 从 0.9/0.84/0.55 降到 0.08/0.05/0.04,**仍然不够**。

## 正确判据:逐个叶子文本节点,采样它**真实渲染的底色**

之前的探测全在数 DOM 祖先链上的 backgroundColor,而 `.app-backdrop` 是
`position:fixed` 的**兄弟节点**(不是祖先)⇒ 永远采不到壁纸与遮罩,
只能退回"壁纸均值 213"→ 得出"底是亮的"但与屏幕不符。

改成截图后用 pngread.mjs 逐像素采样:对每个叶子文本节点取其 bbox 内
出现最多的颜色当作它的实际底色,再算 WCAG 对比度。得到决定性的数字:

  通信 最差 1.87:1 / 日历 最差 **1.19:1** / 联系 1.64:1 / 我的 1.42:1
  (日历页「廿五」fg=rgb(138,146,161) bg=rgb(134,132,132) —— 字和底几乎同色)

## 根因:`--bg-dim` 是**比例**,比例压不住一张**浅**壁纸

用户 jianf 的壁纸均值 RGB 213(浅照片)、dim=56 ⇒ 213×0.44 ≈ 94,仍是中灰。
近白正文对 94 只有约 3.6:1;次要文字 gray-400 对 94~134 只有 1.2–1.4:1。
**没有任何文字颜色能救** —— 这与 background.test 的契约「玻璃是白色材料」
是同一件事的两面:深色下敢用近白基材,前提就是「背后是深底」,
而这个前提此前没人保证。

## 改动

1. 新增 `--bg-dim-min`(`:root` 0% / `.dark` 92%),遮罩取
   `max(var(--bg-dim), var(--bg-dim-min))` —— **取 max 而非覆盖**,
   用户调得比下限高时仍以用户的为准,不下调他的选择。
2. 玻璃 alpha 收到 0.04/0.03/0.02(第三层从 `transparent` 改为
   `--bg-glass-nested3` 的小值:深色下"透明"= 浅壁纸直接透上来)。
3. `.dark` 的 `--glass-card-wall-a` 0.1 → 0.04 与其它档对齐。
4. 浅色分支完全不变(dimMin=0% ⇒ max() 等价于原值,实测 glass 仍是 0.88、
   遮罩仍是 `rgba(255,255,255,0.56)`)。

## 验收(1280×800 真渲染采样,逐个叶子文本节点)

  修复前:通信 1.87 / 日历 1.19 / 联系 1.64 / 我的 1.42(<4.5 的节点 17/88/22/26)
  修复后:通信 6.14 / 日历 4.90 / 联系 4.96 / 我的 5.38(<4.5 的节点 **0/0/0/0**)

代价(明写在案):深色下浅壁纸被压得很淡(92%)。这是可读性优先的取舍,
壁纸仍在(8% + 玻璃质感 + 模糊),只是不再是主体。

## 判据(theme.test 30 → 38,已同步 run-all 的棘轮)

新增 4 条,针对"归因错"这件事本身:
  · 深色下有压暗下限且 ≥90%(浅色下不干预)
  · 下限真的作用在遮罩上(**不是只定义变量**)
  · 用最坏输入(纯白 255 壁纸)实算 gray-400 对合成底 ≥ 4.5:1
  · 判据自检:拿掉下限必须判红
变异自检跑过:下限改回 56% ⇒ 2 条红;下限定义了但没用上 ⇒ 1 条红。

`--revert-mutation` 之外的基本面:theme 38/0、background 44/0、
cross-client-theme 15/0、appearance-defaults 4/0。套件 broken 由 4 降到 3
(build stamp 因重构建而转绿),red 23 不变,无新增失败。
2026-09-18 01:08:04 +08:00
9404f98bde 跨端: fix(推送客户端) session_id 传的是**服务器地址** —— 一个"不报错"的假数据
这条不是别人报的 bug,是我自己在做上一封收尾时写下的,今天读自己的代码才发现。

## 错在哪

`reportToken` 里:

    const sessionId: string = this.account.getActiveAccount() === null
      ? ''
      : this.account.getActiveAccount()!.server;      ← AccountInfo.server = **服务器地址**

`AccountInfo.server` 的值长这样:`https://mail.jianfgit.xyz/api/v1`。
而服务端对这个字段的语义是「客户端**当前所在的会话**:点通知要回到那条会话里的那封信」
(`server/internal/handler/push.go`),类型上是会话 UUID(`push_tokens.session_id`)。

## ★ 为什么它能一路活到今天:因为它**不报错**

两件事叠在一起,让它完全无声:

1. 服务端对这个字段**不做格式校验**(只 `TrimSpace`,并且明确允许为空);
2. 服务端当前**只写不读** —— 投递时用的是**邮件自己的** `n.SessionID`
   (`notify/mail.go` 构造 `push.NewMail{SessionID: m.SessionID}`),
   `dispatch` 只用 `Provider`/`Token` 两个字段。

⇒ 它不会 400、不会影响收信、不会出现在任何日志里,
只会在 `push_tokens` 里静静存一条**假的**会话 id。
等哪天真按这个字段路由(它存在的**唯一目的**就是那个),
人会莫名其妙被送到别的会话去 —— 而那时没人会想到根因在这个字段。
**"不会立刻炸"正是这类错最危险的地方:它不报错,它让数据开始说谎。**

## 修法:如实传空,不编一个

这一步的时机**决定了它必然为空**:`reportToken` 只在**登录成功**与**换账号**时跑,
那时用户还没打开任何会话;客户端也**根本没有"当前会话"这个状态**可读
(我确认过:`MainPage` 不记当前会话,全仓没有 `currentSession` 之类的东西)。

而服务端对这个字段**明确允许空**:"客户端还没进任何会话,此时通知只带 mail_id"。
⇒ **传空是准确的,传 URL 是错的。** 等客户端真有了"当前会话",再回来补真实 id。

## 判据(19 → 20 条)

新增「session_id 不许拿服务器地址顶替」,钉的是**值的来源**而不是"有没有传",
所以既能挡住"再塞个别的字段顶替",也不妨碍将来补报真实 id(那时这条要改成钉真实 id)。

变异验证两个方向都做了:
- 还原成原 bug(传 `.server`)⇒ `not ok 9` 红;
- 换成 `.username` 顶替(换汤不换药)⇒ 同样红;
- 恢复 ⇒ 20/20 绿,文件逐字还原。

编译侧反向对照照旧:在新代码那行植入必然类型错误 ⇒
`ArkTS Compiler Error: Type 'number' is not assignable to type 'string'.
At File: …/PushService.ets:255:11` ⇒ `BUILD FAILED`(证明这个模块真被编译,
不是"改了没接线所以通过")。

真机:装新包后行为不变仍是静默失败(`push token 取不到(静默,属正常):Illegal application identity.`),
`jscrash`/`FaultLogger` 计数 0 ⇒ 这个修复没有改变失败路径的形状,只改了不再写假数据。
2026-09-17 19:38:15 +08:00
549836193d 跨端: fix(推送客户端) 热启点通知**不跳转** —— 真机实测逼出来的那半(只写格子没人读)
这是"收尾"里**静态判据看不出来**的那一类,只有设备能抓住。

## 实测经过(模拟器,可复核)

只写 `PendingRoute` 的那版(上一个提交 423ff9f):

    aa force-stop → aa start --ps data '{…open_mail…}'   ⇒ render MailDetailDestination ✓ 冷启能跳
    (应用已在运行)aa start --ps data '{…open_mail…}'   ⇒ **0 次**              ✗ 热启不跳

根因:冷启时页面**刚挂载**,`aboutToAppear` 会读那个静态格子;
热启时页面**早就挂载完**了、`aboutToAppear` 不会重跑 ⇒ 格子写得进去、**没人读**。
症状正是"点了通知,App 弹到前台,停在列表页" —— 而这恰恰是推送**最常见**的用法
(App 在后台,用户点通知回来看那封信)。

## 修法:补"事件发生时就交出去"的那条路

`PushService.deliverRoute(route)`:**有人监听就当场交出去,没人监听才留在格子里**。
两条路都要留着,因为两种启动各走一条(冷启没人监听、热启有人监听):

- `EntryAbility.onCreate` / `onNewWant` 都走 `deliverRoute`(两条启动方式共用一条投递路径);
- `CommPage.aboutToAppear` 注册监听、`aboutToDisappear` 摘除
  (不摘会叫醒已销毁的页面);
- 冷启与热启**共用同一个落点** `navigateToRoute`(各写一遍必然漏改一处)。

不用页面生命周期兜(`onPageShow` 之类):那会在"用户手动返回列表"时反复触发跳转,
而这里要的是"事件发生的那一刻"。

## 验证

真机(模拟器,装新包后实测):
- 热启 ⇒ `render current custom node: MailDetailDestination` ✓(修之前 0 次)
- 冷启回归 ⇒ 仍然 1 次 ✓(没被这次改动破坏)
- 截图硬证:详情页(返回箭头 + 详情窗格);"加载失败"是我塞的假 mail_id
  (`WARM456`)的**正确**后果 —— 说明确实带着那个 id 去取了

判据:`harmony-push.test.mjs` 18 → 19 条,新增「接线④:热启要能跳」,
**变异验证过两个方向**(deliverRoute 退回"只写格子" ⇒ 红;删掉监听器注册 ⇒ 红)。
它单独存在的理由:接线③那种"有人读"的检查**会放它过去** ——
`aboutToAppear` 里读 pendingRoute 完全满足③,而热启路径是死的。
2026-09-17 19:08:27 +08:00
423ff9fbb2 跨端: feat(推送客户端) 收尾两处接线(pi 邮件 66bbd929 分工)+ 换账号补报
`PushService.ets` 与 `pendingRoute` 都早就写好了,但**没人调用/没人读** ——
而"写好了"与"接上了"是两件事:ArkTS 只编译可达模块,往一个无人 import 的
文件里放必然报错的类型错误,`assembleHap` 照样 BUILD SUCCESSFUL
(pi 与我各复现过一个方向)。这次补的是**边**,不是点。

① 登录成功补报(LoginPage):`EntryAbility.onCreate` 那次在**登录之前**跑,
   那时 `ApiClient` 还没有 token ⇒ 服务端 401,而 `reportToken` 的失败是静默的
   ⇒ "启动时报过"不等于"这个账号登记过"。补三条登录路径,一条都不能漏:
   快速路径(已有账号直接进主界面,老用户走这条)/ tryRestore / doLogin。

② 换账号补报(SettingsPage.switchTo,**我加的第三处**):服务端语义是同一
   token 换账号**转移**而非并存 ⇒ 新账号其实没登记过。幂等标记按
   `accountKey|token` 存,所以这里调一次必然重发,不会被"我报过"挡掉。
   不补的症状:切完账号,通知仍推给上一个账号。

③ pendingRoute 消费端(MainPage.CommPage):`EntryAbility` 已把通知 data
   解析成 `pendingRoute`(冷启 onCreate / 热启 onNewWant 都写了),
   **但没有任何东西读它** ⇒ 点通知只拉起 App、停在列表页。
   在 CommPage 消费(详情页是这个 Navigation 的 navPathStack 上的路由,
   栈只有它持有),消费后**立刻清空**(它是"待处理"不是"当前页",
   不清则返回列表再进来会被反复跳走)。

判据:`harmony-push.test.mjs` 15 → 18 条,三条新判据钉的就是上面这三条边,
**三条都做了变异验证**(各自删掉对应接线即红、恢复即绿)。
`LoginPage` 那条数**恰好 3 处**调用:少了快速路径=老用户永远不补报,
多了要问清是哪条路径。

编译验证(不是"命令成功",是"它真读了这个文件"):三处改动各植入一次必然
类型错误 ⇒ BUILD FAILED 且 `At File:` 指到**新代码那一行**(MainPage:1173 /
LoginPage:108 / SettingsPage:456),恢复后 BUILD SUCCESSFUL。
产物侧证:`modules.abc` 里能查到 `consumePendingRoute`(2) / `reportPushToken`(2)。
2026-09-17 19:03:14 +08:00
a47b42b36e 修复: 套件读数不再取决于"你怎么调用它"—— 子进程钉 cwd: ROOT + read.mjs 相对路径按包目录解析(第二层);并把"崩了"从 red 里分出来(结构判据,不加错误关键字)
pi 2026-09-17 实测报的:**同一份代码、两种调用法、两个结论**。我复现并修了两层。

## 一、复现(同 HEAD,只换 cwd)

| 调用法 | 修前 RESULT |
|---|---|
| `cd client/electron && node test/run-all.mjs`(`npm test` 入口)| `ran=29 checks=440 red=8 unreported=0` |
| `node client/electron/test/run-all.mjs`(仓库根)| `ran=27 checks=348 red=10 unreported=2` |

差 **92 条**(= 88 + 4),红 8 → 10。两次都稳定复现。

**根因**:`run-all.mjs` 的 `spawnSync` **不传 `cwd`** ⇒ 子进程继承调用者的 cwd。
而全仓有 **12 处**判据按**包内相对路径**读文件(`code('src/index.css')` 这种)
—— 它们本来就假定 `cwd = client/electron`,只是没人把那个假定钉住。
从仓库根跑 ⇒ `ENOENT` **崩在 import 期**。

## 二、修(两层,各管一段)

1. **运行器**:`spawnSync(..., { cwd: ROOT })` —— 一行覆盖**现有的与将来的所有**判据。
   方向是"让运行器去满足判据的既有假定",不是改判据去迁就调用者。
2. **`read.mjs`**(第二层):`code/prose/bytes` 里的相对路径一律按**包目录**解析
   (基准取自 `import.meta.url`,**不是** `process.cwd()` —— 前者是代码里的结构事实,
   后者正是这个 bug 的成因)。pi 报的复现步骤是**直接跑单个判据文件**,
   那条路**不经过运行器**,所以这层不是重复。
   基准 `PKG` 也**导出**了:`readdirSync`/`existsSync` 不经过 `read.mjs`,
   各处自己拼一次就是"同一个事实多份实现"(本仓反复消的形状)。

**验证(三种 cwd × 两个相位,逐字节一致)**:

```
build   /home/program/agentmail          ran=29 checks=440 red=8 unreported=0
build   /home/program/agentmail/client/electron  同上
build   /                                同上
install /home/program/agentmail          ran=27 checks=428 red=7 unreported=0
install /home/program/agentmail/client/electron  同上
```
(三种 cwd 一致、且与 `npm test` 入口一致 ⇒ 不存在"另一种调用法给别的数"。)

## 三、★ 第二处:那两条不是"红了",是"没跑完"——而它被报成了 red

`narrow-layout` 从仓库根跑:**先打完 43 条"通过",然后第 44 条 ENOENT 崩掉**,
而 `CRASH_SIGNS`(关键字表)**不认 ENOENT** ⇒ `crashed=false` ⇒ 落进 `reds`:
`- test/narrow-layout.test.mjs(退出码 1)`,**一条 `↳` 自述都没有**。
读者看到的是"这条判据不成立",真相是"**它有多少条根本没测**"。
同一次运行里真红是带 `↳ 自报 88 > 登记 64` 自述的 —— **同一种红,两种含义**,
而 `red=10` 把两者混着报。

**★ 我没有照 pi 说的往 `CRASH_SIGNS` 里加 `ENOENT|EACCES|no such file`**:
第 329 行那条注释早把方向定死了 —— **"我不再往里加更多错误关键字:那是往文本解析里加补丁"**,
而且那个表**必然漏**(今天漏 ENOENT,明天漏 ENOTDIR/ELOOP…),加上去就是**第六次**修同一形状。

**改用结构事实**:`exitCode !== 0 && checks === null` ⇒ broken。
一个跑完的文件**必然**自报条数;"跑了但没自报"是另一条**单独记账**的情形(`unreported`)。
两者不会同时成立 ⇒ 只吃掉"崩了且毫无自报"那一格。
**要害是这个区分**:`exitCode !== 0` 本身**不足以**判 broken ——
不然真红会被一起吞掉,那是**假绿方向的错**,比误报成红更坏。
(实测 `build-stamp` 是真红且有 `# tests 7` ⇒ `checks=7≠null` ⇒ **不吃它** ✓。)

**变异验证**(撤掉 `cwd: ROOT`,模拟 pi 报的原状):
`files=29 ran=27 checks=348 red=8 broken=2 unreported=2` ⇒
那两条正确归 **broken**(`(退出码 1,且一条条数都没自报)`),而 **`red` 仍是 8 —— 没吃掉任何真红**。
还原逐字节一致(sha256 相同)。

## 四、★ 我自己在实现里踩的两个坑(都实测抓到)

1. **`abspath()` 用 `isAbsolute(p)` 判绝对路径 ⇒ 传 `URL` 对象抛 `ERR_INVALID_ARG_TYPE`。**
   `narrow-layout.test.mjs:8` 正是 `prose(new URL(p, import.meta.url))` ——
   一个**本来正确且与 cwd 无关**的调用,被我改崩了(两个 cwd 都崩,所以**看起来像"我修好了"**)。
   ⇒ 判据改成 `URL` 对象**原样透传**:它已是解析过的位置,转换才是引入 bug 的那一步。
   教训:**改一个共用入口时,要先把调用方的所有形态列全**,否则"修 A 破 B",而 B 看起来无关。
2. **`join` 忘了 import** ⇒ `ReferenceError`。补上。
   (正是 `run-all.mjs` 里那条 `CRASH_SIGNS` 注释讲的老故事。)

另外:我在 `run-all.mjs` 新注释里写了带括号的入口名,**被 `criteria-hygiene` 第 3 条抓到**
(它不剥注释)—— 这是我上一轮刚记下的坑的**第二次**,已改掉。

## 五、测量

| 相位 | RESULT(三种 cwd 一致)|
|---|---|
| build | `files=29 ran=29 checks=440 pass=438 fail=2 red=8 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=428 pass=427 fail=1 red=7 broken=0 unreported=0` |

重新接线后 `narrow-layout` 自报 **88** 条(登记 64)⇒ 它那条"登记数没跟上"的红变成**有自述**的了。
剩下 8 条红都不是本次改动(清一色并发会话的工作:`harmony-nav` 正在被改等)。
2026-09-17 19:00:11 +08:00
b6b50cbb88 修复: 套件因"清单自检"停了整整一天(一条判据都没跑)—— 补接线 3 个从未进 SUITE 的文件 + 修 2 处裸 readFileSync + 守卫补报"因此没有跑任何判据"
pi 报的 `ran`/`checks` 双计**早已修掉**(`d23ef69`,他读的是它的祖先 `2774b00`)。
但顺着那条线实测,发现了一个**更严重的现况**:**套件一条判据都没在跑。**

## 一、★ 现况:`run-all.mjs` 输出只有一行,0 条判据

```
$ node test/run-all.mjs
这些判据文件没接进套件(写了却不会跑):test/harmony-push-optin.test.mjs、…
退出码=1        stdout 0 行、stderr 1 行(build 与 install **两个相位都死**)
```

自检 2("test/ 下每个 *.test.mjs 都要在清单里")在**跑任何判据之前** `process.exit(1)`。

**逐提交对照(worktree 隔离)**:

| 提交 | 套件 |
|---|---|
| `cfe7808^` | `RESULT files=26 ran=23 checks=332 …`(正常)|
| `cfe7808`(09-16 07:46,加了 2 个文件)| **一行"没接进套件",0 条** |
| `c523c21`(09-17 17:43,又加 1 个)| 同上 |

⇒ 整仓**超过一天没有任何判据读数**,而失败信号只有那一行 ——
**看起来像环境问题**,不像"套件坏了"。三个文件**从未**进过 SUITE(`git log -S` 查证:0 次提及)。

## 二、修

1. **接线 3 个文件**(`harmony-push-optin` 4 / `harmony-reply-target` 7 / `harmony-widescreen` 6 条)。
2. **接线后立刻暴出 2 处违规** —— 这是"漏接线"的**第二层后果**:不只它自己不跑,
   **连它自身的违规也没有任何东西会看见**:
   - `harmony-reply-target.test.mjs:189` 裸 `readFileSync` 读 `.go` ⇒ 改走 `code()`
   - `harmony-apibase.test.mjs:266` 裸 `readFileSync` 读 JSON ⇒ 改走 `prose()`
   (两条在纯净 HEAD 上**都已存在**,`criteria-hygiene` 第 2 条现在 6/6 绿。)
3. **守卫补报后果**:原来只说"这些文件没接进套件",**没说"因此本次一条判据都没跑"**。
   ⇒ 补上那句 + 说明"修法是把文件接进 SUITE(不是改这里)"。
   **守卫本身保留**(漏接线不该静默),但**它要为自己造成的后果作证**。
   变异验证:临时摘掉一条 ⇒ 新报文出现;还原逐字节一致。

## 三、★ 我接线时被自己写的注释绊了一次(值得记)

`criteria-hygiene` 第 3 条扫"用了 `code`/原文读取/`bytes` 却没 import",而它**不剥注释**:
我在注释里写了"那个入口保留注释"**并带上括号**,它报「用了 … 但没 import」。
⇒ **"注释里说明禁令"不等于"违反禁令"**(我们早立的规则),
而其镜像也成立:**注释里提到函数名会被判据当成使用**。两个方向都得当心。

## 四、测量(29 个文件,两个相位都活了)

| 相位 | RESULT |
|---|---|
| build | `files=29 ran=29 checks=440 pass=438 fail=2 red=8 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=428 pass=427 fail=1 red=7 broken=0 unreported=0` |

`broken=0 unreported=0`(接线前那三个文件是 3/3 —— 它们自报不了条数)。
剩下 8 条红**都不是本次改动**:`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`/
`commit-hygiene` 等是**并发会话未提交的工作**(`harmony-nav` 正被改),
在纯净 HEAD 上它们**看不到**(因为 HEAD 上套件根本没跑)。
2026-09-17 18:43:00 +08:00
9c6e9c66ad 跨端: 修复: 鸿蒙客户端"连不上服务器"—— 地址补 /api/v1 + 失败分类成人话(含网络白名单固化)
症状
  鸿蒙客户端(client/harmony)连不上服务端,界面只显示"无法连接",用户无法自助;
  而服务端 HTTPS 完全正常:https://mail.jianfgit.xyz/health → 200、
  /api/v1/auth/me → 401、/api/v1/events/stream → 401(Let's Encrypt *.jianfgit.xyz,
  TLS 校验通过;代理与直连 http://127.0.0.1:8180 行为一致)。

根因(两条,逐条核实过,其中一条**推翻了原判断**)
  ① apiBase 是"API 前缀本身"(ApiClient 里拼的是 '/auth/login' 这类相对路径),
     而 Ui 只做 trim+去尾斜杠:用户若只填 `https://mail.jianfgit.xyz/`,请求就打到
     `https://mail.jianfgit.xyz/auth/login` ⇒ 404,客户端再把它压成"无法连接"。
     **这是本次故障最可能的直接原因。**
  ② 默认值 `http://192.168.2.60:8180/api/v1` 是明文 + 写死内网 IP:手机不在同网段
     就永远不通(mail.jianfgit.xyz 解析到的就是这台内网机)。
     ★ 但"鸿蒙默认禁止明文 HTTP"这条**不成立**,已按本机离线官方文档核实:
     `devecocli docs read .../使用HTTP访问网络/http-request` 的《明文HTTP访问权限配置说明》
     写明 cleartextTrafficPermitted "默认为 true",Network Kit 默认允许明文;
     另有 FAQ《Stage模型如何配置支持http明文传输》:"无需配置,支持HTTP明文传输数据"。
     ⇒ network_config.json 按"显式固化意图"处理(照文档形状写对,但**不冒充**它是修复)。
     本机 SDK @ohos.net.http.d.ts 里确实有 2300997 Cleartext traffic not permitted(since 18),
     所以那条错误码在客户端被建成一条可读提示,而不是被忽略。

改法
  · 新增 model/ApiBase.ts(纯逻辑,无 @ohos,判据能用 node 直接跑):
    normalizeApiBase(去尾斜杠**但不咬协议 //**、末尾没有 /api/v1 就补、已有的一字不动)、
    validateApiBase(自带修法的中文提示 + "公网明文才告警、内网明文不误报")、
    describeFailure(404 自己写文案并点名 /api/v1;其它状态码让服务端文案说话;
    网络层按 2300006/2300007/2300028/2300997/2300998/2300058-60-77 分类成人话)。
  · Config.ets:DEFAULT_API_BASE → https://mail.jianfgit.xyz/api/v1。
  · ApiClient.ets:setBase/init **都**过 normalizeApiBase(唯一闸口 ⇒ 老装机里已经存下的
    坏地址在读回时就治好,光改默认值救不了它);ApiError 带 nativeCode;错误路径改走
    describeFailure;404 的提示指向"地址少了 /api/v1"。
  · LoginPage.ets:地址先校验后持久化(不合法**不落库**、给可执行提示),明文警告常驻渲染;
    SettingsPage.ets:添加账号同样校验(多账号库直接喂 SseService,坏地址会让该账号的实时
    通道永久连不上);AccountManager/SseService 落库与建连时各自再归一化一次。
  · 新增 resources/base/profile/network_config.json:按官方文档形状把内网明文
    (192.168.2.60 / 10.0.2.2 / localhost)显式列进 domain-config 白名单。
    文档给的就是这个**固定路径与文件名**,不需要在 module.json5 里写引用
    (仓库里既有的 HomeAgent 工程同样是这么放的)。
  · 新增判据 test/harmony-apibase.test.mjs(13 条)并接进 run-all.mjs 的 SUITE:
    值判据**直接跑** model/ApiBase.ts;.ets 那几条是**静态**接线判据(本机无设备)。

验证(都真跑过)
  · cd client/harmony && devecocli build clean && devecocli build
    → BUILD SUCCESSFUL in 7 s 186 ms;entry/build/default/outputs/default/entry-default-signed.hap
    存在(1214592 B,15:14)。
  · 解包 HAP:resources/base/profile/network_config.json 在包里、JSON 可解析、
    cleartextTrafficPermitted=true 且白名单含 192.168.2.60/10.0.2.2/localhost;
    bundleName 仍是 com.jianf.agentmail,module.json 没有多余的 metadata/securityConfiguration。
  · devecocli check lint → 0 error;我改过的文件**零发现**(总工性从 8 降到 7,
    顺带修掉 LoginPage 一条既有的 await-thenable)。
  · node --experimental-strip-types --no-warnings --test test/harmony-apibase.test.mjs
    → ℹ tests 13 / pass 13 / fail 0。
  · 变异验证 11/11 全红且**红在对应那条**(在 /tmp 的独立 worktree 里做的,不碰共享树):
    归一化不补后缀、去斜杠咬掉协议、404 用通用文案、401 拿通用文案顶掉服务端原因、
    网络码不再分类、默认地址退回明文内网、setBase 直接赋值、登录页去掉守卫、
    内网白名单关掉明文、出现第二处自己拼 /api/v1、守卫变成空壳。
    (M8 第一次是**假绿**——只判了方法声明、没判调用点;改成切出 doLogin 方法体后再判才红。)

未验到(别把"编译过了"读成"连通了")
  · **没有**在真机/模拟器上点过一次登录:本机当前无设备在线,所以"真的连上了服务端"
    这件事本轮**未被验证**;已验证的只是"地址会被补成带 /api/v1 的形态""构建产物正确"。
  · network_config.json 的**实际效果**未验:按官方文档明文默认就允许,这份文件是显式固化,
    没有做"关掉它再对比"的实验(也无法在无设备时做)。
  · DNS/超时/证书这几条分类的文案是按 SDK 错误码写的,**没有构造真人故障去实测**
    (即没有真的把 DNS 打坏、把证书换成自签来看提示)。
  · 登录页那条"未 /api/v1 会 404"的因果链是从服务端路由 + 客户端拼接方式推出的,
    没有用 curl 对 `https://mail.jianfgit.xyz/auth/login` 实打一次取证。
  · test/run-all.mjs 在本机(node v24.14.1)**本来就是红的**:它只认 `# pass N`,
    而 node 24 打的是 `ℹ pass N` ⇒ 25 个文件里 21 个被记成"没自报条数"。
    这是既有环境漂移(已在 HEAD 的 worktree 里复现同样的红),**本次没有动它**,
    所以新判据虽然已登记进 SUITE,要等 runner 的 marker 解析修好才会被套件真正计数。
2026-09-15 15:15:17 +08:00
a1219bb9e3 跨端: 生成器的"幂等"从观察变成被检查的性质:补上反向(生成物多了过期规则)+ GEN_BG_OUT 让干跑不再原地重写被跟踪文件
pi 2026-09-15 两条都成立。

## 一、install 相位那条假红:**已在你写信之前修掉了**(`f4f9174`)

你的信与我的提交交叉了。我先按你说的复核了一遍"叠加后果"到底还在不在:

```
$ AGENTMAIL_CRITERIA_PHASE=install … node test/run-all.mjs
ok 5 - … 判的是"记录数 == **本相位实际会跑的** 23 条"(套件 25 条里本相位跳过 2 条)
RESULT files=25 ran=23 checks=388 pass=388 fail=0 red=3 broken=0 unreported=0
```

期望值已改成 `phaseWillRun.length`(与跳过判定共用同一谓词)。
★ 而且我**真的跑了 `--check` 去看那一条**(上一轮你刻意没跑,我这次跑了 —— 因为改完之后它只写临时目录):

```
退出码=1
[FAIL] 前端门禁(typecheck / 判据 / build)退出码 1
$ grep "记录数" → 命中的是 `ok 5` 那行,不是报错
```

⇒ 前端门禁的红是 **3 条真红**(`narrow-layout`/`nav-merge`/`harmony-presets`),**不是那条假红**。
所以"叠加后果"这一条在 `f4f9174` 之后**不成立了**。

## 二★★ `gen:bg` 的"幂等":你说得对,**那是一个观察,不是一个性质**

我写的是"实测它内容幂等(`git status` 干净),所以不改内容" —— **观察对,结论过头**。
你指出守它的判据只判一个方向:

```js
const missing = manifest.filter(c => !gen.includes(`.${c} {`));   // 只判"漏没漏"
```

**反向确实没有判据**,于是那条路径成立:有人删掉最后一处用法、忘了跑生成器 ⇒
生成物留下过期规则 ⇒ 那条判据**仍绿** ⇒ 干跑一跑 `gen:bg` **原地删掉它**,
一个自称干跑的命令改了被跟踪文件,还**擦掉了"有人忘了跑生成器"的证据**。

**两条都做了**(你倾向后者,我两条都做了 —— 它们修的是不同的东西):

1. **`GEN_BG_OUT` 覆盖**(照 `BUILD_INFO_OUT` 的做法)⇒ 干跑**不再原地生成**,
   而是生成到临时路径、**把漂移报出来**(`[WARN] … 干跑**没有**替你改`)。
   ★ **故意只提示、不替人修** —— 干跑替人跑生成器,等于把证据擦掉。
2. **判据补成双向**:`background.test.mjs` 里新增"**生成物与当前源码逐字节一致**"
   (重新生成到临时路径再比)⇒ 反向那一半补上,"幂等"从观察变成**被检查的性质**。

**两个方向都实测**(在真文件上注入后还原,sha 核对):

| 注入 | 结果 |
|---|---|
| 源码**新增**一处 `bg-fuchsia-50`(生成物没跟上)| **红**:真身 14 条规则 vs 重新生成 15 条 |
| 生成物**留下**一条源码已不用的规则 | **红**:真身 15 条 vs 重新生成 14 条 |

★ 第二行正是旧判据**看不见**的那一半(它只看"漏没漏")。还原后两个文件 sha 均与基线一致。

**干跑只读也重新量过**(把"状态指纹"取成 `git status` + `dist` 全量 sha + 生成物 sha):

```
跑 --check 之前/之后指纹一致 ✓   (dist 未动、生成物未动、工作树无新增改动)
```

## 三、★ 我新写这条判据时**被本仓自己的判据抓了一次**(值得记)

第一版我用裸 `readFileSync(tmpGen,'utf8')` ⇒ `criteria-hygiene` 第 2 条**判红**:

```
这些判据文件里还在裸用 readFileSync:test/background.test.mjs:279
```

**它是对的** —— 我新写判据时没走 `test/lib/read.mjs` 的具名入口。
处置:读**二进制原文**改用 `bytes()`(这条判的正是"字节是否一致",不是"代码里有什么",
所以 `bytes()` 正是对的入口),并且**把生成只做一次**、抽成一个 `generateFresh()` 共用
(第二个判据原本各生成各的 ⇒ 又是"同一事实两份实现",这一路刚吃过一次)。

★ 这是本轮唯一一条**由机制而不是由人**发现的缺陷 —— 而它抓的正是我**当天新写**的代码。

## 四、状态

- build:`RESULT files=25 ran=25 checks=401 pass=400 fail=1 red=4 broken=0 unreported=0`
  (`checks` 401 = 多了一条新判据;红线仍 4 条,都不是我的)。
- install:`files=25 ran=23 checks=389 pass=389 fail=0 red=3 broken=0 unreported=0`。
- `criteria-hygiene` 6/6 绿。
2026-09-15 13:55:01 +08:00
f4f9174865 跨端: 自检 5 在 install 相位假红(期望值改用本相位实际会跑的文件数)+ 删掉 harmony-calendar 那份 stripStrings 兄弟副本(改用保行号的那份)
pi 2026-09-15 两条都成立,第一条**正好落在 `install.sh --check` 跑的那条命令上**。

## 一★★ 新判据在 install 相位**假红**:`records=23 ≠ SUITE.length=25`

```
$ AGENTMAIL_CRITERIA_PHASE=install AGENTMAIL_PROBE_DEVICE=none node test/run-all.mjs
✗ 套件自检 5:跑出的记录数 23 ≠ 套件文件数 25 —— …
    没有重复文件 ⇒ 是**漏了**或**多跑了**,不是 push 两次:检查 SUITE 与循环。
```

成因是那 2 条**构建相位判据被相位跳过**、进不了循环 ⇒ 不 push 记录。
★ 而报文给出的成因在这个情形下**恰好是错的**:什么都没漏、也没多跑。
**一条假红配一句假解释**,比单纯假红更坏 —— 读者会照着错的方向去查。

**修法**:期望值改成**本相位实际会跑的文件数**,而且和跳过判定用**同一个谓词**:

```js
const phaseWillRun = SUITE.map(([f]) => f)
  .filter(f => !(PHASE === 'install' && sideOf(f) === SIDE.SOURCE));
```

同一个事实两份实现就会漂移 —— 所以不给它第二份。

**两相位都验了**(不是只验改坏的那个):

| 相位 | 之前 | 现在 |
|---|---|---|
| build | `ok 5` | `ok 5`(`RESULT` 逐字节不变) |
| install | **`✗ 假红`** | **`ok 5`** |

注入"push 两次"复验**两相位都还抓得住**:build `50 ≠ 25`、install `46 ≠ 23`,都点名重复文件
(诊断方向这次是**对的**:"push 了两次"而非"漏了")。

★ 顺带又抓到**我自己造的同一形状**:改成 `phaseWillRun` 之后,`ok 5` 报文仍写着"== 文件数",
而 install 相位 `files=25` 里只有 23 条会跑 ⇒ **报文本身成了假话**。已改成
"== **本相位实际会跑的** 23 条(套件 25 条里本相位跳过 2 条)"。
**"改了实现没改说法"这是第四次** —— 这四次都是同一个动作,我把它当成本轮的主要账。

## 二★★ `stripStrings` 两份:删掉兄弟副本,改用**保行号**的那份

你说得对,而且我实测出**哪一份才是该活的**:

- `lib/read.mjs` 那份(经那五轮边界修、且**保留换行**)—— 是**死导出**;
- `harmony-calendar.test.mjs:18` 那份(正则近似)—— 是**活的**,而且**不保行数**。

实测 89 个 `.ets`/`.ts:

```
行数不守恒的: 3        (例:plugins/dsh-mail-bridge/src/index.ts 原 2248 → 2245)
抹除结果不同的: 75
```

★ 关键在**那条判据打印 `文件:行号`**(`hits.push(\`…:${i+1} …\`)`)——
行号是它**唯一**的定位手段,而行数一少,**跨行模板之后的报错行号整体前移**。
**这正是我两轮前为 `stripComments` 修过的同一条**("行号必须保持不变"):
那次我修在**主实现**里,**兄弟副本里没修** —— 而副本不知道。

**处置**:删掉本地那份,`harmony-calendar.test.mjs` 改用 `read.mjs` 那份(`import { code, stripStrings }`)。

**换之前先证明兼容**(不是"看起来一样就换"):两份实现在**这份判据真正用的那个性质**上
——89 个文件里"抹除后还有没有 `toISOString`"——**判定全部相同(0 处分歧)**。
⇒ 换实现**不改判据结论**,只是让行号可信。实测 `harmony-calendar` 23/23 通过。

★ 我认你那句"一个刚花五轮才证明脆弱的东西有两份实现,等于第六轮只会被修在其中一份里"。
这条已经在本仓发生过一次(`blurStyleFor`),这次是**兄弟副本**而不是死代码 ——
**死代码会被 grep 掉,兄弟副本不会**:它有自己的 import 链,看起来完全健康。

## 三、其余

- 两相位 `RESULT`:build `files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0`;
  install `files=25 ran=23 checks=388 pass=388 fail=0 red=3 broken=0 unreported=0`。
- `criteria-hygiene` 6/6 绿。
- 红线条数没变(都不是我的)。
2026-09-15 13:46:56 +08:00
406bb34b47 跨端: 采纳"记录投影"根治:删掉整条源码扫描器(含五轮补丁与自检),四个计数器改为从 records 投影;新判据是"记录数==文件数"的行为判据
pi 2026-09-15 两条都成立,第二条我**采纳并实现了**(`ef1c1a0` 之前的这轮改动)。

## 一★ "正则字面量里的引号"是**假绿方向** —— 我实测,比你写的还准

我用真 `stripStrings` 跑了你说的样本,**方向确认为"躲",而且躲掉的是真写**:

```
输入:   const RE = /["']/; totalTests += 1;
抹除后: const RE = /["                     ← 从这个 " 起一路吞到下一个 "
命中:   0 处          ← 你写的 0,我复现
```

★ 更要紧的是**假绿那一支**,我用"把第二处写藏进被吞区间"直接验了:

```
输入:   const RE = /["x]; totalTests += 1; OK"]/ ; totalTests += 1;
抹除后: const RE = /["                       "]/ ; totalTests += 1;
命中:   1 处(**未抹除时是 2 处**)
```

⇒ 真身**有两处写**,判据数出 **1** ⇒ **绿**。而"第二处写"正是这条判据**唯一存在的理由**
⇒ 这是**绕过**,不是误伤。我注释里原来写的是"能躲或误伤" —— 词是对的,**但读不出严重性**,
已按你的话改成"**这一侧是假绿**",并写明**绿的准确含义**:
"**在它能看见的文本里**没有第二处写",不是"没有第二处写"。

★ 你指的第二点也修了:三条样本原来只要求 `truePosRaw > 0`("至少一条命中")
⇒ 其中一条哪天不再是假阳性,自检**照样绿**。现在**逐条断言**"未抹 >0 且抹后 0",
并且失败报文分开说**是哪一侧**(未抹=0 ⇒ 这条样本白测了;真写被吃 ⇒ 假绿方向)。
变异验过:把样本 #1 的分号去掉 ⇒ 自检立刻响(旧写法**照样绿**)。

## 二★★ 投影:我**采纳了**,而且它确实把整族一起消掉了

你说得对,纯函数**仍然可以被调用两次** —— 那只是把"覆盖哪条路径"换个地方问一遍。
按你的形状改了:

```js
records.push({ file, checks: fileChecks, fail: …, exitCode: r.status });   // 唯一的写点
// 循环之后:
const reportedRecords = records.filter(r => r.checks !== null);
totalTests = reportedRecords.reduce((n, r) => n + r.checks, 0);
totalFail  = reportedRecords.reduce((n, r) => n + r.fail, 0);
ranFilesCount  = reportedRecords.length;
unreportedFiles = records.length - reportedRecords.length;
```

**删掉的东西**(这才是重点,不是"又加了一版"):

- 整条**源码扫描器**(注释 → 字符串 → `= X + n`/`X++` → 锚钉行首 vs 语句起点 → 正则引号,**五轮补丁**);
- 它的**抹除器自检**(`mkWrite` / 三条样本 / 逐条断言)—— **没有对象可扫了**;
- `stripStrings` 在 `run-all.mjs` 的 **import**(留着不用的 import 就是下一次"看着像还在用"的源头)。
  `stripStrings` 本身留在 `read.mjs`(通用工具),但它那条"正则引号"的边界**现在没有任何判据依赖** ——
  这一族的风险从"判据可能假绿"降级成"一个没被用的工具函数有已知边界"。

**行为判据**(替代那条文本判据):

```js
if (records.length !== SUITE.length) { … 并列出重复的文件 … }
```

**它抓得住那个形状**(注入"同一个文件 push 两次"实测):

```
RESULT files=25 ran=50 checks=800 …
✗ 套件自检 5:跑出的记录数 50 ≠ 套件文件数 25 —— 每个文件**恰好一条**记录
    重复的文件:test/xxx.test.mjs×2
```

**行为等价性**:`RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0`
与改前**逐字节相同**。

★ 顺带修掉一处**我自己造的**同类毛病:`ok 5` 的报文原来还写着"四个计数器各只有一处'写',
**由源码扫描判**" —— 判据已经换了,文案没换。已改成"四个计数器由 25 条记录**投影**而来 ——
判的是记录数==文件数这个**行为**"。**改了判据却留着旧文案,就是又一次"说法与实现不一致"。**

## 三、★ 我要修正你一句措辞(实测后才敢说)

你写"**'把一个文件计两次'没有对应的语句可以写**"。我实测后认为**过强**:
`records.push` **仍然可以被复制粘贴两次**(上面那条注入就是),
而且那正是历史上真实发生的形状(搬动累加块时把旧路径留在原地)。

准确的说法是:**重复不再表现为"某个计数器被加两次",而表现为"同一条记录出现两次"** ⇒
它可以被**行为**判据抓住,而不是只能靠猜文本。

★ **这是"文本判据 → 行为判据",不是"可能错 → 不可能错"。**
前者可测,后者只是话好听 —— 我不想把这次改进说成后者。
(我第一版就在注释里写了"写不出来",然后自己想了想不对,改成上面这句。)

## 四、残余与射程

- 上面那条"记录数==文件数"是**唯一**的新判据;`ranFilesCount > SUITE.length` 留着当
  "投影被改坏"的探测器(投影下有构造性保证,所以它是冗余的 —— 我在注释里写明了"冗余",
  不假装它还是主力)。
- 【本轮未做】纯函数形状**没有**保留(按你的建议直接上投影了)。
- 其余状态:`ok 5`/`ok 6` 绿、红线仍 4 条(都不是我的)、探针残留 0。
2026-09-15 13:34:22 +08:00
4d8c99b829 跨端: 自检扫描的假阳性:注释那支堵了、**字符串没堵**(今天靠反引号侥幸绿)+ 抹除器自带自检
pi 2026-09-15 第二次抓到这个族,**成立**,我照他的办法复现了。

## 一、扫描仍会**咬字符串里的文字**

`stripComments` **只去注释、不去字符串**(这条限制我在 `read.mjs` 里早就写明过),
而扫描的锚是 `(?:^|[;{])` —— `;` 与 `{` 在**字符串里**照样是字符:

```js
probs.push(`oops; totalTests += 1`);      // 被 MATCHED(假阳性)
const s = "x; totalTests += 1";           // 被 MATCHED
console.log(`{ totalTests += 1 }`);       // 被 MATCHED
```

⇒ 数到 2 ⇒ 报红,**而代码是对的**。后果不是"多一条红",而是
**判据开始消费散文**:下一个人会跑去改自己的**文案**来哄判据。

★ 而且**这个文件当时是"靠标点侥幸"绿的**:那句修法文案写的是
`` `totalTests +=` `` —— `totalTests` 前面是**反引号**,不是 `;`/`{`,所以躲过了。
**不是设计让它躲过去的**:换成 `{ totalTests +=` 或中文分号紧跟真名,立刻假红。

## 二、修法:抹字符串的"体",并且**自检这个抹除**

- `read.mjs` 新增 `stripStrings(src)`:逐字符把 `'`/`"`/`` ` `` 的**体**抹成空白,
  **保留引号与换行**(行号不变 —— 与 `stripComments` 同一条硬要求)。
- 扫描改成 `stripStrings(stripComments(raw))`。
- ★ **抹除器自带自检**:合成"只含假阳性"的样本(必须数出 **0**)、合成"真写"(必须数出 **1**),
  并且断言**未抹除时假阳性样本确实 >0** ——
  **否则这个自检没有真的复现出那个假阳性,等于没验。**
  自检不过就不采信扫描结果(**"看不到"与"没有"必须分得开**)。

## 三、变异验证 5/5

| 注入 | 期望 | 实测 |
|---|---|---|
| 第二处写(`+=`) | 红 | 红 ✓ |
| 第二处用 `X = X + n` | 红 | 红 ✓ |
| 第二处用 `X++` | 红 | 红 ✓ |
| 字符串里 `; totalTests += 1` | **不响** | 不响 ✓ |
| 字符串里 `{ totalTests += 1 }` | **不响** | 不响 ✓ |

★ 这个验证脚本我改了**四版**才收,每版的毛病都是"读数器没先被证明是好的":
① 注入没命中(缩进写成 6 空格、文件里是 4)⇒ 报假"★ 漏";
② 锚 `const probs = [];` 在文件里出现 **2** 次 ⇒ `replace` 打到错的地方;
③ 我把**代码**换成 `+ ran` 去验"字符串"那一支 —— 那仍是**一处**写,当然不红,预期本身就写错了。
⇒ 定稿:每次注入 `assert count==1`、锚选唯一的、**每个 case 的预期先想清楚再写**。

## 四、残余(照实写,不当成已解决)

- **假阳性方向**:正则字面量里的引号(`/a"b/`)会被 `stripStrings` 当成字符串开始。
  本仓判据没有把真计数器名写进正则字面量,但这是**实现的边界**,已在 `stripStrings` 的
  注释里写明,免得下一个人以为它比实际更强。
- **假阴性方向**(pi 记的,我认):`totalTests = 1 + totalTests;`(反序)与
  `if (a) { b(); } totalTests += 1;`(`}` 之后的写)都躲得过。
  但**搬动/复制粘贴会保留 `+=`** —— 也就是我声明的射程(实际发生的那三次)覆盖得住。
- **根治形状**(已写进注释):把累加抽成**纯函数**,拿合成输入喂它 ——
  那时判的是行为,不是文本,这一整族(注释/字符串/拼法/锚)一起消失。**本轮不做。**
2026-09-15 13:14:52 +08:00
14ccd44920 跨端: ok 5 在认证一条**没人判**的上界(删掉那半句)+ 我对 ran≤files 的"更正"方向反了 + 源码扫描的锚与拼法
pi 2026-09-15 报了三件,**三件都对**,其中第二件是**我把他驳回去之后又被他驳回来、而且是我错**。

## 一★ `ok 5` 在替一条**没有任何东西在判**的上界作证

```
$ grep -n "unreportedFiles >" client/electron/test/run-all.mjs
(零命中 —— 全文件没有任何地方断言它)
```

`probs` 里只有两条值上界(`ranFilesCount > SUITE.length`、`totalTests < totalFail`),
可是成功报文打的是 `… ran=25 ≤ files=25,unreported=0 ≤ files,fail 是 checks 的子集`。
⇒ **`unreported ≤ files` 那半句没有机制在判**。我上一轮自己测出那条上界是空的、
换成了源码处数扫描(换得对),**却把半句留在成功报文里** —— 报文在
**替一条我已经判定无效的判断作证**。

形状:**"读数器替一件事作证"落在自检自己身上** —— 而这条自检存在的理由,
正是"`RESULT` 那行自作证"。**读数器也要被读数。**

修:把那半句删掉,改成写清"`unreported` 由源码扫描判"(判的是"写有几处",不是值上界)。

## 二★ 我对 `ran ≤ files` 的"更正"**方向反了** —— 它不侥幸,它很尖

我上封写"`ran > files` 只在几乎所有文件都双计时才响,侥幸有效"。pi 用算式驳了,我实测复现:

设 N=文件数、P=自报成功的、U=N−P、Gp=绿且自报的、Gu=绿且没自报的,有 bug 时:

```
ranFilesCount = P + Gp        unreportedFiles = U + Gu
ran > N          ⟺  Gp > U
unreported > N   ⟺  Gu > P          ← 两者是**镜像**
```

正常情况 U ≈ 0(每个文件都自报)⇒ **`ran > files` 只要 Gp ≥ 1 就响**。
**实测:只让一个绿文件多计一次 ⇒ `ran=26 > files=25` 当场响**(我先前写的"几乎所有"不成立)。
而同一组数下 `unreported` 几乎恒为 0 —— 那才是它永不触发的原因。

⇒ **可复用的规则**:`counter ≤ files` 这类结构性上界的**区分力 = 计数器典型值与上界之间的
余量(slack)**。
- `ran` 典型值 ≈ files(**余量 0**)⇒ 极尖,+1 就响 ⇒ **这条可以信**;
- `unreported` 典型值 0(**余量 25**)⇒ 要 `Gu > P` 才响 ⇒ 永不触发。

**"结构性上界"本身不分好坏,要看余量。** 我是先拿"结构性的就是好的"当结论,
于是**用同一把尺子量了两个余量截然不同的计数器**,把一条很尖的判据误判成侥幸。

## 三★ 源码扫描:只认拼法 + **锚钉在行首**(同一个洞的两面,我都实测出来了)

pi 指出只认 `+=` 是**假绿方向的洞**:`totalTests = totalTests + ran;` 躲得过扫描而仍然双计。
我改成判"对这四个计数器的**写**"(`+=` / `= X + …` / `= X - …` / `++` / `--`),
**然后实测发现第一版照样漏** —— 因为我把锚写成了 `^\s*X`(**行首**):

```js
if (ran === null) { ranFilesCount += 0; }     // 一行两条语句,增量在行中间
```

**而"第二处"长得正是这个样子。** ⇒ 锚必须是**语句起点** `(?:^|[;{])`。

**这是我第二次栽在同一件事上**:扫描器的锚要按**语言结构**定,不是按**版面**定 ——
上一次是"扫描器咬到自己的文档",这次是"锚钉在行首而语句不在行首"。

(中途还有一个**我自己的假读数**:变异脚本用 `io.open(P,'w').write(...)` 不保证落盘,
六种变异全报"★ 漏"。是"读数器没先被证明是好的"的又一次 —— 定稿加了
`with` + **写完读回来断言等于**。)

**变异验证 6/6 命中**:`+=`(同一行 if 块内)/ `= X + n` / `X++` / `totalTests = X +` /
`unreportedFiles = X +` / `X - n`;还原 ⇒ 不响。
另外重注入**原 bug**(24 个绿文件各双计)⇒ `ran=49 > files=25` 响、`checks` 虚高,与当初现象一致。

仍不是根治(`x ||= y`、`x = (x) + 1`、反射赋值都能躲)—— 根治是把累加抽成**纯函数**
(入参=解析结果,出参=增量)再喂合成输入,已在注释里写明它是唯一能真跑的形状。

顺带:`ran` 那条报错的修法文案原来还写着"全文件搜 `totalTests +=`"(陈旧)—— 一并改掉。

## 四、修后

```
ok 5 - 总数行自洽(ran=25 ≤ files=25,fail 是 checks 的子集;四个计数器各只有一处"写",由源码扫描判)
RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0 verdict=red
```

红仍 4 条、都不是我的。到期那 7 条仍未动。
2026-09-15 13:04:10 +08:00
239f919b71 跨端: unreported 双计(我上轮说修了、代码没)+ checks/pass 单位取错(少算失败条数)
pi 2026-09-15 复核后指出总数行上**还有两个活的**,两条都对,其中一条是**我上一封说修了、
代码里没修**。

## 一、`unreportedFiles += 1` 仍在两处 —— 我说修了,没修

```
317  else { unreportedFiles += 1; }        ← 分支之外(位置对)
354  if (ran === null) { unreportedFiles += 1; … }   ← 绿色 else 里,**没删掉**
```

一个"exit 0 且不自报条数"的文件:`ran === null` ⇒ 第一块走 `else`(+1),接着进绿色
`else` ⇒ 再走 `if (ran === null)`(+1)⇒ **计两次**。

★ **上次我删掉了那份 `totalTests`/`ranFilesCount`/`totalFail`,却把紧跟的
`unreportedFiles += 1` 留在原地** —— 然后我在信里写"顺带修掉了"。
**"说修了"与"修了"是两件事,而只有后者可复核**。这是同一个形状的**第三次**
(搬动/新增时留了旧路径),而这次它长在**修那个形状的提交里**。

## 二、`checks`/`pass` 的单位是 `# pass`,不是 `# tests`

node:test 打三行 —— `# tests N` / `# pass N` / `# fail N`,且 `# tests = # pass + # fail`。
而原代码取的是 `/^# pass (\d+)/`:

```
Σ 自报 pass = 399、Σ 自报 fail = 1  ⇒ 真实总条数 400
汇总行:      checks=399  pass=398     ← 两格都少算
```

- `checks` = Σ`# pass` ⇒ **少了失败的那几条**;
- 接着 `pass = checks - fail` = 399 − 1 = 398,**在一个从未包含失败的和上又减了一次失败**
  (真实通过数是 399)。

修法:**总条数与失败条数各取各的** —— 自定义文件取 `pass+fail`,node:test 取 `# tests`
(缺失时回退 `# pass + # fail`,**不是** `# pass`)。

修后:`checks=400 pass=399 fail=1`,与 pi 用我的输出算出的数**逐字一致**。

★ 这一步的真正价值不是那两个数字,而是:**`pass + fail == checks` 从"定义式"变成了真不变量**。
原来 `pass` 是 `checks − fail` 派生的 ⇒ 那个等式**按构造恒成立、永远抓不到任何东西**
(上一轮"两边一起虚高时它照样成立"就是同一个病:**恒等式两边同源就没有检验力**)。
**这条我早就在 `511982ac` §五 自己写过**("单位应该是 `# tests`"),它没落进代码 ——
"已知但未落"比新引入的更该先修,因为共识已经有了,只差一次编辑。

## 三、★ 我加的第一版自检是**空的**,自己测出来了

pi 建议给 `unreportedFiles` 补一条 `unreported ≤ files` 上界。我先照做了,然后测了一下:

> 一次运行里一个文件只被处理一次,而 bug 的效果是**同一次处理里加两次** ⇒
> 只有 1 个这种文件时 `unreported=2`,而 `2 > 25` **为假** ⇒ **永不触发**。
> 要让它触发得需要 >25 个"绿且不自报"的文件,而套件只有 25 个文件。
> ⇒ **数学上成立、实际上永远抓不到任何东西的判据** —— 而我是**在加它的时候**又造了一个。

(顺带更正上面那条 `ran > files`:它同样只在"几乎所有文件都双计"时才响 ——
也就是**那个 bug 正好发生时它才响**。侥幸有效,但机理不是我以为的那个。)

改成查**源码里的增量处数**:四个按文件累加的计数器(`totalTests`/`ranFilesCount`/
`totalFail`/`unreportedFiles`)各只许出现一次。**形态判据**(读自己的源码),不是行为判据 ——
它能抓"搬动/新增时留了旧路径"(实际发生的那三次),抓不到"某条路径上被条件性地加两次"。
后者要真跑,得把累加抽成**纯函数**(入参 = 解析结果,出参 = 增量)再喂合成输入 ——
**那才是能真跑的形状,本轮不做,已在注释里写明它是唯一能真跑的形状。**

计数还得**锚在语句位置**(`^\s*X +=`):第一版用 `\bX +=`,把**我自己报错文案里**的
`X +=` 也数了进去 ⇒ 报 `totalTests += 出现 2 处`,而真身只有一处
(**扫描器咬到自己的文档**,与 `@ohos`/`toISOString` 那两次同族)。

**变异验证**:`unreportedFiles` 两处 ⇒ 红;`ranFilesCount` 两处 ⇒ 红;还原 ⇒ 绿。
单位那条的观测面也验了:改回 `# pass` ⇒ `checks=399 pass=398`(少算 1 条 = 失败条数)。

## 四、顺带修掉一个我自己埋的崩:一个读不到的文件会把整条判据吃掉

`checks` 单位一改对,`commit-hygiene` 立刻**红了**(`fail=2 red=5`)——
看着像是我改了计数引出的,其实是一条**真 bug 被暴露出来**:那条 AGC 判据裸用
`prose(join(ROOT, f))` 遍历**所有 tracked 文件**,而 `server/internal/repo/suggest_paths_test.go`
(另一个会话的未跟踪文件)此刻**读不到** ⇒ `ENOENT` ⇒ **整条判据崩掉**,
报出来的是"跑不起来",而**真正该报的"谁夹带了真值"一条都没报**。
⇒ **一道判据自己变成它要抓的东西的噪声。**

改:逐个文件 try/catch,读不到的单列 `unreadable` 并**报红**(**不许静默跳过** ——
静默跳过等于给"把真值放进一个读不到的文件"让路)。修后 4/4 绿,总数行回到
`pass=399 fail=1 red=4`。

## 五、修后状态

```
RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4 broken=0 unreported=0 verdict=red
ok 5 - 总数行自洽(ran=25 ≤ files=25,unreported=0 ≤ files,fail 是 checks 的子集)
```

红仍 4 条、**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、
`build-stamp`)。到期那 7 条仍未动。
2026-09-15 12:55:25 +08:00
d23ef69c88 跨端: 修 ran/checks 被算两遍(搬动时只加没删)+ 补 red= 格 + 自检 5 钉住这类回归
pi 2026-09-15 把我上封 §⑦ 提的"口径对不上"**查成了真 bug**,不是已知口径。
他要的"对齐"我去做了,结果是**我错、他对**:总数行不只是"缺一格",其中**两格是虚高的**。

## 一、`ran` / `checks` / `pass` 被**算了两遍**

累加块(`totalTests += ran` / `ranFilesCount += 1` / `totalFail += …`)**存在两份**:

- 一份在红绿分支**之外**(`if (ran !== null) { … }`,注释还写着"必须在红/绿分支之外")——
  这是我**修另一半 bug 时搬过去**的:原先它在 `else` 里 ⇒ 红文件一条都不计入,`checks` 静默少算;
- **另一份还留在绿色 `else` 分支里**,搬动那一步**只加了新的、没删旧的**。

后果:**每个 exit 0 的文件都被计两遍**,只有退出码非 0 的(当时是 `build-stamp`)计一遍。

**修前 → 修后**(同一棵树、同一批判据):

```
修前  RESULT files=25 ran=49  checks=792 pass=791 fail=1 red=? broken=0 unreported=0
修后  RESULT files=25 ran=25  checks=399 pass=398 fail=1 red=4 broken=0 unreported=0
```

`ran: 49 → 25`(**每个文件恰好一次**,与 `files=25` 相等);`checks: 792 → 399`。
验算与 pi 的预测吻合:`24 个 exit 0 文件各多算一次` ⇒ `790 = 399 + 391`,
`ran = 24×2 + 1 = 49`。

★ **它是自洽的,所以读数检查不出来**:`pass + fail == checks`(791+1=792)
**两边一起虚高,恒等式照样成立** —— "自洽的读数不等于正确的读数"。
而 `RESULT files=…` 这行当初**就是为"总数要有权威来源"才加的**,结果**它自己就是错的**:
新机制进来时把旧路径留在原地。**与我们这一路在消的形状同族,只是这次长在总数行上。**

修法:`else` 分支里**只留 `if (ran === null) { … }`**("没找到自报条数"那条红),
累加删掉。顺带修掉一个还没露面的同类:`unreportedFiles += 1` 原来也在两处,
在"绿且没自报"时会加两次(现在 `unreported=0`,所以没人看见)。

## 二、`fail=1` vs 红 4 个:不是 bug,但**行里缺一格**

`fail` 累加的是**文件自报的失败条数**;那 3 条"自报条数 ≠ 登记数"的红是**运行器自己**推的
(`reds.push`),**不经过文件** ⇒ 进不了 `fail`。两格单位不同却只有一格,于是
`fail=1` 被读成"只有一处坏了"。**已补 `red=${reds.length}`**,并在那行上方写清三格的区别。

## 三、★ 自检 5:这类回归原来**没有任何东西拦着**

这个 bug 的形状是"**搬动一段代码时只加不删**",两处都跑得通、都报得出数,
而**没有任何东西会红**。所以加了一条结构性判据:

```
ran ≤ files      —— 一个文件最多贡献一次 ranFilesCount
fail ≤ checks    —— fail 是 checks 的子集
```

**为什么只判这两条**:`ran ≤ files` 是**结构性的上限**,不需要知道任何自报条数就能判。
而 `checks` **没有**这种上限(条数是文件自己报的)—— 给它编一个上限就是**猜数字的判据**,
所以不编;`checks` 的正确值只能逐文件核(这正是这个 bug 难发现的原因)。

**变异验证**(不是只跑绿):把双计重新注入 `else` 分支 ⇒
`✗ 套件自检 5:ran=49 > files=25 —— 每个文件只该计一次,超出来说明累加块有不止一份`;
还原 ⇒ `ok 5 - 总数行自洽(ran=25 ≤ files=25)`。

## 四、格式改动的影响面我核了

`RESULT files=…` 加了 `red=` 一节 ⇒ 先查**有没有别的解析者**:
全仓(`*.mjs`/`*.sh`/`*.md`/`*.go`)搜 `RESULT files=` 与 `checks=`/`ran=`,
**除 `run-all.mjs` 自己外没有任何消费者**;`mutants/` 那套工具**不共用**这一行
(它自己打 `RESULT mutants=48 ran=48 …`,口径 A = 36 不变)。所以加格是安全的。

## 五、未做

- 到期那 7 条(`STATIC_ONLY`)仍未动。
- 红仍 4 条、**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、`build-stamp`)。
- `mutants` 的 `ran=48`/`mutants=48` 与本次无关(另一套计数),**没动**。
2026-09-15 12:44:13 +08:00
8ce1873166 跨端: 补上"只盖索引、盖不住 push"的那一半 —— pre-push 钩子(预防)+ 远端不变量(闹钟)
pi 2026-09-15 复核时指出我那三件照办的东西里有一条**只盖了半个风险**:

> 那条判据读的是 **索引**(`git ls-files`),它守的是"**不会被再加回来**",
> 不是"**不会被推出去**"。而 blob 现在**确实躺在两个未推送的提交里**。

**他说得对,而且这个洞的性质比"少写一条断言"严重**:我上封自己论证过
"顺序是判据的一部分:先 ignore + `rm --cached`,**再** push",
结果我把**顺序要求写进了提交信息**,没有给它任何机制 ——
而"只活在文字里的规则等于没有规则"**正是我加那条判据的理由**。同一条错我犯了两次,
一次在自己刚写完的理由上。实测确认 blob 在 `b806a05`、`6cf431e`,`origin/main` 里没有它。

## 一、补的是**预防**,不是又一条闹钟

pi 给的那行(`git log origin/main -- <路径>` 应恒空)是**健全不变量**,但它**在泄露之后才响** ——
真红的那天东西已经出去了。所以两件都做,并且**说清各自的性质**:

| | 作用 | 性质 |
|---|---|---|
| `.githooks/pre-push` | **不让它出去** | 预防(可被 `--no-verify` / 没装的机器绕过) |
| `criteria-hygiene` 新判据 | 发现"绕过发生了" | **泄露之后响的闹钟** —— 红了要按"已泄露"处置(轮换),不是"删掉再推" |

**没有把闹钟当成功劳**:判据的失败信息里第一句就写"按**已泄露**处理 —— 去 AGC **轮换**",
免得下一个人把"删掉再推"当成修好了。

### 钩子为什么放 `.githooks/`(进版本库)而不是 `.git/hooks/`(不进)

`.git/hooks/` 里的钩子**不进版本库、别人 clone 不到** —— 那还是"只在我这台机器上有效"。
`.githooks/` 进版本库,`core.hooksPath` 指过去,所以**它跟着仓库走**,换台机器装一次即可。
接线:`deploy/install.sh --git-hooks`;**`--check` 会报告它装没装** ——
理由与 `--check` 本身存在的理由同一条:**门是好的 ≠ 门接着**。

### 验证(两步,第二步才是决定性的)

1. **钩子本身**:喂它"会泄露的范围"⇒ 输出点名两个提交、**退出码 1**;
   喂"已推过的范围"⇒ **退出码 0**。(退出码**不经管道取值** —— 管道会吃掉它,这个坑我踩过。)
2. ★ **git 真的会调它吗**:`git push` 到一个**真的 bare 仓**(`/tmp/bare-test`)⇒
   钩子跑起来、**推送被中止**、`git --git-dir=/tmp/bare-test log` 报**空仓**(什么都没过去)。
   —— 只做第 1 步的话,"钩子写对了但没接上"这个形状**分不出来**。

## 二、判据的**两半**都做了变异验证

这条判据**现在本来就该是绿的**,所以"跑绿"什么也证明不了(**一条永远为空的假判据也会绿**):

- 把 `origin/main` 指到一棵**含该文件**的树上 ⇒ **红**(真泄露它会响);
- 把它的探针改坏(`git log -- <不存在的路径>`)⇒ **红**("空"与"探针坏"分得开)。

第二半是特意加的:否则这条判据的"绿"可能只表示**查法不管用**。
判据开头那条"本地历史里**确实**有它"的自检,就是为了让这个区分成立。

## 三、★ 我在验证过程中**弄坏了共享仓库的一个 ref**,已按远端真值恢复

得说清楚,不能只报"验证通过":我的变异脚本直接改 `refs/remotes/origin/main`,
而 `restore_ref()` 用的是 `git update-ref -d` + `remote remove` —— **对 remote-tracking ref 根本不还原**。
于是我一度把 `origin/main` 指在了自己伪造的提交上(`2b04319`、`201a668`)。
**这是共享工作树,别的会话会读这个 ref。**

恢复方式**不是**凭记忆写一个 SHA,而是**直接问远端**:

```
$ git ls-remote origin refs/heads/main
6702cc2f5e  refs/heads/main
```

现在 `rev-parse refs/remotes/origin/main` 与该值一致,`behind=0 ahead=130`(与我改动前一致)。
**教训**:验证"推送前"这类行为时,**必须用一次性的 bare 仓做目标**(我第二次就是这么做的),
**不能改共享的 ref**。造出来的东西要能被**外部真值**核对 —— 否则"恢复"也只是我自己的另一个断言。

## 四、pi 替我跑的 Go 测试

`cd server && go test ./internal/repo/` → **ok,1.236s**(带着我新加的那笔,`docs/DEBTS.json` 现 15 笔)。
所以我那句"读代码得出的结论、标未验"**可以升级成已验证** —— 谢谢,这条我之前确实跑不了。

## 五、未做

- 到期那 7 条(`STATIC_ONLY`)仍未动 —— 独立工作。
- `RESULT` 汇总行的 `fail=1` 与"红的判据(4/25)"口径不一致,**仍未查**(上封已提及,本轮先记着)。
- 红仍是 4 条,**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、`harmony-presets` 6>5、`build-stamp`)。
  本轮 checks 790 → 792(我加的两条子测试)。
2026-09-15 12:41:05 +08:00
b7db72bd48 fix(套件): 自检 4 的锚点改成**运行时拼**(搜索键不进搜索者源码)—— 治位置依赖,不只治位置
dsh(a673cf13)指出上一版 `lastIndexOf` 只治**位置**("可执行那处在更后面"),
正确性仍依赖"锚点字面量只出现两次且顺序稳定",而位置是会被搬动的 —— 第 7 例就是搬出来的。

改法:锚点在运行时拼接,源码里不存在完整的搜索键:
    const GATE_START = 'const dueStatic = STATIC_ONLY' + '.filter';
    const GATE_END   = 'const probeSummar' + 'y = ';
(`GATE_END` 必须一起拼:只拼 `GATE_START` 时实测 `indexOf(GATE_END)` 命中自检自己那行,
  `to < from` ⇒ 直接走"找不到到期闸区块"的 fail-closed 分支变红 —— 说明兜底在工作,但锚点得拼全。)

实测(pi):
- 正常跑:`ok 4 - 到期闸是记账而不是 exit`、汇总照常;
- 变体(把 `process.exit(1)` 塞回 `dueFailed = true;` 之后):`✗ 套件自检 4` 当场打出来
  (报文:到期闸区块里又出现了 process.exit());撤销后文件字节恢复。
- 自匹配兜底断言(区间里不许出现 `selfSrc.indexOf`)保留。
2026-09-15 12:32:40 +08:00
6cf431ee11 跨端: AGC 客户端配置不入库(gitignore + rm --cached + example)+ 一条判据代替"靠记得"
pi 2026-09-15 的裁定:**gitignore + `git rm --cached` + example,不轮换**。
我照办了,并且把**决定性事实**更正过来 —— 我上一封说"已经进了公开历史",**那句是错的**。

## 一、暴露窗口:我原先的假设**方向反了**

我上一封写的是"它**已经进过**公开仓历史,gitignore 撤不回,要认真考虑轮换"。
**实测不成立**(pi 查的,我逐条复核):

```
$ git cat-file -e origin/main:…/rawfile/agconnect-services.json
fatal: path '…' exists on disk, but not in 'origin/main'      ← 远端没有这个文件
$ git branch -a --contains b806a05                            → 只有本地 main
$ git rev-list --count origin/main..HEAD                      → 119(本地领先,落后 0)
```

**整段鸿蒙工作一次都没推上去。** 所以窗口是**"直到下一次 push"**,不是"已经泄露"。
这把修法从**止血**变成**赶在 push 之前做完就行** —— 顺序因此是判据的一部分:
**先入库 ignore + `rm --cached`,再 push**。哪次先推了,就立刻变成"必须轮换"。

**不轮换我同意**,两条理由第二条更硬:① 文件本来就要打进 HAP,HAP 到谁手里它就到谁手里;
② `server/internal/push/config.go:51-55` 的 `AppSecret` 走 `AppSecretFile`(`resolveSecret`,
`hms.go:95` 读它,例 `/etc/agentmail/hms.secret`),**能替你发推送的凭证不在这个文件里**
⇒ AGC 客户端配置泄露**升级不成"能发推送"**。理由已写进提交信息,免得将来有人"按惯例轮换一次"
(那会白白换掉两个客户端版本的一致性判据)。

## 二、`git rm --cached`:本地那份**必须留着**

它**必须在本地存在才能构建**(`hvigorw` 打包时要读)。所以:

```
git rm --cached <文件>     ← 只动索引,磁盘上那份不动
```

撤完实测:`ls` 仍在(2656 B)、`git check-ignore -v` 命中 `client/harmony/.gitignore:26`、
**`hvigorw assembleHap --no-daemon` 仍然 `BUILD SUCCESSFUL`、0 error**。
(这条我特意重编了一次 —— "撤出索引"与"构建还能用"是两件事,不能靠推理。)

## 三、★ 判据才是机制(pi 说的这条比 gitignore 重要,我同意)

`gitignore` 单独挡不住:这个文件**必须在本地存在**,任何人一次 `git add -A` 就把它加回来了,
而**那次 add 不会有任何东西变红**。所以加了
`commit-hygiene.test.mjs` 的「★ 版本库里不许跟踪 AGC 配置真身」:

- 扫**所有 tracked 文件**(不只 rawfile),找"AGC 配置的形状" —— 同时出现
  `"client_secret": "[!` / `"code1": "<32+ 位十六进制>"` / `"api_key": "[!`;命中即红并点名;
- 按**内容**判,不按文件名豁免(`example` 是**故意**带这些键名的 —— 结构留、值全打掉,
  所以它靠"值都是 `<!…>`"自然通过,而不是靠一个文件名白名单);
- 另一半:**`agconnect-services.example.json` 必须存在** —— 否则新人不知道这文件要长什么样,
  只能问人或猜,而**键名猜错会报一个和"配置缺失"毫无关系的构建错**。

**变异验证**(不是只跑绿):`git add -f` 把真身加回来 ⇒ **判据红并点名**;
`git rm --cached` 还原 ⇒ **绿**。

example 我做了泄漏核对:真文件里所有 ≥12 字符的值逐个比对,**只剩 3 处 `package_name`**
(`com.jianf.agentmail`,它本来就写在 `AppConfig` 里、必须是这个值,打掉了反而误导)。
其余保留原值的是 **AGC 各区域网关域名**(`connect-drcn.dbankcloud.cn` 之类)——
那是华为的公共基础设施域名、不是本项目凭证,打掉只会让模板不能用。

## 四、`blurStyleFor`:删除后生产代码里 5 处注释在说一个**不存在的函数**

函数已按 pi 的裁定删除(`9a10ab2`,并发会话落的)。但删除后 `Wallpaper.ts`(4 处)与
`MainPage.ets`(1 处)还在用**现在时**提它 —— 这比之前更危险:下一个人会去找一个
**已经被有意删掉**的函数,找不到就会**重新实现它**,而"为什么不该回来"正是那次删除唯一值钱的东西。
全部改成过去时 + 已删除,并在 `Appearance.ts` 原处留碑文。
`:251` 那处尤其要改:原文"`blurStyleFor` 也写了、就是没有任何调用点"会被读成
**还差一个调用点没补**,而事实是**连函数都不该有**。

## 五、`debt-visibility` 那条红(pi 数出我漏的那条)

`harmony-deviceprobe.test.mjs`(2 处)**按次数登记、不整文件放行** ——
整文件放行的话,将来在这个文件里写一句真实的「这里没判」就**不会红**。
那 2 处也不是"这块没验",而是对**词表本身**的断言。
另在 `docs/DEBTS.json` 补一笔 `deviceprobe-fixture-timing`(到期前提:两份 fixture
从"人工存文件"变成"当场采集")。

⚠️ **Go 侧未本机验证**:`go test ./internal/repo/` 在本机报
`module cache not found: neither GOMODCACHE nor GOPATH is set`。我读了
`TestDebtLedgerMatchesMeasurement`,它只校验"每笔都有 due/where"+"三笔必须同处登记",
**没有"所有 id 必须在 Go 侧列出"的断言** ⇒ 新增一笔不需要改 Go。
但这是**读代码得出的结论,不是跑出来的**,如实标未验。

## 六、我自己记错的两个数(pi 更正)

- **`STATIC_ONLY` 是 7 不是 8** —— 我上封写 8,`RESULT static=7` 与闸门打的 7 个文件
  都是 7。我记串了。
- `PROBE_DEVICE=none` 下**是 7 条红**,我只列了 6 条,漏了 `debt-visibility`(本笔已修)。

现况:**红 7 → 4**,剩的 4 条**都不是我的**(`narrow-layout` 88>64、`nav-merge` 9>8、
`harmony-presets` 6>5 是别的会话新加判据没更新登记数;`build-stamp` 是 `dist` 没重构建)。
2026-09-15 12:25:25 +08:00
8ea33aeb91 跨端: fix(套件): 把自检 4 移到到期闸之前,并修掉"锚点自匹配导致它空转报 ok"
dsh 报了"移动未完成"(他两次栽在自己的锚点假设上)并把行号级配方留在上一封里。
这一笔把那一步做完,并在做的过程中撞到**移动本身引出的新洞**:

1. 自检 4 从到期闸**下游**移到**上游**(与自检 1/2/3 同层)
   —— 否则"闸一 exit,自检连跑的机会都没有",而那正是它唯一要防的场景。
   `let selfCheckFailed` 的声明一起上移(否则在声明前赋值 → ReferenceError)。

2. ★ 移动后它**空转报 ok**(比原来更糟:连失效都不响)。原因:
   锚点写的是 `indexOf('let dueFailed = false;')` / `indexOf('const probeSummary')`,
   而**这两串字本身就写在自检代码里**(作为 indexOf 的实参)。自检块一旦移到真闸之前,
   `indexOf` 第一次命中的就是**自检自己那两行** ⇒ 扫描区间变成自检自己的两行 ⇒ 真闸从没被扫过。
   ⇒ 修法:锚点改 `lastIndexOf`(真闸在自检之后),并加一道**自匹配断言**
   (区间里若出现 `selfSrc.indexOf` 就判红,不许空转)。

实测(pi):
- 正常跑:`ok 4 - 到期闸是记账而不是 exit`、汇总照常打;
- 变体(把 `process.exit(1)` 塞回 `dueFailed = true;` 之后):**`✗ 套件自检 4`当场打出来**
  (报文:到期闸区块里又出现了 process.exit())—— 修复前这个变体是 0 命中;
- 变体撤销后文件字节恢复(`git diff` 空)。
2026-09-15 12:22:15 +08:00
9a10ab2fcf 跨端: fix(harmony-appearance): blurStyleFor 已删除 ⇒ 换成**碑文判据 + 性质现居地**(不是只删);顺手修两处说谎的标题
pi 2026-09-15 查到根因:`blurStyleFor`(px → 材质档)已**有意删除**(碑文 `Wallpaper.ts:246`:
"没有、也不该有消费者"),而判据文件里还有引用、2 条真在调用 ⇒ `A.blurStyleFor is not a function`。

**只删会把"这个性质搬到哪了"一起删掉**(pi 的建议)。所以两处改成:

1. **碑文/反回归**:`typeof A.blurStyleFor === 'undefined'` —— 有意删除的东西不许悄悄回来;
   外加"理由必须留在原处"(碑文正则,删了理由也会红);
2. **性质的现居地**:导航面板材质现在是**固定系统档** ——
   `common/Theme.ets` 的 `navMaterial: BlurStyle = BlurStyle.COMPONENT_THICK`(从"函数映射"搬到"常量")。
3. 后半段那组(分档边界/单调性/NaN)**整体作废且不硬造等价物**:它们的**主语**没了,
   常数没有单调性、没有越界归并、没有 NaN 方向 —— **性质随主语一起消失,判据变少是对的**。

★ 另修**两处说谎的标题**(本判据属于 pi 的"标签必须等于断言范围"):
- `★ blurStyleFor:分档边界、单调性、NaN 都是行为` → `★ 碑文:blurStyleFor 不许回来 + 理由必须留在原处`;
- `★ 模糊值映射到**系统材质档次**` → `★ 材质档**不再是 px 的映射**:碑文 + 性质现居地`。
  改前它们断言的内容与标题已经完全不是一回事。

实测:`# pass 25 / # fail 0`(此前 退出码 1)。登记 26 → 25 同步(**变少是对的**)。
2026-09-15 12:20:41 +08:00
ee3ada2740 test(登记): harmony-calendar 登记数 10→23(自报 23)—— 这条判据是我写的,登记数该我维护
汇总被到期闸吃掉时看不见这条;修好到期闸后它立刻显形(正是 pi 抓的那个 bug 的收益)。
其它 4 条条数不符(narrow-layout 88>64、nav-merge 9>8、harmony-presets 6>5、commit-hygiene 3>2)
都是**别人的在飞文件**,我不改它们的数字。
2026-09-15 12:16:50 +08:00
171f2323d9 test(套件): 补"到期闸不许吃掉汇总"的自检 4 —— **但它现在放在下游,抓不到它要抓的那种回归**(已知未完成)
补的是我上一封自己承认的缺口("这条修复没有判据钉着")。但实测暴露了更难看的一件事:

**突变检验**(把 `process.exit(1);` 塞回 `dueFailed = true;` 之后):
```
突变侧退出码=1,grep "套件自检 4" → **0 次命中**
```
原因:突变让到期闸**在自检之前就退出了进程** ⇒ 自检**根本没机会跑**。

⇒ 我把判据放在了**被它检查的东西的下游** —— 而这**正是本文件自检 3 存在的原因**
("判据不得写在 `process.exit()` 之后,永远不会跑")。我新写的这条自检,犯了它旁边那条自检专门在防的错。

**修法(未完成,如实记在这里)**:这条自检是**纯静态源码检查**,应当移到到期闸**之前**。
我尝试移动时脚本自己的断言失败(锚点没匹配上),所以**没有移成** —— 现状是:
- 绿侧 ✓ `ok 4 - 到期闸是**记账**而不是 exit`;
- **失效场景未被抓住**:设备在场、到期闸红、有人写回 exit ⇒ 自检不会响。

另外记一条同族:这条自检第一版**指着自己报红**(它的报错文案里就含 `process.exit(` 这串字),
改成 `stripComments()` 后正常 —— 与 `@ohos`/`toISOString` 那两次同族,规矩不变:**不挪扫描口径,去掉不该扫的部分**。
2026-09-15 12:15:40 +08:00
ecab509a57 跨端: fix(套件): 到期闸的 process.exit(1) 排在汇总之前 ⇒ 它把汇总和**所有其它红**一起吃了(pi 实测)
pi 2026-09-15:这块在报文末尾直接 `process.exit(1)`,而它位于汇总之前 ⇒ 只要到期闸红,
`RESULT phase=` 行、欠账余额、条数不符、以及**所有其它红的列表**都不会打出来。
他实测到:`harmony-presets` **实报 6 / 登记 5** 这条真红**一次都没显示过**。

形状:**一道闸把不属于它的证据一起吃掉** —— 与"跳过会在下一轮被读成验过了"同族:
前者让红的**消失**,后者让没验的**看起来验过**。

修法不是"少红",而是**记账 + 继续走完**:
- 到期块改成 `dueFailed = true`(不再 exit);
- 汇总分支条件加 `&& !dueFailed`(否则会误报"全部通过");
- 结尾保留 `exit(1)`,并在"只有到期闸红"时多打一句说明。

实测(`AGENTMAIL_CRITERIA_PHASE=install node test/run-all.mjs`,退出码 1):
```
RESULT files=25 ran=45 checks=772 pass=771 fail=1 broken=0 unreported=0 verdict=red
```
⇒ 汇总**打出来了**,并且 `fail=1` 正是 pi 说的那条 `harmony-presets`(此前被吃掉的)。
`RESULT phase=` 行与到期报文同时在场。

**已知缺口(没掩盖)**:这条修复目前**没有判据钉着** —— 要钉它得跑一次完整套件并断言
"到期报文与汇总同时出现",成本高,我没在这轮加。谁下次碰这块应先补它。
2026-09-15 12:12:19 +08:00
4880c31110 跨端: fix(设备闸): app state 交叉验证(矛盾⇒拿不准)+ 正例换成**真机实采**样本
pi 邮件 `6f902e1e` 指出两件,都对:

1. **我那条正例是自相矛盾的**:我用脚本把 `state #FOREGROUND` 对调,却漏了同一块的 `app state #X`
   ⇒ 造出 `state FG` + `app state BG` 这种**真机上不会出现**的 dump。于是"闸能放行"这条正例
   建在**非法输入**上 —— 它绿,但它没证明任何真机会发生的事。
   现在:正例改用 12:08 **真机实采**的 `fixtures/aa-dump-l-ours-foreground.txt`(两个 mission 都在、
   `state`/`app state` 一致、FG 是我们);
2. 顺手把"矛盾怎么办"钉成规则:同一块里 `state` 与 `app state` 打架 ⇒ **`unverified`**,
   **不许"挑一个信"**(那是把互相打脸的证据当成证据)。`app state` 缺失时不因此判未验
   (否则老格式 dump 会一律未验 —— 那是"更安静的失效")。

判据 8 条全绿;登记同步为 8。
2026-09-15 12:10:49 +08:00
5337d51bfb test(设备闸): 补第二份真样本(单 mission,我们不在列表里)+ 把"状态对调"那条从弱断言改成**严格正例**(pi §4②)
- 新样本 `fixtures/aa-dump-l-single-theirs.txt`:由真样本删掉我们那块得到,形状与 pi 12:03 的活 dump 一致
  (单 mission、`state #FOREGROUND` 是对方)⇒ 正确判决 **`other`**,不是 `unverified`
  —— 否则会把"别人占着前台"和"没读到"混成一种;
- `#FOREGROUND` 全删 ⇒ `unverified`(缺证据不猜);
- ★ 原"状态对调"那条我原先只断言 `ours || unverified`(当时心虚写宽的)。pi 指出:
  **只判"读不出来就不许过"是不够的,还得有正例证明"读出来了真的能过"** —— 否则这道闸的失效方式是
  "永远说未验"(更安静的失效,而且欠账永远还不完)。已改成严格断言 `ours` + `mayAssertOn === true`。
- 登记 5→7(我一度写成 8:我只加了 2 条,5+2=7 —— 相等契约下这种手误会被套件当场抓住,这正是它该干的事)。
2026-09-15 12:08:09 +08:00
4af083e8b8 test(判据): 4 个判据读的是**另一棵树** —— 修掉硬编码的仓库根,并让 stripComments 保持行号
pi 2026-09-15 实测出来的,**这次长在判据自己身上** —— 正是我们前几轮一直在消的那个形状。

## 一、`const ROOT = '/home/program/agentmail'`:规则进来了,对象没进来

`harmony-arkts.test.mjs` 把仓库根写成了绝对路径。后果我按 pi 的步骤亲手复现了:

```
$ git worktree add --detach /tmp/wt-verify 7f4fa26      # 那个检出里 import 顺序**确实**违规
  (核对:最后 import 在第 80 行,而第 63 行已是 `const NAV_MATERIAL_OF…`)
$ cd /tmp/wt-verify/client/electron && node --test test/harmony-arkts.test.mjs
ok 1 / ok 2 / ok 3   # pass 3  # fail 0      ← **在一个明显违规的检出上 3/3 全绿**
```

因为它读的不是 `/tmp/wt-verify`,是 `/home/program/agentmail`(那份早已修好)。
两层后果,第二层最糟:
① 它**永远无法验证任何别的 checkout / CI / 镜像** —— 换目录不是"红",是 `readdirSync` 直接抛;
② 在本机做 worktree 复核时,它**静默读另一棵树并报绿**。
**判据的逻辑是对的、对象是错的** —— 这比"判据写错了"更难发现,因为它在原地永远是绿的。

同一个毛病在 4 个文件里,**恰好全是最近这几笔新写的**(另 10 个鸿蒙判据写法是对的):

```
harmony-admin / harmony-imageprep / harmony-presets / harmony-arkts  →  const ROOT = '/home/program/agentmail';
其余 10 个                                                          →  const ROOT = join(HERE, '..', '..', '..');
```

已全部照邻居改掉。**修好之后在同一个违规检出上:`# fail 1`** —— 它终于会红了。

## 二、修这条时又牵出一个:`stripComments` **改变了行号**

修好路径后,判据报出"最后一个 import 在第 64 行、第 47 行已是语句",
而**真实文件里是第 80 / 63 行**。成因:`stripComments` 把块注释整块抹成 `''`,
而块注释**自带换行** ⇒ 它之后所有行号整体前移。

这不是小节:全仓判据都用 `文件:行号` 定位(`grep -n`、编辑器跳转、`git show` 核对),
**报出来的行号必须能直接用**,否则读者第一步得先猜"这是剥过的还是没剥的"。
改成"块注释里的每个换行换成等量空行"。修完报的就是 **80 / 63**,与文件逐字对上。

## 三、新增两条判据,让这两个形状不能再回来

1. **`★ 判据不许把仓库根硬编码成绝对路径`** —— 扫判据目录里**真代码**
   (`code()` 剥注释,否则本文件自己的说明文字就会误报),找
   `const X = '/绝对路径'` 且**看着像仓库内**的声明。
   **例外按名字放行**(含 `TOOLCHAIN`/`SDK`/`HDC` 的常量)—— 工具链本来就不在仓库里、推不出来;
   按**值**做白名单会逼着下一个人为了过判据去改那个路径的写法。
2. **`★ stripComments 必须保持行号`** —— 造含多行块注释的样本,断言剥完
   **行数不变**、且第 N 行仍是原来的第 N 行;**同时**断言注释内容确实被去掉了
   (别为了保行号把注释留下)。

两条都做了**变异验证**:
- 把 `harmony-admin` 的 ROOT 改回硬编码 ⇒ 新判据**红**,并点名那个文件;还原后绿。
- 在 `MainPage.ets` **import 之前**插一条语句 ⇒ `harmony-arkts` **红**
  (第 79 行 vs 第 1 行);还原后绿。**这条同时证明了"读的是自己那棵树"** ——
  同样这个变异,在修路径**之前**是绿的。

## 四、未做 / 未验

- 到期闸门那 7 条**没动**(要真装真点,是另一件活)。
- **"把 build 做成一条判据"我探了,两个硬障碍**(详见给 pi 的回信):
  ① `client/harmony/oh_modules` 被 `.gitignore` 排除且未入库 ⇒ **全新检出没有它**,
     构建会先死在装依赖上;② 本沙箱**拒写 `/root/.hvigor`**(`mkdir` Permission denied),
     `hvigorw` 在 worktree 里直接 `EACCES: mkdir '/root/.hvigor/project_caches/…'`。
  所以它在本仓能编过、在干净检出编不过 —— 作为判据它现在会**假红**。
2026-09-15 12:06:15 +08:00
c12744e8c3 跨端: 导航条材质选 (a) 固定档(推翻我的 (b))—— 并修掉"注释说 (a)、代码是 (b)"的自相矛盾
pi 2026-09-15 裁定:**推翻 (b),选 (a)**。我原先给 (b) 的理由不成立,他逐条驳了:

1. **WebUI 的导航条根本不读 `--bg-blur`**:`index.css:1114` 的 `.narrow-nav` 是硬编码
   `backdrop-filter: blur(18px) saturate(1.5)`。我引这条支持"两个量不同",
   而同一条也说明**它不由用户偏好驱动**。
2. **WebUI 那个滑杆的语义是"背景"**:`BackgroundPicker.tsx:183` —— `label="模糊"`、
   `hint="虚化细节,避免背景与正文抢注意力"`、`min=0 max=24`,只作用在
   `.app-backdrop{filter:blur(var(--bg-blur))}` 上。
3. WebUI 自己留了**分开的**令牌 `--bg-blur-panel`(`index.css:267`,注释写明
   "与壁纸自身的 `--bg-blur` 分开")—— 它的词汇表本身就拒绝把两者等同。
4. **★ 我给 (b) 的理由不成立**:我写"(a) 会让那个滑杆在导航条上变成死控件",
   可那个滑杆**已经**被壁纸消费了(`MainPage.ets` 壁纸层的 `.blur(bgPlan.blurPx)`)——
   它从来**不是**导航条的控件。(a) 之下它照样是活的。
5. §7.12 的「材质(玻璃)」行原本写的就是固定档 ⇒ (a) 是**回到**已登记契约。

产品向还有一条:**导航条是 chrome,材质应当稳定**,不该因为用户换张壁纸而变厚变薄。

## 最该记的是:我的注释和代码**互相矛盾**

`MainPage.ets` 里那段注释论证的是 (a)、并明确写着"跟随是错的,pi 抓出来了",
而它下面那一行代码是 (b)。**下一个读者会照注释把代码改回去,并引我那句话当权威。**
这是这一路反复在消的形状(说的与做的不一致、而判据看不见),这次落在**注释**上 ——
而注释正是"理由要写清"那条纪律的证据源。已整段重写为真实的 (a) 版本,并把
"(a) 会让滑杆变死控件"这个**错的理由**连同它为什么错一起留在注释里。

## 做掉的东西

- `MainPage.ets`:导航条回到 `.backgroundBlurStyle(Theme.navMaterial)`;
  删掉 `NAV_MATERIAL_OF` 表与 `navMaterialFor` 的 import((a) 之下都是孤儿)。
- `model/Appearance.ts`:删 `navMaterialFor`(它存在的唯一理由就是方案 (b))。
  `blurStyleFor` 现在**没有任何调用点** —— 如实登记在它的文档注释里
  ("有测试"不等于"有人用",上一轮我刚因同形状被抓过),不假装它活着。
- **五处"整条字面表达式"断言改成语义断言**(pi §四):`cross-client-theme`、`harmony-appearance`、
  `harmony-nav`(3 处)此前都在钉
  `/\.backgroundBlurStyle\(NAV_MATERIAL_OF\[navMaterialFor\(…\)\] \?\? Theme\.navMaterial\)/`
  —— 字面换字面,正是 `CRITERIA.md` 不许的"对源码形状的匹配"。
  现在判:① 那一处的材质**来自 `Theme.navMaterial` 这个系统令牌**;
  ② **不许跟随** `bg_blur`(NavBar 真代码里不许出现 `blurPx`/`blurStyleFor`);
  ③ 令牌是 `BlurStyle` 枚举值、不是 `NONE`、且**成员名真实存在于 SDK 枚举**。
- "可达性"那条判据**随契约作废**(它守的是方案 (b)):换成判 (a) 的契约。
  **判据随契约走,不随实现走。**
- `harmony-appearance`:原先判"页面里那张表的键必须是 SDK 成员"。表删了,
  改成**枚举 `blurStyleFor` 的整个值域**(0..40 + 界外 + NaN),逐个核 SDK 成员 ——
  比原来只核表里那三行**更严**。

## 判据自己先错了一次,记下来

新判据第一版**没剥注释**就断言"NavBar 里不许出现 `blurPx`",当场红了 ——
而红的原因不是代码错,是 `NavBar` 的**文档注释**里正好写着
"我一度把档位接过用户偏好(`navMaterialFor(this.bgPlan.blurPx)`)"这句历史说明。
**注释说明禁令 ≠ 违反禁令**;不剥注释,这条判据就会变成"逼人删掉解释",
恰好与本仓库"理由要写清"的纪律相反。改成 `stripComments(bar)` 后再判,并加了一条
"注释里确实留着那处说明"的自检前提。

## 变异体:45 个全部被抓(含 3 个新判据的专属变异)

方案 (b) 落地时配的 13 个变异体**整体作废**(它们锚的代码被删了),标注 `retired`
并写清理由 —— 不是"没跑成"。另有 5 个锚点漂移(我在注释里逐字引用了被锚的那句代码,
污染了通用正则)的**重锚**到真代码;注释里那句逐字引用也一并去掉了
(**注释里逐字抄代码**正是让"按字面锚定"的变异体反复失效的根因)。
新增 3 个针对 (a) 契约的变异体(绕过令牌 / 又跟随 `bg_blur` / 令牌变 `NONE`),全部被抓。

## 未验

- **真机观感仍未验**:三档材质在真机上能不能看出差别、滑杆手感、管理页布局,
  只有真机能答。本机模拟器已起(`hdc list targets` 有目标),但
  `run-all.mjs` 的**设备闸已经到期**(8 个静态判据的前提成立)⇒ 套件现在会挡在
  那道闸上、不打 `RESULT`。**这不是本笔引入的**(前提是环境变了),已单独报给 pi。
- Go 侧 `debt_registry_test.go` 仍未在本机跑(无 Go 模块缓存);pi 已在别处跑过,绿。
2026-09-15 11:58:19 +08:00
e917b8782a 跨端: feat(设备闸): DeviceProbe.ts —— "读到别人的界面"是假绿来源,读之前先判"是谁的",拿不到就记未验
pi 邮件 `971c58fa` §4 / `bf583b0f` §2。别的 agent 在同模拟器上 `aa start` 会抢前台,
之后读到的控件树是**它的窗口** ⇒ 断言可能通过也可能红,**两者都不是在讲我们的界面**(前者=假绿)。

- `foregroundVerdict` 判决只有三种:`ours` / `other` / `unverified`;**只按 bundle 判,不许看标题/文案/控件名**
  (别人的合法 dump 里完全可能有同名控件与同文案,"邮件"这种通用词尤其容易撞);
- `parseForeground` 取不到就 **undefined**(空 dump / 截断 / 格式变了都不猜);
- `mayAssertOn === false` ⇒ 调用侧必须记 **`未验`**(欠账继续开着),**不许**记通过、**不许**静默跳过
  —— 跳过会在下一轮被读成"验过了";
- `unverifiedReason` 统一状态词,并写明"**缺证据 ≠ 没有那个现象**"。

判据喂 4 类样本(pi 指定的最易漏输入):① 我们的 dump ② **别人的合法 dump(含同名控件 + 同文案)**
③ 截断/畸形 ④ **空 dump**(窗口没起来时最常见,最容易被当成"坏现象不存在")。
另加一条只按 bundle 判的反证(把标题改成"像我们"也不许变 ours)。

**顺手抓到自己一个坑**:`firstMatch` 第一版的否定类既接受非 ASCII 也接受**换行**
⇒ 对 `windowTitle: 邮件\n abilityName: …` 会吞掉中文标题并**捕获下一行的键名**,
"标题"读到 `abilityName`(看起来有值、其实指错地方)。判据当场抓到 ⇒ 改成按行取、值允许中文、空值不回退。
2026-09-15 11:54:25 +08:00
38b0e07415 fix(判据): 计数契约由"不少于"改成"相等"(只判下界时,多加的判据不受"被删会红"保护)
pi 邮件 `9fed4386`:`harmony-push` 实报 13 / 清单登记 12 —— 因为 `ran < expected` 只判**下界**,
所以多出来的第 13 条**不在"被删会红"的保护内**(删掉它不会有任何东西变红,保护只覆盖前缀)。

两处改:
1. `harmony-push` 登记 12 → **13**;
2. 计数契约改成**相等**,并把两个方向的成因与处置分开写:
   · `ran < expected`:判据被删/被跳过/check() 被改坏…… 确认该减少条数时改清单数字;
   · `ran > expected`:新加的判据不在保护内 —— 请把清单数字改成实际条数,
     这样"被删会红"才真正覆盖全部判据,而不是只覆盖登记过的那部分。
2026-09-15 11:51:39 +08:00
ab31690348 跨端: fix(推送客户端): 登记标记绑定账号(token 换账号是"转移"不是并存 ⇒ 别把登记状态缓存成长期结论)
pi 邮件 `2518e1a3` 的服务端事实:注销只认注册者本人、token 字符串不是凭证,
**同一个 token 换账号登录是"转移"而不是并存** ⇒ "我登记过没有"的答案**随账号而变**。

原来的 `shouldReportToken(lastReported, current)` 只按 token 存标记 ⇒ 换账号后
(同一 token)会**错误地跳过上报**,而那个账号其实还没登记过这个 token。
改成 `reportMarker(accountKey, token) = accountKey|token` + `shouldReportToken(lastMarker, accountKey, token)`:
**账号变了标记必然不同** —— 于是"换账号必然重新上报"是**性质**,不靠人记得 reset。

判据 13 条全绿(新增"换账号后必然重新上报 / 切回来也要重新上报",并断言两次 marker 不相等)。
2026-09-15 11:51:39 +08:00
ed0ad2508e 跨端: feat(推送客户端): 按线上形状补契约层(DELETE 也带 body / 未知字段 400 / provider 无白名单 / 错误是 {"error"})
pi 2026-09-14 给的线上形状(从 handler/push.go 读的,不是猜的)逐条落成可判的:

- **请求体只放已知键**:`PUSH_BODY_KEYS` 登记四个键,**未知字段服务端直接 400**(不是静默忽略)
  ⇒ 拼错会立刻可见;可选字段为空就不放(空串虽合法,但不放更不容易踩校验)。
- **provider 只做形状校验、没有白名单**(`^[a-z0-9_-]{1,32}$`):判据断言 `apns`/`fcm` 也合法 ——
  客户端**不许**硬编码"只有 hms 合法"去先拦一道(服务端没实现的通道不该变成客户端的 400)。
  这正是"校验的范围必须等于它真正知道的事"的又一落点。
- **token**:空非法、512 上限;形状不合法 ⇒ `buildTokenBody` 返回 undefined,调用侧**静默跳过**,
  不去打一次注定 400 的请求。
- **错误体是 `{"error"}` 不是 `{"message"}`**:判据专门断言 `{"message":"x"}` 解析出 undefined
  (用错键会把"没有消息"当有消息)。
- **注销:`deleted:false`(本来没登记)不是失败** ⇒ 它**不改变分类**,所以**不再是入参**
  (一个不影响结果的入参只会让人误以为它影响结果),判据断 `classifyUnregister.length === 2` 钉住这点。
- **`ApiClient.del` 补可选 JSON body**:DELETE 端点是 JSON body 形状(不是 query、不是 path 参数),
  原来只有 `path` ⇒ 注销会无效。不传 body 时行为与旧版完全一致(向后兼容)。
EOF
2026-09-15 11:43:22 +08:00
8fed8401de 跨端: feat(推送客户端): 契约层 model/PushContract.ts + 6 条设备无关判据(静默失败/enabled:false 正常态/按 provider+tail 比/按 mail_id 去重)
pi 2026-09-14 推送契约的客户端半边。四条不变量里**三条是纯逻辑**,所以先落这三条,
平台调用(Push Kit 取 token、通知权限)留在下一步的 `PushService.ets` 里。

- `shouldReportToken`:取不到 token 不上报;与上次相同不重复上报(否则每次启动打一次接口);
- `tokenTail` / `isRegistered`:GET **只回尾 6 位** ⇒ "登记过没有"必须按 `provider + tail` 比。
  比全文是**看起来更严、其实永远为假**的写法(全文永不等于尾 6 位 ⇒ 每次启动重复上报),
  所以有判据钉它,并在注释里写明"同尾 6 位即视为同一条 —— 这是服务端给的信息量的上界,不是我们的选择";
- `classifyRegister`:`ok-enabled` / `ok-disabled` / `silent-skip` —— **三种里没有一种是"提示失败"**
  (`enabled:false` 是自部署常态 ⇒ 不重试、不提示);
- `parseNotificationData`:不满足约定形状就返回 undefined(坏 JSON 不抛、缺 mail_id/动作不符都忽略)——
  推送是可选通道,收到不认识的东西不许有任何副作用;
- `NotificationLedger`:服务端**无幂等键**(至多一次、无重试/去重表)⇒ 重复保护落客户端;台账**有界**。

另有一条判据禁止契约层引入 `@ohos`/`@kit`(否则这些判据会退化成必须上设备)。
它第一次跑**咬到了解释这条规则的那行注释** ⇒ 改扫 `code()`(去注释),与前面扫描口径那次同族。

`npm test`(install 相位)全绿;余额 `debts=13`。
2026-09-15 11:40:54 +08:00
7f4fa2629e 跨端: ArkTS 编译期硬规则进判据 —— 我插的常量表把 import 挤到了后面,assembleHap 报 arkts-no-misplaced-imports
`hvigorw assembleHap` 在 `f31bc02`/`b806a05` 上都红了一条:

    ERROR: ArkTS:ERROR …/MainPage.ets
    "import" statements after other statements are not allowed  (arkts-no-misplaced-imports)

**是我造成的**:P4c 那笔我在 `MainPage.ets` 里插了 `NAV_MATERIAL_OF` 那张(带注释的)
常量表,位置在**既有 import 之前**。ArkTS 要求所有 import 在任何语句之前,
常量表算语句 —— 编译器直接报错。

## 为什么我那一笔的判据一条都没抓到它

因为**这个仓库里没有任何判据会跑 ArkTS 的编译规则**。我的判据判的是"表达式对不对、
接没接上、颜色写没写死…",它们全绿 —— **文本层面确实没问题**,问题只有编译器知道。
pi 是构建时撞上的。

⇒ 教训不是"下次小心",是**把编译器能抓、而判据不抓的那一类固化下来**。
这一类里有一批**纯文本就能判、不需要设备**,所以它们不该待在"等设备才能验"的欠账里。

## 做了什么

- 新增 `test/harmony-arkts.test.mjs`(3 条,不需要设备 ⇒ **不进** STATIC_ONLY):
  · **所有 26 个 `.ets` 的 import 必须在任何其它语句之前**(就是这次报的那条);
  · 全仓 `.ets` 的词汇层硬坑(对象解构 / `any` / `unknown` / 函数表达式)
    —— 这几条此前**只在"我自己新写的那个页面"里判**,而我恰恰是在**改既有文件**时犯的下一个错,
    编译期硬规则不该按"谁写的"分覆盖;
  · **一条自检**:造已知坏样本(常量插在两组 import 之间)确认检查会红、合法样本不误报
    —— 否则"全绿"可能只是扫描逻辑失效(我第一版就把多行 import 的成员行误判成了语句)。
- 接进 `SUITE`(判据文件数 22 → 23)。
- 文件头明确写了它**不能**替代 `hvigorw`:覆盖的只是"能静态判出来的那几类",
  类型推断/重载解析/Sendable 那些仍然只有 build 能验 ——
  不许把这个文件的存在读成"编译已经验过了"。
- 变异测试 5 条**全部被抓**,含**精确复现我那个错的形状**(把 import 搬到常量表之后)。
  基线 `7/7✓`(逐字节还原)。
- 现口径:`mutants=52 ran=52 skipped=0 on_new_criteria=36 baseline=7/7✓`。

## 工作树里**不是我做的**两处改动(已核实为正确,我没有提交也没有回退)

工作树是共享的:`AdminUsersPage.ets` 与 `MainPage.ets` 在我提交**之后**被别的进程改过,
两处都是**修构建错误**,都核实过是对的:

1. `MainPage.ets`:把两组 import(`CalendarPage`、`NavItems`)从 `NAV_MATERIAL_OF` 表**之后**
   挪到文件最前 —— 就是上面那条 `arkts-no-misplaced-imports`;
2. `AdminUsersPage.ets`:`Chip(text, bg, fg: string)` → `ResourceColor`
   (`Theme.surfaceMuted`/`textSubtle` 是 `Resource`、`chipNeutralBg` 是 `string`,
   第 368 行那个三目因此是 `Resource | string` ⇒ 旧签名编译不过)。

我不提交别人的活、更不回退它;但它们**让基线必须重算**,而**重算基线是有意动作**
(随手重算会把"某次变异没还原"永久掩盖掉),所以 `baseline.sha` 顶部记了原因与哈希来源。

## 未验

- 这三条判据**只覆盖静态可判的那几类**。`assembleHap` 仍然只有真正构建才能验 ——
  这次就是构建先于我所有判据发现的问题,下次还可能是。
- Go 侧 `debt_registry_test.go` 仍未跑(沙箱无 Go 模块缓存),只有 `gofmt`。
2026-09-15 11:27:35 +08:00
bcd4f97e37 跨端: 变异体计数收进仓库 —— 前面报过 40/48/58 四个数,根因是"job 集合"从没定义
pi 用 `/tmp/mut/` 复算后指出:48 也不对。他是对的,而且**不是记性问题、是口径问题**——
我把 9 个 `jobs*.json` 的条目**直接相加**,没做归一:同一个变异体在跨批重锚时被键了多次
(13 组重复、15 条冗余),最典型的是「计划不搬运模糊值」同时挂在 `jobs-b3`/`jobs-blur`/`jobs-blur2`
三个文件、三个不同 `test` 键上 —— 于是"按判据文件分组求和"必然把它算三次,
而"跑在新增判据上的是多少"在交叉归类下**没有唯一答案**(41 或 35)。

更根本的是 pi 指出的第二层:**那个统计脚本根本不在 `/tmp/mut/` 里**(他为了复算是现写的),
而且 `/tmp` 会被清、不在仓库里 ⇒ 变异体数字**只活在信里**。
这一路已经立过同形状的规则(余额打在 `RESULT` 行、权威源在文件里),这条当时漏了。

## 做了什么

- `client/electron/test/mutants/`:把 `mut.py`、`run.sh`、`jobs*.json`、`baseline.sha`
  从 `/tmp` 挪进仓库(`/tmp` 会清、复核方够不着)。
- `mutants/summary.py`:**口径的唯一权威**,定义写死在代码里:
  · 不同变异体 = 按 (file, pat, repl) 去重(`retired` 不计);
  · 跑起来 = 锚点在该文件里**恰好命中 1 次**(与 mut.py 同一条件);
  · `on_new_criteria` = 该变异体的**每一个** test 键都指向本批新增的两个判据文件
    (口径 A —— 不因交叉归类虚高;另报口径 B 作参考,它只增不减,不拿来报数)。
- `run-all.mjs` 的 `RESULT` 行播报它,并**顺带自证基线**:跑不起 `summary.py` 时
  **不静默**(打印 status 与 stderr 末行)——我第一版路径写错,只看到"计数未知",
  真因(`can't open file …/test/test/mutants/summary.py`)被吞掉了。
- `mutants/test-keys.json`:`test` 键 → 判据文件的**唯一来源**(`mut.py` 与 `summary.py`
  共用)。此前两处各写一份,分叉过一次:键名从旧名换成 API 名后 `summary.py` 那份没跟上,
  于是所有锚点被判 `hits=-1`、报出"51 个变异体全部 skipped"。
- 无歧义口径下的**当前真值**:`mutants=48 ran=48 skipped=0 on_new_criteria=36 baseline=7/7✓`
  (口径 B = 41;原始条目 66,其中 `retired` 5)。
- 清掉 pi 指出的三类脏数据:
  · **过期条目**(锚点是修复前的旧写法,`hits=0`)标 `retired` 5 条 ——
    它们**不是"没跑成的变异体"**,重锚后都跑过、都红了;留着只会把 skipped 一直抬高;
  · **重复计数**(multipart 那条在两个文件里各一次)去重;
  · **真 skip** 的 multipart 锚点切片成 `name: 'file',\n contentType: mimeType` ⇒ 真的跑起来了
    (此前命中 2 次,因为 `ApiClient.ets` 有两个 multipart 构造器)。
- 修两处并发/竞争:`mut.py` 的 `tempfile.mktemp()`(Py3 起 deprecated,**TOCTOU**)→ `mkstemp`;
  `run.sh` 的固定 `/tmp/mut/bak` → 按 `$$-$RANDOM` 唯一(并行跑会互相覆盖备份)。

## 未做(如实说)

- **`AdminUsersPage.ets` 有一处不是我做的改动留在工作树里**(11:20:48,我 11:21 的提交之后):
  `Chip(text, bg, fg: string)` → `ResourceColor`。核实过是**正确的 ArkTS 修法**
  (`Theme.surfaceMuted`/`textSubtle` 是 `Resource`、`chipNeutralBg` 是 `string`,
  第 368 行的三目因此是 `Resource | string` ⇒ 旧签名**编译不过**)。
  我**没有提交也没有回退**它 —— 工作树是共享的,不该替别人提交别人的活。
  基线因此重算了(`baseline.sha` 顶部记了原因与哈希来源,重算本身是**有意动作**:
  随手重算会把"某次变异没还原"永久掩盖掉)。
- Go 侧 `debt_registry_test.go` 仍未跑(沙箱无 Go 模块缓存),只做了 `gofmt`。
2026-09-15 11:23:41 +08:00