Commit Graph

183 Commits

Author SHA1 Message Date
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
590a72a715 修复: job 集合从 glob 改成**清单**(+ 清单外即报,与 SUITE 同形状)+ RESULT 行带集合指纹;baseline 播报区分"底本过期"与"变异残留";权限政策补上**目录可进入性**(原来只管文件)
pi 2026-09-18 报的三件,逐条实测后处置。

## 一、job 集合是 glob ⇒ "权威"是**树的函数**(同一段代码两个数)

pi 独立复算对上了(48/48/0、A=36/B=41 与我一致),但发现 48 与 52 **都不是错的** ——
它们读的是**不同的集合**,而**没有任何东西说明读的是哪个**:

| 快照 | job 文件 | 输出 |
|---|---|---|
| 提交态 | 9 个(全跟踪) | `mutants=48 …` |
| 工作树 | 11 个(2 个未跟踪) | `mutants=52 …` |

真因:`glob(JOBS_DIR + '/jobs*.json')` ⇒ **未跟踪的 job 文件静默进入统计**。
这与"写了判据忘了接线"同族,区别是 `run-all.mjs` 有**自检 2** 挡着(清单外的 `*.test.mjs` 直接红),
这边没有对等物。**本仓用"清单 + 清单外即红"解决过同一个问题两次,这是第三次。**

**处置(pi 建议的前者)**:新增 `jobs.manifest.json` 显式列 job 集合,`summary.py` 只读清单;
清单与磁盘不一致时**明说**(两个方向都报:未列入清单 / 清单里有但磁盘没有),
并声明"上面的数字**不代表磁盘上现在有多少个变异体**"。
**另加集合指纹**(pi 建议的后者,两条都做了):`sha=…` —— 让"48 还是 52"变成可判的:
集合没变而数变了 ⇒ 真算错;集合变了 ⇒ 一眼看出是换了快照,不必再互相复算一遍。

**变异验证**(两个方向都试):
```
加一个未列入清单的 job 文件 ⇒ 报「未列入清单:jobs-UNLISTED-probe.json」
                            且 mutants=**48**(未被静默计入)✓
清单里加个不存在的文件      ⇒ 报「清单里有、磁盘上没有:jobs-GHOST.json」✓
```
(`_` 开头的条目是说明,读时滤掉 —— 否则会被当成文件名,`ghosts` 假报一堆。)

## 二、`baseline=4/7✗**有文件没还原**` 是**假警报指向最危险的结论**

实测三个不匹配的文件 **全部与 HEAD 逐字节相同**(`git diff --quiet HEAD` 为空):
`AdminUsersPage.ets`/`SettingsPage.ets` 是提交 `6861934`(09-17 21:15)改的、
`ApiClient.ets` 是 `9c6e9c6` 改的 ⇒ **底本过期,不是变异残留**。

★ 而原来一律打"✗**有文件没还原**" —— 那会让人去翻变异,而真因只是底本没跟上提交。
**两种成因在 `sha256sum -c` 眼里一模一样**,所以播报必须分开,并各给判别方法:

```
baseline=4/7⚠**底本过期**(3 个文件与 HEAD 逐字节相同 ⇒ 是正常提交改过、不是变异残留)
```

**底本已重算**,并按要求在文件里记一行"为什么"(含"重算前必须先证是提交态"这个前提)。
**重算后仍在校验**(变异验证):给 `AppearanceApi.ets` 追加一行 ⇒ 立刻 `FAILED`;还原 ⇒ `7/7 OK`。

## 三、`summary.py` 对非 root 是坏的:`jobs/` 缺 `x` 位

```
drw-r--r-- client/electron/test/mutants/jobs      ← 缺 x
$ runuser -u nobody -- python3 …/summary.py
PermissionError: [Errno 13] Permission denied: '…/jobs/jobs-all.json'
```
⇒ 那份"口径的唯一权威"**只有 root 跑得起来**。已 `chmod 755`,非 root 复跑输出一致。

★ pi 的深层判断成立且我核实了:**这条落在任何判据的射程之外** ——
`deploy/check-file-modes.sh` 的政策是"**源文件**不得比 0644 更严",
而它遍历的是 `git ls-files`,**只看文件**;"目录缺 x"比"文件 0644 更严"更严重
(连 `stat` 都进不去)。
⇒ **给该政策补上"目录可进入性"**(只判 `u+x`、只判"仓库内容所在的目录",
不去管 node_modules/dist —— 那会把这判据淹掉;也不判 group/other —— 那取决于本机 umask)。

**★ 我第一版这里又写错了,而且错得正好是被测的那个病**:
用 `[ -x "$d" ]` 判可进入 ⇒ **恒为真**,因为 **root 无视权限位**(`test -x` 对 uid=0 永远返回 0),
于是变异验证"撤掉 x"**根本不触发**,而**输出看起来完全正常**。
⇒ 改成**直接读权限位**(与同文件里文件那段的位运算同一做法)。
改后变异验证:撤 x ⇒ `[FAIL] 目录不可进入:…(drw-r--r--,缺属主 x 位)`、退出码 1;还原 ⇒ 0。

## 四、顺带修好三处既有权限违规(`check-file-modes.sh` 原来一直红着)

```
.githooks/pre-push                            711 ⇒ 755(保留执行位,**不是**去掉)
test/harmony-arkts.test.mjs                   600 ⇒ 644
model/DeviceProbe.ts                          600 ⇒ 644
```
三者都是**提交态**就这样(不是并发会话弄的)。按脚本自己的"药方按类型分岔"修的。
现在该政策 **退出码 0**。★ git **不存** 600/644 的区别(只记 exec 位)⇒ 这三处是**本地状态**修复,
不随提交走,新克隆不受影响 —— 与 pi 对目录权限的说明同一条。

## 五、pi 报的另两件:**在 HEAD 上已经不存在了**(是过时读数)

- **ArkTS 两处阻断**:`MainPage.ets` 的 import 现在在 71-74 行、最后一个 `const` 在 88 行
  ⇒ **位置正确**;`AdminUsersPage.ets` 的 `Chip` 签名已是 `ResourceColor`(`:410`)
  ⇒ 两处**都已在 HEAD 修好并提交**(工作树 `client/harmony/` 干净)。`harmony-arkts` 判据 3/3 绿。
- **套件现状**:现在 **29** 个判据文件(不是 23),`unreported=0 broken=0`。

## 六、测量

| 相位 | RESULT |
|---|---|
| build | `files=29 ran=29 checks=456 pass=453 fail=3 red=9 broken=0 unreported=0` |
| install | `files=29 ran=27 checks=444 pass=442 fail=2 red=8 broken=0 unreported=0` |

三种 cwd 仍逐字节一致(上一轮的修复保持)。
2026-09-18 04:07:26 +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
99a2d7ad7e fix(webui): 深色模式真的落地了 —— 之前 .dark 是「有意留空」的
用户报「深色模式可读性差」。实测(1280×800,读页面计算值)拿到两个数字:
  · .glass-card 合成成 rgb(237,237,237) 白卡,而其上 --c-gray-900 文字是
    rgb(243,245,248) ⇒ **1.07:1 的白底白字**
  · .nav-rail 合成成 rgb(188,189,190) 浅灰条,未选中文字只有 4.03:1

根因写在 index.css 自己的注释里:`.dark { /* 有意留空 */ }`。当年留空的理由
是「组件没有 dark: 变体,只换令牌会半深不浅」——**方向反了**:组件写的是语义
色阶(bg-white / text-gray-900),灰阶反转后本来就会自适应;真正没适配的是
**手写 CSS 里那几处硬编码白色**(.glass-card 的 0.92、--nav-bg 令牌),
它们不在 --c-* 色板里,所以「色板变量已全覆盖」的判据一直是绿的。

改动:
1. .glass-card / .nav-* 全部改走令牌,主题之间只差 alpha;
   深色给 --glass-card-a: 0.06 + --nav-bg: 30 35 44/0.72。
   遵守既有契约(background.test「玻璃是白色材料」):**基材恒为白**,
   只降 alpha,绝不换成深色层。
2. CalendarView 的非本月农历小字 text-gray-300 → gray-400/500。
   gray-300 在这套调色板里是**分隔线档**(全仓 68 处 border-gray-300、
   当文字只有 5 处),深色下它是 61,68,81,落在深卡上 1.49:1;
   而 gray-400 在深色 4.66:1、浅色白卡 2.54:1,两端都更好。
3. 判据:theme.test 补 3 条(手写 CSS 不许有绕过主题的硬编码白色表面 /
   深色卡片 alpha 必须降低 / **按实际 alpha 合成后**正文须 ≥4.5:1,
   即把 1.07:1 那个事故写成可计算的断言)。background.test 那条
   「.dark 里不许出现 --nav-*」**方向反转**为「必须有且底暗字亮」——
   它原来守的是「还没有深色主题」这个前提,其注释本就写明
   「真做深色主题时这条要一起改」。

验证(不是"改了就算"):
· 自建探针遍历 **9 个页面**(三个通信子页签 / 邮件详情 / 日历 / 联系人 /
  我的 / 写信 / 地址补全弹层):修复前通信 14 处、日历 74 处、
  联系人 13 处低于 WCAG AA;修复后 **9/9 页面 0 处**。
· 三条新判据逐条**变异自检**(还原修复即变红,且失败信息自带药方);
  浅色三个取色值与改动前**逐字节一致**(导航 0.72 白玻璃、卡片 0.92 白)⇒ 零回归。
· theme.test 34 通过 / background.test 44 通过 / tsc 干净。
· 已按 deploy/redeploy-gateway.sh 部署到 systemd 实例,后置清单自动项全绿,
  并在生产实例上复量 9 个页面(同为 0 处)+ 端到端发信(状态 read,非仅入库)。

#深色模式 #可读性 #WCAG
2026-09-17 22:32:25 +08:00
2166f0ed81 fix(harmony): 补齐窗格切换动画(transition 挂在会换的那棵子树上)
用户(2026-09-17):「一方面一点动画都没有」。

★ 第一版写错了,这里记下来 —— 它的错法很典型,下次还会踩:
  只在 onClick 里把 `currentIndex = ...` 包了一层 `getUIContext().animateTo(...)`,
  并把 `.transition(...)` 挂在**内容容器(稳定父节点)**上。
  编译过、判据(当时只钉"有 animateTo")全绿,**但动画是静默失效的**:
  `animateTo` 只负责开一个动画窗口,被换掉的子树自己不声明 transition 就什么都不会动;
  而挂 transition 的那个 Column 在新旧两种状态下**都是同一个节点**,永远不会触发。
  实测取证:把时长临时改成 20s,6 秒后截图仍是硬切(收件箱整版清晰、没有叠影)。

修法(对齐 WebUI `.pane-rise` / `.rise-in`,`index.css:1208`):
· `Theme.paneRiseIn()` —— 4vp 上浮 + 淡入;`riseInOffset = 4` 与
  `@keyframes rise-in { from { opacity:0; transform: translateY(4px) } }` 逐字一致。
· 挂到**if/else 各自的子树根**上(CommPage / ContactsTab / SettingsPane,
  以及常驻+visibility 的日历那一支)—— 这才是会被插入/移除的节点。
· `TransitionEffect.asymmetric`:入场 180ms(durBase)、出场 120ms(durFast)。
  出场更快是刻意的:同长会让新旧两层半透明地叠着,看起来像"闪一下",
  而用户对"闪"敏感(09-14 否掉过整屏淡入)。
· 续用令牌 `durFast=120` / `durBase=180` / `easeOutSoft=cubic-bezier(0.22,1,0.36,1)`。

判据(219 passed / 0 failed):
· 新增「窗格切换有真的过场动画」6 条:令牌数值逐个钉死(120 / 180 /
  cubic-bezier(0.22,1,0.36,1));`transition` 必须挂在 if/else 分支根(≥2 处,
  含日历那一支);必须 asymmetric。
· **变异自检**:删掉那 4 处 `.transition(...)` ⇒ 该条立刻变红;还原 ⇒ 全绿。

留给以后的坑(写进 Theme 注释):`snapshot_display` 单次往返约 1s,
180ms 的过渡**抓不到**(连拍三帧像素级一致,会得出"动画没做"的错误结论)。
要取证就得把时长临时调到 20s 再取样,验完还原。本文件注释里留了这条与那次实测值。

未验:真机手感(模拟器已确认过渡链路生效);出场动画与详情 push 的叠加观感。
2026-09-17 21:33:15 +08:00
686193458a fix(harmony): 底栏黑带 / 联系人点不开 / 我的页不守导航 / 顶栏硬截断
用户(2026-09-17)连报五条:「一点动画都没有」「底部那个黑条是啥意思」
「日历页面、我的页面、联系人页面哪个跟 webui 对齐了」「联系人页面连点都点不开」
「你的顶栏为什么还是硬截断而不是渐变」。逐条查证后修:

── ① 底部黑条 = 系统导航栏区域,不是我们画的 ──
根因:窗口默认给系统导航条留位,那块在深色下是黑的;界面于是看起来底下多一条黑带。
修法:`setWindowSystemBarEnable(['status'])` 隐藏系统导航条(底部那条本就是我们自绘的
悬浮玻璃条),内容铺满全高。
★ 刻意**不用** `setWindowLayoutFullScreen(true)`:实测试过,它也能消掉黑带,但会连
状态栏区域一起吃进布局,页签栏被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)。

── ② 联系人点不开(用户原话「连点都点不开」)──
根因:`ContactItem` / `WorkCard` **根本没有 onClick** —— 卡片画出来了,
但没有任何点击路径。当时判据只钉了"字段与 WebUI 一致",没钉"点了会发生什么"。
修法:点卡片打开那条会话的邮件列表,就地在**本窗格内**展示(新增 `SessionMailsView`
+ `GET /sessions/{id}/mails`),底部导航保留;每封可再点进详情。

── ③ 我的页不遵守导航规则 ──
根因:点底栏「我的」走 `pushUrl('pages/SettingsPage')` 推**独立 @Entry 页** ⇒
底部导航整条消失,要按返回才能再切窗格。WebUI 里 `account` 只是一个 `viewMode`,
与收件箱同级、导航常驻。
修法:`SettingsPage` → `SettingsPane`(去掉 @Entry 与返回键,从 main_pages.json 摘除),
作为**第 4 个内容窗格**挂到 `currentIndex === 3`;`NAV_CONTENT_COUNT` 3 → 4,
`NAV_ITEMS` 第 4 项去掉 `route`;拨动动画包 `getUIContext().animateTo`。
实测:`我的` 页底部导航可见,可滚到「退出登录」「管理」。

── ④ 顶栏硬截断 ──
根因:`Scroll` 与 `List` 都能挂 `fadingEdge`(同在 `ScrollableCommonMethod` 上),
但**只有 MainPage 的 List 挂了** —— MailDetailPage / CalendarPage / SettingsPage /
AdminUsersPage / SessionsPage / InboxPage 的滚动容器全漏。同一次滚动里
列表是渐隐、正文是硬切,看起来就是"顶栏没做渐变"。
修法:13 个容器全部补齐(LoginPage 例外:它的 Scroll 是整页根、不是列表,
写了理由)。实测:视口上沿被切断的文字最暗 170–196,而下方不透明正文是 **19**。

── ⑤ 一点动画都没有 ──
实测确认改造前全仓 `animateTo` / `transition` / `animation` **一次都没用过**。
补:Theme 加令牌(durFast 120 / durBase 180 / easeOutSoft = cubic-bezier(0.22,1,0.36,1),
与 WebUI `--dur-*`/`--ease-out-soft` 同值同曲线);切窗格包 `animateTo`;
导航项选中变色加 `.animation`。

判据(218 passed / 0 failed):
· 新增「渐隐覆盖**所有**页面滚动容器」:按文件枚举 + 计数配平,
  并自检扫到的容器总数(防正则写坏 ⇒ 全绿)。**这条一写就抓到 LoginPage**。
· ①/②/⑤ 与 headless 那几条按新事实改写:内容窗格 3→4、
  `normalizeNavIndex(3)` 归一到自己、导航项里**禁止**再出现 `pushUrl`
  (那正是导航消失的形状)。
· 修掉自己引入的两处判据缺陷:`itemCode` 未定义;以及一条**窗口式断言**
  (从内容层 `}` 往后扫,靠数花括号定界,而注释里就有大量 `.padding({`,
  切片长到 1700+ 字符一路扫进 `onAreaChange` ⇒ 假红),改为纯负向断言。

未验:真机观感;横屏 Auto Split 双栏;日历页深度对齐(本轮只补了渐隐)。
2026-09-17 21:15:11 +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
2e53871a22 fix(harmony): 底栏真正浮起 + 内容穿过 + 加回文字标签
用户(2026-09-17):「底栏不是玻璃质感,滑动内容无法穿过底栏」,
随后「还是在导航栏加上文字吧,没有文字还是不好看」。

── ① 底栏没浮起:`width('100%') + margin` 在 ArkUI 里不缩宽 ──
NavBar 原来是 `Row().width('100%').margin({left:16,right:16,bottom:12})`。
ArkUI 的 margin 加在宽度**外面**:100% 再配左右 margin 不会缩到
「100% − margin」,而是整个顶出父容器、两侧被裁。
实测底栏左缘 x=0、右缘贴满 1256(应各留 16vp=56px)⇒ 看起来是贴边通栏,
不是浮在壁纸上的胶囊。这与收件箱头卡片修过的是同一个坑。
改法:外层 Column 带 padding(与 WebUI `.narrow-nav` 的 margin 同一几何)。
实测修复后左缘 x=56 = **16.1vp**(期望 16)。

── ② 内容穿不过去:让位加错了层 ──
原来把 `NAV_CONTENT_RESERVE` 加在**窗格的 `padding({bottom})`** 上。
那不只"让最后一行滚得出来",还**缩短了窗格本身** ⇒ 内容永远到不了条底下
⇒ 玻璃条背后只剩一张已经被壁纸层模糊过的壁纸 ⇒ 系统材质无东西可糊
⇒ 看起来就是一块普通浅色面板。**这就是"不是玻璃质感"的真正原因**。
改法:窗格满高(内容滑得到条底下,真正穿过),让位改到各滚动容器的
**内容末尾** `contentEndOffset(this.navReserve)`(5 个 List + 日历日程),
与 WebUI 同构(`.narrow-shell` 是 flex-col,列表满高、`.narrow-nav` 叠在上面)。
实测:条内高频细节 0.21 vs 条外正文 6.04 ⇒ **28.8×** 衰减,模糊确实生效。
连带修:FAB / 回复球原来靠窗格 padding 躲开条,现在自己按 navReserve 抬
(否则压在条上),实测 FAB 底距条顶 24.4vp。

── ③ 加回文字:并纠正一处**事实错误** ──
代码注释里写着「WebUI 的底部导航是**纯图标**」,并据此去掉了文字。
那句是**错的**:`NarrowNav.tsx:83-86` 每个导航项是
`flex flex-col items-center justify-center gap-0.5`,里面先 `<Icon />`
再 `<span className="text-3xs leading-none">{short}</span>`
—— WebUI **一直有文字**(通信 / 日历 / 联系人 / 我的)。
那条判据把一个假事实固化成了规则,还反过来挡住了正确做法。
现在按真实契约钉:图标(24vp,与 WebUI 24×24 同几何)+ 文字,
两者过**同一个**选中三元式(只换颜色,不加背景/指示条)。

判据(全部含变异自检):
· ⑤ 重写:两处选中三元式(图标+文字各一)、必须有 Text(item.label)、
  图标 24vp;变异自检覆盖"缺文字 / 文字不换色 / 图标不上色"。
· ④ 改:留白走外层 padding(并反向禁止 margin 做留白);
  让位改为 ≥5 处 `contentEndOffset`,并禁止退回窗格 padding 让位。
· 连带更新 ②/⑤ 与 appearance/logic/widescreen 的组件签名断言
  (组件多接一个 navReserve)。
· 删掉一段会假红的"从内容层 `}` 往后扫"的窗口式断言:它靠数花括号定界,
  而注释里就有大量 `.padding({` 字面量,切片能长到 1700+ 字符,
  一路扫进 `onAreaChange`(那里合法地出现 NAV_CONTENT_RESERVE)——
  正是本仓库反复记的窗口式判据,故改为纯负向断言。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图与像素测量逐项核对。
2026-09-17 18:44:36 +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
c523c21e22 feat(harmony): 邮件详情与「我的」页 1:1 对齐 WebUI,并修两个线上 bug
用户:「为什么邮件页面没有对齐 webui?」「我的页面也没有对齐」,
选定「完全 1:1」。两个页面都不是「没做」,而是**做了一半** ——
`AuthApi` 的 logout/listKeys/createKey/revokeKey 全都写好但没人调,
`Models.ets` 的字段声明漏了服务端一直在返回的那些。

── 邮件详情页(对齐 WebUI `MailView.Header` / `CollapsibleHeader`)──
· 头部改**可折叠**(默认收起):收起只留标题 + 必须常驻的状态点
  (未读 / 权限请求)+ 箭头。WebUI 的理由:顶部信息常驻会把可读区压成
  一条缝(实测 1280×800 下头部 17% + 回复框 31%,正文只剩 48%)。
· 发件/收件改**三段式完整地址**:Agent → `pi@/home/program/agentmail.别名`,
  人 → 只有名字。新增 `model/ReplyTarget.ts`(逐字移植 WebUI `replyTarget.ts`,
  并对齐后端 `models.FormatAddress`)。
· 时间:`localDateTime()` → `2026/09/15 11:37:07`(原为裸 ISO `2026-09-15T03:37:07.14758Z`)。
· 档位:`permissionLabel()` → `只读/目录内/全权`(原为英文 `plan/workspace/full`;
  这个函数早就在 `MailGrouping.ts` 里、列表页也在用,只有详情页没用)。
· 新增抄送行;回复入口改**右下悬浮球**(WebUI `reply-fab`),
  不再是底部 56vp 通栏按钮。

── 「我的」页(对齐 WebUI `AccountPage`,补 5 个缺失 section)──
标题由「账号管理」改为「我的」(多账号只是其中一段,不是整页的目的)。
· 基本资料:用户名/显示名/角色/状态/创建时间/最后登录。
· 权限范围:可调用 Agent / 可访问目录(非管理员;空数组 = 不限)。
· 修改密码:三输入框 + 不一致就地提示(新增 `AuthApi.changePassword`,
  该端点服务端一直有、客户端从未包)。
· 客户端连接密钥:列表 / 新建 / 吊销 + 一次性全文提示
  (`AuthApi` 的三个方法终于被调用)。
· 退出登录:先注销推送 token → 清凭证 → **清全部账号** → 回登录页。
· 整页改为**一个 Scroll**:原来账号列表 `layoutWeight(1)` 占满剩余高度,
  排在它后面的 section 被挤出可视区且滚不到(WebUI 注释里正是这个坑),
  而「退出登录」在最下面 ⇒ 等于退不出去。

── 顺带修掉的两个线上 bug(都是判据发现的)──
① `MeApi.get()` 调 `GET /me`,但服务端**没有**这个路由(只有 `/auth/me`;
   `/me/*` 下是 mail/sessions/keys/appearance 子资源)。
   后果:这把调用恒 404 → `loadRole()` 恒走 catch → `isAdmin` 恒 false
   → 「管理」入口对**包括管理员在内**的所有人永远不显示。
   实证:`GET /api/v1/me` → 404;`GET /api/v1/auth/me` → 200
   `{"user":{"role":"admin",...}}`。修复后截图里「管理」入口已出现。
② 详情页回复写 `req.to = this.fromName + '@'`:那个游离的 `@` 让 `name@`
  被后端 `ParseAddress` 解析成「有 path、无 session」⇒ 落到该 Agent 的
  **默认会话**,而不是用户正在看的那条线索。改用 `replyTargetAddress()`。

── 判据(新增 7 + 8 条,全部含变异自检)──
· `harmony-reply-target.test.mjs`(新):直接执行 `ReplyTarget.ts` 断言行为
  —— 空 path 必须留 `@`(否则整串被当成名字、投递 404)、人只有名字、
  会话位必须带上、`from_workspace` 不得用来拼地址(会得到 `dsh@dsh`);
  并读 Go 源码比对三分支结构。
· `harmony-admin.test.mjs`(+5):`/auth/me` 路径(含服务端注册与响应形状)、
  八个 section 齐全、退出清全部账号、整页一个滚动容器、字段来自服务端。
  变异自检:把 `/auth/me` 改回 `/me` → 判据变红(已验证)。

测试:212 passed / 0 failed;`devecocli build` 通过;
模拟器截图逐项核对(详情收起/展开两态、我的页全部 section)。
未验:真机观感;横屏 Auto Split 双栏。
2026-09-17 17:43:03 +08:00
65c3ef8094 fix(harmony): 悬浮加号移进 Navigation 列表侧,修详情页叠按钮
症状(2026-09-17 模拟器截图硬证):窄屏 Stack 模式点开邮件详情,右下角**同时**
出现两个圆形按钮 —— 详情自己的「回复」(蓝色胶囊)与外壳的 compose 加号,
后者悬空压在详情上。

根因:加号原先是 `Navigation` 的**兄弟**,平级放在外层 Stack 里。
详情画在 `Navigation` 内部,而兄弟节点画在 `Navigation` 之上 ⇒ 永远压住详情。

WebUI 的对应物是 `.comm-pane`(`App.tsx`: `{listBody}<ComposeFab />`):
加号在**列表窗格内部**,窄屏滑上来的详情层把列表整块(含加号)盖住,
详情自己的回复按钮才露得出来。Split 模式下加号仍在左栏,与 WebUI 两栏并排一致。

改法:把 `Stack({ alignContent: BottomEnd }) { 列表; 加号 }` 整体放进
`Navigation(this.navPathStack) { ... }` 的构建体内;通信页根的
`bgActive ? Transparent : pageBg` 保留(判据要求 ≥5 处让出页面底)。

判据(harmony-nav.test.mjs 新增一条,含变异自检):
- compose 加号必须在 `Navigation(...)` 的花括号**内**;
- `.navDestination(...)` 之后不得再出现 compose 加号。
用 `git show HEAD:...MainPage.ets`(改动前版本)复核:两条断言在 bug 版本上
**都失败**(inside=False / after=True),说明判据抓的正是这个层级错位,
不是"存在一个 compose 按钮"那种在 bug 版本上也成立的弱断言。

实测(模拟器 1256×2760):详情页只剩自己的「回复」按钮,加号不再悬空;
截图 /tmp/harmony-fabfix4.jpeg。列表页加号位置不变(仍在右下角)。

测试:200 passed / 0 failed。
2026-09-17 16:52:34 +08:00
fc3fff4f36 fix(harmony): 滚动列表上下边缘渐隐,替掉硬截断
用户(2026-09-17):「上下还是硬截断,不是 webui 那种渐变」。

WebUI 的做法是 `index.css` 给 `.overflow-y-auto` 加
`mask-image: linear-gradient(to bottom, transparent 0, #000 min(12px,10%), ...)`。

ArkUI 的对应物是滚动容器自带的 `fadingEdge(enabled, { fadingEdgeLength })`
(API 14+,定义在 `ScrollableCommonMethod` 上 ⇒ List/Scroll/Grid/WaterFlow 都有),
它淡掉的是**渲染结果本身**,与背景无关。

**刻意不用** linearGradient + mask 手搓渐变色遮罩:那是拿背景色画一个
并不存在的"底色",在壁纸/玻璃主题下必然露馅(壁纸本身带颜色,遮罩层对不上)。

- 新增 `LIST_FADE_LENGTH = 12`(NavItems.ts):与 WebUI 的 `min(12px, 10%)`
  同量级,也接近列表内边距(10vp)—— 静止时几乎看不出,滚动时才起作用。
- MainPage 的 5 个列表容器(收件箱/发件箱/授权/联系人卡片/联系人列表)
  全部挂 `.fadingEdge(true, { fadingEdgeLength: LengthMetrics.vp(LIST_FADE_LENGTH) })`。
  显式传长度而不是用默认 32vp:WebUI 那边踩过同一个坑
  (「渐变用的过猛了,比如收件人候选那里」——固定像素用在高度不固定的容器上)。

判据(harmony-nav.test.mjs 新增一条):常量值、五个列表都挂、长度走
LengthMetrics.vp、禁止 linearGradient+mask 顶替;并做了变异自检
(删掉任一处 fadingEdge 必须判红,实测通过)。

实测(模拟器 1256×2760,密度 3.489):
- 滚动后顶边被切断的卡片文字明显洗白(红色评分 5.5 vs 完整卡片 15.5);
- 底边同样渐隐;截图 /tmp/harmony-fade-scroll.jpeg。

测试:199 passed / 0 failed。
2026-09-17 16:44:17 +08:00
ff75339baa fix(harmony): 图标按 px→vp 正确缩放 + 底栏四入口 + 卡片 10vp 缩进
图标过小的根因(实图硬证):`Path.commands` 的坐标按 **px** 解释,不是 vp。
- `Shape.viewPort` 只裁剪不缩放(官方样例"缩放成立"是因为它用 Rect/Circle
  自带 vp 宽高,不是裸 Path.commands);
- 改动前的 `Path.scale({iconSize/24})` 对 iconSize=24 等于 1.0,同样没缩放。
  症状:FAB 内 compose 只画 24px(应 24vp=84px),且偏在盒子左上角。

修法:`scale({vp2px(iconSize)/24})` 把 24 单位路径放大到 iconSize vp;
`scale` 会连带放大描边,故 `strokeWidth = strokeWeight / vp2px(1)` 抵消,
否则 1.8vp 描边会变成 ~7.3vp 的"实心块"(实图已验证)。
用 `this.getUIContext().vp2px()` 而非全局 `vp2px`——后者已废弃,
`harmony-system-api` 判据对全局调用默认判红。

验收(模拟器实图,密度 3.489):
- FAB 内 compose 图标 84px = 24vp ✓(原 24px)
- 底栏图标描边 7-8px ≈ 1.8/24×28vp=7.3px ✓(原为实心块)
- 四个图标均为清晰描边,与 WebUI icons.tsx 同几何

底栏四入口:新增"我的"(person → pages/SettingsPage),对齐 WebUI
通信/日历/联系人/我的;NAV_CONTENT_ITEMS 保持宽屏侧栏三项,
避免宽屏出现重复 person 图标。

卡片缩进:`width('100%') + margin({left:10,right:10})` 在 ArkUI 里不会
缩到 90%——margin 加在宽度外面,卡片顶出父容器(实测左缘只留 ~1px,
列表卡才有正确 10vp)。改为外层容器加 padding。验收:页头卡与邮件卡
左缘同为 x=35(10.0vp)、右缘 x=1219。

推送:reportToken 先申请通知权限再取 token(部分设备权限未开时
getToken 返回空 / 报 1600004),并补两条文件形状判据钉住
client_id 配置与调用顺序。

测试:198 passed / 0 failed。
2026-09-17 16:27:27 +08:00
a5f680a147 harmony(ui): 登录页 1:1 对齐 WebUI 卡片规格 + 通信页改用系统 Navigation Auto
登录页(LoginPage.ets):对照 WebUI LoginPage.tsx 同规格重写 build()
- maxWidth 380vp 居中卡片(与 WebUI 380px 同档)
- 14vp 圆角(Theme.glassRadius)
- 48vp 品牌图标(Envelope 浅蓝底 accentSoft)
- 显式字段标签(服务器/用户名/密码/用户密钥)
- 40vp 输入框与登录按钮(WebUI 控件同高)
- 分段式登录方式切换(账号密码 / 用户密钥)
- 窄屏安全边距 16vp + Scroll 可滚

通信页(MainPage.ets CommPage):改用鸿蒙系统 Navigation 组件
- Navigation(navPathStack) 包住列表,navDestination 注册邮件详情目标页
- mode(NavigationMode.Auto):phone 自动 Stack、tablet/2in1 自动 Split
- navBarWidth(320) 对齐 WebUI MailList 320px;navBarWidthRange([280,360])
- minContentWidth(360) 参与 Auto 断点计算
- Inbox/Sent 选中邮件改走 navPathStack.pushPath,不再 router.pushUrl
- 切收件箱/发件箱/授权时清空详情栈避免跨栏残留

详情页(MailDetailPage.ets):抽出可复用 MailDetailView
- 既有详情实现导出为 @Component struct MailDetailView
- 新增 initialMailId/initialAccountId/embedded/onBack 输入
- 无 initialMailId 时才读路由参数(兼容旧 router 入口)
- embedded=true 时返回走 onBack 回调(NavPathStack.pop)
- 薄壳 @Entry MailDetailPage 用 Column 包裹(@Entry 根节点须容器)

module.json5:deviceTypes 扩展为 phone/tablet/2in1 三形态

判据:harmony-widescreen.test.mjs 新增 ⑥ 钉住 Navigation Auto 不许退回手搓分栏

验证:ArkTS 编译通过;harmony-arkts/widescreen/nav/cross-client-theme 35/35 通过
2026-09-17 10:58:31 +08:00
f44b15684b fix(push): 通知开关默认改开(修正误解「可配置项=默认关」致 token 从不上报)
★ 功能性硬伤修复:用户实测「新邮件根本没有通知」。

根因:我上一版把 PushService.isEnabled 默认设成关(读不到即 false),
误解了「接入系统通知是可配置项」的本意 —— 用户要的是「服务端没配凭证时
客户端优雅降级」(enabled:false 是正常态,不报错不阻塞),不是「默认关掉
推送功能」。结果 reportToken 最前 gate 直接 return,device token 从未上报,
DB 里 0 个 token,NotifyNewMail 在 len(tokens)==0 时静默返回 ⇒ 永远发不出。

修正:
- PushContract.DEFAULT_PUSH_ENABLED: false → true(契约层默认开)
- PushService.isEnabled: getSync 默认值用 DEFAULT_PUSH_ENABLED(true),
  catch 也返回 true —— 只有用户显式关才不上报
- 判据同步:harmony-push-optin ①② 改默认开口径

验证(服务端→HMS 链路实打已通):
- OAuth access_token 取得(app_secret 有效)
- POST /v1/{appId}/messages:send + test_message 调通,
  HMS 回 80300007 "All tokens are invalid"(假 token 无效,但凭证/形状/认证全被接受)
⇒ 只要 DB 有真实 device token 就能送达(token 由客户端上报)
2026-09-16 08:29:24 +08:00
6cd64a4384 ke: 玻璃面板(系统材质):WideSidebar bgActive 走 BlurStyle,撤回手写 alpha
遵守既有玻璃纪律(cross-client-theme B/C):
- 手写 8 位半透明色一律禁止(半透明归系统材质),撤回 #E6FFFFFF 尝试
- 玻璃 = 系统 BlurStyle(navMaterial) + 不透明系统色 surface,半透明由 BlurStyle 给
- WideSidebar 加 @Prop bgActive:bgActive 时 backgroundBlurStyle,否则 NONE
- 内容面板只保留几何(radius/clip/padding/gap),不加材质(判据按 @Builder 解析
  owner 会与 NavBar 撞 key;壁纸从面板缝隙露出已达成 app-shell 观感)
- GLASS_REGISTRY 登记 WideSidebar#NavItemBuilder(判据按最近 @Builder 命名)
2026-09-16 08:06:36 +08:00
31fdc1a3d8 ke: 宽屏 app-shell 几何落地:padding/gap 10 + 面板圆角 14(WebUI 一比一)
- MainPage 宽屏 Row({ space: Theme.paneGap }) —— gap 10(窄屏无子间距,无害)
- 内容面板 borderRadius(isWide ? glassRadius : 0) + clip(isWide) —— 圆角 14 仅宽屏
- 容器 padding 条件:宽屏 paneGap 10(壁纸从缝隙露出)/ 窄屏 0(贴合全屏)
- Theme.glassRadius=14 / paneGap=10 已在上一提交引入,这里真正用上
- 判据:harmony-widescreen ⑤ 钉 app-shell 几何(令牌值/space/radius/clip/padding 与 WebUI 同值)
2026-09-16 07:58:13 +08:00
fd4b475920 ke: 鸿蒙导航纯图标 + 图标加大 + 玻璃几何令牌
用户反馈:① 图标太小 ② 底部导航栏不允许有文字。

- NavItem(底部导航):去掉 Text(item.label) 纯图标,AmIcon 22→28vp
- WideSidebar(宽屏侧栏):去掉 label 文字(WebUI Sidebar 本就是 60px 纯图标轨),
  导航图标 22→26vp、品牌标 26→30vp;NavItemBuilder 去掉 label 参数
- Theme:新增 glassRadius=14 / paneGap=10(与 WebUI app-shell CSS 变量同值,
  用于宽屏玻璃面板几何;原有 radiusCard 保持系统跟随不动)
- 判据同步:harmony-nav ⑤ 改纯图标(1 处三元式 + 无文字 + 28vp)+ 变异自检三项;
  harmony-widescreen ③ 同样改纯图标(1 处 + 无文字 + 26vp)
2026-09-16 07:54:44 +08:00
cfe7808af4 ke: 鸿蒙 UI 整改:emoji→AmIcon 图标、宽屏侧栏、Md 第三方库、通知可配置项
- common/Icons.ets(生成器产出):43 枚与 WebUI icons.tsx 同几何的 Path 图标
- NavItems.ts:icon(emoji) → iconKey,与 WebUI 导航同图标
- 全页面 emoji 清除(空态改单行灰字,按钮/头像/刷新等换 AmIcon)
- MailDetailPage 接入 @luvi/lv-markdown-in(第三方,替代手写解析器)
- WideSidebar + MainPage isWide(≥768vp):宽屏侧栏 60vp 图标轨、藏底部条、onAreaChange 断点
- 通知=可配置项(默认关):PushService.isEnabled/setEnabled + reportToken 最前 gate (关=不取token/不弹权限/不打网关)+ SettingsPage Toggle + 服务端通道状态提示
- EntryAbility.onCreate 解析 want:冷启点通知也能路由(原来只有 onNewWant)
- 判据:harmony-widescreen(4) + harmony-push-optin(4) 新增;harmony-nav 同步 iconKey/条件 padding
2026-09-16 07:46:52 +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
cb54c53937 跨端: 补上 --check 的反向洞:CHECK_GATE_RC 真正 exit 出去(红门禁不再退 0);干跑不再重写 dist(BUILD_INFO_OUT 改道临时目录,build-stamp 的红不会被干跑刷掉)
pi 2026-09-15 两条都成立,而且第一条是我**自己上一个 commit 的注释在骗人**。

## 一★★ `CHECK_GATE_RC` 只被打印、**从来没被 `exit` 用** —— 我把它从末尾挪走时丢了退出

```
$ grep -n "exit.*CHECK_GATE_RC" deploy/install.sh
(无)
$ 干跑分支终点: exit 0        ← 无条件 0
```

而我上一版注释写的是"**最后用记下的码退出(见文件末尾的 `CHECK_GATE_RC`)**" ——
**文件末尾根本没有那一行**(那里是正式安装路径的 systemctl/curl,干跑走不到)。
我上一轮把那段从"文件末尾"挪到 `go vet` 之前时,**挪了打印、丢了 `exit`**。

⇒ 后果是我上一个 commit **把病修反了**:

| | 第一道红的行为 | 命令退出码 |
|---|---|---|
| 修之前 | 中止一切,诊断全丢 | **失败**(正确的那半) |
| 我那一版 | 诊断全跑完 | **0**(错的那半) |

而丢掉的这一半**正好是会被引用的那一半** —— 这条链上引用过 `install.sh --check` 的 `exit 0`
当绿读数。**"注释说的事和代码做的事不一致"这是我第三次犯**(前两次:`unreportedFiles` 说修了没修、
`ok 5` 报文认证了没人判的界)。

**修法**:干跑分支终点 `exit "${CHECK_GATE_RC:-0}"`。

## 二★★ "干跑"并不只读:它重写 `client/electron/dist`,能**静默刷掉 `build-stamp` 的红**

同一个脚本在别处很小心地不写 `node_modules`("干跑不写依赖"),却**照跑 `npm run build`**。
两个后果(你指出的):

1. **干跑改共享状态** —— 而它自己那句"干跑没有执行"的清单只列了**工作区外**的路径,`dist` 悄悄在射程内;
2. **它能把 `build-stamp` 从红刷成绿** —— 那条比的是 `dist/BUILD_INFO.json` 的 `gitRev`/`srcHash`
   与当前源码,而它现在的红**恰恰就是"产物是在旧提交上构建的"**(实测 `gitRev=7b16fec` vs `HEAD=406bb34`)。
   跑一次干跑 ⇒ 重构建 ⇒ 不匹配消失 ⇒ **一条红没了,而没有任何人决定过"重构建"**。

**修法**:干跑改成 `gen:bg` + **`vite build --outDir <临时目录>`** + `build-info.mjs`(新增 `BUILD_INFO_OUT`)。
**真装的 `npm run build` 一字不改**(仍写真正的 `dist`)。

**实测**(`dist` 内容指纹 sha256):

```
跑之前  ff97258fbcc1de49…
跑之后  ff97258fbcc1de49…   ✓ 未动
BUILD_INFO.json: gitRev=7b16fec vs HEAD=406bb34 ⇒ **红还在**(没被干跑刷掉)
退出码=1(红门禁 ⇒ 非 0)
```

**退出码两侧都验了**(不是只看"非 0"就收):
- 真跑一次全脚本 ⇒ `bash -x` 追到 `+ exit 1`(**真的走到了我那一行**);
- 抽出那一行(逐字节)配三种前置状态 ⇒ `CHECK_GATE_RC=1` → 1;空 → 0;未设 → 0;
- 构造全绿情形(中和两处真门禁)⇒ **退出码 0**,`FAIL` 行数 0。

★ 顺带一条**我自己踩到的坑**:第一次构造"全绿"副本时我把脚本拷到 `/tmp` 跑,
而 `REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"` ⇒ `REPO=/tmp` ⇒
`env-defaults.sh` 找不到 ⇒ 退出 1,**看着像"绿路径也退 1"**。放到仓内(临时文件、跑完即删)才是对的。
**又一次"我的验证环境和生产不同"** —— 这次差异是脚本位置。

## 三、你指出的"同一现象两种政策":加了互相指向,并写明**为什么不能统一**

- `install.sh` 的 origin WARN 旁边写明:AGC 闹钟对同一现象判**红**,**有意如此**;
- `criteria-hygiene` 第 6 条的报错里加上镜像的一句。

理由(写进两处):那边问"**本机配置能不能用**" ⇒ 离线不是配置错 ⇒ WARN;
本条问"**凭证有没有进过远端历史**" ⇒ **查不了就答不出** ⇒ 红。
并且写明统一到任一边的代价:统一到 WARN ⇒ **泄漏告警在离线时静默变绿**;
统一到红 ⇒ 离线机器上一次干跑就报假红。

## 四、残余(照实写)

- `gen:bg` 会**原地重写** `src/background-takeover.generated.css`(已跟踪)。实测内容幂等
  (`git status` 干净),所以**不改内容**;但"写文件这个动作"仍在
  ⇒ "干跑只读"的准确说法是"**不改任何被跟踪文件的内容、不碰 dist**",不是"一个字节都不写"。
  我把这句写进了注释 —— **没验到的部分不写成保证**。
- `go vet` 那条红是 `/root/.cache/go-build` **权限**问题(环境),不是代码;维持既有记录。
- 你上封提的"记录投影"已在 `406bb34` 做完;与本文无关。
- 红线仍 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),**都不是我的**。
2026-09-15 13:41:50 +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
b50a81c362 跨端: AGC 探针 ref 落 finally + 唯一名、披露挪到断言前;install.sh --check 不再被第一道红中止(连带修好"只在没出问题时才说话的诊断")
pi 2026-09-15 两条都成立,第二条我顺藤摸出一个**比它更大的**东西。

## 一★ origin URL 的"恢复"是**类别错误** —— 我认,而且我把它变成了可观察的

你说得对:`origin/main` 那次有真值(`ls-remote` 给的 sha),而 **URL 这个字符串没有外部真值** ——
外部只能证明它的**效果**(能到同一个仓),证明不了它的**值**。我实测两条都到 `6702cc2`,**同仓不同值**,
而我还写了"已对着远端真值恢复"。**纠正:我恢复的是"一个能用的 URL",不是"原来那个 URL"。**

按你的建议加进 `install.sh --check`(**只报值 + 判可达,不断言常量**):

```
[ OK ] 远端 origin = https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git(下面是*效果*检查,值本身无外部真值可比)
[ OK ] origin 可达(`git ls-remote origin` 能拿到 refs)
```

★ **我特意没有断言它等于某个常量**:本仓**没有登记规范 URL**,写死一个就是
**"猜数字的判据"**(真值无处可得时,常量就是我的记忆)。要强判得先把规范值登记进版本库。
三条分支我都用 `set-url` 在 `/tmp` 上验过:可达 → `[ OK ]`;不可达 → `[WARN]`(**不是 FAIL**,
局域网 URL 离线时本来就不可达);未配 → `[WARN]`。

⚠️ 顺带一个**你自己也可能撞到的坑**:把 origin 指向不可达地址(`http://192.0.2.1:3999/…`)时
`git ls-remote` **挂住不返回**(我实测 60s 没回来)。"干跑卡死"比"干跑报 WARN"坏得多
(卡死的门会被当成"这脚本跑不了")⇒ 加了 `timeout 15`。**不可达必须限时。**

## 二★ 探针 ref:两处都修了,而且失败路径我用真泄露验过

按你说的照抄仓库已有先例(部署锁、`$RM` 随机名):

- **唯一名**:`refs/agentmail-probe/<ref>-${process.pid}`。
- **`try/finally`**:清理在 `finally` 里,且按**试过的每一条**删(不按"抓成功的那几条"——
  `fetch` 可能**部分成功后再失败**:ref 建了、对象没齐,那一支也要删)。
- 我对后果的核实与你一致:**不是错判**(判定用 sha,对象不因 ref 被删而消失)——是垃圾/卫生。
  我把它记成"我的判据去动别人的东西",而不是记成"可能报错绿"。

**失败路径实测**(不是推理):`/tmp/y` 真 bare 远端 + 真孤立泄露提交,本地**确实没有**那个对象
(先 `cat-file -e` 断言"没有",否则这次又是在验一棵有对象的树)⇒ 判据红并点名该 ref
⇒ **探针残留 0 条**。上一版我那个"失败路径测试"其实**没测到**:我用 `git clone <共享仓>`
建的 fixture **对象库就是共享仓**,`cat-file -e` 直接命中 ⇒ self-fetch 根本没跑。
**又是"验证环境与生产的关键差异恰好是 bug 所在"。**

## 三★★ 第三条是我自己撞出来的:**披露只在平安无事时发生**

你说"主动披露这个直觉是对的,缺口只在失败路径" —— 我去看代码,发现**披露那段的缺口比失败路径更大**:

```js
assert.deepEqual(published, []);       // ← 判红时从这里抛出
…
if (fetched.length > 0) console.log(`(本条本次临时抓了 N 条…)`);   // ← 在断言**之后**
```

即:**判红时披露永远不执行** —— 而判红恰恰是读者最需要知道"这条判据刚动过仓库"的时刻。
**我上一封说"抓过对象时必须说出来",代码实际做到的是"没出事的时候说出来"。说法与实现不一致,以实现为准。**

已把披露**挪到两条断言之前**,并且在**红路径上实测到了**:

```
#   (本条本次临时抓了 1 条远端 ref 的对象:refs/tags/leak-a83e15 —— 这就是"这条判据会写仓库"的样子)
not ok 6 - ★ AGC 真身从未进过远端历史
```

## 四★★ 顺着它又挖出一个**比我那段大得多**的问题:`--check` 被第一道红中止

加完 origin 检查后我发现**它一行都没打**。插探针定位(`echo [PROBE]` 放在那一块之前,命中 **0 次**):

```
$ bash deploy/install.sh --check      # 套件红(今天 3 条不是我的)
…(判据汇总打完就结束)——**那一块从未执行**
```

真因:脚本是 `set -e`,而 `npm run typecheck && npm test && npm run build` 是**第一个会红的门禁**,
**它一红脚本立刻退出 ⇒ 后面所有诊断一行都不打**。

★ **受害者不只是我新加的,还包括既有的版本库钩子检查** ——
也就是说 `install.sh --check` 那句 `[ OK ] git 钩子已接` / `[WARN] git 钩子没接`
**只在判据全绿时才会出现**。
⇒ **一个只在"没出问题"时才说话的诊断**,与"读数器替一件事作证"是同族。
(你上一轮核过 `--check` 会报钩子——那次套件是绿的,**报的是真的**;
但**这个机制比它看起来脆**:它依赖"门禁恰好全过"。)

**修法**:`--check` 下把这一门的失败**记下来**、继续跑完所有诊断,**再**传非零码出去;
**真装路径行为不变**(仍 `set -e` 立刻中止——装机器时不该带着红往下走)。实测现在:

```
[ OK ] git 钩子已接(core.hooksPath=.githooks,pre-push 会拦 AGC 真身)
[ OK ] 远端 origin = https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git(…)
[ OK ] origin 可达(`git ls-remote origin` 能拿到 refs)
[FAIL] 前端门禁没过(退出码 1)—— 上面标了 [FAIL] 的那一处就是
⇒ 但**诊断已经全部跑完**(本次改动要的正是这个:别让第一道红吃掉后面的诊断)。
[FAIL] go vet / go test 不过 —— 先修好再安装
```

★ 这段的位置我改了**两次**才定:第一版放在文件末尾 ⇒ 实测 `go vet` 红(go 缓存权限)就再也走不到
⇒ **那段话本身又成了"只在一切顺利时才报的总结"**。最后放在 `go vet` **之前**,
无论后面谁红都报得出来。

## 五、我没做的 / 不在射程

- **没给 origin 断言常量**(理由见上:没有登记过的真值,写死就是猜)。
- `go vet` 红是**环境**问题(`/root/.cache/go-build` 权限),不是代码;维持既有记录,本轮不碰。
- 红线仍是 4 条(`narrow-layout`/`nav-merge`/`harmony-presets`/`build-stamp`),都不是我的。
- 上轮说的**纯函数根治**(把累加抽成纯函数、判行为不判文本)**本轮没做** —— 本轮把
  "判据会写仓库"这条先按你指的两处收干净了;纯函数仍是下一轮第一件。

`ok 5`/`ok 6` 均绿、`RESULT files=25 ran=25 checks=400 pass=399 fail=1 red=4`、探针残留 0 条。
2026-09-15 13:26:53 +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
2887ea8acd 跨端: AGC 闹钟:纠正"未 fetch 的对象也在其列"这一假前提 + 自抓缺失对象(良性 tag 不再误红)+ 报错补修法
pi 2026-09-15 驳倒了我这条前提,**他是对的,我照他的办法重测复现了**。

## 一★ 我写过"未 fetch 的对象也在其列(这一点我实测过)" —— **不成立**

`git log <sha>` **必须先有这个对象**才能走可达历史。本地没有 ⇒ `fatal: bad object`(退出码 128)。

实测(`/tmp` 一次性仓库,clone **之后**才把新提交推到新 ref):

```
$ git cat-file -e 953c6138   → 没有
$ git log --oneline 953c6138 -- agc.json
fatal: bad object 953c6138a7320d73773d9b1253042d6680a97364
```

★ 我那次"实测过"大概是测到了**对象恰好在本地**的情形 —— 那一轮我推的 tag 指向的提交
**同时也在 main 上**,clone 时就跟着下来了。**又是"读数器没先被证明是好的",
而这次我把一次假读数写成了"实测过"。** 这比单纯写错更坏:它让那句错话带着证据的外衣。

## 二★ 于是代码实现的规则与那段理由**相反**:它**确实**给日常 tag 设了卡

未 fetch 的 ref 落进 `unresolved` ⇒ 红。实测(旧行为,把 self-fetch 去掉后):
远端加一条**完全良性**的 tag-only ref ⇒

```
not ok 6 … 这几条远端 ref 的 tip 拿不到、也抓不回来
```

方向我仍认("查不了 ≠ 干净"),但**理由改了**:
真正的不变量是"**这个 clone 必须拿到远端每一条 ref 的对象,否则本条红**"。
不改这句,读的人会以为良性 tag 是"无事发生",第一次撞红时当误报消掉 ——
**那正是这条判据最可能被消掉的路径。**

## 三★ `git fetch` 修不好它,只有 `--tags` 行

pi 实测,我也复现:

```
$ git fetch origin         → 分支的对象有了;**tag-only 的还是没有**
$ git fetch --tags origin  → 这才有
```

(tag 跟随只跟随"指向本地已有对象的 tag"。)

**所以我让这条判据自己把缺的对象拿回来**,而且是**精确抓**:

```
git fetch --no-tags origin +refs/tags/<name>:refs/agentmail-probe/<name>
```

**不碰用户的 ref、不拉全仓、不动工作树**;判完 **`update-ref -d` 删掉探针 ref**(不留垃圾)。
拿不到才报红,并且**报错自带修法**(本仓规矩),且修法里写明**只 `git fetch` 不够**。

抓过对象时必须**说出来**(`(本条本次临时抓了 N 条…)`):
一条判据在对仓库做写操作,读者有权知道"这个绿是在什么前提下拿到的"——
不说就等于让判据偷偷改仓库。

顺带改掉两处错话:
1. 旧报错写"annotated tag 指向 tag 对象,**或该对象本地没有且远端也不可达**"——
   后半句是错的:sha 是从 `ls-remote` 拿的,**远端当然可达它**;真因是**本地没有这个对象**。
2. 旧报错**没给修法** —— 而"报错自带修法"是本仓的规矩(AGC 那条正文自己就这么写)。
   **不给修法的红会被当噪音**,而这条判据最不需要的就是被当噪音。

## 四、验证(`/tmp` 一次性仓,共享仓只读)

| 情形 | 旧 | 新 |
|---|---|---|
| 良性 tag-only ref(不含该路径) | **红(误红)** | **ok 6**,并打印"临时抓了 1 条" |
| **真泄露**在 tag-only ref 上(本地无该对象) | 红,但理由错("抓不回来") | **红,点名 `[refs/tags/leak-orphan]`** |
| annotated tag / 旁支 / 干净 main | 红 / 红 / 绿 | 同(未退化) |

探针 ref 每次判完 **0 条残留**;用户的 ref 未被改(当时只有 `refs/heads/localfull`
与 `refs/remotes/origin/main`)。

## 五、★★ 我又把共享仓改坏了 —— 这次是 `.git/config` 里的 `origin` URL

做上面那些对照时我在 `/tmp/w*` 里跑 `git remote set-url origin /tmp/q-origin`,
**以为那是 clone** —— 但 `/tmp/w*` 是我用 `git clone /home/program/agentmail` 建的,
而那棵树里 **`.git` 是指向共享仓的 worktree 指针**(`/tmp/pi-verify-head`、`/tmp/wt-p2` 同)。
`git remote set-url` 写的是 `$GIT_COMMON_DIR/config` ⇒ **改了共享仓的配置**。

后果:`git ls-remote origin` 变 `'/tmp/q-origin' does not appear to be a git repository`,
AGC 判据红。

**恢复方式(对着远端真值,不是对着记忆)**:

```
$ git ls-remote https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git refs/heads/main
6702cc2f5e    ← 与本地 origin/main 一致
$ git remote set-url origin https://gitea.jianfgit.xyz/jianf/MailUI4Agents.git
$ 复核:ls-remote == refs/remotes/origin/main  ✓
```

改动面:只动了 `.git/config` 的一个 key,其余节(`core.hooksPath`、`user`、`branch.main`)
未被触碰;两个 `/tmp` worktree 现在都解析到正确的 origin。

★ 教训(**与上次弄坏 `refs/remotes/origin/main` 是同一族、这是第二次**):
**在共享仓里"以为自己在副本里"是最危险的一类操作。**
上次我损坏的是 ref,这次损坏的是 config —— 两次都是**写操作打在共享 git 目录上**。
可执行的自保:**在 /tmp 造隔离副本前,先断言它有自己的 `.git` 目录**:

```
[ -d /tmp/xxx/.git ]        # 不是文件(worktree 的 .git 是**文件**,指向共享 gitdir)
git -C /tmp/xxx rev-parse --git-common-dir   # 必须等于 /tmp/xxx/.git
```

`git clone <本地路径>` 会因为 hardlink/worktree 语义把 `.git` 指回共享仓 ——
**所以"克隆一份来试"这个动作本身就可能已经在碰共享状态**。
下次做这类对照,先建一个**真 bare 源**再 clone,或直接断言上面两条。
2026-09-15 13:09:12 +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
c4a8c98b9d 跨端: AGC 闹钟**只枚举一条 ref** 而声称"整个远端" —— 改成对全部远端 ref 判
pi 2026-09-15 抓到的**第三个洞**,而且他说得对:这是**同一句话换一根轴**。

## 一、洞:我在"可达性"那侧立了"问不到 ⇒ 红",却在"覆盖面"那侧"看不到 ⇒ 绿"

```js
const branch = rb ? rb[1] : 'main';
const lsr = git(['ls-remote', 'origin', `refs/heads/${branch}`]);   // ← 只有一条
```

判据标题写的是"从未进过**远端**",实际只问**当前分支**一条 ref。于是
**把受污染历史推到旁支、或推一个指向它的 tag** ⇒ 凭证出去了,而这条判据**照样绿**。

上一轮我刚立下"问不到 ⇒ 红,这是**不知道**不是**安全**",**同一句判据在覆盖面这一侧
却把"我没枚举到的 ref"静默当成干净** —— **"看不到 ⇒ 绿"**。
和"把不知道读成安全"是同一句话,只是**宾语换成了覆盖面**而不是可达性。

## 二、修法(pi 给的,**不需要 fetch 任何对象**)

`ls-remote` 问**全部 ref**,对**每一条**的 tip 查该路径是否在其可达历史里。
查明"远端有哪些 ref"是 `ls-remote` 的免费信息,没有理由只问一条。
`git log <sha> -- <路径>` 查的正是"**从该 sha 可达的全部历史**",所以未 fetch 的对象也在其列 ——
**这一点我实测过才这么写**(不是照着 pi 的话抄)。

## 三、★ 我**没有**把"ref 集合恰好等于 HEAD + refs/heads/main"写成不变量

pi 提了两个选项(断言集合恰好已知 / 未枚举的报 unknown)。我选第三条:
**判"有没有哪条 ref 的可达历史里有那个 blob"** —— 它对**任意** ref 集合都成立。

理由是 `pre-push` 删 ref 那次的教训:**一道闸消费了不属于它管辖的东西**。
把"远端只有一条分支"写成不变量,会在**加第一个 tag / 第一条正常旁支**时误红 ——
那是**为了抓泄露而给日常操作设卡**。要判的性质是"泄露",不是"ref 集合的形态"。

## 四、验证:四态 + 一对照,全在**独立 bare 仓 + 独立 clone** 里做

| 情形 | 结果 |
|---|---|
| 干净远端(main + 干净旁支 `feature-clean`) | **ok 6**(不误红) |
| 旁支 `refs/heads/other` 指向含该路径的提交 | **not ok**(红,点名 `[refs/heads/other]`) |
| tag `refs/tags/leak` 指向含该路径的提交 | **not ok**(红) |
| annotated tag(指向 tag 对象) | **not ok**(红;`ls-remote` 同时给 tag 对象与 `^{}` 解引用,两条都查到) |
| **对照**:把副本改回"只问 `refs/heads/main`" | **ok 6** —— **旧写法在同一个泄露远端上是绿的** |

最后那行是这次的关键:**旧判据在"凭证已经推出去了"的远端上报绿**,与 pi 的推断一致。

`unresolved` 那一支(`git log <sha>` 解析不了的 ref)也留着了:**报 unknown 红,不当绿** ——
**查不了 ≠ 干净**。

## 五、与上一轮的交叉(pi §四 说两条"仍活着")

他复核的是 `4d78fc1`,那两条我已在**它的下一个提交 `239f919`** 里修掉:
`unreportedFiles += 1` 现在只有一处(语句位置),`checks` 取 `# tests` 而非 `# pass`。
现测:`checks=400 pass=399 fail=1 red=4` —— 与他算的 400/399 逐字一致。**内容以 `239f919` 为准。**
2026-09-15 12:58:19 +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
4d78fc1580 跨端: 钩子删 ref 的**误红**(全零 sha ≠ 空串)+ 闹钟改读**远端真值**(本地 ref 可被改坏)
pi 2026-09-15 复核时找到两条**一行级**的错,两条都对,都已修。

## 一、`.githooks/pre-push`:删除 ref 被误判成"无法解析范围"

根因一行(原 `:33`):

```bash
[ -z "${lsha:-}" ] && continue        # 判的是**空**,而 git 表示"删除"用的是**全零 sha**
```

于是删除时走 `else`,拼出 `旧sha..0000…`,`git log` 解析不了 ⇒ `exit 1` 中止。实测复现(真 bare 仓):

```
$ git push /tmp/bare-del --delete tmp
pre-push: 无法解析范围 '6702cc2f…..0000…' —— 中止推送(宁可推不出去,也不要盲推)
error: failed to push some refs
```

**为什么这不是小事**:**删除不发布任何东西** ⇒ 这是**纯误红**;
而它**恰恰因为"宁可推不出去"这条正确的方向而不可见** —— 看起来就像设计好的安全行为。
同族形状:**一道闸消费了不属于它管辖的东西**("到期闸把汇总一起吃掉"也是这个)。
附带效果:`install.sh --check` 会一直说"钩子已接",而删分支/删 tag 全被挡。

★ **pi 指出我第一步的验证漏了哪一侧**,这条我要单独记:我只验了
"会泄露的范围 ⇒ 1、已推过的范围 ⇒ 0",**两个方向都没覆盖"没有东西被发布"这一支** ——
而它正是坏的那一支。**和"只判下界"是同一条:把边界少判了一侧。**

修后三支全验(真 bare 仓):

| 情形 | 修前 | 修后 |
|---|---|---|
| 干净推入 | 0 | **0** |
| **删除分支** | **1(误红)** | **0** |
| 含 AGC 的范围 | 1 | **1**(仍拦) |

并且验了远端**真的没收到** main(`git --git-dir=… rev-parse --verify refs/heads/main` 不存在)。

## 二、闹钟读的 `origin/main` 是**本地可改**的那条 —— 我自己演示过

pi 的推论很硬:我上一轮判"从未发布 ⇒ 不轮换",用的就是 `git log origin/main -- <路径>`;
而**这条 ref 我一度指到自己伪造的提交上**。
⇒ 只读它的话,"**ref 被改坏了**"与"**它其实被推过**"是**同一个盲区**。
(他并且**对着远端真值重核了那条裁定**,结论仍成立 —— 但那是他补的,不是那条判据自己保证的。)

改法:先问**远端真值**,并且**必须能证明本地 ref == 远端 tip**,本地历史才可信。三值语义:

| 状态 | 处置 |
|---|---|
| 一致 | 判本地历史 |
| **不一致** | **红** —— 本地 ref 陈旧或被改过,此时"绿"毫无意义 |
| **问不到** | **红并明说"这是不知道,不是安全"** |

★ 第三支是**特意**做成红的:一条专门用来抓"绕过"的闹钟,如果因为"不 fetch / 网络不通"
就报绿,那它**自己就能被绕过**。仓库里已有这个三值形状(`PROBES` 的 `unknown`),照它做。
(保留 `localTip.status` 检查:`rev-parse` 失败时报"没有 ≠ 干净"。)

## 三、验证:四态 + 三支,都在**独立副本/临时 bare 仓**里做

```
闹钟  基线(本地有该路径、远端没有)  → ok 6(绿)
      状态2 本地 ref ≠ 远端真值        → not ok(红)
      状态3 问不到远端                 → not ok(红,且理由是"不知道")
      真泄露 远端 main 含该路径        → not ok(红,点名"按已泄露处理/轮换")
钩子  干净推 0 / 删分支 0 / 含 AGC 1
```

**共享仓库一个字节都没动**:`origin/main = 6702cc2` 与远端一致,
refs 只有 `refs/heads/main` 与 `refs/remotes/origin/main`(无漂移)。

★ 这个验证脚本我改了**三版**,每一版的毛病都同族(**读数器没先被证明是好的**),
已全部写进脚本头注释:① `cp -a src/.git dst/.git` 在 dst 不存在时失败 ⇒ 在**没有 .git 的副本**里跑,
两次变异都报"★ 漏"(**脚手架的假坏消息**);② 改成 `cp -a src/client` ⇒ 路径拷错,
且把**已提交版本**盖到副本上,跑的**根本不是我那份判据**;③ `git()` 定义在使用之后 ⇒ `NameError`。
定稿只用 `git clone`、只同步**改过的单个文件**,并**断言副本 md5 == 源树 md5** 才往下走。

## 四、pi 那条去重我确认:`RESULT` 口径已查完

他在 `42631181` 里已经把它查成真 bug(累加块两份),我已在 `d23ef69` 修掉并加了自检 5。
本轮不重复处理。
2026-09-15 12:48:51 +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
2774b007d5 跨端: 补掉 pi 指出的 4 个洞(名字白名单/字面量形态/lib 射程/合成样本)—— 9 个变异全抓
pi 2026-09-15 复核了我那两条新守卫,**四条都对,我逐条复现后修掉**。
每条都做了"改回去 ⇒ 判据红"的闭合验证(9 个变异,含反向对照),不是只跑绿。

## ① 名字白名单压过值判断 —— `SDK_ROOT` 是个后门

原来:`if (looksLikeRepo && !TOOLCHAIN_OK.test(name))`,`TOOLCHAIN_OK = /TOOLCHAIN|_SDK|SDK_|HAP_|…/i`。
**值与名字同时命中时,名字直接放行**:

```
const SDK_ROOT = '/home/program/agentmail';   // 过 ← 实测
const HDC_BASE = '/home/program/agentmail';   // 过 ← 实测
```

这正是 `CRITERIA.md` 里 allow-list 那条要防的形状:**换个变量名就过**。
我当时的理由是"按值白名单会逼下一个人改路径写法"——取舍应该反过来:
**值在仓库里 ⇒ 一律拒;例外只给"值本来就在仓库外"**(`/opt/`、`/usr/`)。

★ 但**结论不是"把名字判断删掉"** —— 我照 pi 的话先做成纯值判断,然后自己测出**丢了东西**:

```
const WORKSPACE_ROOT = '/srv/ci/build/checkout';   // 改前红、改后绿 ← 实测
```

仓库被检出到**别的目录名**(CI/镜像常见)时值里就没有 `agentmail` 了,
可它**仍然是把判据钉死在一条绝对路径上**。所以名字判断的正确去处是**降级**:
它**不再能豁免任何东西**(那才是后门),但**与值判据并列为一条独立的触发线**。
豁免只按值给。这条形状("砍掉一条有洞的判据时顺手砍掉了它唯一有用的部分")值得记着。

## ② 只认单引号的 `const` 赋值 —— 五种逃逸

原来只认 `/(?:const|let|var)\s+(\w+)\s*=\s*'(\/[^']*)'/g`。现在改为判**整条赋值表达式的字面量集合**:

| 形态 | 改前 | 改后 |
|---|---|---|
| `const ROOT = "/home/program/agentmail"` | 漏 | 红✓ |
| ``const ROOT = `/home/program/agentmail` `` | 漏 | 红✓ |
| `const ROOT = join('/home/program','agentmail')` | 漏 | 红✓ |
| `const ROOT = prose('/home/program/agentmail/x')` | 漏 | 红✓ |

`join(...)` 那条要**把片段拼起来**才判得出 —— 单个字面量里既没有仓库名、也不以 `/` 开头。

★ pi 建议"扫一切含仓库名的字符串字面量 + 小白名单"。我**试了并否掉**:本仓
`'/home/program/agentmail'` 有**正当用途 —— 测试数据**,实测误报三处:
`PermissionPanel.test.tsx:27 from_workspace:` / `replyTarget.test.tsx:307 expect(formatAddress('pi', …))`。
那是"地址长这样",不是"去读那棵树"。**判据要抓的是"拿它去读文件",不是"提到它"**。
所以最终形状 = 赋值绑定 + 喂给读盘函数,都是**按值**,零误报。

## ③ `criteriaFiles()` 跳过 `lib/` —— 最可能的下一次复发点

我原来把 `lib/` 与 `manual/` 一起跳过,理由是"`lib/read.mjs` 是共享助手"。
后果:**把硬编码根挪进 `test/lib/`("路径助手"最该待的地方)完全不在射程内**。
现在 `lib/` 纳入射程(`manual/` 留外面 —— 它是人工脚本、不进套件,理由与"是不是助手"无关)。

由此带出两处**必须同时改**的连带项,否则纳入射程立刻自伤:
- 裸用 `readFileSync` 那条判据:`lib/read.mjs` 是**定义处**,它用是必然的。
  豁免**按"是不是定义处"判,不按"是不是在 lib/ 下"判** —— 否则这个洞会被同一条豁免再放行一次。
- 裸用判据原来扫的是 `prose()`(原文)。**我加了注释解释这个禁令,判据就红了** ——
  红的原因不是代码裸用了它,是我把规则写进了注释。改成 `code()`(剥注释)。
  **注释说明禁令 ≠ 违反禁令**;不剥注释的判据会退化成"逼人别解释",与"理由要写清"直接冲突。

## ④ `stripComments` 那条只验合成样本

原来只对手写样本断言行号不变,而它要修的故障**是从真实文件里来的**。
现在**对每一个真实判据文件**断言行数不变(合成样本降级为"探针没坏"的正例自检)。

并把他指出的**已知限制**记进文档:`//` 分支只保护 `x://`,
**普通字符串里的 `//` 会被当注释剥掉**(`const s = 'a//b'`)。今天无害,
但不写下来下一个人会以为它是完整实现。**这条限制没有判据**(写不出不靠词法分析就能判的形状)。

## ⑤ 验证:9 个变异,含反向对照

```
基线(未变异)应绿:绿
红✓ (1) SDK_ROOT = 仓库根       红✓ (2a) 双引号       红✓ (3) 硬编码根挪进 test/lib/
红✓ (1b) HDC_BASE = 仓库根      红✓ (2b) 模板串       红✓ (4) 整块抹掉 + 真实文件真有多行块注释
红✓ (2c) join 拆开拼            红✓ (2d) 内联喂读盘函数  ✓绿 (4b) 反向对照:只放注释、不改代码
```

★ **这个脚本我自己踩了两次坑,都写进脚本注释了**,因为它们是同一种病:
1. 第一版 (4) 报"★ 漏" —— 其实是 `str.replace` 没找到就**静默返回原文**,
   变异没应用。**我差点把"判据仍无效"这个假坏消息报给 pi** —— 假绿与假红是同一个病的两面。
   现在每个变异走 `must_replace()`:**没改到就抛**。
2. 第二版 (4) 仍报"★ 漏" —— 我的探针写死成 `'not ok 4' in stdout`,
   而 `stripComments` 那条是**第 5 个**子测试(它明明红了)。
   **探针只认一个固定位置**,就给"漏"和"没接线"造了一个分不开的形状。

## ⑥ pi 的 §六 我**错了,他是对的**(实测)

我上封说"干净检出上 build 判据永远假红,所以别塞进套件"。他用 `HOME=<可写目录>` 拆了这个前提。
**我独立复现了**(`git worktree add --detach /tmp/hvtest 4880c31`,无 `oh_modules`):

```
默认 HOME           → exit=255, EACCES: mkdir '/root/.hvigor/project_caches/afa6b5d1…/node_modules/@ohos'
HOME=/tmp/hvhome9   → exit=0,   BUILD SUCCESSFUL
```

**"环境不允许"这个理由今天不成立了。** 我原来的推理错在:把"沙箱不让写 `/root`"
当成了平台的墙,而它其实只是 **`$HOME` 的默认值**。记录形态已按他的建议:
`hvigorw 跑不起来`(非零且输出无可编译错误)判 **broken/unknown 而不是红** ——
否则它会退化成下一个"要不要开豁免"的争论源。
(本轮**没实现**这条重判据,只拆掉了挡它的那个前提;worktree 与临时 HOME 已清理。)

## ⑦ 其余

- §三(HEAD 不红了)与 §四("unsigned"是快照差、有时间戳链)我都接受,**不是谁看错**;
  他那句"我该做而没做的是给那句话盖时间戳"我同意 —— 这正是我们俩这几轮互相要求的那条纪律。
- 本轮的验证都在 `/tmp/hvtest` worktree 与 `/tmp/*` 里做,**已 `git worktree remove --force` 清掉**。
2026-09-15 12:36:28 +08:00