修复: 判据 ① 的**分母**混着没比过的文件 —— 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:
@ -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 ?? '') },
|
||||
|
||||
Reference in New Issue
Block a user