fix(deploy-drift): 判据 ② 是假绿 —— 它比的目录不存在(一个文件都没比过,却报"一致")

pi 逐行读出来的(我实测确认):默认路径写成
`new URL('../systemd', import.meta.url).pathname`,而 `import.meta.url` 在 `deploy/` 下
⇒ 解析成 `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`)。
`readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉 ⇒ `drift` 恒空
⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致",而且它参与退出码。**

这是第四种形态的标本(**边界没说出口 ⇒ 报了个自己都不知道是假的 0**),
而且**自检接不住它**:`layoutSelfCheck()` 每个样本都显式注入 `repoUnits:'/repo/systemd'`
⇒ 真实默认值从没被任何样本走过 ⇒ 写错了自检也 100% 绿。
**注入点把该抓的 bug 藏起来了** —— 同族里这是最难看的一层。

四处一起修:
1. 路径改成 `join(HERE, 'systemd')`,并**导出** `DEFAULT_REPO_UNITS` 让自检能断言默认值本身;
2. `catch` 不再静默:目录读不到 ⇒ **判红**("比不了"不许伪装成"一致");
   **空目录也判红** —— 真实目录有 22 个文件,不可能是空的(这条是被自检逼出来的:
   注入的 readdir 对未知目录返回 `[]`,于是"不存在"能伪装成"空",我第一版修法又栽成恒绿);
3. **反向也判**:机器上有、仓库里没有的(原先永远不报 —— 那正是"仓库里的是旧的、
   机器上的是新的"的另一半)。**口径必须收窄**:`/etc/systemd/system` 下绝大多数是
   系统自带 unit 与 enable 出的软链(实测 111 个),全报等于没报 ⇒ 只报本仓库自己那套、
   跳过软链 ⇒ 现在 0 个;
4. 绿的时候 note 带上覆盖范围("比了 22 个文件,全部一致(目录:…)")——
   空 note 无法区分"一致"和"没比过",而那正是这条判据原先的样子。

自检新增三条判据:★默认单元目录存在、★默认单元目录不是仓库根下那个不存在的 systemd/、
★单元目录读不到 ⇒ 必须判红。另把 `bad[0]`/`badBak[0]` 这类**位置选择器**改成
按名字取(`byName`)—— 位置选择器是另一种"注入点藏 bug":插一条新检查就会改变语义。

**变异确认**(这次先提交、再变异):把默认路径改回 `../systemd` ⇒ 判据 ② **红**,
note 精确报 `仓库单元目录读不到:/home/program/agentmail/systemd(ENOENT)`。
⚠️ 上一次做这个变异时我在**未提交**状态下用 `git checkout HEAD --` 还原,
把自己的改动一起冲掉了 —— 这正是我自己写进 DEV-TOOLING 的那条纪律,我又踩了一次。
这次先 commit 再变异。

验证:`--self-check` 全过;实跑 ② 由"空 note 恒绿"变成"比了 22 个文件,全部一致"。
This commit is contained in:
2026-09-14 20:19:30 +08:00
parent a1ed4ea1a5
commit 5e38914cac

View File

@ -55,6 +55,14 @@ const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, '..');
const DEPLOY_ROOT = '/opt/agentmail/plugins';
/** 仓库里 systemd 单元的**唯一真相**目录(`install.sh` 也从这里 `find`)。
*
* ★ 导出是为了让自检能**直接断言这个默认值本身**。原先的写法
* `new URL('../systemd', import.meta.url).pathname` 解析成
* `/home/program/agentmail/systemd`(**ENOENT**,真身在 `deploy/systemd/`),
* 而所有自检样本都注入 `repoUnits` ⇒ 默认值从没被走过、写错了也没人知道。 */
export const DEFAULT_REPO_UNITS = join(HERE, 'systemd');
/** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。
* 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */
const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']);
@ -619,27 +627,91 @@ export function checkLayout(inject = {}) {
walk(SYS);
push('没有任何 unit/drop-in/.bak 引用源码目录', offenders.length === 0, offenders.join(' '));
// ② 已安装单元与仓库副本一致(仓库是唯一真相)
// ② 已安装单元与仓库副本一致(仓库是唯一真相)—— **两个方向都判**
//
// ★ 这条判据曾经是**假绿**(pi 评审 2026-09-14 抓到,实测确认):默认路径写成
// `new URL('../systemd', import.meta.url).pathname` —— `import.meta.url` 在
// `deploy/` 下,于是它解析成 **`/home/program/agentmail/systemd`(ENOENT)**,
// 真身在 `deploy/systemd/`。`readdir` 抛的 ENOENT 被 `catch { return; }` 静默吞掉
// ⇒ `drift` 恒空 ⇒ **一个文件都没比过,却报"已安装单元与 deploy/systemd/ 一致"**,
// 而且它参与退出码。同族里这是最难看的一种:**边界没说出口 ⇒ 报了个自己都不知道
// 是假的 0**。而 `layoutSelfCheck()` 每个样本都显式注入 `repoUnits`,默认值从没被
// 走过 ⇒ 写错了自检也 100% 绿(**注入点把该抓的 bug 藏起来了**)。
// 三处一起修:路径用 `join(HERE,'systemd')`、比不了**判红**、反向也判。
const drift = [];
const repoUnits = inject.repoUnits ?? new URL('../systemd', import.meta.url).pathname.replace(/\/$/, '');
const compare = dir => {
let entries = [];
try { entries = readdir(dir, { withFileTypes: true }); } catch { return; }
for (const e of entries) {
const full = `${dir}/${e.name}`;
if (e.isDirectory()) compare(full);
else {
const rel = full.slice(repoUnits.length + 1);
const live = `${SYS}/${rel}`;
if (!exists(live)) { drift.push(`${rel}(缺)`); continue; }
let a = '', b = '';
try { a = String(readFile(full, 'utf8')); b = String(readFile(live, 'utf8')); } catch { continue; }
if (a !== b) drift.push(rel);
}
}
const repoUnits = inject.repoUnits ?? DEFAULT_REPO_UNITS;
const liveUnits = inject.liveUnits ?? SYS;
const extra = []; // 机器上多出来的(仓库里没有)—— 原先**永远不报**
const lstat = inject.lstat ?? lstatSync;
const liveLink = rel => {
try { return lstat(`${liveUnits}/${rel}`).isSymbolicLink(); } catch { return false; }
};
compare(repoUnits);
push('已安装单元与 deploy/systemd/ 一致', drift.length === 0, drift.join(' '));
const walkUnits = (dir, base) => {
const out = new Map(); // rel → 内容(null = 读不到)
const go = cur => {
for (const e of readdir(cur, { withFileTypes: true })) {
const full = `${cur}/${e.name}`;
if (e.isDirectory()) { go(full); continue; }
const rel = full.slice(base.length + 1);
try { out.set(rel, String(readFile(full, 'utf8'))); } catch { out.set(rel, null); }
}
};
go(dir);
return out;
};
let repoMap = null;
try {
repoMap = walkUnits(repoUnits, repoUnits);
} catch (e) {
// **不再静默**:比不了就说"比不了",而不是报"一致"。
drift.push(`仓库单元目录读不到:${repoUnits}(${e.code ?? e.message})`);
}
// ★ 空目录也算"没比过",必须判红。这条是被自检逼出来的:注入的 `readdir` 对未知
// 目录返回 `[]`(不抛),于是"目录不存在"能伪装成"目录是空的",`drift` 恒空
// ⇒ **又变成恒绿**(我第一版修法就栽在这里)。真实世界里 `deploy/systemd/` 有
// 22 个文件,**它不可能是空的** —— 空只意味着路径写错或被清空。
if (repoMap && repoMap.size === 0) {
drift.push(`仓库单元目录里一个文件都没有:${repoUnits} —— 路径写错或被清空的信号,不是"一致"`);
}
if (repoMap) {
const liveMap = exists(liveUnits) ? walkUnits(liveUnits, liveUnits) : new Map();
for (const [rel, content] of repoMap) {
if (!liveMap.has(rel)) { drift.push(`${rel}(缺)`); continue; }
const b = liveMap.get(rel);
if (content === null || b === null) { drift.push(`${rel}(读不到)`); continue; }
if (content !== b) drift.push(rel);
}
// 反向:机器上有、仓库里没有。
//
// ⚠️ 这一格**必须收窄**,否则就是噪声:`/etc/systemd/system` 下绝大多数是系统自带
// unit 与 enable 出来的软链(实测 111 个:dbus-*、NetworkManager…),与"仓库副本
// 一致"无关。**一条每次都在报 111 件事的提示等于没有提示。**
// 口径:只报**本仓库自己那套**(同名前缀 / 同 `.d/` 目录),并跳过符号链接。
for (const rel of liveMap.keys()) {
if (repoMap.has(rel)) continue;
if (liveLink(rel)) continue;
const top = rel.split('/')[0];
const related = [...repoMap.keys()].some(
r => r === top || r.startsWith(`${top}.d/`) || top.startsWith(`${r}.d/`) || r.startsWith(`${top}/`) || top.startsWith(`${r}/`)
);
if (related && !extra.includes(rel)) extra.push(rel);
}
}
// 把"比过几个"写进 note —— 空 note 无法区分"一致"和"没比过",而那正是这条判据
// 原先的样子。绿的时候也要留下覆盖范围的证据。
push(
'已安装单元与 deploy/systemd/ 一致(两个方向)',
drift.length === 0,
drift.length === 0
? `比了 ${repoMap ? repoMap.size : 0} 个文件,全部一致(目录:${repoUnits})`
: drift.join(' ')
);
if (extra.length) {
// 不判红(机器上有仓库里没有的 unit 不等于生产配置错了),但必须**说出来** ——
// 否则"仓库里的是旧的、机器上的是新的"那一半永远看不见。
// 用既有的 `push(…, true, note)` 形状(同 ⑥):一条没人打算为它动手的红灯,下场是被学会忽略。
push('机器上另有仓库里没有的 unit(仅提示,不影响结论)', true, `${extra.length} 个:${extra.slice(0, 5).join(';')}`);
}
// ③ 通知脚本在标准位置且可执行
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
@ -733,8 +805,20 @@ export function layoutSelfCheck() {
},
exists: p => p in map,
stat: () => ({ isFile: () => true, mode: 0o755 }),
lstat: () => ({ isSymbolicLink: () => false }),
repoUnits: '/repo/systemd'
});
// ★ 自检必须能走**默认值**,不能只走注入值。原先每个样本都注入 `repoUnits`,
// 于是判据 ② 的真实默认路径从没被任何样本走过 —— 而它当时恰好是错的,照样 100% 绿。
// **注入点把该抓的 bug 藏起来了**(pi 的原话)。这两条直接断言默认路径本身。
const defaultProbe = [
{ name: '★默认单元目录存在(不注入 repoUnits 也走得通)', ok: existsSync(DEFAULT_REPO_UNITS), note: DEFAULT_REPO_UNITS },
{
name: '★默认单元目录不是仓库根下那个不存在的 systemd/',
ok: DEFAULT_REPO_UNITS !== join(REPO, 'systemd'),
note: DEFAULT_REPO_UNITS === join(REPO, 'systemd') ? '又写回 ../systemd 了 —— 那正是 2026-09-14 的假绿' : ''
}
];
// ⑥ 的 git 读取也要能被喂样本,否则它是一条测不到的判据(读不到就放行 ⇒ 恒绿)。
const fakeGit = porcelain => ({ git: () => porcelain });
const bad = checkLayout(fake({
@ -786,10 +870,15 @@ export function layoutSelfCheck() {
}), git: () => { throw new Error('not a git repo'); } });
const fifth = o => o.find(c => c.name.startsWith('已安装的网关二进制'));
const sixth = o => o.find(c => c.name.startsWith('工作区干净'));
// ★ 按**名字**取,不按位置取。原先是 `bad[0]` / `badBak[0]` —— 位置选择器是另一种
// "注入点把 bug 藏起来":在函数前面插一条新检查之后,`bad[0]` 指的就不再是
// "引用源码目录"那条。判据断言的东西必须按名字锚定。
const byName = (o, prefix) => o.find(c => c.name.startsWith(prefix));
const unitRefCheck = o => byName(o, '没有任何 unit');
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: bad[0].ok === false },
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: badBak[0].ok === false },
{ name: '标准目录:干净样本必须判绿', ok: good[0].ok === true },
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: unitRefCheck(badBak)?.ok === false },
{ name: '标准目录:干净样本必须判绿', ok: unitRefCheck(good)?.ok === true },
// 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。
{ name: '网关二进制:嵌了源码路径必须判红', ok: fifth(badBin)?.ok === false },
{ name: '网关二进制:trimpath 过的必须判绿', ok: fifth(good)?.ok === true },
@ -799,7 +888,18 @@ export function layoutSelfCheck() {
// 且**都不判红**(做成红灯就是一条总在亮的判据,本文件头骂过这个病)。
{ name: '工作区干净:脏 → 说出来(且不判红)', ok: sixth(dirty)?.note.includes('脏 1 处') === true && sixth(dirty)?.ok === true },
{ name: '工作区干净:干净 → 明说干净', ok: sixth(clean)?.note.includes('干净') === true },
{ name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true }
{ name: '工作区干净:读不到 git → 不据此判定', ok: sixth(unreadable)?.note.includes('不据此判定') === true },
...defaultProbe,
{
name: '★单元目录读不到 ⇒ 必须判红(不许静默报一致)',
ok: byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/opt/agentmail/bin/service-failure-notify.mjs': 'x', '/opt/agentmail/agentmail-gateway': 'fake-elf' }),
repoUnits: '/nonexistent/systemd'
}),
'已安装单元'
)?.ok === false
}
];
}