fix(pi-bridge): 评审第二轮 —— 判据在健康机器上会退化、"一处覆盖"取决于入口、笔误参数静默放行

pi 读了 `5bc579f` 之后报了两条新的 + 三条小的,全部认下并落地。

## 一、"开关真的被认"那条判据在 /tmp 被清空后失去分辨力

上一版只在"真实测量不足"那个分支里断言(注入大数必须放行)。问题是:
**"不足"正是机器恢复健康后会消失的条件** —— 那天这条判据就退化成"只验
`--measure` 可用"的弱检查,而它守的恰恰是"开关别静默失效"。

两个方向是对偶的、各守一个机器状态,所以改成**按实测分叉、在两个分支里断言相反的方向**:

    真实不足 ⇒ 注入大数必须放行   (开关被忽略则回退测量 ⇒ 2 ≠ 0 ⇒ 红)
    真实充足 ⇒ 注入 0    必须 exit 2(开关被忽略则回退测量 ⇒ 0 ≠ 2 ⇒ 红)

量不到就 `assert.fail` 并说明"无法分叉"—— 不静默跳过(跳过会把"失去分辨力"
伪装成"验过了")。另把"端到端"那条的两个方向拆明白:只验"不足⇒2"时,
一个恒报不足的坏守卫也能绿。

## 二、"一处覆盖全部写点"成立的前提是"从 main() 进来"

`selfCheck()` 是**导出**的(用途就是被直接调),而兜住那三处裸写的 catch 在
`main()` 里 ⇒ 任何绕过 `main()` 的调用者撞上 ENOSPC 拿到的仍是原始英文堆栈。
**"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根**,所以把整段包一层
(`body()` + 统一 catch):与入口无关,`main()` 那个退化为冗余的第二道。
实测:`TMPDIR=/tmp node -e 'import("./deploy/check-deploy-drift.mjs").then(m=>m.selfCheck())'`
现在拿到的是「环境不足…这是环境问题,不是检查器的问题」。

## 三、`--inject-avail=abc` 静默放行(笔误 = 跳过守卫)

`Number('abc')` = NaN ⇒ 判据当"没测到" ⇒ 放行。现在按仓库约定处理:
**非法值 exit 2,未知参数也 exit 2**(`--measure` 少写 `=` 同样炸)。
`null` 仍是合法值("没测到 ⇒ 放行"是有意的),加了判据把这两个方向都钉住。

## 四、三条小的

- 两份实现(`lib/env-error.mjs` 的 `translateEnvError` 与 `deploy/` 的
  `describeEnvError`)**不去重**,但两边各写一句"为什么不复用":
  `deploy/` 的独立性比去重值钱(那份文件头整段在讲"服务不该依赖仓库是否存在")。
  并写明**第三份拷贝出现时再考虑共用**。
- 写点计数口径写进注释:本函数 **6 处写** = `mkdtempSync`×2 + `mk()` 内 ×2
  + 三处裸写。免得与别处"五处"的说法对不上(上一封信里两个实测数字就是这么被误读的)。
- 变异自检的纪律补进 `lib/env-error.mjs` 头注释:**先证明能撤回来再注入变异,
  且还原路径不能依赖被测对象**(那次把备份写进 `/tmp` —— 正是当时被占满的资源,
  备份没写成而变异已覆盖源文件)。现在只对"已在 HEAD 干净提交"的文件做变异,
  还原一律 `git checkout HEAD -- <file>`。

验证:`npm test` **475/475**;`--self-check` 18 条全过;
`TMPDIR=/tmp node deploy/check-deploy-drift.mjs --self-check` ⇒ exit 2 + 人话。
This commit is contained in:
2026-09-14 19:42:58 +08:00
parent 5bc579f910
commit 87359588eb
4 changed files with 130 additions and 44 deletions

View File

@ -351,20 +351,44 @@ function describeEnvError(e, what) {
return err;
}
/** 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
* 一个永远说「一致」的比较器看起来同样令人放心。 */
/**
* 判据自检:**先证明这个检查器能发现差异**,再用它下结论。
* 一个永远说「一致」的比较器看起来同样令人放心。
*
* ★ **本函数自己保证"环境不足说人话",与从哪个入口调它无关。**
*
* 上一版把"覆盖全部写点"那一层放在 `main()` 的 catch 里 —— 但本函数是**导出的**
* (导出的用途就是被别人直接调,例如 `--self-check` 之外的自检脚本)。任何绕过
* `main()` 的调用者,在那几处裸写撞上 ENOSPC 时拿到的仍是原始英文 + 本文件堆栈。
* pi 评审时指出:**"一处覆盖全部写点"成立的前提是"从 main() 进来"** ——
* 而"覆盖范围取决于我以为的入口"正是这一串 bug 的共同病根。
*
* 所以这里把整段包一层(`body()` + 统一 catch),主进程那个 catch 退化为
* 冗余的第二道 —— 冗余是故意的。
*
* 为什么不去用 `plugins/pi-mail-bridge/lib/env-error.mjs` 的 `translateEnvError`
* (判据相同、措辞不同,看起来该合并):**`deploy/` 的独立性比去重值钱**。
* 本文件头整段就在讲"服务不该依赖仓库是否存在",`deploy/` 下的工具同理 ——
* 让它 import 插件目录里的模块,等于把部署工具绑死在插件的目录结构上。
* 判据(`code === 'ENOSPC'` / 消息里含 no space left)是稳定的,措辞各自合适即可。
* **若哪天出现第三份拷贝,再考虑共用** —— 两份还撑得住。
*
* 写点计数口径(免得与别处的说法对不上):本函数共 **6 处写**
* = `mkdtempSync` ×2 + `mk()` 内 `writeFileSync` ×2 + 三处裸写
* (`b/README.md`、`b/lib/extra.mjs`、`a/test/t.mjs`)。
*/
export function selfCheck() {
// ★ `mkdtempSync` 也在 try 里 —— 它本身就是一个写点:临时目录满的时候
// (2026-09-14 实测 `bavail` 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,
// 于是那条路径照样崩出一段指向本文件的堆栈(退出码还不是 2,是未捕获异常)。
let a;
let b;
try {
a = mkdtempSync(join(tmpdir(), 'drift-a-'));
b = mkdtempSync(join(tmpdir(), 'drift-b-'));
return body();
} catch (e) {
throw describeEnvError(e, '判据自检要在临时目录里建两棵样本树');
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
}
function body() {
// `mkdtempSync` 本身就是一个写点:临时目录满的时候(2026-09-14 实测 `bavail`
// 一度真是 0)它会抛 ENOSPC。上一版它在 try 之外,于是那条路径连退出码 2 都拿不到。
const a = mkdtempSync(join(tmpdir(), 'drift-a-'));
const b = mkdtempSync(join(tmpdir(), 'drift-b-'));
const mk = (root, content) => {
mkdirSync(join(root, 'lib'), { recursive: true });
// ★ 自检要在临时目录里造两棵小树。临时目录满了时这里会抛 ENOSPC ——
@ -374,10 +398,9 @@ export function selfCheck() {
writeFileSync(join(root, 'lib', 'x.mjs'), content);
writeFileSync(join(root, 'README.md'), 'doc');
} catch (e) {
// 统一交给 describeEnvError 翻译(见上方):这里只负责 `mk()` 自己能看到的两处写,
// 后面还有三处写在别处(README.md / extra.mjs / test/t.mjs),由 main() 的
// catch 统一兜住 —— 那是"一处覆盖全部写点"的那一层。
throw describeEnvError(e, '判据自检要在临时目录里造样本树');
// 交给最外层那个统一 catch(见函数头注释)——**不要**在这里单独翻译,
// 否则"覆盖范围"又变成"取决于哪一处写",就是这套 bug 的病根。
throw e;
}
};
const out = [];
@ -449,6 +472,7 @@ export function selfCheck() {
rmSync(b, { recursive: true, force: true });
}
return out;
}
}
/**