Commit Graph

295 Commits

Author SHA1 Message Date
b16d07b625 修复: 假红 —— Motion.dur(...) 包一层就让 paneRiseIn 的时长断言失配(pi 报)
pi `7d98245a` §三 报的形状,我**独立复现并量了两棵树**(隔离 worktree,同刻对照):

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

`Theme.paneRiseIn()` 现在写成
    .animation({ duration: Motion.dur(Theme.durRise), curve: Theme.easeRise })
而 `harmony-nav.test.mjs:441` 是 `/duration:\s*Theme\.durRise/` —— 要求 `duration:`
与 `Theme.durRise` **紧邻**,中间多一层 `Motion.dur(` 就失配。
语义上 `durRise` **仍被引用**(`Motion.dur(x)=reduced()?0:x`,只在系统开"减少动效"时折成 0)
⇒ **假红**。

★ 放宽的**只是"中间能不能多一层卷绕",判据的区分力一点没动**(3 个方向实测):
  /duration:\s*(?:Motion\.dur\()?Theme\.durRise/
  ① `Motion.dur(Theme.durBase)`(换令牌)      ⇒ 仍红 ✓
  ② 裸 `Theme.durBase`(包一层来蒙混)          ⇒ 仍红 ✓
  ③ 干净 HEAD 形状但换令牌                      ⇒ 仍红 ✓
  且下面那条 `!/Theme\.durBase/` **逐字仍在**、扫的是**整个函数体**
  ⇒ 放宽的是**包装**,不是**令牌**。

修后:主树 `18 pass / 1 fail`(只剩 not ok 16 那条设备条);
全套 `red 5 → **4**`(`harmony-nav` 从红名单里出去了,其余 4 条红都是别的会话的)。

★★ 顺带量出一条**我认为更值钱**的:无障碍层**零判据**(pi §三 的另一半,我确认)
  · `client/electron/test/` 里 `isAnimationReduceEnabled` / `Motion.dur` / `Motion.reduced`
    grep **各 0 次**;对照 WebUI 侧 `animation-audit.test.mjs:112` **有** reduced-motion 判据。
  · **pi 的变异我复现了**:把 `Motion.dur` 改成恒 `return want`(整个无障碍开关失效,
    源里 6 处受影响)⇒ `harmony-nav` 失败集合与变异前**逐条相同**(`17/2`)⇒ **零反应**。
  · ★ 而我还量到一个 pi 没说的:**11 个真·动画时长站点里有 5 个绕过开关** ——
    `CalendarPage.ets:373,410`、`MainPage.ets:2607,2845,2884` 都是裸 `Theme.durX`,
    只有 6 处走了 `Motion.dur(`。即"收成一个入口"这个设计意图**目前只落了一半**。
    ⇒ 这一半我**没有**加判据也没改源码:`Motion.ets` **尚未被 git 跟踪**,
    对它写判据会让 HEAD 立刻变红(那是别人未完成的在制品,不是我的改动范围)。
    已把精确读数报给 pi,由落 `Motion.ets` 的那次提交去补判据。
2026-09-20 05:45:03 +08:00
1c3335f0c9 修复: 第五个落点 —— 判据写对了,却**没有任何人执行它**(check-file-modes.sh 空转)
pi `4fc16f66` 报的是 rc=2 那半个缝(M18)—— 我复核后**已经在 `e99a657` 修好了**,
而它的**父提交正是 pi 报信时读的 `6ee9902`**。又是同一形状的竞态。

但照 pi §四 那句「**每条分支的前提本身也要能被构造**」做审计时,
我发现**第五个落点,而且这一格的判据本身写得完全正确**:

`deploy/check-file-modes.sh` = 源文件权限政策的**唯一**判据。
它甚至专门修过自己那份 `[ -x ]` 恒真的 root 陷阱(`b7dc9e9`),改成直接读权限位 —— 修得对。
**但它从来没有被任何东西执行过。** 全仓 `grep -rn "check-file-modes"` 只有:
  · `check-deploy-drift.mjs` 3 处**注释**(谈分工)
  · `run-all.mjs:951` **注释**(讲 root 陷阱)
  · `summary.py:77` **注释**,且**说法与实现不符**
  · `summary.py:431` 一句 `print(...)` 的**文案**
⇒ 唯一一处非注释提及是一句 print 的文案。没有 install.sh / redeploy-* / hook / CI / cron 调它。
**而它当时正红着**:2 个文件是 600 + `jobs/` 缺属主 x 位。

★ 为什么这一类**只能靠判据守**(实测):
  `git status` 看不见、`git diff` 看不见、`git ls-files -s` **只记 100644/100755**
  (组/其他读位**不进版本库**)⇒ 把受跟踪文件 `chmod 600 ↔ 644` 两次 `git status` 都空。
  套件也看不见(`jobs/` 缺 x 位时实测 `diag=none`、`ran=48`,全绿)⇒ **没有第二条通道**。
  0600 在"跑的人恰好是属主"时不炸,换身份就是 EACCES,而那串报错**看起来像代码问题**。

修法:
  · 接进 `deploy/install.sh`(check-shared-libs 之后),**走 `--check` 累积通道**
    (照既有 npm_rc/CHECK_GATE_RC)。⚠️ 我第一版**直接调**,而门禁红时它 `exit 1`、
    install.sh 是 `set -e` ⇒ 当场吞掉后面所有诊断:**实测**接线后 `构建 Gateway`
    在日志里命中 **0 次**(接线前 2 次)—— 正是 install.sh:141-157 刚修过的同一个毛病,
    我自己又造了一遍。改累积后回到 2 次,门禁报 `[FAIL] 权限政策没过(退出码 1)`。
  · 修那两个文件:600 → 644 ⇒ 权限政策现在绿(rc=0)。
  · 新增判据 `criteria-hygiene` 第 7 条:政策门禁必须被入口脚本在**可执行位置**调用。

★★ 变异验证时**我自己先假绿了一次**(单记):第一版用 `code()` 剥注释,而
`lib/read.mjs` 的 `stripComments()` 只认 `//` 与 `/* */` —— 那是 **JS** 的注释,
**`install.sh` 是 shell,注释是 `#`** ⇒ 对 shell 文件**原样返回** ⇒ 判据读到了
**我写在它上面那段解释里的** `` #   `check-file-modes.sh` 红时 `exit 1` ``。
  变异①(接线整段删掉)⇒ 第一版 **rc=0(假绿)**;自剥 shell `#` 后 **rc=1** ✓
  变异②(只在 echo 里提一句)⇒ rc=1 ✓
  变异③(gates 恒空 ∀x∈∅)⇒ rc=1(`只找到 0 个`)✓
(③ 第一次 python 锚点没匹配上、变异没施加,我重测并**先打印"变异已施加"**才算数。)

★ 射程如实标出:只管 `deploy/check-*.sh`(政策门禁族);**不管** `check-deploy-drift.mjs`
这类**按需手动工具**(自带 `--self-check`、文档写明"事后自查")——要求它进入口是**错的**。
它实测同样 0 处调用,但**本判据不覆盖它,也没修它**。判"有没有接线",不判"接得对不对"。

★ 通用纪律(CRITERIA.md §16.1.2):**判据自己也要能"被证明它真的在看代码"。**
若判据读的**语言**与 stripComments 实现的**语言**不一致(shell vs JS、Python vs JS),
那"剥注释"是**假的** ⇒ 判据消费散文,而**变异验证是唯一能戳破它的东西**。

全套 checks=516→**517** pass=512 fail=5 red=5 broken=0 verdict=red(5 条红都是别的会话的);
mutants=48 ran=48 skipped=0 diag=none baseline=7/7✓;5 个自检各 rc=0。
2026-09-20 05:22:42 +08:00
efb1c1d2ca 修复: 第四个落点 —— **变异条目的锚点失效**(hits=0):守具有齿,却不在位
pi `6aa2b17f` 报的第三例(`unlisted`/`ghosts` 退 0)**我复核后已经在 `6ee9902` 修好了**
—— 而它的**父提交正是 pi 报信时读的 `4b841e0`**(差 11 分钟)。又是同一形状的竞态。

但顺着同一条线**审计全部 61 个变异条目的锚点**,发现**还有一个同形状的落点,
而且它在真树上是活的**(不是构造的):

    hits=0  SettingsPage.ets 「管理入口不做门禁」(jobs-all.json)

根因:该锚点写的是 **6 空格**,而 `c523c21`(09-17 17:43)把该文件**重排成 4 空格**
⇒ 锚点从此命中 0 次(写进清单时 `bcd4f97`(09-15)它是**对的**)。

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

★ 危害是"**一个变异守具被静默关掉**",不是"数字错了":`ran` 少 1、`skipped=1`
是个**中性数字**,读者看不出少了哪一个。而**手工施加那个变异仍能让判据红**
(`harmony-admin.test.mjs` `# fail 1`)⇒ **有齿,只是没挂上**。

修法(沿用 `summary.py` 的**唯一严重度链**):
  · 新增 `mutant-anchor-stale` ⇒ **1 档**(清单/数据该改;**不是** 2 档 ——
    照 `env-defaults.sh:25` 的反方向:别让"清单没跟上"冒充环境)。
  · 修锚点 6→4 空格 ⇒ `ran` 47→48、`skipped` 1→0,守具重新挂上(实测变异能红)。
  · `whyLines` 过滤器加 `^\s*hits=`:只说"有锚点过期"不够,**点名的才是可行动的**。
  · 两张表都登记(`UPSTREAM_RC` + `DIAG`,`blocksGreen: true`)+ 真跑案例
    (迷你仓库里放一个不含锚点的同名文件 ⇒ `hits=0`)+ 读者侧具名案例。

★ 射程如实标出:判据只看 **`hits == 0`**,**不看 `hits == -1`**(文件打不开是另一回事,
且迷你夹具里目标文件本来就不在 ⇒ 算进来会造假红)。**`-1` 那一半无判据守着**(真树 0 条)。

变异验证(4 个方向全抓):
  ① 新档关闭(回到"只打印")⇒ exitcode-selftest rc=1、2 条红 ✓
  ② 从 UPSTREAM_RC 删掉新码 ⇒ 反向覆盖点名 ✓
  ③ blocksGreen true→false ⇒ 双向口径漂移红 ✓
  ④ 放宽成任何 skipped_detail(含 hits=-1)⇒ rc=1、**5 条红**(夹具假红)
     ⇒ **`h == 0` 这个射程是承重的** ✓

端到端 A/B(隔离 worktree,同刻对照):
  A 锚点已修  ⇒ rc=0、diag=none、ran=48/skipped=0、"锚点已失效" grep **0**
  B 锚点退 6 格 ⇒ rc=1、diag=mutant-anchor-stale、ran=47/skipped=1、grep **2**

★ 通用规则(本仓第 4 次同一形状):**"跑了多少个"与"该跑多少个"之间也要有判据。**
被跳过时 `ran` 只少 1、`skipped` 只多 1 —— 都是中性数字,而"少了哪一个"没有通道。
凡"登记一批东西、再逐个挂上"的结构(变异条目、判据、样本表)都要问:
**挂不上的那一个,谁来说?**

自检 5 个各 rc=0;全套 checks=516 pass=511 fail=5 red=5 verdict=red;
mutants=48 ran=48 skipped=0 on_new_criteria=36 diag=none baseline=7/7✓。
CRITERIA.md §16.1.1 记这一笔(含 4 个变异与 A/B 表)。
2026-09-20 04:49:50 +08:00
c1465e09ab 跨端: 平板三处真 bug(返回回登录页 / 避让重复叠加 / 联系人卡被裁 10vp)
用户报的「在主页返回为什么会直接回到登陆页」是**原语用错**:
LoginPage 用 pushUrl 进 MainPage,路由栈成 [LoginPage, MainPage],
返回自然弹回登录页。而 Logout.ets 早就是 replaceUrl 并写了理由
(「退出后不该还能'返回到已登出的页'」)—— 同一个不变式、相反方向,
只改了一半。三处 pushUrl → replaceUrl,设备实测:返回直接退出 app
(前台变 com.huawei.hmos.browser),不再回登录页。

另两处平板(HUAWEI MatePad Pro, 2800x1840, ratio 1.52 ⇒ 宽屏):
· 内容列 `this.isWide ? Theme.surface : (bgActive ? 透明 : surface)`
  —— 宽屏分支把 bgActive 丢掉了 ⇒ 平板上壁纸永远被挡。
· `top: this.isWide ? paneGap : statusBar` —— 宽屏分支把状态栏避让丢掉
  ⇒ 页签字压在系统时钟下。
· 侧栏自己也加了一次 topInset,而父 Row 的 padding 已经含状态栏
  ⇒ 83+83=166px 空白(用户:「避让有点用力过猛」)。
· 联系人列表项硬写 .height(85)+clip,内容实际要 95vp ⇒ 写信/归档行被裁一半。

判据 harmony-nav 新增「登录/退出必须用同一个原语」并做变异验证
(改回 pushUrl 即红);注册数 18→19,全绿 19/19。

★ 途中发现的记账缺口:设备判据连续失败时走 noteBusySkip 计数,
  .tmp/harmony-busy-skips.json 累到 10 后拒绝再当'礼貌跳过'。
  清除账本 + 让 app 真在前台后立刻 19/19 —— 说明**不是代码问题**,
  是账本把'设备忙'当成了证据。这一点记进 DEBTS。
2026-09-19 23:38:35 +08:00
110de79401 跨端: 三级文字**浅色也一样不够**(12 处 2.85:1)+ 判据不再只验一半
## 一、这是"我自己推的理由"被实测推翻

上一条我把 `textSubtleFor()` 写成**只改深色**,理由是:

> 「浅色下这些令牌本来就是为浅底配的,量它们没有信息量」

**那个理由是错的。** 切到浅色主题复扫,立刻抓到 12 处:

```
"用户名 / 显示名 / 角色 / 状态 / 创建时间 / 最后登录"  2.85:1
"已同步" / "37%" / "3px"(外观区)                    2.85:1
"这一天没有日程"                                      2.85:1
```
`ink rgb(153,153,153)` 压白底 —— 全是 `textSubtle`。

对照 WebUI 浅色(`index.css` `:root`):
- `gray-400` = `107 114 128` = **4.83:1**
- `gray-500` = `90 98 112`  = **6.15:1**

⇒ 系统三级色**两个主题下都不够**。它的语义只是"比二级更淡",
**不保证任何可读性下限**;WebUI 那边两套主题都做过适配,这边一档都没有。

修:`textSubtleFor()` 两个主题都返回 `textMuted`
(系统二级色,语义就是"次要文字",且**跟随主题** ——
不必再维护两个手写常量、不必登记)。

## 二、判据从"只验深色"改成"两个主题都验"

原来那条设备判据**在浅色下直接 skip**,skip 的理由正是上面那句错话。
现在两个主题都扫,断言消息里带上主题名(`深色主题下…` / `浅色主题下…`)。

★ 纪律:**色令牌的可读性是两个主题各自的事,不能只验一半。**
  "另一半没问题"若没量过,就只是推测。

**变异验证**:把 `textSubtleFor()` 改回"两主题都用 textSubtle"
(此时设备正在浅色主题)⇒ **判红**;还原 ⇒ 绿。

## 三、设备复扫(浅色主题)

```
[通信]   扫 41 段,低对比 0
[日历]   扫 84 段,低对比 0
[联系人] 扫 50 段,低对比 0
[我的]   扫 40 段,低对比 0
```
**215 段文字全部 ≥3:1。**

两个主题合起来:深色 219 段 + 浅色 215 段,全绿。

## 四、顺手改掉的

测试名从「深色下短文本必须都读得动」改成「**当前主题**下…」
—— 名字得跟着它实际判的东西走(否则下一个人会以为它只管深色)。

`run-all.mjs` → `checks=515 pass=515 fail=0 skip=0 red=0 broken=0 unreported=0`。

**当前设备主题**:浅色(本轮为验另一半切过去的;服务端 `theme=light`)。
2026-09-19 20:58:59 +08:00
1da4e15a7b 跨端: 补齐「我的」页深色可读性(4 页 219 段全 ≥3:1)+ 判据自己漏报的那一类
上一条把通信/日历/联系扫干净了,但**「我的」页没扫到**
(扫描脚本按文案点不到它 —— 那是侧栏**底部的头像按钮**,不是导航项)。
补上后立刻又抓出问题,并把判据自身的**漏报形状**一并修了。

## 一、「我的」页两处真 bug

1. **主题分段按钮 `SettingsPage.ets:834`**:
   `.fontColor(... ? Theme.surface : Theme.textPrimary)` —— `surface` 是
   **会翻转的面色**,压在 `Theme.accent` 蓝底上,深色下变成近黑。
   设备读数:`深色 2.93:1  ink rgb(32,34,36) bg rgb(34,96,228)`。
2. **权限档徽标 `MailDetailPage.ets:391`**:同一个错法(第 13 处)。
3. **账号名 `SettingsPage.ets:1179`**:三元里的裸 `Theme.accent`
   压在 `accentSoftFor()` 底上 ⇒ `jianf 2.83:1`。
   `AdminUsersPage.ets:386` 同形状(角色徽标)。

## 二、判据自己的漏报形状(比 bug 本身更值得记)

静态防线(`C|会翻转的 Resource 不得当前景色`)**在 bug 存在时是绿的**。
原因是它的提取正则:

    /\.fontColor\(Theme\.([A-Za-z0-9_]+)\)/      ← 要求令牌是**唯一实参**

于是**三元里的令牌全被漏掉**:

    .fontColor(this.appearanceTheme === t ? Theme.surface : Theme.textPrimary)

改成"在 `fontColor(` 之后的整段实参里找所有 `Theme.X`"之后,
它**立刻报出上面第 1、2 两处**(此前一直绿)。

★ **判据漏报的常见形状是它自己的正则太窄,而不是被测代码太隐蔽。**
  这条写成注释留在判据里了。

## 三、我自己犯的批量替换错误(已加判据钉住)

把裸 `Theme.accent` 换成 `accentFor()` 时,**误把 6 处 `backgroundColor` 也换了**。
`accentFor()` 深色给浅蓝 `#80AFF9`,而搭档前景是白色 `accentFg`
⇒ 白字压浅蓝 ≈ **1.4:1**,主按钮文字会彻底看不见。

- 6 处已逐处还原(复核:`grep -c "backgroundColor(Theme.accentFor())"` = 0)。
- 新增判据 **`C2`**:`accentFor / dangerFor / approveFor / warnFgFor / textSubtleFor`
  **只能用于前景**,`backgroundColor(Theme.XFor(...))` 直接判红。
  (`accentSoftFor` 是例外 —— 它本来就是"面"。)
  ★ 修法不是"下次小心点":批量替换一定会再犯,**一行判据把它变成不可能**。

## 四、设备复扫(修后)

```
[通信]      扫 42 段,低对比 0
[日历]      扫 85 段,低对比 0
[联系人]    扫 51 段,低对比 0
[我的]      扫 41 段,低对比 0      ← 新增
```
**219 段文字全部 ≥3:1**(本轮全部针对深色)。

## 五、底本

`baseline.sha` 第 5 次重算。重算前**专门复核**了"那 6 处误改有没有残留"
(不只看 `git diff` 非空就放行):`backgroundColor(accentFor)` 计数为 0、
`git diff HEAD~1` 里 backgroundColor 只有 calendar 那一处(有意改动)。

`run-all.mjs` → `checks=515 pass=515 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:浅色主题下的对比度没扫(本轮全部针对深色)。
2026-09-19 20:43:59 +08:00
4ecddfc2b6 跨端: 语义色/三级文字在深色下没提亮(设备扫出 12 处)+ 判据取样法修到第三版
## 一、判据基建先修对,否则是在听噪声

深色可读性扫描的**取样法迭代了三版**,每版都因为**假红**才改的
(记在这里,因为"判据自己错"比"界面错"更费时间):

1. **取中心一个像素** ⇒ 落在笔画之间、读到的是底色 ⇒ 一片 ratio=1.00 假红。
2. **取全局最暗/最亮** ⇒ 会采到**两个不同的东西**:细字的抗锯齿中间色。
   实例:segmented「日」报 ink `rgb(32,34,35)` / bg `rgb(98,100,101)` 2.69:1 ——
   而截图里它要么蓝底白字、要么深底浅字,两种都远高于 3:1。
   **反复核对才确认是判据的错,不是界面的错。**
3. **步长采样 + 众数** ⇒ 仍然假红:周表头「一」报 2.03:1,
   而逐像素的真实墨是 `rgb(166,167,167)`(**6.6:1**)——
   稀疏网格**整个跳过了笔画**。这不是调参能修好的(字越小越糟)。
4. **逐像素 + 众数=底 + 离底最远者=墨**(最终版)。
   理由:一个文字框里**面积最大的一定是底**,而"离底色最远"就是笔画最实那部分。
   ⚠️ 逐像素是**负担得起的**(整屏已解码在内存,一个框最多 9 万像素)——
   **不要再为了省开销退回抽样**,前两版都因此假红。

## 二、真 bug 三批(都是"深色下没提亮")

### ① 语义色前景(12 处 → 修完设备复扫 0)
WebUI 的语义色也是**双通道**,`.dark` 段把 `red/green/amber-700` 换成浅色
(`248/118/249 → 164/219/195` 那组)。鸿蒙只有一个深色值:
- 「归档」按钮 `Theme.danger` #B91C1C 压深面 ⇒ **2.47:1**(联系人页 7 处)
- 「今天」/「日」等 ⇒ **2.38–2.77:1**

加 `dangerDark/approveDark/warnFgDark`(逐字对齐 WebUI `.dark`)
+ `dangerFor()/approveFor()/warnFgFor()` 入口,22 处接入。

### ② 日历工具条整块配色抄错(不是"深色档选错")
对照 WebUI `CalendarView.tsx:313-331`:

| 元素 | WebUI | 鸿蒙(改前) |
|---|---|---|
| 「今天」 | `border-gray-300` + `text-gray-700`(**中性**) | `accentSoft` 底 + `accentStrong` 字 |
| 月/周/日 未选中 | `bg-white` + `text-gray-700` | `accentSoft` 底 |

鸿蒙把**次要的中性按钮**画成了**品牌色按钮** —— 视觉重量跟主操作一样,
三个档看起来"都像选中"。已按 WebUI 改成中性。

### ③ 三级文字在深色下 2.82:1(**71 处**,全局最大的一个)
WebUI 的 `.dark` 段注释逐字写着这个坑:

> 「`gray-400/500`(次要文字)→ **提亮**。深底上的浅色 gray-400
>  只有约 2:1 对比度,远低于 WCAG AA 的 4.5:1 —— **看得见但读不动**。」

鸿蒙用的系统三级色 `ohos_id_color_text_tertiary` 深色下是 `rgb(102,103,105)`
压深面 **2.82–3.05:1**,而它被用在 10–11px 的**小字**上
(时间戳、农历、空态、辅助说明)。

**不换掉系统令牌**("系统拥有的维度"纪律照旧),加 `textSubtleFor()`:
深色下改用二级色(实测 `rgb(166,167,167)` ≈ 7.15:1,
且与 WebUI 的 `gray-500` 同档位)。71 处接入。

**设备复扫(修后)**:通信/日历/联系三页 **176 段文字全部 ≥3:1**。

## 三、欠账与底本

- `baseline.sha` 第 4 次重算。仍**先逐个取证**:
  `git diff --quiet HEAD -- <f> && echo STALE || echo INTENTIONAL`
  ⇒ 三个全是 `INTENTIONAL`(我改的),不是变异残留。
  把这条取证命令也写进文件,下次照抄。
- `dangerDark/approveDark/warnFgDark` 已登记进 `SELF_OWNED_COLORS`
  + `Theme.ets` 登记表(各带理由与 WebUI 出处)。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:①「我的」页(底部头像按钮,扫描脚本没找到入口)没扫;
② 浅色下的对比度没扫(本轮全部针对深色)。
2026-09-19 20:27:09 +08:00
13b557a742 跨端: 品牌蓝深色下没提亮(24 处字/图标看不见)+ 补深色可读性设备判据
## 一、真 bug:WebUI 深色下把品牌蓝**提亮**了,这边没有

WebUI 的强调色是**双通道**(`tailwind.config.js` 的 `backgroundColor`
/`textColor` 覆盖 + `index.css` 两段定义):

| 通道 | 用途 | 浅色 | 深色 |
|---|---|---|---|
| `--s-blue-600` | **实心按钮底** | `37 99 235` | `37 99 235`(**同值**)|
| `--c-blue-600` | 内容/交互的**蓝字与图标** | `37 99 235` | **`128 175 249`** |

`index.css:353` 写了理由:主按钮底跟着变「会让主按钮在深色页面上
失去『这是主操作』的视觉重量」;而蓝字必须提亮,否则深底上读不动。

鸿蒙只有一个 `Theme.accent = '#2563EB'` ⇒ 24 处字/图标在深色下
对比度 **2.61:1**(设备实测:管理页返回箭头 `‹`),低于 WCAG 图形下限 3:1。

**取证方式**:在跑着的 WebUI 上用 CDP 读**计算样式**(不是读 CSS 源)——
浅色 `37 99 235` / 深色 `128 175 249`,实测确认。

## 二、修法:加前景专用的深色档 + 唯一入口

- `Theme.accentDark = '#80AFF9'`(= WebUI `.dark --c-blue-600`)
- `Theme.accentFor(dark?)` 作为**前景**唯一入口
- **当背景的 20 处保持 `Theme.accent` 不动**(跟 WebUI 的 `--s-*` 一致)

24 处 `.fontColor/.iconColor(Theme.accent)` → `Theme.accentFor()`。

## 三、顺手修掉「转述一层就会漏」这个结构问题

`accentSoftFor(isDark)` 原本的约定是"页面算好深浅色传进来"。
给 `accentFor` 做准备时一数:**8 处**直接用了 `Theme.accentSoft`(没走入口)
—— 约定**已经漏了**,而漏掉的症状正是上一轮那个"深色下白底卡片刺眼"。

于是把两个 `For()` 的参数都改成**可选**:`AppStorage` 是 ArkTS 全局键值存储,
静态类可以直接读(原来"拿不到 Context"的理由不成立)。
⇒ `Theme.isDarkNow()` 成为唯一判断点,调用方不必再各自转述。

## 四、设备判据:深色可读性**扫一屏**

原来只有悬浮球那一条(单个点)。这个 bug 类一天撞到**两批**(12 处 + 24 处),
逐处写判据追不上 ⇒ 改成把当前页所有小段文字都量一遍对比度。

三个实现要点(第一版全踩了,都写进注释):
- **不能只取中心一个像素**:中心多半落在笔画之间 ⇒ 读到的是底色,
  报出一片 ratio=1.00 的假红。改成**框内网格扫描取极值**(最亮=底/最暗=墨)。
- **整屏解码一次**:每点 spawn 一次 ffmpeg 太慢 ⇒ 新增
  `readPixels()`(157ms 解整屏,比逐点快三个数量级)。
- **只判"有真实墨迹"的框**(`hi.L - lo.L >= 0.02`),否则跳过而不是判红。

实测:修前 1 处低对比(2.61:1),修后 **40 段文字全部 ≥3:1**。

## 五、判据自身的三个修正

- `accentSoftFor(dark:)` 的签名断言跟着放宽成 `dark?`,并**补上 `accentFor` 的**。
- **孤儿令牌判据从"一跳"改成"走整条链"**:`KEY_IS_DARK` ← `isDarkNow()`
  ← `accentFor()` ← 24 处页面。只查一跳时它假红 ——
  ★ **"有没有人用"是可达性问题,不是邻接问题**;加中间层(抽 `For()` 入口)
  恰恰是我们鼓励的写法,而旧判据会因此假红。
- 手写色登记表补 `accentDark` 一行理由。

## 六、`baseline.sha` 重算(先核过不是残留)

三个文件哈希对不上。逐个 `git diff --quiet HEAD -- <f>` 取证:
- `AdminUsersPage.ets` / `SettingsPage.ets` —— 本次**有意编辑**;
- `api/AppearanceApi.ets` —— **与 HEAD 逐字节相同** ⇒ 底本取完后被**合法改过**
  (提交 `f811c98`),属 `stale` 不是 `residue`。

按该文件自己那条纪律(「重算必须是一次有记录的动作」)在文件里记了理由。

`run-all.mjs` → `checks=514 pass=514 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 20:05:33 +08:00
3123d83979 判据: 把「面当字」的防线从死名单改成判矛盾(三次迭代都记下来了)
上一版加的静态防线只 grep `fontColor(Theme.surface)` —— **一个名字**。
那是死名单:以后加了新令牌它不会跟上(本仓已记录过多次这类错法)。

改成从源码推:
  **条件 A**:值是 `Resource` 且指向 `sys.color.*` ⇒ **跟随系统主题翻转**
  **条件 B**:既当 `backgroundColor` 又当 `fontColor/iconColor` ⇒ **被当成「面」也被当成「字」**

判决 = **A ∩ B**,不需要维护任何名单。

## 三次迭代(每次错得很典型,所以都写进注释了)

- **① 死名单**(只盯 `surface`):新增令牌不会跟上。
- **② 只要 A**("是翻转的 Resource 就不许当前景色"):**太宽** —— 实测立刻
  报出 20+ 处"违规",全是 `textMuted` / `textSubtle` / `textPrimary`。
  那些**本来就该**当前景色(语义就是文字色,跟随主题翻转是对的)⇒ 假红一片。
- **④ 只要 B**("矛盾就报"):**也宽** —— `accent` / `danger` / `approve` / `warnFg`
  两边都用是正常设计(蓝底白字主按钮 + 白底蓝字返回箭头)。
  和 `surface` 的**关键区别**:那些是**饱和的字符串常量**(不翻转、语义是"色"),
  而 `surface` 是**中性面 + 跟随主题翻转**(当字色 = 把底板当墨水,深色下必然看不见)。
- **最终 = A ∩ B**:恰好命中 `surface` 这一类,且**不随新增令牌失效**。

允许清单现在是**空表**(修完 `surface` 后没有例外),
并写明「已审过、不需要进这张表的」那几个令牌与理由。

## 变异验证(两个都做了,第二个是关键)

1. 把一处 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **造一个判据从没见过的新令牌**(`surfaceAlt`,同样 `Resource` + `sys.color.*`),
   让它同时当背景与前景 ⇒ **判红**(报文里点名 `Theme.surfaceAlt`);还原 ⇒ 绿。
   —— 这条比 ① 重要:它证明判据**不是靠记住 `surface` 这个名字**在工作。

## 顺带确认

`pageBg` / `surfaceMuted` / `wallpaperScrim` / `border` / `overlay` 这五个
同类"会翻转的面"**一处都没被当字色用过** —— 这一类现在全清了。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`。
2026-09-19 19:39:02 +08:00
21132647bc 跨端: 12 处「深色下字看不见」的真 bug + 判据基建补上「看像素」这一层
## 一、判据基建:本目录终于能**看像素**了

此前只能靠 `dumpLayout` —— 那是**结构化描述**,报的是"组件声明了什么",
不是"屏幕上画成什么样"。两者会分叉,而观感类结论只能在像素上得出来。

新增 `lib/harmony-device.mjs`:`screenshot()` / `pixelAt()` /
`hexToRgb()` / `closeColor()`(用 ffmpeg 转 1×1 原始 RGB,不引依赖)。

**它当场证明了它的价值**:`cross-client-theme` 新增的设备判据
用真实像素抓到下面这个 bug —— 静态判据全绿时它藏得好好的。

## 二、真 bug:**12 处**把 `Theme.surface` 当前景色用

`Theme.surface` 是 `sys.color.ohos_id_color_list_card_bg` ——
一个**跟随系统主题翻转**的 Resource:浅色近白、**深色近黑**。

- 浅色下当白字用**碰巧对**(白字压蓝底)
- **深色下字变成黑的**,压在品牌蓝 / danger 红 / warn 琥珀上**几乎看不见**

设备现场:写邮件悬浮球是品牌蓝 `#2563EB`,截图里那个铅笔图标**几乎是隐形的**;
读圆心像素得到 `rgb(32,34,36)`。往左偏 50px 读到底色才见 `rgb(36,99,235)`。

`Theme.accentFg`(`#FFFFFF`)的注释原话就是「品牌底上的文字」—— 为这个场景存在,
却**一处都没用**。

修:12 处 `fontColor/iconColor(Theme.surface)` → `Theme.accentFg`
(`MainPage` 10 + `InboxPage` 1 + `SessionsPage` 1)。改完全仓 0 处残留。
另在 `Theme.ets` 给 `surface` / `accentFg` 都补上"能当什么、不能当什么"的注释。

## 三、判据(两条,都做了变异验证)

1. **设备条**(`cross-client-theme`):读悬浮球像素 ——
   ① 品牌色**真的画成** `#2563EB`(声明 ≠ 渲染);
   ② 球上图标与底色 **WCAG 对比度 ≥3:1**(压在上面的东西得看得见)。
   把 `.accentFg` 改回 `.surface` ⇒ **判红**;还原 ⇒ 绿。
2. **静态防线**(同文件):全局 grep「`fontColor/iconColor(Theme.surface)`」一处不许有。
   设备条只能看一处,而这个错法有 12 处 —— 静态防线管住整类。

## 四、判据自身踩的三个坑(都写进注释了)

- **采样点撞上图标**:第一版取球心,读到 `rgb(32,34,36)`,差点当成"品牌色没渲染"。
  截图一看球是蓝的,深色那点是**铅笔图标**。⇒ 往中心左偏 30% 球宽。
- **假设错了 FAB 的位置**:按"屏幕右下角"找(`x1 > 屏宽*0.6`),
  实测 `[942,1997]`(`x1=942` vs 阈值 1910)⇒ 永远找不到、**静默跳过**。
  原因是列表窗格是**左栏**,球在"左栏的右下角"。⇒ 形状只用站得住的那部分(下半部)。
- **设备判据要自己搭现场**:不加自导航时它**永远跳过**(前面的判据把前台留在管理页),
  而那看起来像"功能没了"。加自导航后立刻开始工作并抓到 bug。

## 五、欠账

- `harmony-maildetail-missing-three` → **count 0(结算)**:三块都做完了
  (转发 `b7c5d8b` / 改名建议 `c2f35d1`+`e79a86a` / 往返预算 `ac62daf`)。
  如实记着**未验**的那点:预算条的**点击**没在设备上走通
  (模拟器顶部 155px 是系统手势区,折叠头部恰在其中)。
- `static-criteria` 5:`cross-client-theme` **升级了一半**,仍留在名单里 ——
  `.ets` 那半只有悬浮球这一处上了设备,其余令牌仍是静态对齐。
- `debt-visibility` 登记 `cross-client-theme` 1 处边界声明(带出处)。

`run-all.mjs` → `checks=513 pass=513 fail=0 skip=0 red=0 broken=0 unreported=0`;
Go 侧 `./internal/repo/...` 通过。
2026-09-19 19:29:49 +08:00
ac62dafde1 跨端: 往返预算编辑上线(详情页缺的第三块)+ 修折叠头部点不中的真 bug
审计发现详情页缺三块功能之三:**往返预算**(WebUI `BudgetEditor`)。
`MailApi.setBudget` 早就有了,缺的是 `getBudget` 与 UI。

## 一、预算条(对齐 WebUI,两态)

显示态是徽标(`已用/上限 来回` / 不限时「预算不限」/ 用尽时标红),
点它进入编辑态:输入框 + 保存 + 重置 + 取消。

★ 「用尽」的判定**走服务端的 `remaining`**,不自己拿 `used >= max` 算:
不限时服务端给 `remaining: -1`,那个式子在那种情形下会算出"已用尽"。
两端都得用服务端的口径。

★ 「保存」与「重置计数」走**同一条 PUT**(服务端支持两者同时给,
它的注释原话:「加到 20 并从头算」是一次很自然的操作,
拆成两个请求只会让前端多一次往返)。

**契约实测**(不是形态检查):
    GET  → {"max_rounds":0,"used_rounds":1,"remaining":-1,"unlimited":true}
    PUT {"max_rounds":7,"reset":false} → {"max_rounds":7,"remaining":6,"unlimited":false}
    PUT {"max_rounds":0,"reset":false} → {"remaining":-1,"unlimited":true}      ← 0 = 不限

## 二、真 bug:折叠头部的展开区只有 **14px** 高

设备实测 dump:`Row [1277,112][3122,126]` —— 可点区高度 = 文字高度(14px 字号)。
而全屏之后**状态栏也在 y=112 那个区间** ⇒ 点它反复触发**系统手势(下拉通知)**
而不是展开头部。我为此试了七八次,每次都回到桌面。

而收起态头部里藏着**这个会话的全部旋钮**(收件人/时间/抄送/权限档/预算)——
点不中就等于那些都看不到。

修:给那一行 `height(36)`(与左边返回键同高)。实测可点区
**14px → 43px**(`[1277,112][3122,155]`)。

★ 判据 + **变异验证**:去掉 `.height(36)` ⇒ 判红;还原 ⇒ 绿。

## 三、判据

`harmony-logic` 新增「折叠头部展开区高度 ≥32vp」一条(含变异自检)。
`run-all.mjs` → `checks=511 pass=511 fail=0 skip=0 red=0 broken=0 unreported=0`。

**未验**:预算条在设备上的**观感与点击**(模拟器顶部 155px 是系统手势区,
而头部恰在其中 —— 坐标式点击在那里会被系统抢走;真机上头部在状态栏之下)。
预算条的**数据链路**已用 curl 逐条实测(见上),但"点保存按钮后徽标变化"
这一步没在设备上走通。
2026-09-19 17:41:09 +08:00
e79a86ab79 跨端: 改名建议条接到详情页(端到端实测:点「接受」别名真的改了)
上一批做完了接口层,这批接 UI —— 详情页现在真的显示建议条并能操作。

## 做了什么

提示条放在正文之前(与 WebUI 同位置):Agent 给的理由 + **后果说明**
(「改名后需用 name@path.xxx 寻址;旧别名立即失效……」)+ 「接受」/「忽略」。
蓝底(`accentSoft`)而不是警告色:它不是故障,是一个**待决定**;
深色下走 `accentSoftFor`(就是前一批修的那个主题感知浅底)。

`doAcceptRename` 走 `PUT /sessions/{id}/alias`(与"人手改别名"同一端点),
`doDismissRename` 走专用 dismiss 端点 —— 服务端记下被驳回的别名,
否则每次打开会话都要重新点一次「忽略」。

## 途中三处**我自己的**错误(都值得记)

1. **写错了请求体字段名**:我按记忆写成 `session_alias`,而服务端
   `updateAliasRequest` 的 tag 是 **`alias`**(`sessions.go:95-97`),
   WebUI 也是 `{ alias }`(`client.ts:537`)。而它的 `Decode()` 是
   `DisallowUnknownFields()` ⇒ 传错名字**直接 400**(不是"被忽略")。
   这是今天第三次同源错误(`AppearancePayload` / `ForwardMailRequest` / 这次)——
   **请求体字段名必须去服务端 struct tag 里读,不能凭记忆写**。

2. **判据跟着实现一起错**:我把判据也写成"断言 `session_alias: alias`" ——
   等于把这处错误**锁进两处**。判据已改成断言 `this.alias = alias`,
   并加了一条反向断言"不许出现 `session_alias: alias`"。
   ★ 这是"判据引自己的实现当依据"的又一次实证。

3. **UI 写完忘了接数据**:提示条的 UI 做完、编译过,但**没有调用
   `getRenameProposal`** ⇒ 页面上永远不显示(服务端明明有建议)。
   —— 正是本仓反复出现的"写好了但没人调用":静态判据查得出"代码里有这个方法",
   查不出"页面从没调过它"。
   ⇒ 补上 `loadRenameProposal()`,并**单独一个 try**(建议条是附加信息,
   它失败不该把正文也变成错误态)。

## 端到端验证(真数据)

造一封带标记的真邮件投进一个真会话 → 服务端识别(`rename_proposed: "rename-verify"`)
→ 详情页出现建议条(dump 读到理由 + 后果说明 + 两个按钮)
→ 点「接受」→ 库里别名真的变了:

    处理四桥冒烟测试邮件  →  rename-verify

## 判据

`run-all.mjs` → `checks=510 pass=510 fail=0 skip=0 red=0 broken=0 unreported=0`。
`hvigorw assembleHap` 成功;前端重建 + 重打包。

**未验**:「忽略」按钮的端到端(服务端记下驳回、不再重复弹)——
接口层已有判据钉住路径,但没在设备上真点过。
2026-09-19 17:23:23 +08:00
c2f35d1023 跨端: 会话改名建议的接口层(详情页缺的第二块,服务端三个端点齐)
审计发现详情页缺三块功能之二:**Agent 的改名建议条**(WebUI `MailView.tsx:324`
的 `RenameProposalBar`)。这一批做**接口层**,UI 接线下一批。

## 为什么这件事不只是"少个提示条"

WebUI 那段的注释写得很清楚:

> Agent 干到一半自己改掉,人上一秒记住的地址下一秒就失效。
> 提议 + 人确认,既让 Agent 表达意图,又保证寻址稳定性由人掌握。

也就是**寻址稳定性**的设计 —— 别名是人的寻址入口,Agent 只能**提议**。

## 做了什么

1. `model/SessionRename.ets` —— `RenameProposal`(`alias` / `reason`,
   字段名与服务端 json tag **逐字对齐**)
2. `api/SessionApi.ets` —— 三个动作:
   · `getRenameProposal(sessionId)` → `{"proposal": {...}}` 或 `{"proposal": null}`
   · `acceptRename(id, alias)` → **`PUT /sessions/{id}/alias`**
   · `dismissRename(id)` → `POST /sessions/{id}/rename-proposal/dismiss`
3. 判据(`harmony-logic`,+1 条):字段名、响应外壳容忍 `null`、
   接受走别名端点、驳回走专用端点、方法名与 WebUI store 同名、+ 变异自检。

★ **接受为什么不另开端点**(服务端注释原话):
「那条路径已经有唯一性校验与 409 处理,复制一遍只会多一个出错的地方」。
所以"接受建议"与"人手改别名"是**同一条路**,而"驳回"是另一条
(它不是改别名,是"别再问了"——服务端记下被驳回的别名,
否则每次打开会话都要重新点一次「忽略」)。

## 端到端验证(真数据,不是形态检查)

造了一封带标记的真邮件投进一个真会话:

    POST /mail/send {reply_to: …,
      body: "内容正文。<!-- agentmail:rename-session alias=\"rename-verify\" reason=\"验证改名建议链路\" -->"}

    → 200 {"rename_proposed":"rename-verify", …}

然后:

    GET /sessions/{id}/rename-proposal
    → {"proposal":{"alias":"rename-verify","reason":"验证改名建议链路"}}   ✓ 形状与我的一致
    SELECT body FROM mails …
    → "内容正文。"                                                        ✓ 标记被剥掉

两件事都验到了:**服务端识别建议**,且**标记从人读的正文里剥离**
(HTML 注释在 Markdown 渲染器里会变成可见文本,所以必须剥,不能指望渲染器吞掉)。

## 判据

`run-all.mjs` → `checks=510 pass=510 fail=0 skip=0 red=0 broken=0 unreported=0`。
`harmony-logic` 30 → 31。`hvigorw assembleHap` 成功;前端重建 + 重打包。

**未做**:UI 接线(详情页的提示条)。接口层已完成并验证,
但"页面上真的显示建议条并能点接受/驳回"要下一批。
2026-09-19 17:12:40 +08:00
b7c5d8b1e7 跨端: 邮件转发上线(详情页缺的入口)+ 修两个真 bug(动作球重叠 / 键盘挡住按钮)
审计发现详情页缺三块功能之一 —— **转发**。服务端 `forward.go` 完整、
`MailApi.forward()` 也早就写好了,缺的只是这一页的入口。

## 一、转发(对齐 WebUI `ForwardBar`)

字段与占位文案逐条对齐:收件人 / 抄送(**可折叠**,默认收起)/ 说明 / 引用原文。
`subject` 留空让服务端自动加 `Fwd: ` 前缀(它处理了 "Fwd: Fwd:" 无限叠加)。

★ 请求体用了**专用类型** `ForwardMailRequest`,不复用 `SendMailRequest`:
服务端 `forwardRequest` 只认五个字段,而它的 `Decode()` 是 `DisallowUnknownFields()`
⇒ 多带一个(`body` / `reply_to` / `attachment_ids`…)就 **400**。
—— 这正是我今天在 `AppearancePayload` 上刚犯过的那个错,**端点一个类型一个请求体**。

★ 顺带发现:`MailApi.forward` 的签名**原本就写错了**(参数类型是 `SendMailRequest`)
—— 一直没被发现,因为**从来没有调用方**。"写好了但没人用"的代码,
连它自己的类型对不对都没人验过。

## 二、真 bug ①:两个动作球**几乎完全重叠**

转发球与回复球**各自**写在 `Stack({alignContent: BottomEnd})` 里、各带一个 margin。
实测 dump 的 bounds(密度 2.875):

    转发 [2984,2010][3122,2148]
    回复 [2949,1998][3110,2159]      ← 重叠区 x ∈ [2984,3110]

屏幕上只看得到一个球,**转发入口等于不存在**。
根因:`Stack.alignContent` 把**每个**子元素都摆到同一个角,margin 只是各自微调。
修法:用 `Row({ space: 12 })` 包住两个球、由 Row 带 margin 到角落。
**设备实测**(修后 dump):`[2776,2021][2914,2159]` 与 `[2949,1998][3110,2159]`,不重叠。

## 三、真 bug ②:键盘一弹,「转发」按钮就被顶出屏幕

转发弹层第一版**没有高度**(只有 `padding(16)`)⇒ 尺寸由内容决定。
而 ArkUI 默认的键盘避让是 `KeyboardAvoidMode.OFFSET`(整体上移)——
上移之后 `TextArea` 与「取消 / 转发」按钮**跑到键盘下面**,点不到。
实测截图:只看得见收件人输入框 + 键盘。

修法两半(缺一不可):
① 弹层给明确高度 `height('60%')`(与回复弹层一致,它一直没出问题);
② 说明框改 `layoutWeight(1)`(不是固定 `height(70)`)—— 键盘顶上来时它自己缩短,
   把按钮留在屏内。

**设备实测**(键盘弹出时 dump):`取消 [1196,2054][1426,2158]`、
`转发 [2880,2054][3110,2158]` 都在屏内(屏高 2232),且 `clickable=true`。

## 四、判据(这两条固化了上面两个形状)

`harmony-admin` 新增两条**静态形状**判据(它们抓的是写法,不需要设备):
1. **同一 `Stack` 里的多个圆形按钮必须被 `Row` 包住**(否则重叠);
2. **底部弹层必须有明确高度** + 会撑高的子元素用 `layoutWeight`
   (否则键盘一弹按钮就被顶出屏幕)。

★ 为什么用静态判据而不是设备判据:这两个 bug 的**形状**在源码里就看得见
(`Stack` + 各自 margin / 弹层缺 `.height`),而设备判据要摆出"键盘弹出"这个态,
成本高且不稳。静态判据在这里是**更快更准**的那一层。
(设备判据仍保留在别处,验"真的能打开、真的渲染出来"。)

## 五、验证

`run-all.mjs` → `files=32 ran=32 checks=509 pass=509 fail=0 skip=0
red=0 broken=0 unreported=0`(`harmony-admin` 28 → 30)。`hvigorw assembleHap` 成功。

**设备实测**:转发球与回复球分开显示(各自图标可见);
点转发球 → 转发框打开(「转发「…」」+「抄送」折叠开关 + 取消/转发);
键盘弹出后按钮仍在屏内可点。

**未验**:真发一封转发(收件人输入在自动化里不稳 ——
`uitest inputText` 是**追加**而非替换,且 `keyEvent Back` 会退出页面而不是收键盘。
这条留待真人操作窗口,与 `harmony-p4c-boundary-decls` 那笔同性质)。
2026-09-19 17:00:44 +08:00
1bf687f506 判据: 图片上传链的设备判据(用真实素材)+ 静态欠账从 6 减到 5
继续升级到期的静态判据。这一批做 `harmony-imageprep`(上传链),
并顺手把已完成的 `harmony-admin` 移出欠账名单。

## 一、图片上传链:用**真实素材**验压缩决策的输入

`run-all.mjs` 的 `STATIC_ONLY` 里那条登记写着
「上传链的设备侧:`@ohos.multimedia.image` + 相册要设备才能真跑」。
上面 30 条判的都是 `model/ImagePrep.ts` 的纯逻辑(阈值、单调性、边界…),
它们全绿时有一件事从未验过:**那些数字与设备上真实的图片对得上吗**。

新判据用**用户真上传过的那张壁纸**(`GET /me/appearance/image` 取回,
1402×1122 / 152570 字节 —— 不是合成图),断三个跨端事实:
① 设备上读到的像素尺寸 = 决策时用的尺寸;
② 真实体积在客户端上限之内;③ 该尺寸走 `planCompress` 首档**不缩小**
(长边 1402 < 2560)+ `judgePick` 放行。

★ **为什么不合成图**:纯色能压到几 KB、噪声几乎压不动,用它们验阈值
会得到"怎么都对"的假绿。真实照片的行为才是要验的那个。

★ **诚实标注了没做的那一半**(写在判据注释与 `DEBTS.json` 里):
本判据用的是**设备上的 `file`/`ls`** 这一独立来源读素材属性,
**没有**跑 `image.createImagePacker()`。跑它需要一个**用户选图**入口
(`DocumentViewPicker`,要人操作系统选择器),自动化里没有稳定路径;
而编一个"绕过选择器直接调 `packJpeg`"的测试专用入口,会是**只有测试在用的代码**
——那种代码不会被真实场景触到,验它等于验一个不存在的东西。

## 二、静态欠账 6 → 5(还完就划掉)

`harmony-admin` 那条**已升级为设备判据**(上一批做的),
所以它**不该再留在 `STATIC_ONLY` 里** —— 那个名单是给"还欠着的"记账的。
留着会让余额虚高,而这正是这个机制要防的(欠账不显形就等于没有)。

## 三、途中被两条"登记一致性"判据拦了两次(都按它们给的方向修)

1. `debt-visibility`:我在 `harmony-imageprep` 里新增了两处边界声明
   ("未覆盖/未验"这类词),而余额里没登记 ⇒ 红。
   **按它要求的顺序做**:先补 `docs/DEBTS.json`(`harmony-p4c-boundary-decls`
   那一笔的 note 里写明"只做了一半"),再把登记次数 4 → 6。
2. `commit-hygiene`:`DEBTS.json` 说 `static-criteria=6`,实测 5 ⇒ 红。
   —— 这条正是"可见的那个数字是副本,漂移了必须两边一起改"。
   改数字之外还在那一笔里写了**为什么减**(admin 升级并移出名单)。

★ 第三处被拦很有意思:`debt-visibility` 是**按词表数自己**的判据,
我第一次修时在那个文件里写了一句话里含"仍未覆盖",于是它把自己数多了 1 处
(12 → 13)。那一刻是**判据在正确地工作**("多一处即红")——
我写的其实是**引用**另一笔账,不是新的边界声明,所以改成了不带判定词的措辞。

## 四、判据

`run-all.mjs` → `files=32 ran=32 checks=507 pass=507 fail=0 skip=0
red=0 broken=0 unreported=0`。`harmony-imageprep` 30 → 31;
`harmony-admin` 移出静态名单(仍在套件里,28 条)。
`hvigorw assembleHap` 成功;前端重建。

**剩余到期未升级**:`harmony-appearance`(已有 2 条设备判据)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults` —— 逐条来。
2026-09-19 16:18:10 +08:00
fc295893cb 跨端: 深色模式下的品牌浅底不跟随 —— 12 处「选中/未读」底色在深色页上刺眼
## 真 bug(设备实测)

深色主题下,「我的」页**选中**的那张账号卡片仍是接近纯白的浅蓝
(实测像素 `(255,255,255)` 级别的浅底压在 `(32,34,36)` 的深色页上)。

根因:`Theme.accentSoft = '#EFF6FF'` 是**写死的浅色**,而它被当作
「选中态背景」用在 12 处(未读邮件行、选中账号、分段选中、登录页模式切换…)。

**为什么只有 WebUI 没这个问题**:它有 CSS 变量的**反转发**机制 ——
`index.css:113` 的 `--c-blue-50: 239 246 255` 在 `.dark` 段(`:475`)被换成
`28 37 54`(深蓝黑)。ArkTS 的 `static readonly` **一个常量一个值**,
没有那层机制 ⇒ 静态常量必须自己提供两个取值。

## 修法

1. `Theme.accentSoftDark = '#1C2536'`(对齐 WebUI `.dark --c-blue-50` 的 `28 37 54`)
2. `Theme.accentSoftFor(dark)` 作为**唯一入口** —— 不在页面里各自
   `isDark ? a : b`:那样每处都会各写一遍,迟早漏一处
   (WebUI 那条"由 test/theme.test.mjs 逐档断言"就是为防这个)
3. **深浅色从哪来**:只有 `MainPage` 算得出(它读 `resourceManager` 的
   `colorMode`)。所以走 `AppStorage` 单向发布(与徽标、windowInsets 同一套):
   MainPage 算 → 写 `agentmail.appearance.isDark` → 各窗格 `@StorageProp` 读。
   6 个文件、12 处,全部改用 `accentSoftFor(this.isDarkNow)`。

**设备实测**:深色下「我的」页账号卡片与收件箱未读行都变成深蓝底,
像素 `(32,34,36)` 与页面底一致(不再刺眼)。

## 判据(这条是新加的,形状值得记)

`cross-client-theme` 新增:**品牌浅底必须有深色变体**。断三件事:
1. 深色变体存在,且**取值从 WebUI 的 `.dark` 段反推**(不是随手挑一个深色);
2. 有按主题选值的**入口**(防"页面各自写三元");
3. **用到它的地方真的走那个入口** —— 扫描所有 `backgroundColor(… accentSoft …)`
   并排除 `accentSoftFor`,把漏改的位置**逐行报出来**。

第 3 条在我改到一半时**当场列出了剩下 9 处**(`CalendarPage:1172`、
`ComposePage:255`、`LoginPage:327`…)—— 这就是它该有的样子:
不是"断言存在某个常量",而是"断言没有一处漏改"。

★ 写这条判据时踩了自己一次:JS 模板串里嵌了反引号包围的标识符
(`` `accentSoft` ``),直接 SyntaxError。改用字符串拼接。

## 判据

`run-all.mjs` → `checks=506 pass=506 fail=0 skip=0 red=0 broken=0 unreported=0`。
`hvigorw assembleHap` 成功;前端重建。

**未验**:日历/登录页在深色下的观感(只逐处改了底色,没逐页截图)。
2026-09-19 16:02:26 +08:00
a18014e1e3 判据: 管理页设备判据(到期静态判据的第一条升级)+ 修设备判据基建的三个真 bug
`run-all.mjs` 的 `STATIC_ONLY` 里登记着 6 条"只能静态验"的判据,前提是
「本工作区能装、能点设备」。设备现在可用 ⇒ 它们**到期**了(欠账当场变红)。
这一批升级第一条:`harmony-admin`(用户管理页)。

## 一、新设备判据:管理页真的能打开、列表真的渲染

上面 27 条静态判据判的都是逻辑与接线形态。它们全绿时,
"管理页能不能打开、列表能不能渲染"**一句都没验过** —— 而它最容易坏:
路由注册对了但入口没接上、接口回来了但列表没渲染。

判据自己搭现场:拉起应用 → 进「我的」→ 滚到底 → 点「管理」→
断言真的在管理页(出现「新建用户」)且**渲染出用户行**。

## 二、途中撞出三个**判据自身**的 bug(都已修,都写了教训)

1. **`tapText` 从来没成功点过任何东西**(`lib/harmony-device.mjs`)
   `findByText` 返回的是**数组**,我当单个节点用了 ⇒ `node.attributes` 恒
   `undefined` ⇒ 恒返回 `false`。症状极隐蔽:调用方以为"没找到那个文案",
   实际是帮手自己坏了。修:从数组里挑,且**优先挑可点的那个**
   (同名文案常常一个可点、一个不可点)。

2. **判据之间互相干扰**(新增 `backToMain`)
   每个设备判据都是写操作,会把前台留在它操作完的那一页。`harmony-admin`
   把人留在管理页 —— 那是 `pushUrl` 出去的独立 `@Entry` 页,**没有侧栏**
   ⇒ 后面的 `cross-client-gesture` / `harmony-appearance` 找不到侧栏、
   双双 skip(skip 原因写的是"宽屏侧栏找不到",看起来像功能没了)。
   `launchOurApp` 解决不了(`aa start` 只切前台,不弹栈)⇒ 新增 `backToMain`。

3. **"找不到元素"要先分清是功能缺失还是判据没摆好现场**
   本判据连栽四种形态,每一种都伪装成"功能缺失":
   · 锚点文案错(入口是「管理」,我按「用户管理」找 —— 后者只是说明的一部分)
   · 元素在滚动下方(dumpLayout 只报可见节点)
   · 文字节点不可点(`.onClick` 在包住它的容器上,ArkUI 的常态)
   · **滚动步长跨过了它**(诊断打印现场才发现:停在了「系统通知」那一带,
     而「管理」只占 ~0.04 屏,一次 0.2 屏的滑动必然越过)
   ⇒ 最后改成"**先滚到底、再小步回扫**"(只管往前找在不均匀列表上必漏),
     并加了 `AGENTMAIL_ADMIN_DEBUG=1` 的诊断入口把现场打出来。

## 三、判据

`run-all.mjs` → `files=32 ran=32 checks=505 pass=505 fail=0 skip=0
red=0 broken=0 unreported=0`(连跑两次稳定)。
`harmony-admin` 27 → 28 条。`hvigorw assembleHap` 成功;前端重建。

**设备实测**:管理页从「我的」页打开,显示「管理 / 用户管理 3 / 新建用户」
与三行用户(jianf 管理员 / gui-lab 用户 / test 用户)。

**仍到期未升级**:`harmony-appearance`(已加 2 条设备判据,但登记里其余部分仍静态)、
`harmony-logic`、`cross-client-theme`、`appearance-defaults`、`harmony-imageprep`
—— 按"每条缺什么设备侧验证"逐条来,不为了消数字而凑。
2026-09-19 15:53:34 +08:00
ff172ed2c1 判据: 修 AmIcon 尺寸检查的假红 —— 它跨过 } 吃到了下一个组件的修饰符
这条判据抓的是真形状(`AmIcon({…}).width(48)` 会让图标贴左上角),
但正则写宽了:`[\s\S]{0,200}?` 允许**跨过 `}`**,于是这种**正确**写法被判红:

    Row({ space: 4 }) {
      AmIcon({ … })
      Text('新建')
    }
    .height(26)      ← 这是 Row 的,不是 AmIcon 的

实测(日历工具栏新加的「+ 新建」按钮):报 `CalendarPage.ets:1223 → .height(`,
而那一行链在 `Row` 上。**是判据误报,不是代码错。**

修法:扫描区间**不允许出现 `{`/`}`**。`AmIcon({…}).width(48)` 里参数花括号已被
`)` 收尾,而跨到下一个组件的路径会被排除。

★ 修完**立刻做了变异验证**(这类放宽最容易顺手把判据改废):
   把真实的 `AmIcon({…})` 改成 `AmIcon({…}).width(48)` ⇒ **判红**;
   还原 ⇒ 绿。既要"不误报",也要"仍能抓真错",两半都得验。
2026-09-19 15:22:34 +08:00
f655453424 跨端: 审计补强 —— 邮件行的「抄送 N」+ 断点差异登记 + 两处"看起来有其实没有"的澄清
继续「全面对齐 WebUI 和鸿蒙」。这一轮做的是**逐页对照审计**(子代理通道被
session daemon 的端口占用堵死,改为自己逐处读源码对照)。

## 一、补上邮件行的「抄送 N」(真缺失)

WebUI `MailList.tsx:350` 行上有「抄送 N」,鸿蒙**完全没有**。而数据一直在:
`cc_list` 服务端确实返回(实测回包字段列表里有),只是鸿蒙的 `MailLike`
接口漏了这个字段 ⇒ 一封抄送给多个人的邮件在列表里看不出任何区别。

修法:接口加 `cc_count`,`MailSummary` 加 `cc_list` + `cc_count`,
收件箱/发件箱两处填充派生值,行上按 WebUI 的位置渲染。
**设备实测**(造了一封带 2 个抄送的真邮件):行上出现「抄送 2」,
位置与 WebUI 一致(主题行下方、灰字)。

★ 接口用**数字**而不是 getter:`MailSummary implements MailLike`,
而 ArkTS 的 interface 里不能声明 getter(编译报 "incorrectly implements interface")。

## 二、有意**不抄**附件标记(发现 WebUI 那段是死代码)

WebUI 行上还有一个 📎 + 数量的标记(`MailList.tsx:351-355`)。但核对服务端
**实测回包**:`GET /me/mail/inbox` 既没有 `attachments` 也没有 `has_attachments`
⇒ `mail.attachments?.length ?? 0` **恒为 0**,那个标记在 WebUI 上**从不出现**。

所以鸿蒙这一轮**有意不抄它** —— 照抄一个不工作的东西,只会多一处
"看起来有、永远不亮"的代码。要做这个功能得先让服务端在列表回包带上附件计数
(一次 JOIN 的事),那是独立的一件事,已登记进 `docs/DEBTS.json`
(`mail-list-attachment-count`)。

★ 这一条与鸿蒙 `MailSummary.has_attachments` 那个字段一起处理掉了:
它还留在那里会误导人(服务端永不返回它 ⇒ 恒 false)。

## 三、两端宽屏断点不同 —— 登记 + 判据(此前**无任何记录**)

审计点名要核实的这条确认成立:

    WebUI: `NARROW_QUERY = '(max-width: 1023px)'`
           含义 = 「三栏(60 导航 + 320 列表 + ≥520 详情 ≈ 900px,再加余量)放不下就退化单栏」
    鸿蒙:  `isWide = width >= 768`
           含义 = 「要不要显示**侧栏**」(鸿蒙内容区是一个窗格,没有并排三栏)

**含义不同,所以数值不同本身不算错** —— 这与手势阈值同一条口径
(语义各自成立时,数值不必强求一致)。但**用户可见的后果**是:768–1023 宽
(常见竖屏平板、窄窗口)下 WebUI 是单栏+底栏、鸿蒙是侧栏+内容,
同一宽度在两端长得不一样。

处理:① 登记进 `docs/DEBTS.json`(`wide-breakpoint-divergence`,
带三个待定选项);② 加判据 —— 它**不**要求两端取值相同,而是要求
「取值可读 + 含义写清 + 差异被登记」三件事同时成立。

★ 写这条判据时又踩了同一坑:用 `code()` 读注释 ⇒ 永远红。
`criteria-hygiene` 判据的头顶就写着"判代码用 code、判理由用 prose"。

## 四、判据

`run-all.mjs` → `checks=505 pass=505 fail=0 skip=0 red=0 broken=0 unreported=0`
(cross-client-theme 15→16)。`hvigorw assembleHap` 成功。

**未验**:附件标记(有意不做);抄送行在**深色**下的对比度未单独验。
2026-09-19 15:13:51 +08:00
f811c9887a 跨端: 三个真 bug(外观保存 400 / 改档位界面不动 / 内容列溢出屏幕)+ 设备判据
这一轮从「全面对齐 WebUI 和鸿蒙」开始,先做设备层判据升级,结果**判据一上线就连撞三个真 bug**
—— 它们全都是静态判据照不到的形状:**数据对、界面不动**。

## 一、外观保存从来就没成功过(PUT 400)

`payloadFromLocal` 复用了 `AppearanceResponse` 当请求体,而那个类型是 **GET 的响应**:
带着 `has_image` / `image_bytes` / `saved`。服务端的 `Decode()` 是
`DisallowUnknownFields()`(严格,**有意为之**)⇒ **每一次保存都被拒收(400)**。

症状极隐蔽:本地 `@State` 立刻变 ⇒ 肉眼看着像成功了;只有看 hilog 的 HTTP 状态码
才发现 400。修法是加 `AppearancePayload`(**恰好**服务端 `models.Appearance` 的五个字段)。

★ 这是"两端共用同一个类型"的代价:请求与响应本来就不该同形。
★ 服务端严格是**对的** —— 它帮我们抓到了这个错误。修客户端,不是放宽服务端。

## 二、改了档位,页面背景一点不变(两层原因)

**第一层**:`SettingsPage` 存进 store 了,但 `MainPage` 的 `bgPlan` 只在启动时算一次,
之后没人动 ⇒ 发布一个 `AppStorage` revision(计数器,不是布尔 —— 布尔 true→true
不发变化通知),`MainPage` 用 `@StorageProp + @Watch` 接住。

**第二层(更隐蔽)**:接上之后**还是不动**。因为 `bgPlan` 是 `@State BackgroundPlan`,
而 **ArkTS 的 `@State` 观察不到类内部字段**的变化 —— 渲染读的正是
`this.bgPlan.kind` / `.layers`。hilog 一对证据同一次启动相差 100ms:

    Appearance: sync: bgKind=preset … hasImg=true    ← 数据是对的
    Wallpaper: kind=none layers=0 active=false       ← 渲染读到的还是旧值

修法:加 `@State bgContentRev: number`,每次算完 plan 就 +1,**并在 Builder 的
条件表达式里消费它**(ArkUI 按"这个 Builder 读了哪些 @State"决定是否重渲染;
只加计数器而渲染不读,等于没加 —— 判据同时断这两半)。

★ 同一个坑本仓出现过(`AppearanceStore` 的注释里写着这句),这次换了地方发作。

## 三、「我的」页右端内容被顶出屏幕(追了很久的 `56.000000` 之谜)

真相有**两层,两层都值得记**:

1. `dumpLayout` 里 `Slider` 节点的 `text='56.000000'` 是**无障碍文本**
   —— 屏幕上根本没这串字(截图可证)。**dump 的 text ≠ 看得见的字**。
2. 真正的问题是那个**看得见的** `Text('56%')` 落在 `x=3250`,而屏宽 3184
   ⇒ **它在屏幕外**。用户只看得到滑杆、看不到数值。

根因:`MainPage` 里"侧栏 + 内容列"是 `Row` 并排,内容列写 `.width('100%')`
—— 在 Row 里 `100%` 是**父容器全宽**,与侧栏的 60vp **相加** ⇒ 必然溢出。
实测内容列 `[229,28][3357,2204]`,右边缘超出屏幕整整 173px(= 60vp)。
修法:改 `.layoutWeight(1)`(吃剩余空间)。修完实测 `[229,28][3156,2204]`,
`Text('56%')` 落在 `[3049,1803]` —— 屏内。

★ 为什么值得一条设备判据:**同一处错误在不同 pane 上表现不同**
(日历页自己算宽度就没露出来),很容易被当成"某一页的样式问题"去调。

## 四、设备判据基建(这一轮加的能力)

- `lib/harmony-device.mjs` 新增 `launchOurApp` / `ourAppInFront` / `tapText` / `swipe`。
  `swipe` 里 clamp velocity 并写明那个坑:`uitest` 的 velocity 越界**不报错**,
  只回一句 "out of range, the default value will be used",静默换成默认 600。
- 三条新设备判据(`harmony-appearance`):壁纸档位真的切换 / 窗格内容不得超出屏幕。
- 修了一个**元问题**:设备判据在套件里**恒跳过**(要求"现场已经摆好"),
  只有手动摆好才通过 ⇒ 那等于没有判据。现在它们**自己搭现场**
  (拉起应用 → 导到目标页 → 操作 → 复位)。`cross-client-gesture` 与
  `harmony-appearance` 都改成了这样,套件里 `skip=0`。
- 途中撞出的两个判据自身缺陷(都写了注释):
  · `root0` 用**切页前**的快照 ⇒ 套件里红、单独跑绿(通过与否取决于跑之前那一屏)
  · 侧栏项筛选没排除**品牌标** ⇒ 想点「日历」却点到「通信」

## 验证

`run-all.mjs` → `files=32 ran=32 checks=503 pass=503 fail=0 skip=0
red=0 broken=0 unreported=0`(含设备判据:gesture 9、appearance 27)。
`hvigorw assembleHap` 成功;前端重建 + 重打包(`build-stamp` 7/7、`packaging` 5/5)。

设备实测(HATriple 3184×2232):
· `PUT /me/appearance` 从 **400 → 200**(服务端访问日志),
  库里 `jianf` 的记录从空变成 `bg_kind=preset / bg_dim=56 / bg_blur=3`。
· 壁纸真的透出来了:预设档缝隙 `#E0E2E4`、不设档 `#FFFFFF`(像素级对比)。
· 「我的」页 `56%` / `3px` 正常显示在屏内。

**未验**:壁纸在真机上的观感(渐变是否好看、压暗 56% 是否合适);
这一轮只验了"数据通了、界面响应了、内容没被裁掉"。
2026-09-19 15:03:40 +08:00
4a5318ca28 跨端: 手势补设备判据(真滑、标题真变)+ 修三处"设备判据假红"的典型错法
上一提交把滑动翻页做完了,但只验到"代码形态对"。**真装上跑时一次都没触发** ——
这一条补上设备实测,并把途中撞出来的三类错法记进判据注释。

## 一、为什么必须有设备判据

第一次装上跑:**代码全对、手势一次都没触发**。原因是起点 x=2600 落在
**右栏**(日程面板 `Column [2207,112][3184,2204]`)—— 事件根本没进网格列。
只有静态判据的话,结论会是"手势已实现、判据全绿",而用户真去滑时一动不动。

所以判据断的是**界面真的翻了**(滑动前后各 dump 一次,断言月标题变一格),
不是断"日志里有 turned=true" —— 后者只证明判定通过、证明不了有人会动。

## 二、途中撞出来的三类错法(都写进注释了)

1. **`uitest` 的 velocity 越界会被静默替换**
   我传 150(想表达"慢一点"),它只回一句
   `The swipe velocity out of range, the default value will be used.`
   —— 不报错、不改退出码,只是默默换成默认 600。于是"慢滑"变成"更慢的滑",
   看起来像手势没生效。**传合法值(200~40000)+ 读回执**才能避免。
   新增 `lib/harmony-device.mjs` 的 `swipe()` 帮手(与 `tap()` 同族,
   内部 clamp,并在注释里写了这个坑)。

2. **滑动是有副作用且不可撤销的写操作 ⇒ 不能盲目重试**
   第一版写"不生效就再发一次(最多 3 次)",结果实测**把日历一次翻了 3 格**
   (标题跑到 2026年11月)。重试不是"再试一次",是"再翻一页"。
   只有幂等操作才允许盲目重试。改成:**只发一次 + 等足够久**(最多 8 秒轮询)。

3. **坐标不能用"到边界差一点"的比例**
   取 `width * 0.68` = 2165,距网格列右边界 2207 只有 42px ⇒ **一次都不触发**;
   同一台设备、同一份代码,起点改 2000 立刻生效。
   起点贴边时触摸点会被判到相邻的右栏。改成取**列中段**
   (`0.62` / `0.13`,两端各留几百 px 余量)。

另:本判据第一版用"滑一次 + 固定等 2500ms + dump",**单独跑通过、接进
run-all 后失败**(设备繁忙时不够)。固定等待是设备判据最常见的假红来源 ——
改成轮询到标题变化。诊断留了 `AGENTMAIL_GESTURE_DEBUG=1` 门控,
失败时能一次看到"前台/坐标/轨迹",不用事后手动复现。

## 三、设备实测结果(HATriple 3184×2232)

· 月档:左滑 9月 → **10月**(`dx=-556.5 dy=0 ms=624 turned=true`);
  右滑回 **9月**(`dx=+556.5`)。
· 周档:`2026年9月14–20日` → 左滑 → **`9月21–27日`**(正好 +7 天,
  证明步长走的是 `stepDaysOf` 而不是写死 1)。
· 斜滑(dx=400 dy=800):`PanGesture({direction: Horizontal})` 在系统层
  就没识别 ⇒ 比鸿蒙侧的 `SWIPE_AXIS_RATIO` 更早拦住(正确行为)。

**仍未验**:56vp / 1.4× / 700ms 这三个数**手感是否合适**,只能真人滑过才知道。
我验的是"判定逻辑 + 接线 + 真能翻页"。

## 四、验证

`run-all.mjs` → `files=32 ran=32 checks=498 pass=498 fail=0 skip=0
red=0 broken=0 unreported=0`。
(含新设备判据:cross-client-gesture 9 条,其中第 9 条是真滑。
 `build-stamp` 7/7、`packaging` 5/5 —— 按判据要求重建 + 重打包,没有改记录迁就。)
2026-09-19 14:16:30 +08:00
28e3da76d4 跨端: 左右滑动翻页(P6 第 3 步)+ 还 gesture-semantics 债 + 修跑不起来的判据基建
★ 这一轮从用户一句「滑动手势呢?」开始。查下去发现它不是"顺手加个手势",
  而是 `docs/DEBTS.json` 里挂着的一笔债 —— `gesture-semantics` 的原话是:

    「P6 第 3 步:鸿蒙侧出现滑动手势代码时**立即建**判据
      (此前建 = 只有一端存在的假判据)」

  也就是**先有手势、再钉语义**。WebUI 2026-09-14 就有滑动翻页(用户当时
  亲口提的),鸿蒙一直没有 ⇒ 之前建判据会是空真(∀x∈∅)。

## 一、手势本体(两端语义逐项对齐,数值各自定)

按 `HARMONY-ALIGN-PLAN.md:118-128` 显式选的 **(b) 口径**:
「手势的物理量本来就不该强求同值……该对齐的是**语义层**」。

· 判定逻辑放**纯逻辑层** `model/Calendar.ts` 的 `judgeSwipe`(可被 node 直跑,
  写在 .ets 里就跑不了判据,语义没法被单测钉住)。四道门:位移 / 纵向优先 /
  快滑窗口 / 方向。
· 阈值**不引用** WebUI 的 40 / 1.5 / 600(那是把巧合当契约),
  各自定为 56vp / 1.4× / 700ms,并在注释里写出取值依据。
· 接线在 `CalendarPage.ets`:`PanGesture({direction: Horizontal})` +
  `onActionStart`(记时 —— `GestureEvent` **没有时间戳字段**,我查了 SDK
  的 gesture.d.ts 确认)+ `onActionEnd`(读 offsetX/offsetY)。
· ★ 挂在**网格列**上而不是整页:右栏(日程/编辑器)里有输入框与可滚内容,
  整页挂会让「在表单里横划一下」变成翻月。
· ★ 翻页复用 `shiftRange()`(与 ‹ › 按钮**同一个来源**)—— WebUI 的注释
  专门交代过:各写一套的话,阈值、边界、三档行为迟早分叉。
  手势回调里**不准**直接改 year/month(锚点是唯一真相,年月只能由
  `shiftRange → syncYearMonthFrom` 派生)。

**有意差异(记录在案,不是漏做)**:WebUI 在周/日档会额外检查「触点是否落在
可横向滚动的区域里」,是则让给滚动条(用户 2026-09-14 报过这个冲突)。
鸿蒙周档是「一行 7 格按 layoutWeight 等分」、**不横滚** ⇒ 该条件不适用。
哪天加了横滚必须同时补上它。

## 二、判据(8 条语义契约 + 行为层)

新增 `cross-client-gesture.test.mjs`:两端都真有手势 / 方向映射逐项相同 /
纵向优先 / 快滑窗口 / 复用同一翻页函数 / 无边界回弹 / 有意差异被记录 / 自检。
**只比语义、不比数值**,并反向断言鸿蒙的阈值常量不得直接取 WebUI 的那三个数。

`harmony-logic.test.mjs` 加行为判据:真跑 `judgeSwipe`,把四道门各自验一遍
(只钉字符串的话,一个 return 写漏了照样全绿)。

## 三、顺手修掉的三处**判据基建**缺陷(不修就没法验证上面这些)

1. `run-all.mjs` 只认 `# pass N`,而 node v24 打的是 `ℹ pass N`
   ⇒ **21 个文件被记成"没自报条数"**、套件在 HEAD 就恒红(memory 里记过这条,
   修法也记过,今天终于落进代码:4 个正则加 `(?:#|ℹ)`)。修完 `unreported` 27 → 0。
   代价是暴露出一批此前被"没自报"掩盖的真实问题(下面 4~6 条)。
2. `harmony-nav` 的设备判据 `navItemsOf`:宽屏过滤条件从「左边缘靠左 1/6」
   改成「**整个盒子在侧栏轨道内**」。旧条件把**日历网格的格子**
   (实测 `[229,511][366,794]`)也当成导航项,一屏数出 11~12 个。
   这是设备实测抓出来的 —— 我第一版还以为是"底部簇混进来了",
   打印真实数据才发现是隔壁页面的格子。
3. `harmony-logic` 两处:从 `NAV_ITEMS` / `NAV_SIDEBAR_ITEMS` **各自的数组**里取
   label。原先把全文件 `label:` 一网打尽 ⇒ 得到 7 个(4+3 混在一起),
   任何一边改对了它都会红。

## 四、被判据拦住后的正经修法(每条都按判据自己给的方向改,不改判据迁就代码)

· `cross-client-theme` A2 拦住我:新增的 `navActiveBg`/`navBrandFg`/`badgePlain`
  未登记;又拦住我:`sseColorOf` 里四个裸色值。→ 抽成 `Theme.sseConnected` 等
  四个令牌(取值对齐 WebUI 的 Tailwind 类)+ 登记 + 在 Theme.ets 的表里写理由。
· 同一条判据的"死令牌"检出:`Theme.durBase` 声明了却从没人读。
  **查 WebUI 才发现壁纸淡入是真有的动效**(`index.css:742-752` 的
  `.app-backdrop` 从 opacity:0 → 1,180ms)⇒ 补上而不是删令牌
  (删掉等于把差异抹平、还说成"清理")。reset 在 `animateTo` **外**,
  与 `calPaneIn` 同一条纪律。
· 玻璃登记:`NavItemBuilder` → `SidebarItem`(重构后按最近的 @Builder 命名),
  登记同步跟上。
· `harmony-admin` 的退出判据:退出逻辑抽成 `api/Logout.ets` 的 `performLogout()`
  (两个入口——「我的」页与侧栏底簇——必须做同一件事,尤其"先注销推送 token"
  那一步)。判据相应改成**追到实际执行处**(两半都断:按钮调了 + 函数真清了全部),
  并写明"别再退回直接匹配 SETTINGS_PAGE 的写法"(那会随重构假红,
  下一个人只会去改判据而不看行为)。
· `harmony-widescreen` 的连接点色值:色值搬进 Theme 后,判据改成断
  「令牌定义对了 + 侧栏真的用了它」两半(只断任一半都有假绿形态)。
· `align-refs`:`CalendarView.tsx` 变了,按判据要求**读一遍差异**再更新登记
  (差异只有农历小字的灰阶档位 gray-300→gray-400/500,**骨架未变**)。
· `criteria-hygiene`:`harmony-contacts` 自造了一个 `code()`、`harmony-widescreen`
  裸用 `readFileSync` ⇒ 都改用 `lib/read.mjs` 的共享入口。
  途中撞出一个**判据自己的 bug**:`code()` 的块注释正则
  `/\*[\s\S]*?\*\//` 会把注释里出现的 `/*`(如 `/」**` 这种中文夹星号)
  当成块注释起点,一路吃到几十行后的 `*/`,把中间的 import 全吞掉 ——
  于是 hygiene 判据假红"没 import"。改掉那处写法后正常。

## 五、验证

判据面:`run-all.mjs` → `files=32 ran=32 checks=497 pass=497 fail=0
skip=0 red=0 broken=0 unreported=0`。
其中新/改判据:gesture 8、nav 18、widescreen 7、logic 30、admin 27、
cross-client-theme 15、criteria-hygiene 6。
构建:`hvigorw assembleHap` 成功;前端 `npm run build` + 重新打 AppImage/deb
(`build-stamp` 7/7、`packaging` 5/5)。

**设备实测(HATriple 三折叠 3184×2232,hdc 连 127.0.0.1:5555)**:
· 农历在格子里真的显示(1=二十 / 7=廿六 / 19=**初九** / 11=八月),与 WebUI 一致;
  这条同时验证了**服务端农历路由已部署**(之前线上是 404)。
· 日历左右两栏:编辑器出现在**右栏**、左栏月份仍可见(单栏模式下编辑器会整页盖掉它)。
· 侧栏 3 项 + 底部一簇;徽标回到图标右上角。

**仍未验(如实标注)**:滑动翻页的**手感**(阈值 56vp/1.4×/700ms 是否合适)
只能真人滑过才知道;我只验了判定逻辑与接线形态。动画同理 ——
机制已验证(`animateTo` 驱动 + reset 在窗口外),但"看起来顺不顺"未做取样验证。

docs:`DEBTS.json` 销掉 `gesture-semantics`(并记结算说明)、
`HARMONY-ALIGN-PLAN.md` P6 从「✅(滑动翻页除外)」改为 ✅。
2026-09-19 14:01:21 +08:00
e8b260dd70 维护: 底本重算(4 个文件漂移 = **底本过期**,不是变异残留)—— 逐个核实后按本文件的协议记一行"为什么"
pi `df62ad3d` 那封的 §四 我复核后**已经在 `d07494e` 修好了**(父提交正是 pi 报信时的
HEAD `93dabb4`):`if (r.red) reds.push(r.red)` + `DIAG[*].blocksGreen` 让
`summary.py` 的 status 真的进了 `verdict`。同刻 A/B(只切 baseline 一个变量)实测:
残留态红清单 diff **恰好 +1 行**(`(summary.py)baseline-residue ——…`),`red` 10→11;
stale 态 diff **0 行**。⇒ "stale ⇒ 提示(0) / residue ⇒ 红(进 verdict)" 这条口径成立。

这轮顺手核"每个文件的登记/实际读数"时,撞上底本自己的状态:
`baseline=3/7(底本过期…)`。逐文件核过 —— 4 个漂移**全部**是
`git diff --quiet HEAD -- <f>` 为空的**提交态**(各自有明确的提交):
  · AdminUsersPage.ets  ← 0e5eec6 (09-18 11:47)
  · BackgroundPicker.ets、SettingsPage.ets ← 36ef15a (09-19 12:30)
  · ApiClient.ets      ← bcd7e7f (09-18 12:55)
⇒ 是**底本过期**,不是残留(两者在 `sha256sum -c` 眼里一模一样,
所以"重算"必须是有记录的动作,否则会永久掩盖真残留)。

重算后:`sha256sum -c` 7/7 OK、套件 `diag=none baseline=7/7✓`(原 3/7)。
**并验证重算没有把真残留一起盖掉**:改脏一个在底本里的文件 ⇒ 仍报
`diag=baseline-residue baseline=6/7✗` + 那条红进 verdict(red 10→11)✓。

★ 记一条观察:这次漂移属 `baseline-stale`(rc=0、只提示)那一档 ——
它**不假红**(正常提交不会天天红),但也**不会被自动发现**:
我是靠主动逐文件核查撞上的,不是它自己报的。
"不假红"与"会被发现"在这个闸上仍有取舍,这条记为已知残留。
2026-09-19 13:01:39 +08:00
d3140c213c 补充: 给"条数登记校验"加锚点(自检 5b)—— ★ 而我第一版锚点自己写成了**空真**
`788d7cc` 把条数校验挪进 `else { … }`(红绿都跑)之后,**它没有自检**:
下一个人完全可以再挪回 `else if` 后面,那时**什么都不会红**
(红文件又收不到条数回执),而缺口**只在文件恰好红时隐形** ——
正是它上次潜伏到 `c523c21` 的原因。⇒ 补自检 5b。

做法(锚点落在**实际发生的比较**上,不许落在源码文本上,§16.3):
在 `else { … }` 里每次比较都 `countCheckRan.add(file)`,
5b 从 `records`(谁真的自报了条数)**独立重算**应当被评估的集合,再要求它被覆盖。
**不读 `reds`、不看那条校验自己的输出** —— 否则就是"读数器自作证"。

★★ 而**我第一版 5b 是空真的**(自捉,如实记):
我原来比的是"凡**条数不符**的文件都必须被记录过" ——
**而这条修复本身就把 harmony-admin 的登记数对齐了 ⇒ 那个集合恒空 ⇒ 断言恒真。**
变异测试当场抓到:把 `countCheckRan.add` 挪回"只绿才走",**5b 一声不响** ——
那一刻我才发现它不是"通过",是"**没有对象**"。

改成比 **"所有自报了条数、且没崩的文件"**(红绿都含):红文件必在其中 ⇒ 非空,
且"红文件被漏记"必被抓。另加**反空转**:集合为空而并非全部 broken ⇒ 自检自己报失效。

变异验证:挪回"只绿才走" ⇒ 5b 报出 6 个红文件名
(cross-client-theme / build-stamp / align-refs / harmony-push / criteria-hygiene / harmony-admin);
基线不报 ✓。修后全套(树内):files=31 ran=31 checks=487 pass=476 fail=11 red=10
broken=0 unreported=0。

`CRITERIA.md` 补两条可复用教训:
① **"拿现有数据试一遍"要试到"数据非空"** —— `∀x∈∅` 的判据看起来和真判据一样绿;
② **修好一件事会同时消灭它自己的测试对象** ⇒ 锚点不许建立在"当前的错误状态"上,
   要建立在**恒在的集合**上("谁自报了条数",而不是"谁条数不符")。
2026-09-19 12:56:19 +08:00
788d7ccb20 修复: 条数登记校验**只在"绿"的那条路上** —— 文件越红,它的登记数越没人守(假绿方向)
设备在场时跑全套,顺手核了每个文件的『登记条数 vs 实际条数』,发现:

  narrow-layout    登记=64 实际=88 exit=0  绿 ⇒ 校验生效(报了)
  nav-merge        登记=8  实际=9  exit=0  绿 ⇒ 校验生效(报了)
  background       登记=43 实际=44 exit=0  绿 ⇒ 校验生效(报了)
  harmony-presets  登记=5  实际=6  exit=0  绿 ⇒ 校验生效(报了)
  harmony-admin    登记=22 实际=27 exit=1  ★ 红 ⇒ 校验**走不到**(grep 0 命中)

根因:这条校验原来写在**最后一个 `else`**("退出码 0"那条路)里,而
`r.status !== 0` 会**先在 `else if` 里 reds.push 并跳过它**。
⇒ **文件越红,它的条数登记越没人守** —— 假绿方向:
harmony-admin 那多出来的 5 条判据**不在"被删会红"的保护内**,
而它恰好是红的 ⇒ **只要它一直红,缺口就一直是隐形的**;
等它修绿那天校验才第一次生效,那时多出来的几条可能早被删了。
(同族:`unlisted`/`blind`/`baseline=` 的结论到不了 `verdict`,§16.1。)

★ 而且这个缺口**已经真的吃过一次**:`c523c21`("邮件详情与「我的」页 1:1 对齐")
给 harmony-admin **+5 条判据**(它自己的 commit message 就写着 "+5"),
**却没同步登记数**,而当时该文件是红的 ⇒ 5 条新判据至今裸奔。

修法:把条数校验移进 `else { … }`(红绿都跑;先 push 退出码红,再判条数)。
⚠️ broken(崩了/一条条数都没自报)**不在这里**判 —— `diedWithoutReporting` 已吃掉它,
再叠一条"没找到自报条数"只是噪音:**"判据没答 ≠ 判据答错了"**(§17)。
并做那个"显式、可复核的编辑":登记数 22 → 27(和 c523c21 欠下的那 5 条对齐)。

变异验证:
· 红的 harmony-admin 登记数改 99 ⇒ 报『自报 27 条 < 登记的 99 条』✓
· 改成 27(对齐)⇒ **不报条数**、只剩"退出码 1" ✓
· 修后全套:red=10(harmony-admin 那条从"走不到"变成"报出来"后 +
  对齐登记数又收回,净额 0),另 4 个绿文件的条数不符**照旧照报** ✓

`CRITERIA.md` 新增通用规则:**校验写在哪条分支上,决定它保护谁。**
凡"出错时要额外检查 X"的守卫,先问:**这条分支真红的时候,它还跑得到吗?**
2026-09-19 12:51:29 +08:00
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
d8277a5977 跨端: 导航项徽标两侧补齐(我上次"撤回"错了 —— WebUI 是有的)
上一笔我凭"两侧导航都没有徽标"把刚写好的徽标**撤回**了。那是错的:
`client/electron/src/components/Sidebar.tsx:88-95,111-125` 明确有——

```
const badge = isComm ? unread + pendingPerms
                      : modes.includes('contacts') ? contacts.length : 0;
const badgeTone = isComm && pendingPerms > 0 ? 'perm' : isComm ? 'unread' : 'plain';
```

并且是 `absolute top-0.5 right-1` 压在导航项右上角,`>99` 显示 `99+`。
我当时只看了底栏 `NavItem`(那里确实没有),就把结论推到了"两侧都没有"。

## 补的东西

- 新增**纯逻辑** `model/NavItems.ts` 的 `navBadgeCount` / `navBadgeTone` / `navBadgeText`,
  逐条对齐 WebUI 的口径:
  · **通信** = 未读 + 待决策(两类"要动手"合起来);
  · **联系人** = 联系人数;日历/「我的」= 0(没有徽标);
  · 色调:待决策**橙**(有人卡在那儿等)优先于未读**红**(只是还没看);
  · 负数当 0(计数来自网络,不假设它干净);`>99` → `99+`。
- **两侧**(底栏 `MainPage.NavItem` + 宽屏 `WideSidebar.NavItemBuilder`)都接上,
  且读**同一组** AppStorage 键 —— 两处各算一套,数字迟早对不上,而用户同时看得到它们。
- 计数发布走 `AppStorage`(单向:窗格写、导航栏读),与 `KEY_WINDOW_INSETS` 同一套机制。
  不把这两个数提到 `MainPage`:那样"从没进过通信页"也会去发请求。
- 徽标位置对齐 WebUI 的 `absolute top-0.5 right-1`(压在项的右上角)。
  ★ 第一版我排在文字**下面**,截图一眼可见那颗 3 掉到了「联系人」标签底下、
  还把 48vp 的项撑高了 —— 方阵节奏乱掉。

## 判据(harmony-widescreen 6 → 7)

第 ⑦ 条**直接执行**鸿蒙侧的纯函数,且期望值在测试里**独立算一遍**
(不复用被测函数,否则是"用实现验实现")。

★ 接线部分我写错过一次,变异测试当场拆穿:第一版只判
`assert.match(src, /navBadgeCount\(/)` —— 把**渲染处**的调用换成 `0`
(徽标永远不显示),文件里仍留着一处调用,判据照样全绿。
⇒ 改成判**把值交给 Text 的那一行**,并且认出两侧写法不同(底栏走 helper
`Text(this.navBadgeOf(key))`,侧栏就地内联 `Text(navBadgeText(navBadgeCount(...)))`)。

**4 个变异方向全咬**:通信漏算待决策 ⇒ 红;色调优先级写反 ⇒ 红;
侧栏渲染处换空 ⇒ 红;底栏渲染处换空 ⇒ 红。

设备实测:侧栏「联系人」显示红 **3**(3 个联系人),位置在图标右上角。
2026-09-18 13:30:38 +08:00
7b3028342a 跨端: 宽屏侧栏根本不像 WebUI —— 因为我上一版"复刻"的依据是编的
用户:「你自己看看宽屏的侧边栏和webui有哪怕一丁点的相似之处嘛?」

并排截图(WebUI 1100×700 @2x vs 三折叠展开态 3184×2232)之后,差异一眼可见:

| | WebUI | 鸿蒙(改前) |
|---|---|---|
| 文字标签 | **有**(通信/日历/联系) | 没有 |
| 选中态 | **浅蓝底块** | 只换颜色 |
| 「我的」 | 底部头像按钮进入 | 甩给 `onSettings` → **pushUrl 推页** |
| 品牌标颜色 | `#475569` 石板灰 | 品牌蓝 |
| 项间距 | 48px 项 + 4px gap,**贴顶一簇** | `layoutWeight(1)` 等分铺满(395px/项) |

## 根因:`WideSidebar` 里那段"复刻 WebUI"的注释是**编的**

```
 * WebUI 的 `Sidebar`(60px 宽)是**纯图标轨**(无 label 文字)……
 * 选中态:图标变色(`navFgActive`),**不加背景块、不加指示条、不加文字**
 * (用户 2026-09-16:「底部导航栏不允许有文字」⇒ 侧栏同样按纯图标走)
```

两条都错,而且都能在源码里当场证伪:

- `Sidebar.tsx:110` 明明有 `<span className="text-3xs leading-none">{short}</span>`
  —— 通信/日历/联系三个标签一直都在;
- `index.css:1590` 的 `.nav-item[data-active='true'] { background-color: … }`
  就是底块,而且 CSS 注释**专门**说了侧栏必须有它:
  「宽屏侧栏是 48px 宽的竖条,图标底下那一块底色是它**唯一的选中线索**,
  所以"只变色"不能无差别推广到所有 `.nav-item`。」

我犯的错是**把底栏那条纪律套到了侧栏上**:用户 2026-09-14 说「底部导航栏选中
对应的文字和图标变色即可」、2026-09-16 说「底部导航栏不允许有文字」——
两句都针对**底部导航栏**,而侧栏是另一种东西(`index.css:1595-1606` 把这个区别
写得很清楚)。更糟的是我把这个错误**写进了判据**(`harmony-widescreen` ②③ 与
`harmony-nav` 的宽屏分支),于是判据锁住的是我编的理由,一路全绿。

## 修

- 侧栏项 = **图标 + 文字标签 + 选中底块**(`navActiveBg` = `--nav-active-bg` #DBEAFE,
  判据**直接读 WebUI 的 CSS** 取值,不写死、更不引自己的注释)。
- 品牌标:`navBrandFg` = `#475569`(**像素取证**:2x 截图里品牌标附近最常见的墨色
  是 `rgb(71,85,105) ×206` = `--nav-fg-muted`,即中性石板灰,**不是**品牌蓝);
  尺寸/圆角按 WebUI `w-10 h-10 rounded-xl`(40×40、圆角 16);点它回收件箱。
- 项**贴顶一簇**(`Column({ space: 4 })` = WebUI 的 `gap-1`),不再 `layoutWeight(1)`。
- 删掉单列的"设置"入口(`onSettings` 回调一并删除)—— 那正是用户 2026-09-17 报过的
  「我的页面完全没有遵守 nav 的导航规则」(push 页 ⇒ 侧栏整条消失)。
  「我的」由 `ForEach(NAV_CONTENT_ITEMS)` 覆盖(该常量**含第 4 项**,
  走 `onSelect(3)` = 窗格,与底栏同一套)。
- 补避让:侧栏原先**完全没有** `topInset` ⇒ 全屏之后品牌标被状态栏时钟压住。

## 判据(并修掉它们锁住的错误)

- `harmony-widescreen` ②③ **重写**:从"纯图标 / 只变色"改成
  "有文字标签 / 有选中底块 / 不许留 `onSettings`",并读 WebUI `index.css` 拿真实色值。
- `harmony-nav` 宽屏分支:原来断言「侧栏项**不该有文字**」—— 同一条编造。
  改成"图标(Path)画出来了 **且** 文字命中源码 `NAV_ITEMS`"。
- `harmony-nav` 宽屏形状阈值 `boxH > screenH*0.08` 是**错的**:48vp 项在密度 2.875 下
  是 138px,而阈值要求 >178px ⇒ 四项全被滤掉(当时"通过"只是因为项被另一个 bug
  压成了 39vp)。改成 `*0.04`,并补一条"必须有文字"把**品牌标**(40vp 无文字的可点方块)
  排除在外。

**变异测试 3 个方向全咬**:去掉文字标签 ⇒ 红;去掉选中底块 ⇒ 红;Theme 色值写错 ⇒ 红。

★ 顺带记一条**我差点犯的错**:我一度按 density 3.5 换算,算出"60vp 侧栏被压成 49.4vp",
去查 flex 压缩、加 `.flexShrink(0)` —— 全是假的。实测密度是 **2.875**
(`138px ÷ 48vp = 2.875`),侧栏 173px ÷ 2.875 = **60.2vp**,与声明完全一致。
**没有压缩,是我除错了。** 已撤回那笔改动并把口径写进注释。

harmony-widescreen 6/6、harmony-nav 18/18、harmony-window 9/9、harmony-arkts 5/5、
harmony-contacts 5/5、harmony-calendar 30/30、harmony-system-api 5/5、harmony-logic 28/28。
2026-09-18 12:52:48 +08:00
009ea172b7 跨端判据: 导航判据第一次在宽屏下跑就误报 —— 它只认底栏,而宽屏导航是左侧栏
三折叠展开态(3184×2232,宽高比 1.43)实测 `harmony-nav` 的行为判据变红:

    底栏要渲染出 ≥1 个带文字标签的可点导航项(实际 0)

## 红得没错,但没用

`navItemsOf` 里写着 `+m[2] < screenH * 0.75 ⇒ 丢掉`,也就是**只找屏幕下 1/4 里的
可点容器**。而宽屏按设计导航就是**左侧栏**(`WideSidebar`),实测形状
`Column [28,985][201,1380]` —— 一条都落不进"下 1/4",于是取到 0,
判据转身说"导航没挂 / 被盖住 / 全不可点"。

★ 这条判据**从来没有在宽屏下跑过**(宽屏分支此前从未真正运行 —— 这正是本轮
  建三折叠模拟器要解锁的东西)。第一次跑就误报,说明"红"也需要先确认
  它在判什么,不能一看红就去改被测代码。

## 修:分模式判,且**两侧的契约本来就不同**

| | 窄屏(底栏) | 宽屏(左侧栏) |
|---|---|---|
| 形状 | 屏幕下 1/4 的可点容器 | 屏幕左 1/6 内、宽 < W/4、高 > H*0.08 的可点容器 |
| 文字 | **必须有**(标签就是底栏的主体) | **必须没有** |

侧栏无文字不是"放宽",是两侧本来就不一样:WebUI 的 `Sidebar`(60px)是纯图标轨,
且用户 2026-09-16 明确说过「底部导航栏不允许有文字」⇒ 侧栏同口径。
实测侧栏项子树只有 3 个节点 `Column → Stack → Path`,**没有任何 Text**。
拿"有文字"去要求纯图标轨,只会永远红。

⇒ 宽屏改为判**图标确实画出来了**(每个导航项子树里有 `Path`)且**不该有 Text**
(有文字就说明有人往纯图标轨里塞了标签 —— 那正是被否掉的方案)。

★ 两个条件分开写,**不能**只把"下 1/4"放宽成"下 1/4 或左 1/6":宽屏下内容区
  卡片也在左侧(x 很小)且可点,放宽就全被当成导航项 ⇒ "导航项数 ≥1" 恒真,
  判据等于没有。所以还要加宽度条件把内容卡片(宽 900+)排除掉。

★ 宽高比用 **> 1.2** 而不是绝对 vp:源码 `isWide` 判的是 `width >= 768`(vp),
  而 dumpLayout 给的是 px,换算要写密度 —— 而密度是设备属性,写进来就是第二份
  真相(这条判据刚因为"包名写死"吃过一次一模一样的亏)。

## 自检补上宽屏那支

`navItemsOf` 的形状判断改了之后,**宽屏那一支此前没有任何自检覆盖** ——
而自检没覆盖的分支就是下次回归不会响的那一支。按实测形状造合成树
(3184×2232、`Column [28,985][201,1380]`、子树只有 Path),并断言:
侧栏取到 2 个而不是 3 个(第 3 个是内容卡片,被误当导航项的话
"导航没挂"就永远判不出来);窄屏样本不许被误判成宽屏。

## 两个模式都实测过

- 窄屏(fold single,1008×2232,比例 0.45):底栏 4 项,标签
  通信 / 日历 / 联系人 / 我的 全部命中源码清单。
- 宽屏(fold triple,3184×2232,比例 1.43):侧栏 5 个图标项,零 Text、各有 Path。

18/18 绿。
2026-09-18 12:20:22 +08:00
4ff6b10260 跨端: 联系人页补齐「写信 / 归档」—— 顺带撞出三个只在跑起来才现形的 bug
用户:「你自己看看这些页面和webui有哪怕一丁点的相似之处吗?」
把两个客户端**同一个宽度**并排看之后,缺的很具体:WebUI 联系人卡片有
「写信 / 归档」,鸿蒙一个都没有。补的过程撞出三个缺陷,都不是"代码不合法"——
编译器与既有判据全绿:

## ① 归档打的是**服务端不存在**的路由(死函数)

`MailApi.archiveContact` 打的是 `DELETE /me/contacts/{name}/{path}`:

- `grep 'me/contacts' server/cmd/server/main.go` **零命中** ⇒ 按钮接上去就是 404;
- 全仓**没有任何调用方** ⇒ 它是个从没跑过的死函数,所以"没有归档按钮"这件事
  一直没暴露这个错。

服务端真实形状是 `POST /api/v1/contacts/archive`(`handler.ArchiveContact`),
body 二选一 `{session_id}` / `{address}`。**跟 WebUI 同口径用 session_id**:
address 会随别名变化,只有 session_id 是会话的身份。

## ② 用已存 token 恢复登录**永远进不去主界面**(最恶劣的一个)

`LoginPage.tryRestore` 验完 token 就结束了 —— 设了 `loggedIn = true`
(界面出现「登录成功:jianf」)却**从无跳转**。本文件另外两条成功路径
(`aboutToAppear` 快速路径、`doLogin` 末尾)都有跳转,唯独这条没有,
而它**恰恰是老用户最常走的那条**(重启时 token 还在,`doLogin` 根本不会被调用)。

症状最坏的地方是它**看起来是成功的**。实测(模拟器 + jianf 的永久 key):
日志只有 `→ GET /auth/me` 然后什么都没有。补齐 ① 跳转 ② 账号登记
③ SSE 连接(后两条是 `doLogin` 有而这里缺的,少了它们进主界面是个瘸的状态,
而且因为 `aboutToAppear` 的快速路径靠账号命中,下次启动还会重走这里 ⇒ 永远进不去)。

修后实测:启动即进主界面(可见文本变成「发件箱/授权/收件箱 · 2 组 · 50 封」)。

## ③ 时间戳原样印出来

卡片直接印 `last_activity` ⇒ 屏上是 `2026-09-18T02:50:35.49065Z`。
WebUI 是 `09/18 10:50`(`toLocaleString('zh-CN', {month,day,hour,minute})`,**本地**时区)。
加 `MailGrouping.shortTimeOf`(走 `Date` 取本地字段;**不许 `.slice()`** ——
那是拿 UTC 的月/日当本地时刻,UTC+8 的 09-15 00:30 会显示成 09-14 16:30)。
实测:`112 封 · 09/18 10:50`,与 WebUI 逐字一致。

## 补的界面(三折叠模拟器 3184px 展开态实测)

- 两种视图**各一处**「写信 / 归档」(WebUI 两视图同语义),WebUI 用 `.reveal`
  悬停显形,**鸿蒙不能照抄**:`index.css` 那段注释已经踩过这个坑
  (触摸设备没 hover ⇒ 按钮透明却仍可点,一个看不见却按得动的破坏性按钮更糟),
  WebUI 的修法是只在真支持悬停的设备上隐藏 ⇒ 鸿蒙这两个按钮**常显**。
- 归档先确认,**两视图共用同一个确认框**(WebUI 原话:换个视图就换套确认 UI
  只会让人对「自己点了什么」更没底);文案逐字一致。
- 列表视图的 meta 行原来放 `last_preview`,于是同一联系人在两视图里的关键信息
  不一致 ⇒ 改成与卡片视图同源(`N 封 · 时间`)。
- ★ 一处只有跑起来才会发现的坑:列表视图的 ListItem 钉死 `.height(85)` +
  `clip(true)`,而确认框比 85 高 ⇒ **按钮被裁掉、点不了也退不出**。
  确认态下高度交给内容自己定。

## 判据(新增 harmony-contacts,5 条;4 个变异方向都跑过)

判的都是"按下去会发生什么",不是"按钮在不在":
① 归档打的是服务端真有的路由(同时钉住**没有**再用那条不存在的 DELETE —— 只钉前者的话,
   加个平行实现也能全绿);② 两视图各一处动作且去向正确;③ 确认框只有 1 个定义、
   两视图都调它、文案逐字一致、确认态下点卡片不开会话;④ 时间戳走了格式化且
   函数本身不走 slice;⑤ **切出 `tryRestore` 的函数体**判它自己含跳转
   (只判全文件出现次数的话,另外两条路径里那两句就够让它变绿)。
2026-09-18 12:17:41 +08:00
0e5eec61bd 跨端: 登录页那个"emoji"是 Unicode 符号 —— 顺手修好图标贴左上角(共 4 处)
用户两条反馈,都是**看着界面**报出来的,而编译器与所有既有判据全绿:
①「为什么登陆页不是app图标,而是一个emojy?」
②「你自己看看那个图标的位置正常吗?」

## ① `Text('✉')` 被系统渲染成彩色 emoji

登录页的品牌标识原先写的是 `Text('✉')`(Unicode U+2709)。HarmonyOS 字体链里有
**彩色 emoji 字体**,U+2709 自带 emoji 字形 ⇒ 渲染成一枚黄白色风信封 emoji:

- `.fontColor(Theme.accent)` 对彩色 emoji **无效**(界面显示的是 emoji 自带颜色);
- 与底栏/侧栏那些 `AmIcon` 线描图标不是同一套视觉语言;
- 实测截图硬证:大屏下那枚 emoji 比旁边的文字还显眼。

而本仓**早就有** `ICON_PATHS.brandMark`(就是 App 图标上那个信封,专为品牌标识画的)。
换成 `AmIcon({ iconName: 'brandMark' })` 即可 —— 走 `Path.stroke()`,跟主题色走。

## ② 图标贴在卡片左上角(量出来的)

`AmIcon` 内部那个 `Stack` 是 `iconSize` 那么大、**默认靠左上**排版。调用方写
`AmIcon({…}).width(48).height(48)` 想要个大点的可上色盒子时,**外层盒子变大、图标不动**。

实测(三折叠 3.5 密度,`dumpLayout` 读的**实际 bounds**):

    卡片 [1523,521][1661,659]  138×138px
    图标 [1526,524][1589,587]   63×63px   ← 左边距/上边距都只有 3px
    ⇒ 图标中心偏 10vp

**不是一处**:全仓扫出 4 个同样写法。修法是套一层
`Stack({ alignContent: Alignment.Center })`(仓里回复球与悬浮加号本来就是这么写的,
所以它们一直是对的):

| 位置 | 图标 | 盒子 | 原状态 |
|---|---|---|---|
| 登录页品牌卡 | brandMark 24 | 48×48 | 贴左上 ✗ |
| 用户管理刷新键 | repeat 20 | 40×40 | 贴左上 ✗ |
| 联系人视图切换 | cardView 18 | 40×40 | 贴左上 ✗ |
| 收件箱组头箭头 | chevronRight 12 | 20 槽位 | 贴左 ✗ |
| 回复球 / 悬浮加号 | chatBubble / compose | 56×56 | 本来就对(尺寸在外层 Button 上) |

修后实测:偏移 **−34.5px → 0.5px**(0.14vp,亚像素级)。

## 判据(harmony-arkts 3 → 5 条)

- **图标不许用 Unicode 符号充当**:扫 `Text('…')` 里单个符号的情况。
  ★ 只框 U+2600–U+27BF / U+2B00–U+2BFF / U+FE0F,**刻意不含基本箭头段**(U+2190–U+21FF)——
  `→` 在正文里是标点不是图标,框进来会误伤大量正常文案。
  (我第一版把箭头段也框了,结果自检自己先红 —— 断言写错就是写错,不靠放宽它来「修」。)
- **尺寸不许直接链在 `AmIcon` 上**:把"图标贴左上角"这个坑的**形状**钉住,
  并自检"套了 `Stack` 的正确写法不许被误判"。

**两个变异方向都跑过**:放回 `Text('✉')` ⇒ 红 ✓;给 `AmIcon` 直接加 `.width(48)` ⇒ 红 ✓。
2026-09-18 11:47:51 +08:00
fbe7879981 跨端: 鸿蒙日历补「月/周/日」三档 —— 原来只有月视图
用户列的缺失之一(WebUI `CalendarView.tsx` 的 `type Scale = 'month'|'week'|'day'`)。
鸿蒙这边 `CalendarPage.ets` 自己的注释里就写着"没做"。

## 三处必须一起改,所以档位进 model 而不是页面里一串 if

档位切换同时改变三件事,任一漏改都**不报错、只是静静地不对**:

| | 月 | 周 | 日 |
|---|---|---|---|
| 标题 | `2026年9月` | 跨月时两头写月份 `9.28 – 10.4` | `2026年9月18日 周五` |
| 翻页步长 | ±1 **月** | ±7 天 | ±1 天 |
| 显示格子 | 整月网格 | 一行 7 天 | 一行、只亮一格 |

所以 `model/Calendar.ts`(纯逻辑,node 直接跑)新增:
`CalendarScale` / `addDaysIso` / `weekDaysOf` / `rangeTitleOf` / `stepDaysOf` / `scaleLabel`。
页面只调用,不在渲染里重写 —— 重写就是"三处里漏改一处"。

★ 步长按档走是**必须**的:周档按 1 天翻看起来像日档、日档按 7 天翻会跳过一周,
  两者都不报错。判据钉"三档步长两两不同"。
★ 周/日档的 7 天必须与月网格用**同一个** `weekStart`:不一致的话同一日期在两档下列位置
  不同,用户看到的是"切个视图日期就跳位了"。
★ 日档的格子仍摆在一行的**星期列**里(不是居中大字):上下翻日时格子不会在屏幕上跳。

## 实现要点

- 档位状态叫 `calScale` 而**不是** `scale` —— ArkUI 的 `CustomComponent` 已有 `scale`
  修饰符,同名直接编译失败(实测报 `Property 'scale' ... is not assignable to ...`)。
  判据钉住这个命名。
- 周/日档下 `year/month` 由锚点 `selectedIso` 推出来(`syncYearMonthFrom`),
  不允许两处各自保存"当前是几月" —— 否则会出现"标题写 9 月、周视图显示 10 月那周"。
- `monthKey()` 加上**档位**前缀:切档时那 7 个 iso 就是月网格里的 7 个,
  不带档位的话键完全重叠 ⇒ 一个节点都不被替换 ⇒ 过渡静默不播、旧 `inMonth` 残留。
- 空态补 `layoutWeight(1)`:实测周视图下"这一天没有日程"贴顶、**下半屏是一大片空壁纸**。

## 判据(harmony-calendar 23 → 30 条)

7 条新增,每条对着一个具体错法。**变异自检跑过两个方向**:
- 周档步长改成 1(看起来像日档)⇒ 判红 ✓
- 跨月周标题漏掉结束月份(`9.28 – 4`)⇒ 判红 ✓

## 实测(模拟器,逐档截图)

月/周/日三档切换后:周档标题 `2026年9月14–20日` + 一行 14~20,日档标题
`2026年9月18日 周五` + 只亮 18,月档回到整月网格。三张截图逐一核过。
2026-09-18 11:08:05 +08:00
af2d2b5cad 跨端: 鸿蒙动画补齐 —— 并且发现原来的「逐字一致」是假的(令牌存在 ≠ 动画用了它)
用户:「A,同时把鸿蒙app的动画补齐」。

## 先纠一条错的前提(这是本轮最有价值的发现)

`Theme.ets` 的注释与 `harmony-nav` 的判据**都**写着:「三个数与 WebUI **逐字一致**:
`--ease-out-soft: cubic-bezier(0.22,1,0.36,1)`、`--dur-fast: 120ms`、`--dur-base: 180ms`」,
`paneRiseIn()` 就按 `durBase(180)` + `easeOutSoft` 做。

三个令牌**确实存在**(`index.css:310-312`)—— 但这句话把「令牌存在」当成了「动画用了它」:

| WebUI 里 | 真实用途 |
|---|---|
| `--dur-base: 180ms` | **只**用在壁纸淡入(`:747`) |
| `--ease-out-soft (0.22,1,0.36,1)` | **只**用在 transition(壁纸、控件变色) |
| **所有 @keyframes 动画** | 硬编码 **150ms / 200ms** + `cubic-bezier(0.22, 0.61, 0.36, 1)` |

全仓 `(0.22,0.61,0.36,1)` 出现 **5 次**(rise-in×3 / cal-in×2),`(0.22,1,0.36,1)` 只出现
**1 次**(令牌定义处)。**是两根不同的曲线** —— 旧代码的面板入场比 WebUI 慢 30ms 且曲线偏软。

所以令牌拆成两组,各对齐各的:控件类 `durFast + easeOutSoft`;动画类
`durRise(150)/durMenu(140)/durCal(200) + easeRise(0.22,0.61,0.36,1)`。

## 补的动画(对齐 WebUI 三个 @keyframes)

- `menuIn()` —— 弹层:下移 4vp + 缩到 0.985 + 淡入(`@keyframes menu-in`)。
  ★ 适用范围照搬 WebUI 注释那条窄口径(「只给真正是弹层的东西」):那条规则曾挂着
  `glass-control`,于是每次切视图**所有按钮与输入框一起淡入位移**,09-15 被摘掉。
  挂到写邮件页的账号候选列表上。
- `calendarSlide(forward)` —— 日历翻月:从 ±12% 横向滑入(`cal-in-next/prev`)。
  方向由新增 `@State slideForward` 带进 `animateTo` 闭包;网格键加 `monthKey()` 前缀
  保证月份一变所有键全变(否则"跨年同名月"那类边角会静默不播)。
- 写信页整页 `rise-in`、回复框 `rise-in`(WebUI 挂在回复框本身,不是外层遮罩 ——
  挂遮罩上会让整个屏幕一起位移,看起来是"页面在动"而不是"框弹出来")。

**实测确认真的会播**(不是"编译过了"):按本仓记录的手法把 `durCal` 临时改成 8000
做慢动作,连拍三帧 —— 截图硬证**两张月历同时在屏**(九月淡出、十月从右侧 12% 滑入),
验完还原成 200ms。

## 判据:从「令牌存在」改成「动画真的用了那个令牌」

`harmony-nav` 那条判据原文锚在令牌上,所以它对上面那个 bug **一辈子全绿**。
重写为:
- 从 WebUI `index.css` **读出** `rise-in` 的真实时长与曲线(`150ms` + 那条 bezier),
  再断言鸿蒙的 `durRise` 与曲线字面量与之逐字一致 —— 而不是"仓库里有没有 180 这个数";
- ★ 把 `paneRiseIn()` 的**函数体抠出来**单独断言它引的是 `durRise/easeRise`,
  且**不得出现** `durBase/easeOutSoft`。

**这一步不能省 —— 我第一版就漏了它**:断言了令牌存在、也断言了曲线字面量存在,
但没断言函数用了它们;于是把函数体换回旧的错值后判据**仍然全绿**。
变异自检抓住了这一点,补上函数体断言后同一个变异 ⇒ 判红 ✓。
与今天修的另一处同源:**判据要锚在"这个东西被用在哪",不是"它存在"**。
2026-09-18 10:56:57 +08:00
a87a88ea2a 跨端: 全屏是**窗口级**的 —— 光给 MainPage 让位,等于把黑边换成顶栏压字
上一提交(cac026e)把黑边消掉了,但**只给 `MainPage` 加了避让**。
实测截图硬证:写邮件页的「取消」与时钟「09:49」重叠、「发送」与 wifi/电量图标重叠。

## 根因:`setWindowLayoutFullScreen(true)` 不只作用于当前页

它是**窗口级**的:一旦设上,这个窗口里**所有**用 `router.pushUrl` 推上来的页
(写邮件/会话/收件箱/邮件详情/用户管理)都从 y=0 开始画。

所以上次那个错与更早那次(6861934 只删全屏不留避让)**同源**:
都是"同一件事只做了一半"。上次少的是**步骤**,这次少的是**页面**。

## 改法:把"消费避让"变成每个 @Entry 页都得做的事

- `model/WindowInsets.ts` 加 `topInset(insets)`:取 0 时(未全屏/取不到)
  表达式的值与旧代码**逐字相同** ⇒ 没全屏的环境行为不变,不会把谁顶下去。
- 五个页各按自己的形状让位:
  - 固定 56vp 顶栏(写邮件/会话/收件箱/用户管理):
    `height(56 + topInset(...))` **与** `padding(… top: topInset(...))` 一起加 ——
    只加 padding 会把固定的 56 切掉 39(按钮压扁),只加 height 则内容仍贴 y=0。
  - 满高容器(邮件详情):`padding({ top })` 加在 `@Entry` 包装层。
    ★ **不能加在 `MailDetailView` 里面**:它同时被 `MainPage` 的 Navigation 内嵌复用,
      而那层已经让过位了 —— 加在里面就变成让两次(39vp 变 78vp)。
      这类"同一组件两种入口"的坑与"悬浮加号要放在 Navigation 内部"同源:
      **让位的量取决于它被挂在哪一层**。

## 判据(harmony-window 8 → 9 条)

接线⑤ 枚举**所有** `@Entry` 页并要求它们消费避让 —— 口径是
"有人在窗口上开了全屏 ⇒ 每个 @Entry 页都得让",而不是"检查 MainPage 做了没有"。
后者在新增一个推上来的页时会静默逃掉,而"新增一个页"正是最常发生的事。

豁免要带**可机器复核**的理由(不再是"这个页先不管"):
- `Index.ets`:DevEco 模板欢迎页,不在 `main_pages.json` 流程里;
- `LoginPage.ets`:根容器 `.align(Alignment.Center)` ⇒ 结构上碰不到 y=0。
  但"居中"是可能被改掉的性质 ⇒ 判据**断言那个居中写法仍然存在**,
  谁把它改成贴顶,这条先红,逼他回来重新想这个页要不要避让。

**变异自检两个方向都跑过**:
- 把 ComposePage 避让整个拿掉(重演"只给 MainPage 加")⇒ 红 ✓
- 只加 padding 不加 height(会压扁按钮)⇒ 红 ✓

★ 期间还修掉两处"判据锚在当时的字符串上"(不是放宽,是它把"加一个正当的避让"
  与"真犯那个错"判得一模一样):
  - `harmony-nav` ④ 的留白断言、`harmony-widescreen` ④ 的 navReserve 断言,
    都改成剥注释后验**形状与不变量**,而不是写死字面表达式。改完变异仍咬得住。
2026-09-18 10:34:00 +08:00
cac026e9e2 跨端: 上下黑边真的消了 —— 全屏 + 避让是"同一套东西的两半",上次只删了一半
用户第三次报同一条:「你再看看页面底部,那么大的黑色,你看从头到尾都没
修好,你能不能好好看看我给你的示例工程怎么处理上下黑边的」。

## 根因:上一次把"两半"当成了"一件事",删掉一半就以为修好了

`6861934` 的注释白纸黑字写着「★ **刻意不用** `setWindowLayoutFullScreen(true)`」,
理由是「实测过:它确实也消掉黑带,但会连状态栏区域一起吃进布局,于是页签栏
被时钟/电量盖住(截图硬证「07:43」与「收件箱」重叠)」。

那次实测**是真的**,结论**下错了**:被盖住不是"不该全屏",而是
**只做了全屏、没做避让**。示例工程里这两件事本来就是**同一套东西的两半**:

    common/.../util/WindowUtil.ets        → setWindowLayoutFullScreen(true)
                                          + getWindowAvoidArea(TYPE_SYSTEM /
                                            TYPE_NAVIGATION_INDICATOR)
    features/mine/.../view/MineView.ets:251 → .margin({ top: statusBarHeight + …,
                                                        bottom: naviIndicatorHeight })

只做前半 ⇒ 内容跑到状态栏底下没人让(那次退回的原因);
只做后半 ⇒ 黑边照旧(这三次报修的原因)。退回的代价是**黑边留了三天**。

## 实测(模拟器 1256x2760,四页一致)

    修前:顶部纯黑 136px、底部纯黑 60px + 手势条 20px
    修后:四页**纯黑段均为 0**;y=0..135 是壁纸(时钟浮在上面,正是示例工程的效果)
          y=2662+ 壁纸铺到底、底栏浮在手势区之上

## 改了什么

- `entryability/EntryAbility.ets`:拆出 `setupFullScreenWindow()`,
  在 `loadContent` **回调里**调(与示例工程同一位置 —— `px2vp` 要用 `getUIContext()`,
  那要有已加载内容才拿得到)。全屏 + 读两个避让区 + 订 `avoidAreaChange`。
  `setWindowSystemBarEnable(['status'])` 保留(状态栏要看得见),但**不再靠它**消黑边。
- `model/WindowInsets.ts`(新):纯逻辑 `insetsFromAvoidArea()`,不 import SDK ——
  判据才能在 node 里直接喂样本验换算。形参叫 `toVp` 而**不是** `px2vp`:
  后者是 SDK 已废弃的全局函数名,`harmony-system-api` 按名字扫,同名形参会误报。
- `pages/MainPage.ets`:`@StorageLink(KEY_WINDOW_INSETS)` 订阅;状态栏高度当
  **内容层**的 `padding-top`(**不是**根 Stack —— 壁纸必须铺到屏幕四边,根上加
  padding 会把壁纸一起缩进去,黑边只是换个地方出现);底栏与内容末尾让开手势条。
  reserve 收成**一个** `recomputeNavReserve()`,宽度变化与避让变化两个触发点共用
  (转屏只改避让不改宽度,各写一份迟早漏一个)。

## 判据:`test/harmony-window.test.mjs`(8 条)

钉的是"两半必须同时存在"——**只钉一半的话,"退回某一半"照样能全绿通过**,
而那次退回恰恰就是删了一半。纯逻辑 3 条(换算/取不到就是 0 不猜/键名是常量)
+ 接线 4 条(全屏在、避让在、布局真消费了值、纯逻辑模块不许 import SDK)
+ 设备行为 1 条(全屏没把应用搞成白屏)。

**变异自检两个方向都跑过**(这是本轮最该记的一步):
- 删掉 `setWindowLayoutFullScreen`(重演 6861934)⇒ 2 条红 ✓
- 删掉 `getWindowAvoidArea`(只全屏不让位)⇒ 1 条红 ✓

★ 第一版判据**锚错了**:正则直接扫全文,而注释里正好有 `setWindowLayoutFullScreen(true)`
  这个串 —— 把真正的调用删掉后判据**仍然全绿**。锚落在"代码对自己的描述"上了。
  加 `stripped()` 剥注释后,变异才咬得住。这条与仓里那条"判据的锚不能落在
  被守对象的自述上"是同一件事,这次是它的实例。

## 顺带修的两条既有判据(不是放宽,是它们把"当时的字符串"当成了"要守的坑")

- `harmony-nav` ④:留白断言写死了 `bottom: NAV_BAR_BOTTOM` 那个字面串。
  它本来要守的是"留白靠 padding 不靠 margin"(margin 在 ArkUI 里加在宽度外面,
  100% + margin 会顶出父容器)—— 那是另一件事。改成剥注释后验三段在不在、
  离底是否**从** `NAV_BAR_BOTTOM` **起**。变异(padding→margin)仍判红 ✓
- `harmony-widescreen` ④:同上,写死了 `? 0 : NAV_CONTENT_RESERVE`。
  改成"宽屏必为 0、窄屏含 NAV_CONTENT_RESERVE(可再加避让)"。

两处都是"加一个正当的避让"与"真犯那个错"会红得一模一样 —— 那就不再是守坑,
是守字符串。

另:`align-refs` / `build-stamp` / `packaging` 三条 broken 是前端 `99a2d7a`
(另一个人改的 `CalendarView.tsx`)带来的,与本轮无关,留给他。
2026-09-18 09:47:22 +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
f2cddf41d2 修复: 包名从 com.agentmail.harmony 改成 com.jianf.agentmail 后,有一条行为判据**永远走"设备忙"跳过**
被发现的方式值得记:那条判据从没红过(它从不执行断言),是**"跳过也要有界"**
那条闹钟把 42 轮连续跳过顶成红,才露出来的。这正是设界要抓的形状 ——
判据既不算红也不算绿 ⇒ 永远不必被升级。

## 根因:包名有四处字面量,改名只改了三处

AGC 拒绝 `com.agentmail.harmony`(`harmony` 是包名保留字),于是改成
`com.jianf.agentmail`(见 `docs/ALIGN-REFS.json` 的 `agc.packageName` 与
`align-refs.test` 那条一致性判据)。但那个字面量在判据里是**各自抄的**:

  · `AppScope/app.json5`                ← 唯一权威
  · `harmony-deviceprobe.test.mjs:24`   ← 改了(它判 AGC 匹配)
  · `harmony-nav.test.mjs:810`          ← **没改** ⇒ 前台判定永不成立
  · `lib/harmony-device.mjs:280` 注释   ← 没改(只是注释,但同样在传播旧名)

于是 `foregroundBundle(hdc) === 'com.agentmail.harmony'` 恒假,行为判据每次
都记一次"设备忙"。账本 `.tmp/harmony-busy-skips.json` 实测数到 **45**。

## 修法

1. `lib/harmony-device.mjs` 新增 `ourBundle()` —— 从 `AppScope/app.json5`
   **唯一权威处**读包名(惰性、读不到返回 null,由调用方决定)。
2. `harmony-nav.test.mjs` 的比较改成 `fg !== ourBundle()`。
3. 清除 `.tmp/harmony-busy-skips.json` 里那 45 轮**假忙计数** ——
   它们不是设备争用,是字面量漂移,留着会污染"跳过余额"的含义。

## 判据(防止这一类漂移再发生)

`harmony-deviceprobe.test.mjs` 新增 3 条(8 → 11,已同步 run-all 棘轮):
  · 判据目录里**不许再出现包名字面量**(除 lib/ 唯一读取处与本文件——它要拿
    真机样本 `fixtures/aa-dump-l-*.txt` 里的包名做**逐字**对照,属具名例外)
  · 判据自检:ourBundle() 读得出值、且不带 `harmony`(AGC 保留字)
  · 变异自检:把一条消费方改回硬编码 ⇒ 必须判红(已验证)
    且注释里的历史说明**不许误红**(`align-refs` 正靠注释解释为什么换名)

验证:harmony-deviceprobe 11/0、harmony-nav 18/0(行为那条**真跑过**、
不再是跳过)、criteria-hygiene 6/0。
2026-09-18 08:39:21 +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
dc261ad537 修复: 三张手写表互相比对、没有外部锚点 —— 同步删「箭头函数」后 red 与基线**逐字相同**
pi 2026-09-18 报的。**成立,我复现,读数与它逐字相同**;而且我测出
**pi 建议的补法①也堵不住它**,所以换了锚点。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

## 六、验证与状态

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

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

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

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

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

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

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

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

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

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

## 四、验证与状态

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

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

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

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

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

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

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

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

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

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

## 四、验证与状态

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