修复: 判据 ① 的**分母**混着没比过的文件 —— scanned++ 在 readFile 之前,读失败静默跳过

与 A 条(`catch { return; }` 吞 ENOENT ⇒ 报"一致")**同一族**:分母里混着没真比过的
对象 ⇒ 报出来的 `0` 不是闭合的。这条是**我自己写的**代码里的同形错误。

## 形状

    scanned++;                                   // ← 先加分母
    try { text = String(readFile(full,'utf8')); } catch { continue; }   // ← 读不到就静默跳过
    if (text.includes(REPO)) offenders.push(full);

⇒ 一个「文件存在、但读不到」(EACCES / 悬空软链 / I/O 错)**被计入"比了 N 个",
却从没被 grep 过**,note 照样报"比了 N 个文件、命中 0"。
实测复现(喂一个"存在但读抛 EACCES"的文件):note 说"比了 **2** 个",真正被 grep 的只有 **1** 个。

## 修法

1. `scanned++` 移到 `readFile` **成功之后** ⇒ 分母 = 真读到内容的条数;
2. 读失败的单列 `unreadable` 并**判红** —— "我没能检查它"与"它没问题"是两件事;
3. 失败 note 把三类分清楚(引用仓库的 / 软链指向仓库的 / **我没读到的**),
   第三类点明"这几条没被检查",否则读者会把它读成"它引用了仓库"。

## 变异验证(两边都跑)

变异①`scanned++` 挪回读之前(旧语义)⇒ 新判据**红**;
变异②读失败**静默跳过**(既不红也不计)⇒ 新判据**红**。
修后自检 66 通过 / 3 失败,那 3 条是**既有线上红**(①b 权限 3 文件 600↔644、
⑥ 二进制含 63 处源码路径),与本次改动无关。

## 顺带:把 §六 那条推理写进注释

`DEPLOY_ROOT` **故意硬编码**、不读 `AGENTMAIL_PREFIX` —— 因为前缀写错会以**红**暴露
(② 与 `configNeedle` 都落到 `else fail('指向别处')`),是 **loud failure** 而非假绿。
并写明:真要收应从 `current` 软链推导,而不是再加一个三方要同步的常量。

★ 另记一条口径(pi §一 给的验收是 133,今天实测是 **135**):判据数的分母会随机器
变化(这里是 70 普通文件 + 65 软链),**所以验收标准不该钉死在某个绝对值上**,
该钉的是"分母 = 真读到内容的条数"这个**关系**。
This commit is contained in:
2026-09-21 04:54:45 +08:00
parent ecf98d7d36
commit 258b88da22

View File

@ -53,6 +53,20 @@ import { execFileSync } from 'node:child_process';
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
/**
* 部署根(`/opt/agentmail/plugins`)—— **故意硬编码,不认 `AGENTMAIL_PREFIX`**。
*
* 这不是漏做,是一个**权衡后接受**的选择(pi 评审 2026-09-14 给了这个论证,我采纳):
* 前缀写错**不会造成假绿**,而是以**红**的方式暴露 ——
* 若真实前缀不是 `/opt/agentmail`,下面 ② 的 `execStart.includes('/opt/agentmail/plugins/…')`
* 立刻落到 `else fail(… '指向别处')`;`configNeedle`(宿主配置那两行)同理报红。
* 也就是说这里的硬编码是 **loud failure**,不是 silent pass ⇒ 可以接受。
*
* ⚠️ 所以**不要**为了让这里"能认任意前缀"而放心地去设 `AGENTMAIL_PREFIX`:
* 三个工具(`install.sh` / `redeploy-gateway.sh` / 本文件)目前已统一读那个变量,
* 而本文件**不读**。真要收,最小一步是让 `DEPLOY_ROOT` 从 `current` 软链自身推导,
* 而不是再新增一个需要三方同步的常量(那正是"三套 PREFIX 各说各话"的老路)。
*/
const DEPLOY_ROOT = '/opt/agentmail/plugins';
/** 仓库里 systemd 单元的**唯一真相**目录(`install.sh` 也从这里 `find`)。
@ -856,6 +870,8 @@ export function checkLayout(inject = {}) {
const realpathForLinks = inject.realpath ?? realpathSync;
let scanned = 0;
let scannedLinks = 0;
// 存在、但**读不到内容**的条目:它们既不能算"比过",也不能当成"没问题"。
const unreadable = [];
// ★ **不划圈**:每个普通文件都读一遍再 grep(pi 评审 2026-09-14,我原先按后缀取)。
//
// 我原先把这条推迟了,理由是"实测零违规 ⇒ 扩口径只增噪声"。pi 用**算术**驳回了口味问题:
@ -884,6 +900,23 @@ export function checkLayout(inject = {}) {
if (e.isDirectory()) walk(full);
else {
// 不划圈:任何普通文件都读(见上面的论证)。软链在下面单独按 realpath 判。
//
// ★★ `scanned` **必须只在真的读到内容之后**才加(2026-09-21 修)。
// 原先 `scanned++` 在 `readFile` **之前**,而下面的 `catch { continue; }` 是静默的
// ⇒ 一个「文件在、但读不到」(EACCES / 悬空软链 / I/O 错)会被计入分母
// **却从没被 grep 过**,note 照样报"比了 N 个文件、命中 0"。
// 这与本文件里 pi 抓到的那个假绿(`catch { return; }` 吞 ENOENT ⇒ 报"一致")
// 是**同一族**:分母里混着没真比过的对象 ⇒ `0` 不再是闭合的。
// 实测复现(喂一个"存在但读抛 EACCES"的文件):修前 note 说"比了 2 个",
// 而真正被 grep 的只有 1 个。修后读不到的单列 `unreadable`,并**判红** ——
// 因为"我没能检查它"与"它没问题"是两件事,前者不该产出绿的结论。
let text = '';
try {
text = String(readFile(full, 'utf8'));
} catch (err) {
unreadable.push(`${full}(${err?.code ?? err?.message ?? '读取失败'})`);
continue;
}
scanned++;
// 软链另算:上面只 grep 了**内容**,跟随软链的单元必须按目标位置判。
try {
@ -893,8 +926,6 @@ export function checkLayout(inject = {}) {
if (real.startsWith(`${REPO}/`)) repoLinks.push(`${full} → ${real}`);
}
} catch { /* 悬空软链:读不到目标,交给 ② 的 walk 报 */ }
let text = '';
try { text = String(readFile(full, 'utf8')); } catch { continue; }
if (text.includes(REPO)) offenders.push(full);
}
}
@ -903,13 +934,20 @@ export function checkLayout(inject = {}) {
// 覆盖面写进 note:`0` 只有在"它能被证伪的范围"写明之后才是结论
// (这正是这条判据当初报"0 个文件"时缺的那句话)。
// 现在范围是**闭合**的(全部文件),不再只是"对我划的那个圈成立"。
const refOk = offenders.length === 0 && repoLinks.length === 0;
const refOk = offenders.length === 0 && repoLinks.length === 0 && unreadable.length === 0;
// 失败时的 note 要把**三类**分清楚:引用了仓库的 / 软链指向仓库的 / **我没读到**的。
// 第三类单列且点明"这几条没被检查" —— 否则读者会把"读不到"也读成"它引用了仓库"。
const refBad = [
...offenders,
...repoLinks,
...(unreadable.length ? [`读不到(**这几条没被检查**,不是"它们没问题"):${unreadable.join(';')}`] : [])
].join(' ');
push(
'没有任何 unit/drop-in/.bak 引用源码目录(含软链指向仓库)',
refOk,
refOk
? `比了 ${scanned} 个文件(**全部**,不筛后缀)、命中 0;其中软链 ${scannedLinks} 个另按 realpath 判目标`
: [...offenders, ...repoLinks].join(' ')
: refBad
);
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
@ -1207,6 +1245,67 @@ export function layoutSelfCheck() {
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}));
// ★ ① 的**「读不到」分支**(2026-09-21):文件存在、但 `readFile` 抛错(EACCES /
// 悬空软链 / I/O 错)时,它**既没被 grep、也不能算比过**。原先 `scanned++` 在
// `readFile` 之前、`catch` 静默 `continue` ⇒ 分母虚增、note 照样报"比了 N 个、命中 0"。
// 这与本文件那条假绿同族:**分母里混着没真比过的对象 ⇒ `0` 不是闭合的。**
// 样本形状:两个文件,其中一个读时抛 EACCES。
const badUnreadable = checkLayout({
...fake({
'/etc/systemd/system': [
{ name: 'a.service', isDirectory: () => false },
{ name: 'locked.service', isDirectory: () => false }
],
'/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/etc/systemd/system/locked.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}),
readFile: p => {
if (p.endsWith('locked.service')) {
const e = new Error('permission denied');
e.code = 'EACCES';
throw e;
}
if (p === '/etc/systemd/system/a.service') return 'ExecStart=/opt/agentmail/agentmail-gateway';
const m = {
'/repo/systemd': '',
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
};
if (p in m) return m[p];
throw new Error('ENOENT');
}
});
// ★ 分母样本:两个文件、都能读到 ⇒ note 必须说"比了 2 个文件"。
// 配上面那条「读不到 ⇒ 1 个」,两条一起把**分母**钉死(只钉一侧会漏掉虚增)。
const goodLinkReadable = checkLayout({
...fake({
'/etc/systemd/system': [
{ name: 'a.service', isDirectory: () => false },
{ name: 'b.service', isDirectory: () => false }
],
'/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/etc/systemd/system/b.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': [],
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
}),
readFile: p => {
const m = {
'/etc/systemd/system/a.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/etc/systemd/system/b.service': 'ExecStart=/opt/agentmail/agentmail-gateway',
'/repo/systemd': '',
'/opt/agentmail/bin/service-failure-notify.mjs': 'x',
'/opt/agentmail/agentmail-gateway': 'fake-elf'
};
if (p in m) return m[p];
throw new Error('ENOENT');
}
});
// ★ ① 的**软链分支**:单元内容是干净的(不含仓库字面量),但软链**指向**仓库 ⇒ 必须红。
//
// 这条形状三条判据原先全都看不见(pi 反例):① 只 grep 内容、② 比的内容相同
@ -1353,6 +1452,15 @@ export function layoutSelfCheck() {
ok: unitRefCheck(badLink)?.ok === false && /→/.test(unitRefCheck(badLink)?.note ?? '') },
{ name: '★软链指向仓库外 ⇒ 不许红(否则这条判据恒红)',
ok: unitRefCheck(goodLink)?.ok === true },
// ★ 「读不到」必须是红,且要点名是哪一条 —— 否则它会被当成"它引用了仓库",
// 也可能被下一个人"顺手"改回"读不到就跳过"。同时钉住分母:绿样本里的
// `比了 N 个` 必须等于**真的读到内容**的条数(这里 2 个文件里 1 个读不到 ⇒ 只 1 个)。
{ name: '★文件存在但读不到 ⇒ 必须红,且点名"这几条没被检查"',
ok: unitRefCheck(badUnreadable)?.ok === false
&& /没被检查/.test(unitRefCheck(badUnreadable)?.note ?? '')
&& /locked\.service/.test(unitRefCheck(badUnreadable)?.note ?? '') },
{ name: '★可读文件仍要计入分母(两文件都读得到 ⇒ "比了 2 个文件")',
ok: /比了 2 个文件/.test(unitRefCheck(goodLinkReadable)?.note ?? '') && unitRefCheck(goodLinkReadable)?.ok === true },
// 依赖树:一致必须绿、变了必须红、一侧没有必须红 —— 三面都钉。
{ name: '★依赖树一致 ⇒ 绿,且说出比了几个包', ok: sameDeps?.ok === true && /2 个包/.test(sameDeps?.note ?? '') },
{ name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },