Commit Graph

27 Commits

Author SHA1 Message Date
a0e950109a 修复: 判据注释说"这个值来自 Go 源",实际**一个字节没读** —— 服务端上限真漂移 29 pass/0 fail 一个字不变
pi 报的那格(4b 的 if(false))已由并发会话的自检 4c 补上,我变异验证通过(三种恒假写法都红)。
这轮顺着"第 2 列现在每次运行都可见"去读那 6 条理由,挖出**同族的另一条缝**:

`harmony-imageprep` 的
  /** 服务端壁纸上限(appearance.go 的 appearanceMaxBytes() 默认值) */
  const SERVER_LIMIT = 4 << 20;
注释说它来自 Go 源,而它一个字节的 Go 源都没读。实测(同刻 A/B):
把 Go 里那个默认值改成 8<<20(真漂移)⇒ 本文件 **29 pass / 0 fail 一个字都没变**
⇒ 这条判据存在的全部理由(客户端上限要留在服务端那道门之内,否则必然 413 /
白扔分辨率)在服务端那道门真动了时**不会红**。危险处在于注释让读者以为已对齐。

修法(三件):
① 从**真源头**解析,且**两处都读、要求相等** ——
   ⚠️ 我第一版只读了 appearance.go 的 `return 4<<20`,那是**兜底分支**;
   生产里 config.C 非 nil ⇒ 生效值来自 config.go 的
   `MaxAppearanceBytes: getEnvInt64("AGENTMAIL_MAX_APPEARANCE_BYTES", 4<<20)`。
   "读了源"还不够,还得问"读的是不是生效的那一处"(同族缝的下一层)。
② 解析失败必须红,**不许静默回退到硬编码**(回退 = 把"我读不到"变成"值是对的")。
③ 新增一条判据钉住"真的读出来了":两处都无 err、生效值等于 config 那处、等于兜底那处、且 >0。
   并**如实标出标签范围**:这证明"与默认值对齐",**不证明**"与运行值对齐" ——
   AGENTMAIL_MAX_APPEARANCE_BYTES 可覆盖;别把本条读成"413 已不可能发生"。

变异验证(每个只动一处):config.go 默认值→8MB ⇒ fail=2;
appearance.go 兜底→8MB(两处不一致)⇒ fail=1(恰为"两处相等"那条,
余量那条**故意不红**,因为生效值没变 ⇒ 所以"两处相等"必须单独存在);
config.go 那行删掉 ⇒ fail=2(不许静默)。基线 30 pass / 0 fail。

另:`STATIC_ONLY` 第 2 列里那句「+ 服务端,三样本机都没有」是**假话** ——
`server/internal/handler/attachments.go` 在本机、其 Go 测试 `-run Attach` 跑得通、
且本判据根本没连服务端。已改成"欠的只有设备侧那一半"(列每次运行都播报,假话会被读出来)。
注册条数 29→30 已同步。全套:files=31 checks=396 pass=384 fail=12 red=9 broken=3(跑在隔离 worktree)。

`CRITERIA.md §16.4`:通用规则 —— **注释里写"这个值来自 X"不构成读 X**;
凡"必须与别处一致"的判据先问:它真读了别处,还是抄了一份?
2026-09-19 12:42:32 +08:00
36ef15a8f2 跨端: 顶栏不再自己铺白条 + 日历改左右两栏 + 常驻窗格动画真的会播(用户三处实测指出)
用户三条反馈,逐条对应:

① 「底栏数字为什么显示在图标下面?」
   WebUI 的徽标是 `absolute top-1 right-[22%]`(脱离文档流、浮在图标右上角),
   我写成了 `Column` 的第三个子节点 ⇒ 参与竖向布局、掉到文字下面。
   改用 `Stack({ alignContent: Alignment.TopEnd })` 锚在**图标**上。
   (顺带撞了 skill 里明写的坑:Stack 没有 `.justifyContent()`。)

② 「一个横着过去的白条,我真的服了」/「期望:融进背景」
   WebUI 的顶栏**自身没有底色** —— 只有 `border-b border-gray-200`
   (`ContactPanel.tsx:64`、`CommTabs.tsx:40`、`CalendarView.tsx:295`),
   底色由所在面板给;壁纸开启时那层面板是玻璃色(`index.css:876`)。
   鸿蒙三个窗格顶栏写死了 `Theme.surface`(实心白)⇒ 无论壁纸开没开,
   顶上都是一条不通明白带。改成与**页面底**同一口径
   (`bgActive ? Transparent : surface`)+ 补下边框。

③ 「日历页面和webui布局完全不同」
   WebUI 是**左右两栏**(`CalendarView.tsx:452-457`):左 `flex-1` 网格、
   右 `400px` 常驻面板(日程 / 编辑器 / 小时网格三态互斥)。
   鸿蒙原来是**单栏竖堆**。重搭为两栏,`paneWide` 由 `.onAreaChange`
   量本页**自己的**宽度(不是屏幕宽度 —— 宽屏下这一页已被侧栏占掉一截);
   编辑器改占右栏位置(不再整页盖掉正在看的那个月)。

④ 「最严重的动画问题你一点也不该改」
   日历是**常驻挂载**(`visibility` 控制,因为它里面 today 要随时间重算、
   也要保住"正在看哪个月"),而 `.transition()` 只在**挂载/卸载**时触发
   (SDK 原话 \"when it **appears and disappears**\")⇒ 挂在它上面的
   `.transition(paneRiseIn())` **一帧也不会播**,切过去是硬弹。
   WebUI 踩过同一个坑并把错法写进了 `index.css:1305-1320`
   (「只挂了类,却没让触发窗口出现 ⇒ 类挂着、动画永远不播」),
   它的解法是 `html.view-switch` 重放窗口。ArkUI 对应物是
   `animateTo` + 显式 `calPaneIn` 属性(`opacity` + `translate`)。
   ★ reset 必须在 `animateTo` **外**:写进回调里会与同帧的 1 相抵,
     渲染层只看得见最终值 ⇒ 动画退化成一个瞬移。

顺带修:
· 宽屏侧栏 = 3 项(通信/日历/**联系**)+ 底部一簇(头像/主题/退出),
  与底栏的四项(含「我的」)**不是同一份清单** —— WebUI 的 Sidebar 与
  NarrowNav 本就不同(`Sidebar.tsx:26-44` vs `NarrowNav.tsx:37-40`+143)。
  新增 `NAV_SIDEBAR_ITEMS` / `ME_PANE_INDEX` / `SIDEBAR_ITEM_*`。
· 退出登录抽成 `api/Logout.ets` 的 `performLogout()`:侧栏底簇与「我的」页
  两个入口必须做同一件事(尤其"先注销推送 token"那一步),复制一份就会不一致。
· 徽标 `'plain'` 档底色:WebUI 是石板灰 `--c-chrome-600`(#475569),
  我写成与未读共用红色 ⇒ 「联系」的徽标看起来像"有未读"。
· 主题快捷开关(侧栏底簇):对齐 `ThemeToggleButton` —— 从 `system` 翻转时
  落到**当前生效值的反面**(不是回 system;那可能毫无变化、让按钮看起来坏了)。
· 「浓度」→「压暗」+ 数值带单位(原先屏上印 `56.000000`;WebUI 是 `suffix="%"`)。

判据(8 个套件全绿:logic 28 / nav 18 / widescreen 7 / window 9 /
arkts 5 / contacts 5 / calendar 30 / system-api 5):
· `harmony-widescreen` ② 重写:**回读 `Sidebar.tsx` 数 `short:` 的个数**
  要求鸿蒙同数,并断言底部一簇三键真的被调用(`this.onToggleTheme()` ——
  第一版写成 `/onToggleTheme/`,变异测试当场证明它不咬:属性**声明**还在,
  正则照样匹上)。新增 ⑧:三档色调各自底色,期望值从 `--c-chrome-600` 读出。
· `harmony-nav` 动画条重写:改判**机制真的存在且被驱动**
  (旧断言 `.visibility(...).transition(...)` 锁的正是那个 bug ——
  判据引自己写的注释当依据,就会把错误锁死)。新增 reset-在-animateTo-外
  这条断言(否则动画退化成瞬移)。
· `harmony-nav` 设备条:`navItemsOf` 的宽屏过滤从"左边缘靠左 1/6"
  改成"**整个盒子在侧栏轨道内**" —— 旧条件把日历网格的格子
  (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
  新增 `navRailItemsOf`:导航轨贴顶、底部簇在屏底,按位置切一刀。
· `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS`
  **各自的数组**里取 label —— 原先把全文件 `label:` 一网打尽,
  得到 7 个(4+3 混在一起),任何一边改对了它都会红。

设备实测(HATriple 三折叠,3184×2232):侧栏 3 项 + 底簇 / 底栏徽标回到
图标右上角 / 日历左右两栏与 WebUI 并排同构。

server/go.mod:补 2da38bb 漏提交的 lunar-go 依赖。
2026-09-19 12:30:05 +08:00
8a3d66a7c6 修复: pi 报的两条 STATIC_ONLY 发现 —— 第 2 列**没有读者** + static= 与"到期"是**两个量**(闸可被静默关闭)
pi 单独发来它答应我的那两条(在**当前 HEAD** 上重测),我逐条复现、修掉,
并在过程中**自己连错两次**(都被变异抓出来,已写进 `CRITERIA.md §16.3`)。

## 一、复现(我跑的,同刻 A/B)

**发现 1**:第 2 列「当初只能静态的原因」**没有任何判据在读** ——
两个解构循环都用 `,` 把它丢掉(`:1295`/`:1306`),唯一读者是**到期点名时打印**
(= 它最不需要被检验的时刻)。把它改成假话 ⇒ `red` 与红清单**零变化**。

**发现 2**:把 6 条探针全改指恒 `false`(`run: () => false`)⇒
**到期点名 1 → 0**,而 **`static=6` 一字不变**。
根因:`:2987` 的 `static=${STATIC_ONLY.length}`(余额)与 `:1605` 的
`if (dueStatic.length > 0)`(到期)是**两个量**,只有后者决定"到期"发不发生。
★ 这条口子**是闸自己邀请的** —— 它的选 (b) 写着"并改换一个更准的到期前提",
换探针是**被鼓励的动作**,而"新探针是否真的适用于这条判据"**没有任何判据在问**。

## 二、修法

1. `static=` 改成 **`static=6(其中已到期 6 条)`** ⇒ 关闸读得出来(Y→0 而 X 仍 6)。
2. **每次运行都全表播报** `STATIC_ONLY`(文件 + 原因 + 是否到期**并排**)⇒ 第 2 列有读者。
3. **自检 4b** 钉住"那一跳真的存在"。

## 三、我自己的两次错修(都被变异抓出)

| 修法 | 变异 | 结果 |
|---|---|---|
| ① 理由必须点到探针的某个标识符 | 拿**现有 6 条真理由**跑 | **红了 5 条** ⇒ 假红是噪音,已撤 |
| ② 断言 `staticDetail` **变量**内容完整 | 只删那句 `console.log` | **4b 仍报 ok**,真实输出播报段 **0 次** |
| ③ 断言源码含 `${staticDetail}` 形状 | 同上 | **仍 ok** —— 锚点写在这段自检**自己的注释**里,`includes` 命中注释 |
| ④ 剥离注释后再数 `+ staticDetail);` | 删 print | **4b 红 ✓** |

## 四、验证

· `AGENTMAIL_PROBE_DEVICE=ok` ⇒ `static=6(其中已到期 6 条)`、⏰ 标记 6 个、4b ok;
  `=none` ⇒ `已到期 0 条`。
· 变异 2(改指恒 false):基线 `已到期 6 条`/点名 1 → 变异 `已到期 0 条`/点名 0 ⇒ **关闸可见**。
· 变异 4(删 print):4b **红**,红句"可执行代码里没有任何一处把 `staticDetail` 拼进输出"。
· 全套隔离跑:`^RED=0`、自检 4b 打印 1 次、播报段 1 次。
· 全仓 grep:**没有**任何下游解析 `static=` 的旧形状 ⇒ 格式改动安全。

## 五、文档

`CRITERIA.md` 新增 **§16.3**,含那张"我连错四次"的对照表与三条可复用教训:
① 让字段可证伪 ≠ 给它加一条会红的规则(先拿现有数据试);
② 变量对 ≠ 打出去了;
③ 锚点自匹配要靠**剥注释**治。以及通用规则:
**一个没人读的字段,先问它该被谁读 —— 给它读者;不该被读就删掉。**

★ 文件:`client/electron/test/run-all.mjs`、`CRITERIA.md`。
2026-09-19 11:53:04 +08:00
3c53510b2c 修复: **同一优先级写了两遍 ⇒ 两个顺序** —— diag 与 rc 排序相反,组合态下 UPSTREAM_RC 不变式为假(pi 实测)
pi 顺着我这几轮新分的"1 还是 2"往下试,找到一条**两条判定链排序不一致**的口子 ——
它正好长在我刚分的那个岔路口上。**我复现了,是当前代码的真 bug。**

## 一、口子

`summary.py` 里同一个文件有**两份**优先级表,而且**顺序相反**:

```
diag(原选择处):unlisted/ghosts 排第一                                ⇒ 组合态报 manifest-mismatch
rc  (原返回处):blind/unreadable/baseline-unrunnable|unknown 排第一    ⇒ 组合态退 2
```

⇒ 两类**同时**成立时(`unlisted` + 单文件不可读 / + 跑不了 `sha256sum` / + git 答不了),
`diag=manifest-mismatch`(`UPSTREAM_RC` 表里 **1**)而**真 `rc=2`**
⇒ `run-all` 那条不变式 `UPSTREAM_RC[diag] === rc`(`:1173`)**在其上为假**。

**实测**(裁 `PATH`、root 可达):修前 `diag=manifest-mismatch`、`rc=2`、表值 `1` ⇒ 不一致。

★ 后果**不是假绿**(`manifest-mismatch.blocksGreen=true` ⇒ 照样红、note 也转印),
而是**严重度被低估**:2 档的码被 1 档的码**盖住** ⇒ 读者以为"只要改清单",
而真相是"**连数都没读成**"。**rc 通道从此不可信。**

## 二★★ 根因:12 个案例**个个只动一维** ⇒ 不变式只在**对角线**上验过

`exitcodeSelfTest` 里**确实**有那条不变式,但它只跑自己构造的案例,
而那些案例每次只动**一个**维度(`unlisted` / `ghosts` / `blind` / `unreadable` / 各 `baseline-*`)
⇒ **组合(off-diagonal)无人可达**。

## 三、修法(两件,缺一不可)

1. **一条链推两个结果**:`summary.py` 里按同一顺序算出 `(diag, rc_want)` **一对**,
   `RESULT` 行用它、`sys.exit` 也用它 ⇒ 排序不可能再漂移。
   ★ 为什么**不是**"把两条链顺序改成一致":那还是**两份**表,下次加条件两处又会各自漂移
   —— 正是本仓反复消的"**一份事实两处实现**"。
2. **显式走一遍 off-diagonal**(新组合案例):
   `未列入清单 + 跑不了 sha256sum ⇒ diag=baseline-unrunnable(2 档优先)且 rc=2`。
   ⚠️ 这条**不能**只靠"每跑必断不变式"代替:组合态下若 `diag` 又被低档码占住,
   那条断言就**永远验不到 2 这一档**。

## 四、验证

· **修后组合态**:`diag=baseline-unrunnable`、`rc=2` ⇒ 与 `UPSTREAM_RC` **一致** ✓。
· **新案例**:`ok 组合:未列入清单 + 跑不了 sha256sum ⇒ … rc=2(真打出 diag=baseline-unrunnable,UPSTREAM_RC=2 ✓)`;
  `--only-selftest=exitcode-selftest` **rc=0、^RED=0、ok=15**。
· **变异**(把单链 2 档与 1 档换序 = 复现修前两条链)⇒ **rc=1、^RED=2**:
  `组合:… rc=1(期望 rc=2 且输出含 "diag=baseline-unrunnable"(真打出 diag=manifest-mismatch,UPSTREAM_RC=1 ✓))`
  ⇒ 排序本身被锁住;且**连带**抓出 `盲读` 那条(换序后盲读也走 `manifest-mismatch`)。
· **全套隔离跑**(worktree,只带本笔三个文件):`^RED=0`、因果红 0、对照红 0。
  (`fail=10`/`red=10` 是并发会话的跨端线与设备竞争,与本笔无关。)

## 五、文档

`CRITERIA.md` 新增 **§16.2**(§16.1 的镜像:那边是"结论到不了",这边是"两个都到了但互相矛盾"),
含那条通用规则:

> **凡"多条判定链各自挑一个代表"的地方,都要问:它们挑的是不是同一个?**
> 只测**单维**永远证明不了这件事 —— **对角线上的绿,对组合态没有发言权。**

★ 文件:`client/electron/test/mutants/summary.py`、`run-all.mjs`、`CRITERIA.md`。
2026-09-19 10:07:50 +08:00
271e0a8ee4 文档: **光进默认路径不够 —— 结论必须到得了"决定颜色的那一格"**(§16.1,同一条缝的三个落点)
pi 顺第一/二例的线实测出**第三例**,与我在 `4b841e0`/`6ee9902` 修的那两例
**是同一句话的第三次出现**,而 `CRITERIA.md` 里**一个字都没有** ——
修复在代码里、形状没进规范 ⇒ 下一个人只会修好其中一例。本节补上。

## 缝的形状(三次都一样)

> `summary.py` 把结论说对了、退出码也说对了,**但没有一条通道把它送到
> "决定 `verdict` 的那一格"** ⇒ 读者看到的是一个**看着完全正常**的读数。

## 三个落点(每次位置都不同,所以必须分开记)

| # | 落点 | 现象 | 修法 |
|---|---|---|---|
| 1 | **正则匹配就走不到 `status`** | 盲读时照样打 `RESULT mutants=0 …`(匹配)⇒ `status=2` 从没被读;`sp.stdout` 全文件只一处引用 ⇒ 三行 `✗✗` 一个字都到不了读者 | 盲读改打 `NO-READING` + **先判 `status`** |
| 2 | **部分可读**(`chmod 000` 单个 job) | `blind` 为假、数字是真算的 ⇒ 那行**匹配正则且看着正常** ⇒ 修法①**对它无效** | 只有**先判 `status`** 兜得住 ⇒ 两层**各治一例**,不是叠保险 |
| 3 | **`unlisted`/`ghosts` 退 0** | 警告已算出,却被 `whyLines: status !== 0 ? whyLines : []` **丢掉** ⇒ 磁盘 13 个 job、套件报 48、"未列入清单"在套件输出 grep **0** 次 | 退出码按**性质**分:`unlisted`/`ghosts`⇒**1**(清单该改=数据)、`blind`/`unreadable`⇒**2**(权限=环境);转印放宽成 `(note \|\| status !== 0)` |

★ **第 3 例的尖处**:`unlisted`/`ghosts` **不是**环境问题,是**清单该改**。
按 `env-defaults.sh:25`"失败要说清是环境问题…不要让它冒充代码缺陷" —— **反向也成立**:
**别让"清单没跟上"冒充环境**。故归 **1** 不归 **2**。

## ★★ 为什么会连着踩三次

三例的**上游判别都做对了**(`unlisted` 算出来了、`blind` 判出来了、`unreadable` 分出来了),
错的全在**最后一跳到 `verdict`**。⇒ 通用规则:

> **每加一条"发现问题就报告"的逻辑,都要问一句:
> 它的结论最终喂给了哪一个决定颜色的变量?**
> 只 `print` 不算到达;落到 `reds`/`brokens`/`dueFailed`/`selfCheckFailed` 之一,
> 或让退出码变成下一层会读的那个值,才算。

## 验证

· **已锁住**:`--exitcode-selftest` 有真跑案例(`['未列入清单 ⇒ 1', …]`,`:1062`)、
  `--verdict-selftest` 有判定案例(`['manifest-mismatch(status 1)⇒ 不许绿', …]`,`:1976`)。
· **变异验证**:把 `unlisted or ghosts` 从 `return 1` 改回 `return 0`
  ⇒ `--only-selftest=exitcode-selftest` **rc=1、2 条可归因红**
  (`未列入清单 ⇒ 1:rc=0(期望 rc=1 … UPSTREAM_RC[manifest-mismatch]=1 与真跑出来的 rc=0 不符)`)。
  ⇒ 这条缝**有判据守着**,不靠下一个人再读一遍源码。
· **端到端 A/B**(干净 worktree,同刻对照):

  | | A:12 文件(清单一致) | B:13 文件(1 个未列入清单) |
  |---|---|---|
  | `diag=` | `baseline-stale` | **`manifest-mismatch`** |
  | "未列入清单" 在套件输出 | grep **0** | grep **1** |
  | `(summary.py)manifest-mismatch` 红 | **0** | **1** |

· 本提交**只改文档**:`client/electron/test/CRITERIA.md`(新增 §16.1);隔离 worktree 跑全套 `^RED=0`。
2026-09-19 09:45:53 +08:00
5c2ada60a2 文档: **"孩子管不着的东西"在因果判据里不存在** —— 比"边际递减"更硬的停止理由(§6.1.6)
pi 复核层 10 时提出:层 10 的修法(要求 `RED ` 行)**推翻了层 7 的原则**
("锚点不能落在被守对象的自述上")—— 把锚点从"行为"搬回了"自述"。
**这条我核对成立**:`:2674` 的原则与 `:2774` 的修法同住一个文件、且方向相反。
⇒ 停止理由从"成本 > 收益"升级为**"层 11 的修法必须推翻层 7 或层 10,两者互斥"**。

## ★ 但我把 pi 的建议修法①也测了 —— 得到比"原则互斥"更强的结论

**pi 的 ① 不是逃逸口。** 它主张"改坏**孩子读的外部依赖**(而非孩子自己的源码)
⇒ 通道没有可检的信号"。实测(`exitcode-selftest` + 假体 + 通道):

```
通道只盯外部 summary.py                  ⇒ 干净树 rc=0 ✓,改坏外部 ⇒ rc=1 ⇒ ① 有效
通道**同时**盯外部 summary.py + 自己源码  ⇒ 干净树 rc=0 ✓,改坏外部 ⇒ rc=1
                                            ⇒ **① 被绕过**(同刻对照:red 与基线一致、diff 0 行)
```

★★ 根因(**这是这一整串真正的底**):

> 因果判据的要求是:**改坏 X ⇒ 孩子的判词必须变**。
> ⇒ 这要求 X **因果上游**于孩子的判词 ⇒ **孩子必须读 X**。
> ⇒ 孩子读得到 X ⇒ **孩子能观察 X 被改坏**(同 uid、可读,`summary.py` 是 `-rw-r--r--`)。
> ⇒ **"孩子管不着的东西"被因果判据自身的定义排除掉了。**

⇒ 层 7 那句"锚点必须落在**它管不着**的东西上"在这个接口里**没有可满足的解**:
· 要"因果有效",X 就必须被孩子读到;
· 要"孩子管不着",X 就不能被孩子读到;
· **二者不可同时成立** ⇒ 不是"还没爬到",是**接口本身没有解**。

★ 唯一出路是**换接口**(父进程自己算、或进程外观测)—— 那不是"再修一条判据"。
  实测本仓**进程外观测通道 grep 0 处**(`strace`/`inotify`/`atime` 差分全无),
  且孩子的输出只有 stdout + 退出码,**两者都由孩子自己产生**。

⇒ ★ 与 pi 的"原则互斥"是同一结论的**两个证明**:pi 证明**两条原则不能共存**;
  我证明**这个接口里不存在既"因果有效"又"孩子管不着"的对象**。
  **两者都指向"停",而后者不依赖"哪条原则优先"的判断。**

## ⚠️ 顺带记一条我自己的量错(与 pi §四 同族)

我用 `python3 heredoc` 拼通道时,**引号嵌套写出了 SyntaxError** ⇒ 通道**在干净树上就崩**、`rc=1`。
我差点把它读成"通道生效"。**是 stderr 里的 `SyntaxError` 救了我。**
⇒ **"量到 ≠ 量对了"**:任何构造出的攻击,**第一句读数必须是"它在干净树上无症状吗",
且必须看 stderr,而不是只看 rc。**

## 验证

· 干净 git worktree(HEAD=7ca6ae1,仅覆盖本文件):`^RED` **0 条**、
  因果红 0、对照红 0、不可归因红 0。
· ⚠️ 工作区当前 `red` 偏高是**并发会话的漂移**(`harmony-window.test.mjs` 未接线 ⇒
  清单自检整体停;`harmony-system-api`/`harmony-nav`/`harmony-widescreen` 设备竞争红),
  **与本次改动无关**;我那两个文件 `git diff` 为空以外的部分一行未动。
· 本提交**只改文档**:`client/electron/test/CRITERIA.md`(新增 §6.1.6)。
2026-09-18 09:33:29 +08:00
7ca6ae14c6 修复: **对照能被"按内容分流"的通道骗过** —— 红必须是"报出来的"不是"退出来的"(层 10)
pi 2026-09-18 报的层 10。**成立,我复现,读数与它逐字相同。**
**同意 pi:这一串到层 10 为止,不再往上找。**

## 一、缺陷:对照只问"输入→输出是否非平凡",没问"那个输出是不是被守对象产生的"

pi 的构造:快入口**读自己的源码**、发现"我被改坏了"就 `process.exit(1)`。
它在**两种输入下都答对**(原样走真实现 ⇒ 绿;被改坏 ⇒ 自己退 1)⇒
上一版那条"对照"(未改坏必须 rc=0)**通过**,而层 7 的假体照旧静默。

实测:**`red=9`、红清单 diff 0 行** —— 与 pi 的读数逐字相同。

★ 根因:**对照只问"输入→输出映射是否非平凡",没问"那个输出是不是被守对象产生的"。**
通道可以**自己**产生那个"红"(靠检测自己的源码),于是它既是仪器又是被测者 ——
层 9 是"仪器归被测者管",层 10 是"**仪器可以自己造假象,而它造得让对照满意**"。

## 二、修法:把"红"从退出码改成可归因的报告

· ① 干净孩子必须 **rc=0 且 0 条 `RED ` 行**(健康的自检不该喊红);
· ② 改坏后的孩子必须 **rc=1 且 ≥1 条 `RED ` 行**(它得说出**为什么**)。
· ⇒ 只会 `process.exit(1)` 的通道**什么也没说** ⇒ ①过②不过 ⇒ **红**。

实测 pi 的分流通道 + 假 ok×13 ⇒ **红**:
`因果判据的红不是报出来的:exitcode-selftest 改坏后 rc=1 却一条 RED 行都没有`

★ 一般化:**"它红了吗"必须落在"它说了什么"上** ——
退出码是**任何人**都能给的(连崩了都给),而 `RED ` 行是被守对象**自己的判断**。

## 三、⚠️ 残留:通道打一行假 `RED ` 仍能过

我实测了:通道把 `process.exit(1)` 换成
`console.log('RED 假报告…'); process.exit(1)` ⇒ 同样两问都答对、`red=9`、0 条因果红。
**成本只高了一行 `console.log`。** 这与 pi §二的根因完全同形:
**通道能分辨两种输入,就答对两问。**

· pi 的 ① 方向(改坏**孩子读的外部依赖**)能穿透这个分流 —— 但我实测:
  **只有 `exitcode-selftest` 读外部 `summary.py`**;另 4 条的生产符号
  (`probeIsDue`、`parseSkips`、`verdictOf`、`summarizeMutants`)都在 `run-all.mjs` **自己里**。
  ⇒ **① 不 generalize**,它只堵 1/5。
· pi 的 ②(加第三种输入"改坏无关符号")也测了:**定向通道**(只认自己那一个改坏形态)
  在无关符号被改坏时仍走真实现 ⇒ rc=0 ⇒ **② 通过** —— 被绕过。

## 四、定价与停止(§6.1.5)

层 5→10 六轮,每轮都真实、每轮读数都硬,但**边际收益在递减**:
| 层 | 攻击成本 | 修法后剩余 |
|---|---|---|
| 5/6 | 一处编辑 | 同步删多处仍静默 |
| 7 | 写假体 | 锚点到表外 |
| 8 | 删表 | 新自检漏配因果对被②抓 |
| 9 | 桩化通道 | 通道恒 rc=1 被对照抓 |
| 10 | 读自己源码的分流通道 | **打一行假 RED 仍过**(成本 +1 行) |

★ 三个结论:
1. **校准价值在层 7~9 已拿满** —— 层 10 只是在**重复同一课**。
2. **真世界里没有攻击者会这样改代码**:它的威胁模型要求攻击者同时改生产符号
   + 通道 + 假体 + 假 RED 行 —— 这与"直接删契约行"的真实风险等级相距太远。
3. **留下的东西是可复用的**(§6.1.4 的判据 + §6.1.3 的"每加一条防线回答两问"),
   **其余的建议停在这里**:不承认它封死,也不再往上爬。

⇒ **同意 pi:这一串到层 10 为止。** 注意力转回到到期那 6 条判据的升级。

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

| 变异 | 结果 |
|---|---|
| 层 10 pi 的分流通道 + 假 ok×13(修复前)→ **复现** | `red=9`、红清单 diff 0 行 |
| 层 10 pi 的分流通道 + 假 ok×13(**修复后**) | **红**:`rc=1 却一条 RED 行都没有` |
| 假 RED 行通道 + 假体(残留) | `red=9`、0 条因果红 —— **残留,已登记** |
| 通道恒 rc=1(层 9 的对照) | 红红 5 条(未被破坏) |
| 回归 层6/7/8/5/契约行删掉/M43/M45/M47/M48 | 各 1 条 |
| **`CAUSAL` 5 对逐条直验**(含新可归因判据) | 改坏后**每条** rc=1 |
| 干净树假红检查 | `^RED` **0 条**、对照红 0、不可归因红 0 |

## 六、状态

· 五个自检单独跑全 rc=0;默认跑 `checks=462 pass=458 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· 本提交含 `client/electron/test/run-all.mjs`(可归因判据)与
  `client/electron/test/CRITERIA.md`(新增 §6.1.4 + §6.1.5)。
· 提交前 `HEAD=5afb2c1`。
2026-09-18 09:18:06 +08:00
5afb2c1550 修复: **判断据的那个人也归判据管** —— T 全桩后与基线逐字相同、层 7 攻击完整放回(层 9)
pi 2026-09-18 报的层 9。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:因果判据的**观测通道**住在它自己那张可编辑的表里

层 7 我为了把因果判据的成本从 4.3s 压到 52~696ms,加了快入口 `--only-selftest`,
而它当时自带**第二张分发表 `T`**(`{'probe-selftest': probeSelfTest, …}`,`:2300`)。
我在上一封把它如实标成"**副作用、不是防线**" —— **层 9 就长在那个副作用上。** 实测:

```
T 全换成 () => 1 桩        ⇒ red=9,与基线**逐字相同**、因果 RED **0 条**
T 全桩 + 假 ok×13(层 7)  ⇒ red=9、**红清单 diff 0 行**
  ⇒ 层 7 的攻击被完整放回,而代价只是"把 T 里 5 个值换成 () => 1"。
```

为什么因果判据看不见:它观察孩子**只经 `T` 的分发**,而它比的"孩子退出码"
正是 `T` 提供的 ⇒ **判据的观测通道被它自己要观测的对象控制着。**
⇒ 层 7 问"**被测者**的话可信吗",层 9 问"**测量者**的话可信吗"。
★ 也是本仓老形状的又一次:`T` 与 `SELFTESTS` 是**同一事实两份实现**。
(pi grep 证实:`T` ↔ `SELFTESTS` 的交叉约束 **0 处**。)

## 二、修法一:消除"两份实现"本身 —— `T` 不该单独存在

`SELFTESTS` **上移**成唯一的一份登记(原来它在块内、且在快入口**之后**),
快入口从它 `find` 派生 ⇒ **"桩化 `T`"这个动作没有落点**。
⚠️ 五个自检都是 `function` 声明(**提升**)⇒ 引用它们没有 TDZ 问题;
而**调用**它们仍必须排在 `DIAG` 等 `const` 表之后 ⇒ 那个 `if` **留在原位**
(这正是它位置的理由,见原地那段"const 不提升"的注释)。
★ 验证:`grep -c "const T = {"` ⇒ **0**;5 个 `--only-selftest` 全 rc=0;默认跑不变。

## 三、★★ 修法二(我加的,比 pi 的建议更硬):给因果判据加**对照**

pi 的 ② 是"若保留两份则加交叉约束(键相等 + **值必须是同一个函数对象**)"——
那是对的,但它**只堵 `T` 这一个形状**。我顺着想了一层:

> **只要判据只有"改坏 ⇒ 必须 rc=1"这一问,任何"恒 rc=1 的通道"都能让它绿。**
> 不管是表、是 wrapper、还是环境变量。

⇒ 加一问:**没改坏的时候,它必须 rc=0。** 实测把快入口改成恒 `exit 1`(模拟被桩化的通道)
⇒ **5 条 RED**(`观测通道本身有问题(观测到的"红"不能归因给改坏)`);
而"改坏生产符号"那一半仍照常红。

★ 这是"锚点必须落在被守对象管不着的地方"的**行为**版本:
**我不需要知道通道长什么样,只要求它在两种输入下给出两种答案。**
★★ 因此它比交叉约束**更强**:我把**重新引入一张 `T2` 并桩化**的版本也测了
⇒ 同样 **5 条对照红** —— **它堵的是"形状",不是"那一个洞"。**
⚠️ 代价:每对自检跑**两次**(默认跑 5.1s → **6.8s**,可接受)。

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

| 变异 | 结果 |
|---|---|
| **层 9(a)** `T` 全桩(修复前) | `red=9`、逐字相同、因果红 0 —— **复现** |
| **层 9(b) 最尖**:`T` 全桩 + 假 ok×13(修复前) | `red=9`、红清单 diff **0 行** —— **复现** |
| **层 9(b) 重放(修复后)**:假 ok×13 | **红**:`把 exitcode-selftest 守的生产符号改坏之后,它仍然全绿` |
| **通道恒 rc=1**(模拟桩化) | **红 5 条**:`观测通道本身有问题` |
| **重新引入 `T2` 并桩化** | **红 5 条**(同上 ⇒ 堵形状,不只堵那个洞) |
| 回归 **层8** `CAUSAL=[]` / **层6** 掏空 / **层5** 删整条 / 契约行删掉 | 各 1 条 |
| 回归 M43 / M45 / M47 / M48 | 各 1 条 |
| **`CAUSAL` 5 对逐条直验**(含新对照) | 改坏后**每条** rc=1 |
| 干净树假红检查 | `^RED` **0 条**、对照红 0 |

## 五、★ 一条我认为该长期留着的判据(pi 建议,我采纳并写进 `CRITERIA.md`)

> **每加一条防线,要同时回答两问:**
> ① 它守什么?(设计意图)
> ② **它自己新增了哪些"必须被守"的东西?**(副作用)
>
> 层 9 就是第②问没问的产物 —— `--only-selftest` 在**性能上是净收益**
> (4.3s → 52~696ms),却往被守集合里加了一个**没人守**的成员。

⇒ 与"每根轴的底看起来都像整体的底"是同一现象的两种说法:
**防线是会长出新的被守对象的。**

## 六、⚠️ 残留(不声称层 9 封死)

· **多处同步删仍可静默**(层 5~8 的老残留,本轮未动):删 `CAUSAL` 对 + `SELFTESTS` 登记
  + CLI guard + `--only-selftest` 分支 + 函数体 + 契约项 ⇒ 仍静默。
· **对照本身也可能被绕过**:如果有人把对照那一段源码删掉,它同样没人守 ——
  这与层 8 同形。我**没有**给对照再套一层(会无限递归),只把这条如实写在这里。
  ★ 现在的**净收益**是:绕过它需要改**两处**(删对照 + 桩化通道),而不是一处。

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=462 pass=458 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· 本提交含 `client/electron/test/run-all.mjs`(`T` 消除 + `SELFTESTS` 上移 + 因果对照)与
  `client/electron/test/CRITERIA.md`(新增 §6.1.3)。
· 提交前 `HEAD=a023286`。
2026-09-18 08:55:38 +08:00
a023286158 修复: 守着锚的那张表**自己没人守** —— CAUSAL = [] 与基线逐字相同,层 7 的攻击被完整放回(层 8)
pi 2026-09-18 报的层 8。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:我上一条那句"表本身也被真跑锚住了"**只对了一半**

被守的是"表里的锚文本在源码里唯一",而 **"表里该有几条"没有任何东西在问**。实测:

```
CAUSAL = [](整表清空)      ⇒ red=10,与基线**逐字相同**、因果 RED 0 条
只留 1 对(删掉另外 3 对)    ⇒ red=10,那三条自检**从此失去因果锚**、无任何提示
CAUSAL = [] + 假 ok×13        ⇒ red=10、因果/空壳 RED **0 条**
  ⇒ **层 7 的攻击被完整放回来了,而代价从"改函数体"变成"删掉表里一行"。**
```

★ 根因:**锚点不能落在"被守对象的自述"上**(层 7),
而这里是 **"守着锚的那张表自己的内容"没有锚** —— 上一轮是"判据自述",这一轮是"**锚表的自述**"。
★ 新轴:**守护者自己进入了被守集合** —— 前几层问的都是"**被守的东西**怎样",
层 8 问的是"**守它的东西**怎样"。

## 二、修法:覆盖面必须有一个**不在表里**的来源

用**两个**,都是已有的、不必新写表:
· ① `contractEntries` —— `CRITERIA.md` 契约行(**外部文件**,与层 5 **同一个锚点**;
  一个外部来源同时守两件事);
· ② `SELFTESTS` 的**键**(源码结构)—— 让"新加了一条自检却没配因果对"也能被抓。

⚠️ **空真的坑**:只写"每个 `CAUSAL` 项都对应一个真自检"(反向)**挡不住 `[]`** ——
`[]` 恰好满足那个空真("每个"在空集上恒真)。⇒ 必须**正向**要求覆盖。
★ 与本仓那句同族:**"判据存在" vs "判据在路径上"**;这里是 **"表非空" vs "表够长"**。

## 三、⚠️ 我实现时踩的坑:**两个命名空间别混**(被我自己刚写的判据当场抓住)

`wired` 装的是**函数名**(`probeSelfTest`),`CAUSAL`/契约行装的是**旗标名**(`probe-selftest`)。
我第一版拿 `wired` 比 `CAUSAL` ⇒ **5 条全报"幽灵名"、另 5 条全报"没有因果锚"**
(一次红 2 条、方向相反)。⇒ **判据没错,是我把两套名字当成了一套。**
修法是改用 `SELFTESTS` 的键(旗标名,与 `CAUSAL` 同命名空间)。
★ 教训:**同一个东西在两个地方有两套名字时,"名字相等"不是可传递的。**

同一次还抓出第二个我自己的 bug:第 5 对的锚文本在 JS 源码里只写了 `\d`,
运行时会**塌成 `d`** ⇒ 锚文本永远匹配不上(唯一性报 0,而不是 1)。
★ 这两处都是**新判据抓新判据**——判据互相盯着是有效的。

## 四、补第 5 对(`mutants-line-selftest`)

层 7 时表里只有 4 对,`mutantsLineSelfTest` 一直没有因果锚。
★ 我实测了三个候选锚:`未列入清单`(7 处)、`baseline-stale`(22 处)改坏后 **rc=0**
⇒ **它不读那两处**(那是别的函数在用);只有读 `RESULT` 行那个正则改坏后 rc=1。
⇒ 这也是"**关系表写错就会被真跑抓住**"的又一次实例。

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

| 变异 | 结果 |
|---|---|
| **层 8(a)** `CAUSAL = []` | **红**:`这些自检**没有因果锚**:…(要求 5 条,因果表只有 0 对)` |
| **层 8(b)** 只留 1 对 | **红**(缺 4 条) |
| **层 8(c) 最尖**:`CAUSAL=[]` + 假 ok×13 | **红**(修复前 0 条) |
| 幽灵名(CAUSAL 放不存在的自检名) | **红 3 条**(幽灵名 + 覆盖不全 + 因果判据退出码 2) |
| **方向②** 新自检 + 登记 + 契约项但**不给因果对** | **红**(`要求 6 条,因果表只有 5 对`) |
| **`CAUSAL` 5 对逐条直验**(从源码解析表,防我手抄错) | 改坏后**每条** `--only-selftest` rc=1 |
| 回归 层6 掏空 / 层7 假ok×13 / 层5 删整条 / 契约行删掉 | 各 1 条 |
| 回归 M43 / M45 / M47 / M48 / 名字级未接线 | 各 1 条 |
| 干净树假红检查 | `^RED` **0 条**、因果红 0、覆盖红 0 |

## 六、⚠️ 残留(如实登记,不声称层 8 封死)

· **多处同步删仍可静默**:把 `CAUSAL` 对 + `SELFTESTS` 登记 + CLI guard + **`--only-selftest` 快入口那一项**
  + 函数体 + 契约项**全部**删掉 ⇒ 实测 `red=9`、`^RED ` **0 条**(与基线相同)、`自检段:4 个自检`。
  ⇒ 与层 5/6/7 同形:我把成本从一次编辑抬到**五六处**,**性质没变**。
· ★ 顺带如实记一笔:**我的层 7 修法自己新增了一个引用点**(`--only-selftest` 的名字表)——
  它让删除**更贵了一点**(要一起删),但它本身也是一个新的"必须同步删的地方"。
  这不是设计意图,是副作用,写在这里以免下一轮把它当成"防线"。

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=462 pass=458 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· ⚠️ 口径变化**不是**本提交造成的,是**另一个会话**的提交 `f2cddf4`
  (`harmony-deviceprobe` 期望条数 8→**11**,加了 3 条包名漂移回归锁)⇒ `checks 459→462`;
  同一次也修掉了 `harmony-nav` 的**设备占用红**(那条红曾连红 42 轮)⇒ `fail 5→4`、`red 10→9`。
  ⇒ 我这次没有引入新的红,也没有消掉别人的红。
· 本提交含 `client/electron/test/run-all.mjs`(第 5 对 + 覆盖判据)与
  `client/electron/test/CRITERIA.md`(新增 §6.1.2)。
· 提交前 `HEAD=f2cddf4`。
2026-09-18 08:44:23 +08:00
72f35ff9cd 修复: 条数**可以伪造** —— 打满 13 条空 ok 就绕过了层 6 的下界(层 7:锚点不能落在自述上)
pi 2026-09-18 报的层 7。**成立,我复现,读数与它逐字相同。**

## 一、缺陷:下界判据的输入是"自我报告"

层 6 我加的是"比条数下界",而条数是从**输出**里数的 ——
**数的是它"打了几条",不是它"验了几条"**。于是把 `exitcodeSelfTest` 的体换成
"循环打 13 条空 `ok`、`return 0`"(不打任何真断言):

```
基线:      checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red
假 ok×13:  checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red   ← 逐字相同
下界相关 RED: 0 条(13 ≥ 下界 12 ⇒ 相等,无从对比)
```

**最尖形式**:假 ok×13 **+** `UPSTREAM_RC['baseline-residue']=99`(= 它本该抓的那个)
⇒ 0 条 RED、0 次提及 UPSTREAM。
**对照**(不掏空、只改值)⇒ `red=9→10`,真报"与真跑出来的 rc=1 不符"(⇒ 掏空是唯一原因)。

★ 根因:**下界判据的输入是"自述"** —— 判据自己说它做了多少,而它可以撒谎。
**锚点不能落在"被守对象的自述"上,必须落在它管不着的东西上。**

## 二、三种"看起来更硬"的替代我都否掉了(理由:仍在自述这一侧)

· **黄金输出**(逐字节比对期望输出)= 又一份**手写数据**(本仓刚证过那族可被同步改);
· **扫源码找关键词**= "**提到 vs 就是**"(本仓反复证过它脆);
· **毒化环境变量**(该模式下每条自检必须报红)= 把"必须红"的断言写在**自检函数体内**
  ⇒ **假体不打它**。★ 我实测过:模拟毒化后假体 `red=9`、下界红 0 —— 与层 7 同一问题。

## 三、修法:**因果锚点** —— 改坏被守的生产符号,那条自检必须红

```
· 假体(空 `ok`)在符号被改坏时仍然打 ok ⇒ 它不红 ⇒ **红**(判据抓它);
· 真自检读了那个符号 ⇒ 符号坏 ⇒ 它报 RED ⇒ 绿。
⇒ 这是"自检真的读了那个符号"的**行为**证据,不是它的自述。
```

实现:
· 快入口 `--only-selftest=<名>`(**实测 52~696ms/条**,对比 `--X-selftest` 要跑整套 suite 的 4.3~4.9s);
· 隔离用 `mkdtempSync` + `cpSync(join(HERE), …)`(**只拷 `test/`,1.6MB / 6ms**);
· `CAUSAL` 表:`probe-selftest`→`probeIsDue`、`skip-selftest`→`parseSkips` 的正则、
  `verdict-selftest`→`verdictOf` 的返回式、`exitcode-selftest`→`UPSTREAM_RC['baseline-residue']`。

★ **关系表本身也被真跑锚住**(这是它与"下界数字"的关键区别):
表里每一对都必须实测"改坏了它真会红";把符号写错(写成它不读的)⇒
那条自检改坏后**不红** ⇒ **红**。⇒ **表自己也被因果判据守着。**

## 四、我实现时踩的两个坑(都记在注释里)

① **必须拷整个 `test/`**:run-all 的**自检 1/2** 要 `readdirSync(test)` 与 `SUITE` 对齐,
   缺文件会让它在跑自检**之前**就 `exit(1)` ⇒ **读到的是假红**。
   我第一版少拷东西时就这么读错过:`rc=1` 看着像"改坏生效",其实是清单自检停了整套。
   这与本仓"隔离要用**最小**夹具"不冲突 —— 最小**自洽**的夹具,不是最小字节数。
② **锚文本的唯一性必须在除去 `CAUSAL` 表本身**的源码里数:
   我第一版没排除 ⇒ `probe`/`verdict` 立刻报"锚文本不唯一",而真因是**表里那一行字面量**,
   不是生产里有两处。★ 又是那句:**"提到"与"就是"在文本上长得一样。**

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

| 变异 | 结果 |
|---|---|
| **层 7** 假 ok×13(exitcodeSelfTest) | **红**:`把 \`exitcode-selftest\` 守的生产符号改坏之后,它**仍然全绿** ⇒ 它没在读那个符号(或它是个空壳)`,`red=9→11` |
| **层 7 最尖**:假 ok×13 + `UPSTREAM_RC`=99 | **红**(同上,修复前 0 条) |
| 回归 **层6** 掏空 | 1 条(`只打出 0 条断言`) |
| 回归 **层5** 删整条 `skipSelfTest` | 1 条(契约要求而 `SELFTESTS` 缺) |
| 回归 **M43 / M45 / M47 / M48 / 名字级未接线** | 各 1 条 |
| 干净树假红检查 | `^RED` **0 条**、因果红 **0 条** |
| `CAUSAL` 表 4 对逐条直验 | 改坏后每条 `--only-selftest` rc=1(未改坏 rc=0) |

## 六、⚠️ 残留(如实登记)

· **跨文件同步删 `CAUSAL` 表项 + 对应生产符号**仍可绕过(与层 5/6 同形:我把成本从
  一次编辑抬到两次,性质没变)⇒ **不声称层 7 封死**。
· 另外:`CRITERIA.md` 里那三名"下界"仍可被改小(层 6 的残留),
  但**现在它不再单独构成绕过** —— 改小下界后仍要过因果判据(假体不读符号 ⇒ 红)。

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=459 pass=454 fail=5 skip=0 red=10 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· ⚠️ `fail=4→5`、`red=9→10` **不是**本提交造成的:新增的是 `test/harmony-nav.test.mjs` 的
  设备占用红(连续 **32** 轮被别的会话占着前台,`K=3` 上限后按设计报红)。
  **我用 stash 证实过**:在**不含本改动**的干净 HEAD 上同样是 `red=10` + 该文件 `# fail 1`。
· 本提交含 `client/electron/test/run-all.mjs`(快入口 + `CAUSAL` 因果判据)与
  `client/electron/test/CRITERIA.md`(新增 §6.1.1)。
· 提交前 `HEAD=3175ee7`。
2026-09-18 08:31:01 +08:00
3175ee7267 修复: **函数体可以被掏空** —— 名字/登记/guard/契约行全在,里面不检查任何东西(层 6)
pi 2026-09-18 报的层 6。**成立,我复现,读数与它逐字相同。**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 七、验证与状态

· 五个自检单独跑全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**。
· 本提交含 `client/electron/test/CRITERIA.md`(§6.1 补层 6 说明 + 契约行加下界)与 `client/electron/test/run-all.mjs`。
· 提交前 `HEAD=ecefd50`。
2026-09-18 08:12:44 +08:00
ecefd50791 修复: 一整条自检(函数体+登记+guard)三处同步删掉是**零痕迹** —— SELFTESTS 没有下界
pi 2026-09-18 报的层 5。**成立,我复现,读数与它逐字相同。**

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

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

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

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

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

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

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

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

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

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

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

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

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

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

| 变异 | 结果 |
|---|---|
| **H1 / H1'** 三处同步删 | **红**:`契约里要求存在的自检在 SELFTESTS 里没有:skip-selftest / exitcode-selftest`,`red=9→10` |
| 契约里写一个不存在的自检(反方向) | **红**(`verdict-selftest`) |
| 契约行 0 条 / 2 条 | **红**(要求恰好 1 条) |
| 回归 **M43** 窄正则 | 1 条认不出 |
| 回归 **M45** 只删样本 | 1 条"没有样本"(★ 我第一次跑成 0 条,是**我的脚本**同时删了 `REQUIRED_FORMS` 的名字 ⇒ 那是共漂移不是 M45;改正后 1 条) |
| 回归 **M47** 标签撒谎 / **M48** 空见证 | 各 1 条 |
| 回归 名字级(未接线自检) | 1 条 |

## 七、验证与状态

· 五个自检单独跑仍全 rc=0;默认跑 `checks=459 pass=455 fail=4 skip=0 red=9 broken=0 unreported=0 verdict=red`、
  `mutants=48 ran=47 skipped=1 on_new_criteria=35 diag=none baseline=7/7✓`;正常态 `^RED ` **0 条**(无假红)。
· 本提交含 `client/electron/test/CRITERIA.md`(新增 §6.1 + 契约行)与 `client/electron/test/run-all.mjs`。
· `HEAD=dc261ad`(提交前)。
2026-09-18 07:58:07 +08:00
22715e8af0 docs(判据): §17 变异只证明样本被抓;标签必须等于断言范围(两种收法,选一个) 2026-09-14 17:36:02 +08:00
b16e2d24fd fix(欠账): RESULT 行同时打"登记 vs 实测"(副本漂移可见 + 不等即红);共享树 stash 纪律与两条推广入册
pi 2026-09-14 三条。

1. **可见的那个数字是副本**:`docs/DEBTS.json` 手可编辑,而它唯一的守卫是 Go 那条断言
   —— 相位耦合(打印它的相位里 Go 断言未必跑过)+ 副本会漂移(我自己 §12 那句话)。
   现在 RESULT 行同时给出:

   `debts=9(registered) measured_static=5==登记 ✓(…;本相位只有 static 有权威实测,其余由 go test 侧判据比对)`

   即:**本相位能实测的(static,权威来源就是代码)当场比对并标注 ✓/✗**;
   **测不了的明说由谁比对**,不冒充已实测。并新增判据:**static 的登记值 ≠ 实测值即红**。

2. **共享树上不要用 `git stash` / `--autostash`**(pi:同一台机器,只是自动化了)——
   它把**别人**未提交的改动也收走,冲突/中断时可能留在 stash 里,而 ta 只看到"我的改动不见了"。
   入册 CRITERIA §8.1:优先 `git worktree add`;必须原地操作就先约定窗口;事后核对别人的文件还在原处。

3. **两条推广入册**(各只出现过一次,但形状会复发):
   · §15 **判据必须自足**:不许通过共享可变状态在判据之间传递结论 —— 顺序依赖的显形方式
     永远是**假绿**(我踩过:余额由 SKIP 那支写入、登记判据去读 ⇒ 同包按文件顺序跑 ⇒ 读到 0);
   · §16 **"要提醒人的"输出必须走默认路径**(`TestMain` 收尾打印在常态 `go test` 里不存在),
     配套动作是 pi 那句:**写完"可打印的余额"之后,去看一眼默认路径实际打出来的样子**。
2026-09-14 17:21:26 +08:00
512b3f8f80 fix(更正): 我登记的"不存在映射表"是错的(blurStyleFor 一直在,缺的是调用点);豁免按文件+次数抓出来的
pi 2026-09-14 三条接续,其中 §3 那一条**抓出了我自己的一个错误结论**。

1. **§3 交叉提醒(豁免按"文件 + 次数")→ 直接翻出我漏掉的东西**:
   把 `bgBlur` 的豁免改成计数后,逐处核对出现次数时发现 `model/Appearance.ts` 里
   **有一个 `blurStyleFor(bgBlur)`** —— **px → 系统材质档的映射表早就在那儿**
   (文件注释还写着「判据可以直接跑它」),只是**没有任何调用点**。
   而我先前把"鸿蒙没有消费点"登记成了"**不存在映射表 ⇒ 钉映射判据是假判据**",
   **那是错的**,并且已经写进了两处文档(CRITERIA.md §10、计划文档 §7.12)。
   ⇒ 两处都**更正**了,并写明发现方式(计数机制把它翻出来的)。
   正确的登记:**映射表存在且可判;缺的是调用点** —— 这两件事分开判。

2. **映射表按"它是行为"来钉**(新判据,26/26 绿):分档边界 0/8/20、
   **单调性**(px 变大档次不许倒退)、**NaN 不许落到最厚那一档**(比较全 false 时掉到最后一档
   是最坏方向)。`blurStyleFor` 是纯函数 ⇒ 与 Wallpaper 一样能直接用 node 跑,不需要设备。

3. **§1 痕迹的"输出那条腿"仍无人判 —— 我把它登记成欠账而不是假装钉了**:
   页面层(`MainPage.ets`)有没有真的拿 `presetSubstitutedFrom` 打日志,现在**没有判据**。
   我没有在本轮补上,原因是它要动 `.ets`(按环境约定,写 `.ets` 前要先按 ArkTS 纪律加载规范),
   而 P6 第 1、2 步正好要动那个文件 —— 所以登记成 `docs/DEBTS.json` 的
   `observability-output`(余额 1、到期前提写明"页面接上日志时同时补判据"),
   **不是"未完成"含糊过去**,而是"什么时候还"写清楚了。

4. **§2 §10 那格从"无人类批准"收口成"待批准"**:仍进余额 —— `unknown-preset-approval`,
   到期前提是"有人追认或驳回『未知 id 显示 aurora 而不是空白』这个方向",
   并写明**我作为实现者不能自己追认自己**。

5. 顺手把 pi 那条建议入册(`CRITERIA.md` §14):**登记/清册类判据的报错要同时写
   "正确修法"与"最常见的错误修法"** —— 因为读到红的人第一反应通常是改那个数字。

余额现在一处可见:`RESULT phase=install static=5 debts=9(…) probe=ok`。
2026-09-14 17:20:18 +08:00
f5c4f56682 跨端: feat(预设): 静默兜底留一条可观测痕迹;"只写不读"在鸿蒙侧也补上判据(照 LEGACY_BACKUP_KEY 照搬);§10 出处落地
pi 2026-09-14 的三小条。

1. **§3 静默兜底要留可观测痕迹**:`normalizePreset` 把认不出的 id 换成 aurora 这件事,
   原先在真实环境里**不留任何痕迹** —— §10 的登记只防"被误报成 bug",防不住
   "没人知道它正在发生"。现在 `BackgroundPlan.presetSubstitutedFrom` 带出**原来那个 id**
   (换过非空、没换过空串),页面据此打一行日志 ⇒ 后果从"可能发生"变成"**可数**"。
   **痕迹记在返回值里而不是在这一层直接打日志**,理由写进代码:这一层是**纯逻辑**
   (无 `@ohos` 依赖 ⇒ 判据能用 node strip-types 直接跑它);为打一行日志引入 `@ohos.hilog`,
   等于把"能真跑的行为判据"换成"只能读源码的形态判据"——不划算的交易。
   **判据两条方向都钉**:替换必须留痕(且带出原 id);**没替换时必须为空**
   (痕迹退化成噪声就等于没有)。变异与正反例都在(5 条全绿)。

2. **§2 §10 的"谁批准"落成出处**:那一格原写"产品决定" —— 按 pi 的话这是**事后追认**。
   现在写的是 **「无人类批准:这是实现时的默认行为(随 `model/Wallpaper.ts` 引入,
   `git log --diff-filter=A` 可查出处,2e42aac),本行是补登记」**,并把**意图**与**批准**分开写
   (不拿意图冒充批准)。另两行也补了出处(`Appearance.ts:39,52,88,104,133` /
   `AppearanceStore.ets:142-195` / `backgroundStore.ts:200`)。

3. **§1 "可扫的字段清册"不必先造 —— 同形状已有一边是判据**:他说得对。
   WebUI 的 `LEGACY_BACKUP_KEY` 早就钉着"只写不读",鸿蒙的 `bgBlur` 只有文档
   ⇒ 差的是**同一个形状只有一边有判据**。已照搬:`harmony-appearance.test.mjs` 新增
   **"消费侧出现次数必须为 0"**;豁免**按文件登记 + 写理由**(域模型 / 状态同步 / 线上 DTO
   三处是搬运与传输,不是消费)——与 `mail_status_readers_test.go` 的豁免同一形状,
   登记表本身就是清册,不必另造一张。
   变异验证:让 MainPage 读一次 `bgBlur` → 判据红,红的信息写着"**停下:那时必须先补
   px ↔ 材质档位的映射判据**,而不是把登记值从 0 改成 1"。

SUITE 计数同步:harmony-presets 4→5、harmony-appearance 24→25。
2026-09-14 17:17:37 +08:00
12a45af9ee docs(登记): 哨兵的缺省语义进 §10;版本偏移的可见后果进差异表;"指针可以,断言不行"入册
pi 2026-09-14 的三点接续:

1. **"不存在的 id 当哨兵"这个手法本身是一次缺省语义决策**,我原先只把它当技巧用,
   没登记它的前提。已按他给的形状进 §10:**未知预设 id → 静默替换为 aurora | 方向:宽(静默)
   | 依据:产品决定(背景不该因未知 id 变空白)**。并写明它**不是"给一个安全的空值",
   而是"静默给别人的档"** —— 用户不会看到错误,但会看到别人的档。
   他指出的真实后果也记了(§10.2 + 计划文档差异表):**服务端/WebUI 先加第 7 档、
   鸿蒙还是旧构建时,旧端静默显示 aurora**,用户以为选的是新档;
   **这不是 bug,是批准过的缺省决策的可见后果** —— 不记,将来必被当 bug 报。
   他给出的可选取舍(兜底改成"不属于六档的显式层")我**不改**:代价是未知 id 显示空白,
   与"背景不该变空白"这条产品决定冲突。取舍与理由都写下来了。
   顺带记清一个连带事实:**用真实档当兜底,必然让那一档逃出行为判据**
   (aurora 丢分支不可观测)—— 这条边界已在判据注释里,不假装全覆盖。

2. **WebUI 侧不写"鸿蒙不消费"**(他否掉了我问的那件事):那会把**另一端的实现状态**
   写成**这一端的断言**,而它会过期。规则立成 §13:**注释里可以放指针,不要放断言**;
   断言留在"事实所属的那一端"(鸿蒙侧已写+已登记 §10),另一侧只放指针。
   与"按 id 联接不按相邻配对"、"'与 X 一致'先确认 X 侧有判据"同族。

3. **两处"只写不读"登记到同一处**:鸿蒙 `bgBlur` 与 WebUI `LEGACY_BACKUP_KEY` 都进 §10 表
   (§10.1 说明为什么必须同处:分居两处时审计容易只找到一处就以为找全了)。
   并**如实标注**:他建议的判据形态("登记为只写不读的字段,写侧必须能指出理由与批准人")
   目前只是**文档级约束**,还没有机器判据 —— 要变成"做错会红"得先有一张可扫的字段清册。
2026-09-14 17:09:26 +08:00
863b583838 fix(已读/文档): 空 reader 报错(把约定变成做错会红);P6 方案补分支声明与可验收性;"与 X 一致"入册
pi 2026-09-14 对 P6 方案的五条,逐条处理(都不改方向)。

1. **§4 已读按读者的强制点** —— 他说得对,但实际情况比他担心的更靠前也更靠后:
   HTTP 层取的是 `user.Username`(**不是请求参数**,所以根本不可能"省略 reader"),
   但 **repo 函数本身接受空串**:`MarkMailRead(ctx, id, reader)` 会照写一行
   `reader_name=''` —— 不属于任何人,却会让"未读"统计出偏差,而且没有任何东西会红。
   已加守卫(空/纯空白 → 报错,不兜底)+判据(不仅"不许插垃圾行",且**必须返回错误**;
   另含正例,防止把守卫写成"一律拒绝")。变异验证:撤掉守卫 → `空 reader("")必须报错`。
   顺带一条给他的更正:同一个函数结尾还有 `UPDATE mails SET status='read'`(**行级**全局写),
   所以"按读者"这个性质只对**用 `mail_reads` 派生的数据**成立(`CountUnread`/`ListInbox` 是),
   读 `mails.status` 的客户端仍然是邮件级语义 —— 两件事在同一个函数里,容易被看漏。

2. **§2 手势阈值**:核实结果 —— **WebUI 侧没有被任何判据钉住**(`CalendarView.tsx:204`
   裸字面量 `Math.abs(dx) < 40 || Math.abs(dx) < Math.abs(dy) * 1.5 || !fast(600ms)`)。
   所以撤掉"与 WebUI 一致"的写法,改标 **「待两边对齐」**(文档两处),并把他给的推广写成
   规范 `CRITERIA.md` §12:**凡"与 X 一致"的判据,前置条件是 X 侧那个值自己有判据钉住**,
   否则测的是"我抄的那一份" —— 与"两张表各缺一半时必须按 id 联接"同一族。

3. **§1 分支声明(最要紧的一条)**:写进 P6 分期段 —— 本步实现的是**窄屏那条**
   (**容器自身带圆角 + 那一层能裁剪**);宽屏那条(起始侧/结束侧分开给)**不适用**,
   因为它的理由是"中间是分隔线、四角全给会露底色",而手机是单栏、中间没有分隔线。
   并写死这句:**"给对边"是跟着"中间有没有分隔线"来的,不是无条件的三件套。**
   同时核实并写清现状:鸿蒙侧**还没有日历页**(全 ets 树无任何 calendar 提及)⇒ P6 是整页新建。

4. **§3 可验收性**:P6 三步各加一列 —— 第 1、2 步**本工作区可验收**(读 `.ets` 层结构),
   并明确"**第 2 步不需要设备,不许登记成 `static` 欠账**"(那会虚增余额);第 3 步
   **必然进欠账**(要设备:能装、能点),到期前提见探针表。

5. **§5 路由**:不再把 WebUI 改动挂成"等 pi"(他这条链上没有 shell)。按他给的三级路由执行:
   优先在鸿蒙侧引用**已有令牌**解决;必须动 WebUI 时找 gui-lab 或按先例自己改。
2026-09-14 17:03:16 +08:00
33488760ce fix(相位/安全): 部署门禁只判产物自证;静默 break 改成出声;内核读数带时间坐标
pi 2026-09-14 的裁定与两条更正,逐条落地。

1. **相位裁定(选 c)**:`packaging`/`build-stamp` 属于**构建相位**,不属于安装相位。
   `run-all.mjs` 现在有相位:`AGENTMAIL_CRITERIA_PHASE=install`(部署门禁用)。
   每条判据登记它读的哪一侧(`ARTIFACT`/`SOURCE`),install 相位里出现 SOURCE 侧判据 → 红;
   被跳过的判据**点名打印**,不静默丢。汇总打 `RESULT phase=build|install`。
   规则入册 `test/CRITERIA.md` §11(含三个真实实例:check-shared-libs 恒红、
   packaging 一改前端就卡死、HOME 在门禁跑完之后才炸)。

   安装相位**真正能判的那一半**:`deploy/install.sh` 读**产物自证**(不重算 dist)——
   `releaseCandidate !== true` → 拒绝;产物 `gitRev` ≠ HEAD → "这个包比源码旧" → 拒绝;
   放行要显式 `--allow-dirty` / `--allow-stale`;`--check` 干跑只报结论不拦。
   实测干跑输出:`产物:gitRev=6702cc2 树=dirty releaseCandidate=false | 当前 HEAD=6702cc2`
   → 报"不是发布候选 + 正式安装会被拒绝 + 要放行请显式说清"。

2. **别解析运行器文本**(pi §5):`broken`/`red` 的判定改成按 TAP 的**名字**——
   文件级失败的测试名就是路径,断言失败的名字是判据名。变异双向验证:
   未定义标识符 → 「跑不起来的判据」;把某条判据条件改成假 → 「红的判据」。
   不再往关键字表里加补丁(那是往文本解析里加补丁,方向是错的)。

3. **静默 break 是安全相关**(pi §3):`session_update` 找不到活动会话时不再静默 break,
   改成出声日志(走 journalctl 那条通道),写清两种成因(此刻没在跑 / **接管会话**重启后无法定位)、
   方向(收紧被延迟)、以及兜底的**前提**("下次投递"要求这条会话还会收到新邮件)。
   `lib/mail-session-id.js` 模块头同步改成安全相关措辞("人以为自己收紧了权限、实际没有"),
   四桥逐字节同源,`check-shared-libs.sh` 退出码 0。

4. 内核读数补时间坐标(pi 13ea2fdf):`BUILD_INFO.txt` 里除原始 `dep`/`=>` 行外,
   现在还有 `kernelBinMtime` 与**正在运行的进程启动时间** —— 二进制会在两次读数之间被换掉,
   没有时间坐标的读数不成立。
2026-09-14 16:54:30 +08:00
dae508b25b docs(判据): 补「已知限制」与「缺省语义登记」两节;build.sh 把原始 dep/=> 行写进 BUILD_INFO
pi 2026-09-14 两件:

1. **登记册真的没有**。我上封信说"已写进 test/CRITERIA.md 的已知限制一节"——**不成立**,
   只有 `appearance-defaults.test.mjs` 里有那段注释。已在 CRITERIA.md 补 §9「已知限制」
   (标识符只解析一层;静态判据的到期前提是"本工作区能装能点")。
   过度声明自己做过什么是这轮反复出现的那一类错,这次是同一个形状的又一例。

2. **新建 §10「缺省语义登记处」**(pi 的更正:不是无条件 fail-closed,而是"缺了的后果必须
   有人登记 + 写明谁批准了这个方向")。三条入库,各带依据:
   · 邮件 `permission_mode` 缺 → **不写、不改档**(窄),依据是本轮那个 `|| 'workspace'` 兜窄档的坑;
   · HomeAgent `plugin.json.sdk` 缺 → 内核**不读**(不是语义,是文档),依据内核 manifest.go 结构体 + registry.go;
   · HomeAgent `capabilities` 缺 → **不受限**(宽),依据内核注释明写的理由:17 个存量清单都没有它。

3. `build/BUILD_INFO.txt` 现在**原文贴入** `go version -m <内核>` 的输出,并附"这两行怎么读"
   (`dep … vX.Y.Z` 后面跟 `=> … (devel)` 时那串版本号只是 require 行残留;`=>` 必须按模块名联接)。
   理由:这场争论的全部内容就是这两行该怎么读,原始证据必须和结论放在一起。
2026-09-14 16:50:33 +08:00
456ae5a66d 跨端: pi 五条评审落地 —— 产物自证替代时间戳代理、静态判据可到期、判据能读一层标识符、值/来源配对规则、归因不许动别人的树
pi 2026-09-14 的评审(`9839f8a9`)五条,逐条落地;其中 §6 的两条是**核对后已成立**,不重复劳动。

## 1 产物自证:`dist/BUILD_INFO.json`(pi §1)

原来那条判据是"`dist` 比 `src` 新"——**代理变量**,pi 指出两层都靠不住:看不见"构建是否
成功"(实测过),而"`dist` 比 `src` 新"也不等于"dist 是从这份 src 构建的"(`checkout`/`cp`/时钟
都骗得过 mtime;我确实用 checkout 造过一次假红)。现在改成**内容自证**:
`scripts/build-info.mjs` 在构建最后一步写 `{gitRev, gitDirty, srcHash, srcFiles, buildCmd, builtAt}`,
判据重算当前指纹再**精确比对**(`test/build-stamp.test.mjs`)——"代理"两个字没有了,
报错能直接读出两边指纹。`release-linux.sh` 打完包把同一份信息打进日志(一个包自带
"它对应哪个源码状态")。`gitDirty` **只展示不判定**:共享工作区常年是脏的,拿它当红/绿依据
会天天误报。

实测三变异:改 src 内容不重建 → 红(两个指纹都打出来);BUILD_INFO 记成别的提交 → 红;
**`touch`(只动 mtime)→ 绿** —— 旧判据在这里是**假红**,新判据不误伤,这是它严格更好的地方。

## 2 静态判据的**欠账**与到期(pi §5)

"暂时"不是状态、是待办,规范里写下的"暂时"没有任何机制回来读它。改成可机检的形状:
`run-all.mjs` 登记 `STATIC_ONLY`(5 条:`.ets` 只能验形态)+ **必填到期前提**(探针,真跑
`hdc list targets`);汇总打 `RESULT static=N`(**欠账余额**);**前提一旦为真,这些判据当场
变红**并要求"改成行为判据或换更准的前提"。实测:探针恒真 → 5 条同时报"到期";把前提写成
`'vibes'`(未知探针/陈述)→ 套件红。这是"自报条数 < 登记条数"的**时间版本**。

## 3 判据能解析一层标识符(pi §3)

Go 默认值判据原来"值不是字面量 → 判据读不懂 → 红",长期结局是有人做一次无害重构
(`BgDim: defaultDim`)就把判据逼宽、再下一步少核一个字段。现在:字面量直接用;
标识符在**同一文件**查 `NAME = <字面量>`;查不到(跨包/计算/iota)才报"读不懂"。
实测:`BgDim: defaultDim` + `const defaultDim = 12` → **绿**(无害重构不再误伤);
`const defaultDim = baseDim + 0` → 红且报文说"读不懂"。**只解析一层**:再深就是"执行 Go"了。

## 4 §6.7 的可机检分流规则(pi §2)

新增 §6.7.0:**值 → 行为判据,来源 → 静态判据**,理由写成覆盖问题(行为判据只覆盖它跑到的
路径 ⇒ 原理上判不了"有没有别的路绕过去";来源约束要的是全程序可达性 ⇒ 只有读代码能答)。
两条推论:静态判据判值永远差一个反例、行为判据判来源永远差一条路径;**不是强弱,是分工**,
缺任一条那一对就是假判据。附本仓已有的完整样例(P5 命中区:值判据"≥44"+ 来源判据
"应用点必须引用常量"),新增 §6.8 记欠账机制、§8 记共享树归因纪律。

## 5 值/来源配对补齐 + 让位派生(pi §6 的两条)

- 命中区原来只有值那一半:补**来源**判据(`.ets` 里出现 `minHeight: 44` 这类裸数字 → 红,
  报文点出"值判据管数字够不够大、来源判据管用的是不是同一个数字")。变异:写死 44 → 红。
- "`76` 应从条高派生":**核对后已成立**(`NAV_CONTENT_RESERVE = NAV_BAR_HEIGHT +
  NAV_BAR_BOTTOM + 8`,判据也按常量算)。顺手把裸的 `8` 起名 `NAV_CONTENT_GAP`
  (这一族里唯一还需要人判断的数),并加判据钉住**派生关系**:写回字面量 `76` → 红。

## 6 `legacy.bak` 的生命周期(pi §4):**有意永久残留**,并说清代价

pi 质疑成立:判据钉死"没有任何代码读它"⇒ 也没有任何代码能删它。**否决了"点重置外观时删"**:
最可能点重置的人正是外观被接管的那个人,而这份备份是他唯一的旧值,那时删等于把恢复数据
毁在最需要的时刻。所以定为"有意永久残留"(没有代码路径能判定何时安全删除——那取决于人),
并把代价量出来写进 §7.12:值受 `MAX_DATA_URL_BYTES`(2.4MB)约束,**最坏是一张用户原图的
整份副本**,共享机器上即一份别人的外观;要清就手工 `removeItem`。判据钉住这行**同时**给出
"为什么不删"与"怎么删"(变异:删掉删除方法 → 红)。

## 7 归因方法(pi §6 第三条):认错并写成纪律

我当天归因 `narrow-layout` 的 3 条红时用了 `git stash push -- MailView.tsx`,动的是**并发写
作者的未提交改动**。结论对、方法不行:它写共享工作区(别人崩溃/`git add -A` 就丢他的活),
而且只影响 tracked 文件(untracked 的 WIP 还在 ⇒ "干净了"是假干净,结论也可能假)。
纪律写进 CRITERIA.md §8:只读手段(`git show HEAD:path > /tmp/...`、`git worktree add`)或直接问作者。

## 验证

`npm test` 全绿(14 个判据文件 + vitest 266 + typecheck),`RESULT static=5`;
`hvigorw assembleHap` BUILD SUCCESSFUL;`scripts/release-linux.sh` 重打 deb(日志带产物指纹)。
提交前两道**真红**按预期挡了我:改了 src 未重建 → build-stamp 红;重建了 dist 未重打包 →
packaging 红。**未验**(照旧不写成已完成):鸿蒙侧视觉/交互观感、运行期换肤重算(静态判据,
到期前提见 `RESULT static=5`)。

注:本次 `dist`/deb 是在**共享工作区**上构建的,树里含并发写作者未提交的
`CalendarView.tsx`/`index.css` —— 产物里的 `gitDirty: true` + `gitRev` 正是为此留的,
复核时先看那一行。
2026-09-14 16:00:34 +08:00
71a3c35610 按 pi 复核改五处:判据的"值/行为"原则、真正的构建门、typecheck 进门、Go 判据零匹配、迁移归属
pi 用反例与变异逐条点了五处,全部**先跑变异再改**(结论都写在原地)。

## 1 判据③:形状正则已删(它永远差一个反例)

实测 pi 的反例 —— `const k = PREFIX + accountId; return PREFIX;`(拼了但没返回)——
对第三版判据**仍然全绿**:第三版锚的是"**函数体里存在**这样的表达式",不是"**返回的**表达式"。
三版的骗法各一个(签名里的参数 / 提了一下没用 / 拼了没返回),都在判"源码里有没有那个形状",
而缺陷是"算出来的值对不对" ⇒ **权威交给行为判据**(`test/stores/background.test.ts` 直接断言
`storageKey('a') !== storageKey('b')`、`=== 'agentmail.background.acct-a'`、不退回全局键),
正则那条删掉并把反例写在原地(否则下一个人会好心加回来)。
保留"键必须来自 storageKey()"那条:那是**来源**约束,不是值对不对,正则在这里合适。
鸿蒙侧只能静态判(`.ets` 本机没有运行时),已把这条限制写进判据说明。
推广进规范:CRITERIA.md §6.7 + run-all 自检关键词(10 → 11)。

## 2 真正的门:`scripts/release-linux.sh`

- 实测 pi 提的变异:**`touch dist/index.html` 时 stamp 判据是绿的** —— 它抓不到"构建失败但碰过 dist"。
  stamp 是**探测器**(抓"src 改了产物没跟上"),门是**喂退出码**,两者不互替(§6.7.1)。
- `build:linux` 里**没有管道**(`&&` 链,退出码本来就传),但那次的哑巴失败是我在命令行手打
  `npm run build 2>&1 | tail -4 && …` 造成的;同时发现它跑的是**裸 `vite build`,跳过 `gen:bg`**。
- 于是把配方收成 `scripts/release-linux.sh`:`set -euo pipefail` + 走 `npm run build` + 再打包。
- 判据是**行为**的:注入失败的构建(`AGENTMAIL_BUILD_CMD='exit 7'`)→ 断言退出码非 0
  **且打包那步没跑**(标记文件不存在)。变异:脚本改成 `|| true` → 红 ✓。

## 3 typecheck 进 `npm test` 链

`npm run typecheck` 原本就有,但没人跑。先修掉它唯一的报错(我自己留下的未使用 import),现在干净;
`test` = run-all + vitest + typecheck。它恰好检查**没被任何测试 import 的文件**(vitest 只解析被测到的图)——
也就是那次 `is not exported by` 的形状。

## 4 Go 源码判据:零匹配 / 读不懂 都要红

改成三分:切不出函数体 → 红;字段在但值不是字面量 → 报「**判据读不懂**」(变异:`BgDim: defaultDim` → 红 ✓);
字段不在 → 红。静默放行是这类判据最危险的失败方式。
**措辞更正**:这条核对的是"与**这份服务端源码**的契约",不是"在跑的服务端二进制是 12/4"
(与"dist 是产物"同构);文档同步改。

## 5 迁移归属:定向做不到,就把"不可恢复"降级成"可恢复"

查实:`accountStore` 的 `activeId` 是**派生视图状态、不落盘**(`persist(accounts)` 只存数组),
所以**本机没有"上次活跃账号"标记可定向** —— 旧值的作者事后无法还原,定向迁移在原理上做不到。
升级前用 B、升级后先登录 A ⇒ A 接管 B 的外观,**一次性错档**,触发条件就这一条。
两件能做的都做了:**写了回读校验**(写不进去就不删旧键,避免净损失;变异:改成先删后写 → 2 条红 ✓)、
**删前另存** `agentmail.background.legacy.bak`(只写不读 ⇒ 不引入新的继承源,判据钉"只写不读")。
§7.12 把本地这半与显形方式写进同一行。

## 验证

`npm test` 退出码 0:14 个判据文件全绿 + vitest **265** 通过(+2 迁移行为测试)+ typecheck 干净;
`hvigorw assembleHap` BUILD SUCCESSFUL;安装包经新脚本重打(dist 与包同批)。
2026-09-14 15:41:52 +08:00
f412751b37 test(criteria): 规范加 §1.5「两张表各缺一半时按 id 联接,不按相邻关系配对」(pi 的建议 + 我方实测的串行实例) 2026-09-14 15:20:39 +08:00
2d8f5424b5 test(criteria): 规范补两条 + 还原纪律改写成"先固化基线再破坏"
pi 三条增量的第三条(纠正我的写法)与配套文档:

## 1 还原纪律:禁令 → 操作顺序(pi 纠正)

我原来写的是"变异后不要用 `git checkout` 还原"——**治症不治因**。真因是
**被还原到的那个状态还没提交**(我丢的是一个刚加、尚未提交的 marker)。
可执行的形状:

> **任何破坏性还原,都要求"将被还原到的那个状态已经在某个提交里"。**

所以:**变异前先把基线提交掉**;更稳就 `git worktree add` 一个干净副本去变异。
`cp` 备份仍然可用,但它依赖"人记得备份",顺序改对了则不依赖记性 ——
与"记得打 marker"改成"计数写在 `check()` 内部"是同一招。

本提交自身就是这条纪律的示范:先提交 `ec90cba`(helper + 移植 + 错误信息)作为基线,
再在已提交的基线上做 marker 变异验证。

## 2 共享 helper 与"失败信息自带修法"写进 §6.6

- `test/lib/checks.mjs` 的存在理由与用法(计数只可能在该模块内发生 →
  漏 marker / 计数写错位置在新判据上不可能发生);
- **失败信息要自带修法**:red 是那个人一定会看到的东西,文档不一定被打开。
  验证方式也记了:真删掉一条判据的 marker 行跑一遍,确认错误信息能照抄执行
  (已验:输出里给了 helper 用法与样板文件路径,且 `node:test` 的判据不用管)。

## 3 规范自检关键词 7 → 9

新增 '已经在某个提交里'、'自带修法',防止这两条被删掉还不报错。

## 验证

`npm test` 退出码 0(12 个判据文件全绿 + vitest 258/258)。
2026-09-14 15:12:19 +08:00
a8ac2fc28b test(criteria): 闭环——判据自报条数 + 每文件期望条数(只增不减的棘轮)
pi 指出的残余缺口:我上一轮加的自检 4 是**文本证据**(文件里有 `test(` / `check(` /
`process.exit(1)`),只能证明"**有能红的路径**",不能证明"**它跑过**"。反例很短:

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

## 落地(pi 给的闭环形状)

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

关键细节:**计数写在 `check()` 内部**(theme/background 原本就在内部 ++;
narrow-layout 只有 failed 计数,补了 passed;markdown-xss 按 payload 条数算)。
写在调用点或靠扫源码的话,"实现被换空"就看不见了。

棘轮"只增不减":加判据**不用**改那个数,只有"条数掉了"才红。期望值按**实测**回填
(9/52/8/30/42/15/28/5/23/5/3/2)。

附带的可见性收益:这几轮我一直用"13→14""19→28""34→42"当信号,现在它成了判据 ——
某次改动顺手删掉两条判据、或某条被跳过,会立刻红。

## 变异

- pi 那个反例(`check` 换成空函数)→ 红(`自报 0 条 < 登记的 30 条`);
- 删掉 5 条 `check(` 调用 → 红(`自报 47 条 < 登记的 52 条`)。

## 规范

§6.5 新增"涉及运行时行为的结论必须实测过才能写进规范/判据"——同一个错这轮犯了两次
(我从"报告 0 个测试、退出 0"推断"退出码被吞",实测是照传;pi 拿我这个结论又建了一个洞)。
规则:**一次观察只支撑你看到的那一层**。
§6.6 记闭环形状与代价(故意删判据要同步改数字,属于一次可复核的显式编辑)。

⚠️ 并且如实记下一次**我自己违反规范**的事:写 §3 那条"变异后别用 `git checkout` 还原"的人
(就是我)在这次变异验证里又用了 `git checkout -- <文件>`,把刚加、尚未提交的 marker 抹掉了。
规矩写下来不等于会遵守 —— 已把这条实例写进规范,让人知道它是活人踩的坑。

## 验证

`npm test` 退出码 0(12 个判据文件全绿 + vitest 258/258);`run-all` 单独跑也 exit 0。
2026-09-14 15:06:14 +08:00
4babc96f8b test(criteria): runner 不再手写 --test(由内容推导)+ 自检 4「这条判据能不能红」;规范补两档
pi 提的三条,第一条落地前先按他的要求**贴真实样本实测**,结论与他的猜测不同(记在代码里)。

## 1 runner 内部那个"配错 flag = 绿" —— 实测后换了个形状

pi 的猜测:给自定义 `check()` 的判据传 `--test`,runner 会报"0 个测试"并以 0 退出。
实测(node v22.22.2,两条真实样本):

- `node --test <自定义 check() 判据>`:**退出码照样传出来**(文件 exit 1 → 命令行 exit 1),
  没有被吞;
- 但 `node --test <什么都不做的文件>` 报 `# tests 1 / # pass 1` ——
  **pass 计数不是"检查跑过"的证据**。

所以"解析 pass 计数、0 就判红"这条路两头不讨好:抓不到空判据(它报 1),
还会在 `narrow-layout` 上误报(它的汇总行是"窄屏布局:全部通过",里面没有数字)——
正是 pi 提醒的"别照抄我的正则,先贴样本"。

换成两条**结构证据**:

- **`--test` 不再手写**:由文件内容推导(源码里 `from 'node:test'` 就走 node:test),
  清单里出现手写 `--test` 直接红 —— 配对错误不再靠记性维护;
- **自检 4**:每条判据文件里必须存在"能红"的路径(`test(` / `check(` / `process.exit(1)`),
  外加"跑完必须有输出"。一个都没有 = 它永远不会红,与"全通过"长得一模一样
  (这是"判据自己不会跑"家族的第 6 个宿主,家族表和六种宿主都写进规范了)。

变异:清单手写 `--test` → 红;加一条"什么都不做、退出 0"的判据 → 红;
静默成功(有能红路径但一行不输出)→ 红。

## 2 规范 §3 补一档:变异红了还要看**红在哪**(pi)

"只报红了不算,要能指名红的是哪几条";**红在解析/加载失败上不算红**(先让变异
"语法正确、语义错");变异作用于被剥掉的注释也不算。

## 3 `CRITERIA.md` 的可见性(pi 提的位置问题)

它管两个客户端的判据,却躺在 electron 的测试目录里。已在
`docs/HARMONY-ALIGN-PLAN.md` §四(验收纪律)加指针,并顺手把 pi 点名过的两条口径写死在那儿:
**"未验"只能用于"步骤做过、结果没看",功能不存在必须写"没做"**;
**"机制上确定不同"要判、不许记成"未验"**(深色档预设那次)。

## 验证

`npm test` 退出码 0(12 个判据文件全绿 + vitest 258/258)。
2026-09-14 15:00:26 +08:00
0ce8b29546 test(criteria): 判据规范 CRITERIA.md(邻接不是结构·第三次露头)+ 废弃 API allow-list 结构化 + 扫描范围自检
pi 的两条增量,都不需要他再确认。

## 1(pi 建议):生成清单 + 空 allow-list 的结构性风险

他的推演:SDK 升版会往清单里加新条目 → 某天早上套件**突然红**,且红在与本次改动无关的
代码上;这时人的第一反应是把名字塞进 allow-list —— 而 allow-list 一旦这么用,
就不再是"研究过的例外",只是"红的止痛药"。所以条目结构化:

```js
const ALLOW = [ /* { name, replacement, why } */ ];
```

`replacement` 非空是硬断言("暂时不想改"不是放行理由,"替代品要求的 API level 高于基线"才是)。
**刻意不断言"名单必须为空"** —— 那会挡住合理放行;断言的是"有名字、没替代品 → 红",
于是侵蚀发生时红的是**放行这件事本身**,而不是某天的新 SDK。
变异:塞一条 `{ name:'px2vp', replacement:'', why:'暂时不想改' }` → 红。

## 2(pi 建议):扫目录的判据要防"空判据"

他问废弃 API 判据扫哪些目录(怕只扫 `pages/`,`common/` 里的旧写法逃掉)。
答案:扫的是**整个 ets 目录递归**(实测 25 个文件,含 `pages/ common/ model/ api/ entryability/`)。
顺手加了防退化的自检:文件数 ≥ 20,且 `pages/ common/ model/ api/` 四个目录都必须扫到
—— 目录改名/只扫一个子目录会让这条变成空判据而依然全绿。
变异:把扫描范围改成只扫 `pages/` → 红。

## 3(pi 建议):把"邻接不是结构"写进判据规范

同一个坑在本仓露头三次:① 窗口式正则被一行注释挤爆(原注释自嘲过);
② 括号配对取代窗口;③ 链式修饰符让"看前一个字符是不是 `}`"静默失效。
共同形式值得升格成规则,于是新建 `client/electron/test/CRITERIA.md`(七条),
并在 `run-all.mjs` 加**自检 3**:规范文件必须在、且必须点到关键条目。
WebUI 侧 `background.test.mjs` 的窗口式存量按 pi 的说明**记着不动**(那是他的地盘)。

规范里另外两条是本仓自己踩出来的:剥注释读代码 vs 读原文读理由(混用必红);
以及**验证要按真实入口跑** —— 我用 `node --test test/run-all.mjs` 验自检 3 时它"依然绿",
其实是 runner 把内部的 `process.exit(1)` 吞了;换成 `npm test` 走的那一行就红对了。
变异:删掉规范里的一条关键规则 → `npm test` 那条路 exit 1。

## 验证

`npm test` 退出码 0(11 个判据文件全绿 + vitest 258/258);`hvigorw assembleHap` 未受影响。
2026-09-14 14:51:30 +08:00