Files
MailUI4Agents/docs/DEV-TOOLING.md
JianFeeeee 743e397916 fix(prune)!: 构建暂存那段清理**一直是空转的**(ls -1t 对目录打 路径: 头)+ del() 失败分支补齐覆盖
## 那个真 bug:`ls -1t <多个目录>` 不打裸名字

追 pi 的 shim 建议时撞出来的,与 shim 无关 —— 是我为了给它造样本才发现的:

    ls -1t /tmp/agentmail-gateway-build-*     # 这些是**目录**(mktemp -d 造的)
    /tmp/…-20260101-000000:
    agentmail-gateway
    /tmp/…-20260105-000000:
    agentmail-gateway

`ls -1t` 收到**多个目录参数**时会列出**每个目录的内容**并打 `路径:` 头 ——
于是 `mapfile` 拿到的全是 `…000000:` / `agentmail-gateway` 这类行,都不是文件名
⇒ `basename | grep -oE '[0-9]{8}-[0-9]{6}'` 取不到时间戳 ⇒ 每条都判
"文件名无时间戳,判定不了" ⇒ **一个都不删**。
也就是说本段注释里写的那个问题("每次部署留下一个 24MB")**从来没被清理过**。
修法:`ls -1dt`(`-d` 让目录自身作为条目,不打头)。

**为什么一直没人发现**:自检夹具用 `: >` 造的是**普通文件**,而生产是**有内容的目录**。
夹具形状与生产不一致 ⇒ 夹具自己认了错形状,而判据 197 行又只按**文件名**判
"窗口内的还在、窗口外的不在",于是判据也认了。这是 docs 第 13 条那一族。
→ 夹具已改成真目录 + 里面放 `agentmail-gateway`;**改完立刻变红**("干净样本:/tmp 构建暂存
只留窗口内那 1 份"失败),证明夹具现在真的有分辨力,然后加 `-d` 转绿。

顺带确认:其余三处 `ls -1t`(`agentmail-gateway.bak-*`、`pre-deploy-*.db`、`pre-prune-*.db`)
glob 到的是**文件**,不受影响;插件快照那处(第 294 行)本来就已经写了 `-1dt`。
生产现场实测:`/tmp` 下确实还躺着 1 份 24MB 暂存没被收掉。

## `del()` 的失败分支:采纳 pi 的"让 rm 自己失败"

他指出的第三条路(我原先只想到 immutable 与注入点)是对的:本机以 root 跑、权限拦不住;
`unshare -r` 被拒(`/proc/self/uid_map: Permission denied`);tmpfs 无 `chattr +i`。
**改机器的权限**不如**让 rm 失败**。

实现上走了 `RM="${RM:-rm}"` 而不是 PATH shim,理由:`in_use` 会把命令行里含该路径的进程
判成"在用",而自检必须把路径写在命令行上 —— 实测评 PATH shim 时确实被 `in_use` 挡掉、
`del()` 根本没被调用(那次"测试通过"是假的)。`$RM` 默认就是 `rm`,生产行为逐字不变。

自检新增 4 条(并通过变异确认有区分力:去掉失败判定 ⇒ 强断言变红):
退出码 2、必须打出 `[FAIL] 删不掉`、不许出现收尾汇总、那条路径必须还在。

★ 变异还暴露出一条**弱断言**:单看"退出码 = 2"在变异后**照样通过**(脚本别处也有退 2 的路径)
—— 已在注释里注明它弱、区分力来自另两条,没有让它冒充证据。

★ 顺手修掉一处自指的措辞:我原先在报错里抄了收尾汇总的原话("已删除 N 项"),
于是 `grep -c '已删除'` 命中**这句报错自己** ⇒ "有没有虚报成功"这个检查把自己的措辞
当成了证据。改写成不含该字面量的说法(与 `grep -c 用例名` 是同一族:判据锚在元文本上)。

## pi 的另两点

· `diffSummary` 带 `ctx` 时**会读文件**(判 `scripts.test` 要读两侧原文),不带是纯内存比较
  —— 已写进函数头,免得以后有人当纯函数用而在大树上意外吃到 I/O。
· "自检样本不独立"的三种形态(位置选择器 / 共享夹具状态泄漏 / 探针无分辨力)
  **合成 docs 第 13 条**(修法同一个:显式命名 + 显式复位),并把上面"夹具形状必须与生产
  一致"作为配套一条写进同一条 —— 今天的真 bug 正是它。

验证:prune 自检 22/22、干跑 exit 0;npm test exit 0;drift 自检 35/0;check-shared-libs exit 0。
2026-09-14 20:53:54 +08:00

309 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 开发工具配置:为什么关掉 pi-lens 的自动改写
本仓库**明确关闭** pi-lens 的项目级自动格式化与 autofix。这份文档记录原因,
配置本身在 `.pi-lens.json`(根目录)。
## 一句话
pi-lens 编辑任一文件后会「安全格式化」它,而它的默认格式化器与本仓库的手工排版
不兼容 —— 每次编辑都会产生与内容无关的大面积 diff,把真正的改动埋掉。
已经造成过两次**真实损失**。
## ⚠️ `.pi-lens.json` 必须是严格 JSON
pi-lens 的配置加载是:
```js
// pi-lens dist/index.js
PROJECT_CONFIG_BASENAMES = ['.pi-lens.json', 'pi-lens.json']; // 没有 .jsonc
function parseConfigFile(configPath) {
const text = fs.readFileSync(configPath, 'utf-8');
raw = JSON.parse(text); // ← 不做去注释处理
}
// 解析失败 → 打一行警告,然后**整份忽略**
```
所以**带 `//` 注释会让这份配置完全失效**,而失败方式是静默的:
进程照常跑,只有启动时一行 `[pi-lens] ignoring invalid project config`。
这个坑真的踩过:第一次写这份配置时把大段理由写成了 `//` 注释,
于是「已经关掉了」这个结论是假的 —— 防护从一开始就没生效。
现在理由放在本文件里,JSON 里只留一个 `$comment` 指针。
## 为什么必须关
pi-lens 在编辑文件后会走 smart-default 回退选择格式化器(见它的
`FORMATTER_POLICY_BY_EXTENSION`):
| 扩展名 | 默认格式化器 |
|---|---|
| `.ts` `.tsx` `.js` | biome(默认 tab 缩进 + 双引号) |
| `.html` | prettier(双引号、`<!DOCTYPE html>` 变小写、按 80 列折行) |
两套默认值都与本仓库的排版冲突。两次已发生的损失:
1. **`19a3161`**:`git add -A` 把约 7000 行 biome 重排扫进了功能提交,
那次提交无法审查(还掩盖了一处 Go 文件的删行)。
2. **`a404cba`**:prettier 改写了 `client/electron/index.html` —— 单引号变双引号、
DOCTYPE 变小写,直接打破 `test/theme.test.mjs` 的两条断言(该测试要求
同步内联脚本里是 `classList.add('dark')`,单引号)。
## 为什么不是「把格式化器配成本仓库风格」
试过:把缩进、引号、lineWidth 全部对齐之后,`biome format --write` 仍然改动
17 个文件 —— 本仓库的注释按语义换行、数组与调用按可读性手工折行,
这些格式化器还原不了。
## 谁在守着这个仓库的格式
不是格式化器,是这些:
- `tsc --noEmit`(前端类型)
- `go vet` / `gofmt -l`(Go)
- tree-sitter / ast-grep(pi-lens 的结构规则与安全规则,**只读、不改写**)
- 各包的测试套件(`npm test`、`go test ./...`、`node --test`)
## 相关
- `biome.jsonc`:只挡得住 biome,**挡不住 prettier** —— 两者是并列的候选格式化器,
各有各的配置。所以真正的开关是 `.pi-lens.json`,它两条改写路径一起关。
## 部署残留清理(`deploy/prune-deploy-artifacts.sh`)
部署会留下两类持续增长的东西:网关旧二进制(每份 ~24MB)与插件快照(opencode/dsh 的
快照含 node_modules,一份几十 MB)。2026-09-14 实测 `/opt/agentmail` 累计到 **1.3GB**,
其中旧二进制 ~790MB、快照 ~540MB。
```bash
bash deploy/prune-deploy-artifacts.sh # 干跑,只报告
bash deploy/prune-deploy-artifacts.sh --apply # 真删(默认:网关留 3 份、每插件留 3 份快照)
bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项,两侧都验)
```
三条硬规矩:**保留回滚窗口**(发布纪律要求有回滚目标,所以不是全清);**绝不删正在使用的
快照**(扫 /proc 的 cmdline 与 cwd,命中就跳过 —— 删掉它进程一重启就找不到自己的代码);
**在线数据库永不入列**。
注意那个"在用"判断必须排除**本进程及其祖先链**:调用方常把路径写在命令行里,
不排除就会出现"永远判为在用"(与 `pkill -f` 杀掉自己那条命令同一个坑,已写进脚本注释)。
### 四类残留与各自的窗口
| 类别 | 来源 | 窗口 |
|---|---|---|
| 网关旧二进制 `agentmail-gateway.bak-<ts>` | `redeploy-gateway.sh` | 保留最新 3 份 |
| 插件快照 `<plug>/<ts>` | `redeploy-plugin.sh` | 每插件保留最新 3 份,`current` 永远保留 |
| 数据库/附件**备份集** `backups/agentmail-<ts>.db` + `attachments-<ts>.tar.gz`、`data/agentmail.db.bak-<ts>` | `reset-demo.sh` 与早期手工留档 | 保留最新 1 集 |
| `/tmp/agentmail-pre-deploy-*.db`、`/tmp/agentmail-pre-prune-*.db` | `redeploy-gateway.sh`、`prune-test-sessions.sh` | 各保留最新 2 份 |
同一个 `<ts>` 的库与附件包算**一个备份集**,一起进出窗口 —— 拆开留没有意义。排序按
**文件名里的时间戳**而不按 mtime:09-02 的两份备份被 09-08 的一次"打开看一眼"改了 mtime,
按 mtime 排会把最老的判成最新的。**没有时间戳的文件一律不碰**(判定不了就不删)。
`pre-prune` 那份是会话归档(不可逆操作)唯一的回滚点,所以它单独一条窗口、报告里也写明身份。
### 三条判据,以及它们各自防的那个错
1. **在线库不入删除清单**(`del()` 里的 `readlink -f` 比对):备份删错能重建,在线库删错回不来。
2. **每个插件至少留一份"上一版"**:只有 `current` 时脚本报 `⚠ 无回滚目标`。它只报告、
**不造快照** —— 造不出来的东西不该假装有。(2026-09-14 那次事故就是这么暴露的:
四个插件各只剩 `current`,回滚目标没了。)
3. **`--self-check` 两侧都验**:干净样本该删的删、该留的留、在线库不动必须是绿的;
把窗口外的备份换成**指向在线库的符号链接**,脚本必须**拒跑**(退出码 1),
不是"删了才发现"。最后一条是"自检没有碰生产根":比对前后 `/opt/agentmail` 的清单指纹。
### 事故记录:自检把生产当成了沙箱(2026-09-14)
`--self-check` 第一版用 `ROOT="$t" … bash "$0"` 传假根。脚本读的是 `AGENTMAIL_ROOT`,
于是这个前缀赋值被静默忽略,自检的 `--apply` 打在了**生产根**上,删掉一批回滚备份
(3 个旧网关二进制、8 个插件快照、09-02 的两组备份集)。
**为什么没被发现**:`PRUNE_TMP_DIR` 那一路的变量名是对的,所以输出的 `/tmp` 段看着"确实是假根",
干跑那一轮的报告也像模像样 —— 半对的状态比全错更难认。是 `bash -x` 里那行
`ls -1t /opt/agentmail/…` 露的马脚。
**改法不是"下次小心"**:① 传对变量名;② 自检的根目录必须在临时区,否则拒跑;
③ "自检不碰生产"进判据(前后指纹比对)。这条与 `pkill -f` 杀自己同类:
**安全装置自己出错时,产出的是一份看着正常的报告**。
## 判据纪律:三种"看起来验过了"的失效形态(2026-09-14)
同一天里,三条自己写的判据各以一种方式失效 —— 它们**都绿着**,但都不再判别任何东西。
记在这里,因为这是**可迁移**的那部分;具体实证留在各自的头注释里(下面有索引)。
### 一、钉装饰:断言落在注释上,不落在机制上
`env-guard.test.mjs` 曾对源码文本断言 `/ENOSPC/` 与 `/环境/`,而那段**解释性注释里
本来就有这两个词** ⇒ 把整段翻译逻辑删掉、只留注释,判据照样绿。
**改法**:能被反面样本喂的抽成纯函数(`translateEnvError`),断言**行为**
(ENOSPC 要翻译;普通错误必须**原样返回同一个对象** —— "什么都翻译"比不翻译更坏)。
### 二、分支退化:判据绑在一个会变的环境上
"空间不足 ⇒ exit 2"那条原本靠"本机 `/tmp` 恰好是满的"来验。机器一恢复健康
(`/tmp` 被清空),那条就自动跳过、**无声失效**。
**改法**:给被测物开一个**只为测试存在**的开关(`--inject-avail`),让两种机器状态
都验得了;两个方向都要验(只验"不足⇒2",一个恒报不足的坏守卫也能绿)。
### 三、跑不到的分支:断言在,区分力不在
按"真实测量"分叉的那版写成了
`realAvail < MIN ? (不足分支) : (充足分支)`,而**本机真实可用就是 0** ⇒ 永远走
不足分支。于是把开关**整个忽略掉**,断言**照样绿**。
比"分支退化"更狠:退化至少留了一行自我声明("此条退化为弱检查"),**短路是无声的** ——
代码看起来两个方向都验了,实际只跑了一个。
**改法**:不与真实测量比,让**两个探针互为反面**(注入 1 字节必须 exit 2,
注入 128 MiB 必须放行),并断言**输出里的判定词**而不只是退出码 ——
退出码可能与真实状态巧合相同。论证对真实值**任意取值**都成立。
### 变异纪律(做"删掉机制看判据红不红"时)
1. **先证明你能撤回来,再注入变异。**
2. **变异只对"已在 HEAD 里干净提交"的文件做**;还原只走 `git checkout HEAD -- <file>`。
3. **还原路径不得依赖被测资源。** 一次把备份写进 `/tmp` —— 正是当时被占满的那个资源,
备份没写成而变异已覆盖源文件;是同一次"手写 `||` 兜底把失败吞掉"才让恢复变得不确定。
4. **判据不得用被测物证明自己**(第 3 条是它在"还原"上的投影)。
5. **别只看过滤后的输出。** `grep '^not ok'` 会丢掉"整份文件没跑起来"这个信息
(语法错时只报一条 `not ok 1 - test/xxx.test.mjs`)。退出码 + `# pass`/`# fail`
汇总行才是可靠信号 —— 也**不要**为此引入手抄的"期望用例数"常量:手抄常量会过期。
6. **判据的退出码不许经管道取值。** 反例(2026-09-14,我自己踩的):
`bash deploy/check-shared-libs.sh 2>&1 | tail -25; echo "exit=$?"` ——
`$?` 拿到的是 **`tail` 的**退出码,于是那条脚本的红(真值 1)被我报成了 0;
两个失败信息之所以还看得见,只是因为它们走 stderr 没进管道。
**用 `$PIPESTATUS[0]`,或先落文件再读** —— 管道会改写量纲,与上一条同族。
7. **"注入点"会把该抓的 bug 藏起来。** 2026-09-14 实例:`checkLayout` 的每个自检样本都
显式注入 `repoUnits`,于是那条判据的**真实默认值从没被任何样本走过** ——
而它当时恰好是错的(解析到不存在的 `/home/program/agentmail/systemd`),
判据因此**一个文件都没比过却报"一致"**,自检还 100% 绿。
⇒ 默认值本身要有判据(导出常量 + 断言存在)、样本要留至少一条**不注入**的;
⇒ 同类还有**位置选择器**(`bad[0]`/`badBak[0]`):在函数前面插一条新检查就改变了
既有断言的语义 —— 断言要按**名字**锚定。
8. **"看起来在比、其实没比"要设成一条自查。** 同一轮里它出现了三次:
目录路径错(`../systemd`)、空目录被当成"一致"、依赖是**符号链接**而选目录时
只挑 `isDirectory()` ⇒ 三次都产出"通过"。
⇒ 判据绿的时候**也要留下覆盖范围的证据**("比了 22 个文件"/"165 个包"):
空 note 无法区分"一致"和"没比过",而那正是这三次的样子。
⇒ 一条判据如果**只能靠真文件系统喂**,它就没法被自检 —— 读写都要走可注入面。
9. **变异之前先提交。** 我在**未提交**状态下变异,然后用 `git checkout HEAD -- <file>`
还原,把自己的改动一起冲掉了(这条纪律我写过、还是踩了)。
顺序必须是:提交 → 变异 → 确认红 → `cp` 还原 → `cmp` 校验。
10. **改"布局/文件集"时,先在脑子里跑一遍"如果这一步被回滚,部署判据会怎么变"。**
(pi 2026-09-14 提的,很准。)实例:把 `lib/user-question.js` 搬到 `test/lib/`,
收益是"快照更干净";但**回滚它会让快照立刻多出一处运行时漂移** ——
因为快照是"搬家后"的树,而仓库回到了"搬家前"。这类改动的收益账里没有这一项,
于是它不体现在任何判据里,只体现在回滚之后的红灯上。
11. **判据的退出码不许经管道取值**(与第 6 条同源,写清取法):临时命令里
`cmd | tail -25; echo $?` 拿到的是 `tail` 的退出码。**要取就读 `${PIPESTATUS[0]}`**。
脚本侧四个 deploy 脚本都有 `set -o pipefail`(`install.sh` 还带 `-e`),
所以脚本内的管道判定是对的 —— **但那是判据的一部分,不是风格**:
`redeploy-plugin.sh` 的 `if ! node … | sed …; then` 与 `install.sh` 里同形状那处,
依赖 `pipefail` 才测的是被检程序的状态;谁重构时把 `set -o pipefail` 删了或挪了位置,
判定会静默变成"`sed` 成功即成功"。**动那几行要连着 pipefail 一起想。**
12. **判据的输出必须能自证"它比完了全部对象"。** 实例:`check-shared-libs.sh` 的
`show_diff` 里 `cmp … | head -3` 在 `set -euo pipefail` 下返回 1 ⇒ 独立调用触发
`set -e` ⇒ **脚本当场中止**:只报第一个分叉文件,后续对象与收尾汇总都不打印。
退出码**恰好还是 1**(判定是对的),所以光量退出码看不见它 ——
这正是"判定对、证据被截断"。修法 `|| true`,并在注释里写明它不是风格而是判据。
13. **自检样本必须"独立":每条样本都要说清它锚在哪一条检查、用的是哪一棵树。**
这一族我 2026-09-14 一天里踩了三次,合起来记比拆成三条好,因为**修法是同一个**
(显式命名 + 显式复位)—— 三者都是"断言的语义被当前状态悄悄改掉":
| 形态 | 实例 |
|---|---|
| **位置选择器** | 自检里 `bad[0]` / `badBak[0]`:前面插一条新检查之后,锚点指的就不是原来那条了 |
| **共享夹具状态泄漏** | 新补的四条豁免样本第一版**全红**:它们继承了两棵树里既有的改动(`extra.mjs`、只存在于 `a` 的 `test/`),红的理由根本不是要测的那件事 |
| **探针自身没有分辨力** | E 那次的探针按**文件名**判两侧,而两条路径 basename 相同(`deploy/service-failure-notify.mjs` vs `/opt/agentmail/bin/…`)⇒ 两侧读到同一个串、样本"通过"得毫无意义。与 `grep -c 用例名` 那个假数、"名字出现在 `# Subtest:` 头"同族不同形:**锚在"名字"而没有锚在"哪一侧"** |
配套一条(同一天踩到):**自检的夹具形状必须与生产形状一致**,否则夹具会把真 bug 藏住 ——
`prune-deploy-artifacts.sh` 的构建暂存自检用 `: >` 造**普通文件**,而生产是
**有内容的目录**;正是这个差异让"`ls -1t` 对多个目录打 `路径:` 头 ⇒ 那段清理一直空转"
这个 bug 藏了很久(判据自己在用文件名判"删了没有",夹具认了错形状,于是它也认了)。
14. **退出码也有量纲。** `node deploy/check-deploy-drift.mjs --self-check` 的退出码
**不是**"自检的结论":自检本体 28/28 全过,但同一个进程接着跑了宿主判据、
于是整体 exit 1。报"自检失败"就是把两个量纲混成一个。
⇒ 报结论时**分开说**:"自检本体 N/M 通过;整体退出码还包含 X"。
15. **注释里的数字无法被判据守住。** 同一文件里曾同时写"写点五处"和"共 6 处"
(且它的式子 2+2+3 加起来是 7,实际 10 处)——**三处说法三个数**。
与"不要手抄期望用例数常量"同源:**两组矛盾的数字比没有数字更糟**,
因为它让读者以为有人数过。要判覆盖完整只能靠**机制**(整段 try/catch),不靠数数。
11. **一条新判据上线时,先找它可能与哪些既有不变量冲突。** 2026-09-14:
新加的"本平台不可达 ⇒ 搬去 `test/lib/`"与既有的"共用模块四方**逐字节同源、
连相对路径一起钉**"(`deploy/check-shared-libs.sh`)**方向相反** ——
我只看⻅了自己那条,于是"按规则推断出的正确动作"把共用判据打红两处,
还差点让下次部署**静默删掉**一个 dsh 桥的生产模块(`lib/user-question.js`)。
⇒ 可达性只能当**报告**,不能当搬家判据;判据里读共用清单做豁免(不手抄)。
### 两条同族的实现纪律
- **测试文件之间不许互相 `import`**(启用 `node --test` 时每个文件一个进程、
模块导入是进程内的)⇒ 被引的那个文件的用例会在**引用者那个进程里再注册一遍**。
实测:从测试文件取夹具,让巨行用例(单条往临时目录写 ~12 MiB)跑了**两次**
(测试总数 475;修完 459)。夹具放**非测试模块**(`lib/session-fixtures.mjs`),
判据也进套件(`env-guard.test.mjs` 里那条扫描)。
- **提交前先看 `git status` 里有没有"不是我的"文件。** 这个工作区是**多 agent 共用的**
—— 2026-09-14 20:02~20:04 就有另一条会话在改 `deploy/install.sh`、
`deploy/prune-deploy-artifacts.sh`、`deploy/redeploy-gateway.sh`(`-trimpath` 那组加固),
而当时我正在同一个仓库里连续提交。`git add -A` 会把**别人没写完、没审过的改动**
一起做进我的提交里,而且从 `git log` 上看不出来是谁的。
**规矩:显式列出要提交的路径,别用 `-A`/`-u` 图省事**;提交信息里也不要把
别人的改动算作自己的成果。(这次三次提交都是显式路径,事后 `git show --stat` 核对过。)
- **写点要"一处覆盖全部",且覆盖范围不取决于入口。** 同一条"ENOSPC 被翻译成环境问题"
在这套代码里被漏过**三次**,而且是**三个不同的入口假设**(不是同一处错了三次):
① `test/lib/session-fixtures.mjs` 的 `writeSession`(第一版只包 `writeFileSync`,
`mkdirSync` 在 try 之外);
② `deploy/check-deploy-drift.mjs` 的 `mk()`(`mkdtempSync` 也**是**一个写点,却在 try 之外);
③ 同一个文件的 `main()` —— 兜底层放在调用方,而**被兜的 `selfCheck()` 是导出的**
⇒ 任何绕过 `main()` 的调用者拿不到翻译。
三次都可以概括成一句话:**"我以为的入口/哪一行"决定了覆盖范围**。
- **判据过宽和过窄都是坏的。** "测试文件不许互相 import"那条判据自己同时踩过两边:
过窄(只匹配静态 from,漏掉动态 `import()`)、过宽("文件里出现别的测试文件名"
把**注释里的散文引用**也算违规,还被自己注释里的示例字面量点亮)。
最终形状只能是"解析真引用"。
- **因果特定的判据 + 因果无关的判据要配对。** "测试文件互相 import"只能发现**已知成因**;
同族的另一种成因它看不见 —— 实测**跨文件同名用例不会被 runner 拦**
(两个文件各写一个同名用例 ⇒ `# tests 2 / # pass 2 / # fail 0`,零警告)。
所以补一条不挑成因的运行期判据:`test/lib/run-suite.mjs` 从**同一次运行的 TAP**
里数结果行,重名即红。
- **判据的锚点必须与结论一一对应。** 同一条重名检查,锚 `^(ok|not ok) <n> - <名字>`
(结果行)是对的;锚"名字出现过"是错的 —— TAP 里名字既出现在 `# Subtest:` 头、
又出现在结果行,**效应 2 倍、噪声也 2 倍且恰好同值**,于是"4"看起来还能解释;
若行种类是 3,就会把"两次"读成"三次"。**别让噪声与效应同阶。**
- **`lib/` 与 `test/lib/` 的边界**(2026-09-14):部署脚本是 `cp -a "$SRC/." "$STAGING/"`
加一条 `rm -rf "$STAGING/test"`(**没有** `EXCLUDE_DIRS` 这种变量)⇒ **`lib/` 整份进快照**。
于是规则必须是可判定的:`lib/` = 从**生产入口**可达的模块;只被测试引用的放 `test/lib/`。
判据在 `test/lib/reach.mjs`(真走 import 闭包,含按路径 fork 的子进程入口)
与 `test/layout-boundaries.test.mjs`。
★ 别写成"被 `src/` **直接** import":实测 22 个 `lib/` 模块里 4 个 `src` 直接引用数为 0
(`addressing.js` 被 `lib/inbox-format.js` 传递引用、`mail-session-id.js`、`crash-notify.mjs`、
`user-question.js`)—— **直接引用数不是可达性**,所以判据真走图。
★★ 但这条例外更要紧:**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**。
`user-question.js` 在 pi 侧只被测试引用、却在**四桥共用清单**上
(`deploy/check-shared-libs.sh` 的 `ALL_LIBS`,它是 **dsh 桥的生产代码**)。
按"不可达就搬走"处理它,会同时打红两处(共用模块缺失 + 共用测试已分叉),
而且**下一次部署会静默把它从生产快照里删掉**。
所以可达性只能当**报告**,不能当搬家判据 —— 判据里读共用清单做豁免。
(教训的完整形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
这里两条不变量方向相反,而我只看见了自己那条。)
### 索引(实证在各自文件头注释里,此处不复述)
- `plugins/pi-mail-bridge/test/lib/tmp-space.mjs` —— 测量层:`bavail × bsize`、
**`0` 是"真的没有"而不是"不知道"**(只有 `null` 才是不知道)。
- `plugins/pi-mail-bridge/test/lib/env-error.mjs` —— ENOSPC ⇒ 人话;为什么它是纯函数、
为什么**不与** `deploy/` 的实现合并(`deploy/` 的独立性比去重值钱)。
- `plugins/pi-mail-bridge/test/lib/reach.mjs` —— `lib/` 与 `test/lib/` 的边界判据。
- `deploy/check-deploy-drift.mjs` —— 现场比树(判据自己算,不靠手抄常量)+
判据自检的两侧验证 + 为什么它自带兜底层。
- `deploy/check-deploy-drift.mjs` 的运行输出本身**就是**部署状态的判据来源;
**不要**把它抄成一份哈希清单往外发 —— 抄出来的那一刻就开始过期
(2026-09-14 发给 `jianf` 的清单在两次提交后就作废了)。
### 附:一条被自己的结论"半路纠正"的观察(留作提醒)
2026-09-14 排查时看到 `permission_requests` 里有一条 `result IS NULL` 的挂起请求
(11:56 创建,已挂 3h54m),当时的推断是"worker 被重启/超时杀掉 ⇒ 停在授权上没人解除
⇒ 这是那个 bug 的同族缺口"。
**实际是正常状态**:网关的等待窗口是 `PermissionWaitWindow = 10 * time.Minute`
(`server/internal/models/permission_mode.go`),请求 12:06 就失效了,
界面靠 `AttachPermissionDeadline` 推导出的**时刻**(不是布尔快照)自己判断过期。
**教训**:看到与已知 bug 形状相同的现象时,先把"它是否已经由另一层按设计处理掉了"
查完再下结论 —— 否则会把一个正常状态写成缺陷,而这类误判会以"我发现了新问题"的
语气传播出去,比沉默更贵。