fix(deploy-drift): 补上 EXCLUDE_DIRS 挖掉的依赖树洞(②b)+ 写明排除口径表

pi 逐条对过 `EXCLUDE_*` 与 `redeploy-plugin.sh`,最要紧的是 `node_modules` 那格:
部署脚本**把依赖拷进快照**("依赖必须进快照:仓库外没有 node_modules 可借"),
而本文件整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。
这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿。

新增判据 ②b:读锁文件里的**版本集合**做签名(一个文件、24ms、实测 165 个包)。
选它而不是逐文件比:抓的是"依赖树漂移"这个真实风险,又不会被 `node_modules`
里的缓存噪声乱报(真的逐文件比 `node_modules` 不现实)。
- 一致 ⇒ 绿并说出比了几个包;版本变了 ⇒ 红;**一侧没有 ⇒ 也红**(不许当"未比"放过)。

过程中连踩两个同形状的坑,都记在代码里:
1. 依赖是**符号链接**(`@earendil-works/pi-coding-agent -> /usr/lib/node_modules/…`),
   第一版只挑 `isDirectory()` ⇒ 空手而归 ⇒ 恒报"两边都没有依赖树,未比"
   —— **又是"看起来在比、其实没比"**;
2. 我把 `repoUnits`/`liveUnits`(systemd 目录)传给了找锁文件的函数,
   于是永远找不到 ⇒ 同一个形状再来一次。
⇒ 教训:一条判据如果**只能靠真文件系统喂**,它就没法被自检;
`findDepLock` 因此改成只走注入面,自检样本把它真正走一遍。

另外:
- 排除口径写成**表**(对齐/未对齐各自说明),不再只是"逐条对齐"一句话;
- `dist.old` 加进 `EXCLUDE_SUFFIX`(部署脚本会 `rm -rf` 它,仓库里若有会造成**永久假红**);
- `coverage` 保留排除但写明"仓库里当前不存在,无实际影响,保留是为了不假装对齐";
- `.cache`/`*.log`/`.DS_Store` 三处**方向相反**(本文件比脚本更宽)写明为已知取舍。

自检新增三条:★依赖树一致 ⇒ 绿且说出包数、★版本变了 ⇒ 必须红、★一侧没有 ⇒ 必须红。
自检 25 条全过;实跑 ②b 报"165 个包,版本集合一致"。
This commit is contained in:
2026-09-14 20:22:12 +08:00
parent 5e38914cac
commit 3b5c51c41e

View File

@ -63,11 +63,24 @@ const DEPLOY_ROOT = '/opt/agentmail/plugins';
* 而所有自检样本都注入 `repoUnits` ⇒ 默认值从没被走过、写错了也没人知道。 */
export const DEFAULT_REPO_UNITS = join(HERE, 'systemd');
/** 与 deploy/redeploy-plugin.sh 的排除清单**逐条对齐**。
* 对不齐就会出现「脚本说一致、部署脚本却拷了别的」这种假绿。 */
/** 与 `deploy/redeploy-plugin.sh` 的排除清单对齐 —— **但口径必须写明,
* 因为对不齐就会出「脚本说一致、部署脚本却拷了别的」这种假绿**
* (pi 评审 2026-09-14 逐条对过,下表是核对后的实况):
*
* | 项 | 部署脚本 | 本文件 | 处置 |
* |---|---|---|---|
* | `test` | `rm -rf "$STAGING/test"` | 排除 | 对齐 |
* | `.git` | `rm -rf` | 排除 | 对齐 |
* | `node_modules` | **拷进快照** | 原为整份排除 ⇒ **假绿** | **改判**:见下方 ②b 依赖树判据 |
* | `dist.old` | `rm -rf "$STAGING/dist.old"` | 原不排除 ⇒ 仓库里若有就**永久假红** | 加进 `EXCLUDE_SUFFIX` |
* | `coverage` | 不排除 | 排除 | 仓库里当前不存在,**保留排除**(无实际影响,写明以免被当成对齐) |
* | `.cache`/`*.log`/`.DS_Store` | 只排 `node_modules/.cache`、顶层 `*.log`/`.DS_Store` | 任何深度 | **方向相反**:本文件比脚本更宽 ⇒ 快照里的深层残留看不见。危害小,保留;写在这里免得"以为对齐了" |
*
* ⚠️ `node_modules` 那格是最要紧的:脚本把依赖拷进快照,本文件原先整份跳过它,
* 于是"仓库换过依赖、快照还是旧的"会被报成**逐字节一致**。现在由 ②b 单独判。 */
const EXCLUDE_DIRS = new Set(['test', '.git', 'node_modules', 'coverage']);
const EXCLUDE_FILES = new Set(['.DS_Store']);
const EXCLUDE_SUFFIX = ['.log'];
const EXCLUDE_SUFFIX = ['.log', '.old']; // `.old`:部署脚本会 rm -rf staging 里的 dist.old,仓库里若有它会造成永久假红
/**
* 宿主表。`load` 决定用哪套判据(见文件头那张表)。
@ -581,6 +594,37 @@ export function selfCheck() {
*
* @param {object} [inject] 注入点(判据自检时喂假文件系统)
*/
/** 在 `node_modules/<vendor>/<pkg>/` 这类位置里找锁文件(浅层即可,不递归整棵树)。
*
* ⚠️ 只走**注入面**(`readdir`/`exists` 参数),不直接碰 `readdirSync`/`existsSync`
* —— 否则自检样本喂不进去(第一版就是这样:样本造了真目录,判据却用注入的
* `readFile` 去读,于是恒报"依赖锁读不到",自检红)。
* 一条判据如果只能靠真文件系统喂,就等于**没法被自检**。 */
function findDepLock(pluginRoot, lockName, deps) {
const { readdir, exists } = deps;
const roots = [join(pluginRoot, 'node_modules')];
for (const r of roots) {
let vendors = [];
// ⚠️ 依赖很可能是**符号链接**(实测 pi 的 `@earendil-works/pi-coding-agent` 是指向
// `/usr/lib/node_modules/…` 的软链)⇒ 只挑 `isDirectory()` 会**空手而归**,
// 于是这条判据恒报"两边都没有依赖树,未比" —— 又是"看起来在比、其实没比"。
// 软链也要跟进去(`readdirSync` 跟软链,如实测)。
try { vendors = readdir(r, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
for (const v of vendors) {
let pkgs = [];
const vd = join(r, v.name);
try { pkgs = readdir(vd, { withFileTypes: true }).filter(e => e.isDirectory() || e.isSymbolicLink()); } catch { continue; }
for (const pk of pkgs) {
const f = join(vd, pk.name, lockName);
if (exists(f)) return f;
}
const direct = join(vd, lockName);
if (exists(direct)) return direct;
}
}
return null;
}
export function checkLayout(inject = {}) {
const readdir = inject.readdir ?? readdirSync;
const readFile = inject.readFile ?? readFileSync;
@ -641,6 +685,13 @@ export function checkLayout(inject = {}) {
const drift = [];
const repoUnits = inject.repoUnits ?? DEFAULT_REPO_UNITS;
const liveUnits = inject.liveUnits ?? SYS;
// 依赖树比的是**插件目录**,不是 systemd 目录 —— 我第一版把 repoUnits/liveUnits
// 传给了 findDepLock,于是它永远找不到锁文件、恒报"两边都没有依赖树,未比"。
// 又一次同一个形状:一条**看起来在比、其实没比**的判据(这次是我自己写的)。
// 依赖树拿哪个插件比?pi(本文件历史上就是为它写的,且它是唯一带 node_modules 的)。
const DEP_PLUGIN = inject.depPlugin ?? 'pi-mail-bridge';
const repoPluginDir = inject.repoPluginDir ?? join(REPO, 'plugins', DEP_PLUGIN);
const livePluginDir = inject.livePluginDir ?? join(DEPLOY_ROOT, DEP_PLUGIN, 'current');
const extra = []; // 机器上多出来的(仓库里没有)—— 原先**永远不报**
const lstat = inject.lstat ?? lstatSync;
const liveLink = rel => {
@ -713,6 +764,49 @@ export function checkLayout(inject = {}) {
push('机器上另有仓库里没有的 unit(仅提示,不影响结论)', true, `${extra.length} 个:${extra.slice(0, 5).join(';')}`);
}
// ②b 依赖树一致 —— **补上 `EXCLUDE_DIRS` 挖掉的那个洞**
//
// pi 评审 2026-09-14 指出的那格:部署脚本把 `node_modules` **拷进快照**
// (注释写着"依赖必须进快照:仓库外没有 node_modules 可借"),而本文件
// `EXCLUDE_DIRS` 整份跳过它 ⇒ **仓库换过依赖、快照还是旧的,判据报"逐字节一致"**。
// 这正是 `EXCLUDE_DIRS` 上面那句注释自己预言的假绿("对不齐就会出现
// 『脚本说一致、部署脚本却拷了别的』")。
//
// 做法:读锁文件里的**版本集合**做签名,一个文件、24ms、165 个包。
// 只比版本集合(不比内容):它抓的是"依赖树漂移"这个真实风险,
// 又不会因为 `node_modules` 里的缓存时间戳之类的噪声乱报。
const depSig = file => {
try {
const d = JSON.parse(String(readFile(file, 'utf8')));
const pkgs = d.packages ?? {};
const v = Object.entries(pkgs).map(([k, x]) => `${k}@${x.version ?? ''}`).sort();
return { n: v.length, sig: createHash('sha256').update(v.join('\n')).digest('hex') };
} catch { return null; }
};
const lockRel = 'node_modules';
const lockName = 'npm-shrinkwrap.json';
let depNote = '两边都没有依赖树,未比';
let depOk = true;
{
const a = findDepLock(repoPluginDir, lockName, { readdir, exists });
const b = findDepLock(livePluginDir, lockName, { readdir, exists });
if (a && b) {
const sa = depSig(a);
const sb = depSig(b);
if (!sa || !sb) { depOk = false; depNote = '依赖锁读不到/解析不了,比不了'; }
else if (sa.sig !== sb.sig) {
depOk = false;
depNote = `依赖树不一致:仓库 ${sa.n} 个包 vs 已部署 ${sb.n} 个包(重新部署即可对齐)`;
} else depNote = `${sa.n} 个包,版本集合一致`;
} else if (a || b) {
depOk = false;
depNote = `一侧有依赖树、另一侧没有(仓库 ${a ? '有' : '无'} / 已部署 ${b ? '有' : '无'})`;
}
// ②b 与 `lockRel` 的关系写在名字里:它比的是被 EXCLUDE_DIRS 排除的那部分。
void lockRel;
}
push('已部署依赖树与仓库一致(EXCLUDE_DIRS 排除的那部分也要判)', depOk, depNote);
// ③ 通知脚本在标准位置且可执行
const script = '/opt/agentmail/bin/service-failure-notify.mjs';
let scriptOk = false;
@ -806,7 +900,10 @@ export function layoutSelfCheck() {
exists: p => p in map,
stat: () => ({ isFile: () => true, mode: 0o755 }),
lstat: () => ({ isSymbolicLink: () => false }),
repoUnits: '/repo/systemd'
repoUnits: '/repo/systemd',
// 默认把依赖树指向假目录,免得自检真去读仓库/生产(样本要能覆盖它)
repoPluginDir: '/repo/plugins/pi-mail-bridge',
livePluginDir: '/repo/plugins/pi-mail-bridge'
});
// ★ 自检必须能走**默认值**,不能只走注入值。原先每个样本都注入 `repoUnits`,
// 于是判据 ② 的真实默认路径从没被任何样本走过 —— 而它当时恰好是错的,照样 100% 绿。
@ -875,8 +972,50 @@ export function layoutSelfCheck() {
// "引用源码目录"那条。判据断言的东西必须按名字锚定。
const byName = (o, prefix) => o.find(c => c.name.startsWith(prefix));
const unitRefCheck = o => byName(o, '没有任何 unit');
// ②b 依赖树:正反两面都要有样本,且**必须真的走读盘路径**(不能只喂内存 map)——
// 这条判据的价值全在"它真的读了锁文件",所以样本用真临时目录。
const depFixture = (packages) => {
const root = mkdtempSync(join(tmpdir(), 'drift-dep-'));
const dir = join(root, 'node_modules', '@vendor', 'pkg');
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'npm-shrinkwrap.json'),
JSON.stringify({ packages: Object.fromEntries(packages.map(p => [p, { version: p.split('@').pop() }])) }));
return root;
};
const depSample = (repoPkgs, livePkgs) => {
let a = null; let b = null;
try {
a = depFixture(repoPkgs);
b = livePkgs ? depFixture(livePkgs) : mkdtempSync(join(tmpdir(), 'drift-empty-'));
// 注入面把**夹具目录**路由到真实 fs,其余路径仍走内存假件 ——
// 这样"找锁文件"那段真实逻辑被走了一遍,而 systemd 那几条判据仍可控。
const roots = [a, b].filter(Boolean);
const onDisk = p => roots.some(r => p.startsWith(r));
return byName(
checkLayout({
...fake({ '/etc/systemd/system': [], '/repo/systemd': [] }),
repoPluginDir: a,
livePluginDir: b,
readdir: (dir, opts) => (onDisk(dir) ? readdirSync(dir, opts) : []),
readFile: (p, enc) => (onDisk(p) ? readFileSync(p, enc) : (() => { throw new Error('ENOENT'); })()),
exists: p => (onDisk(p) ? existsSync(p) : false)
}),
'已部署依赖树'
);
} finally {
for (const d of [a, b]) if (d) rmSync(d, { recursive: true, force: true });
}
};
const sameDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@2.0.0']);
const diffDeps = depSample(['a@1.0.0', 'b@2.0.0'], ['a@1.0.0', 'b@9.9.9']);
const missingDeps = depSample(['a@1.0.0'], null);
return [
{ name: '标准目录:引用源码目录的样本必须判红', ok: unitRefCheck(bad)?.ok === false },
// 依赖树:一致必须绿、变了必须红、一侧没有必须红 —— 三面都钉。
{ name: '★依赖树一致 ⇒ 绿,且说出比了几个包', ok: sameDeps?.ok === true && /2 个包/.test(sameDeps?.note ?? '') },
{ name: '★依赖树版本变了 ⇒ 必须红', ok: diffDeps?.ok === false && /不一致/.test(diffDeps?.note ?? '') },
{ name: '★一侧没有依赖树 ⇒ 必须红(不许当"未比"放过)', ok: missingDeps?.ok === false },
{ name: '标准目录:.bak 里引用源码目录也必须判红', ok: unitRefCheck(badBak)?.ok === false },
{ name: '标准目录:干净样本必须判绿', ok: unitRefCheck(good)?.ok === true },
// 二进制那条两侧都要真:嵌了源码路径必须红,trimpath 的必须绿。