fix: 回滚 user-question.js 那一搬(它把 check-shared-libs 打红两处),并把 drift 的非运行时差异摘出来

pi 逐处对文件后指出:我按"本平台不可达 ⇒ 搬去 test/lib/"把 `lib/user-question.js`
搬走,打红了 `deploy/check-shared-libs.sh` 两处(实测确认,脚本真退出码 1):

    共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
    共用测试已分叉:test/user-question.test.mjs(opencode vs pi)

根因不是取舍而是口径:**`lib/` 上挂着两条方向相反的不变量** ——
① 共用模块四方逐字节同源(`check-shared-libs.sh`,连相对路径一起钉);
② 本平台生产可达(我新加的规则)。而 `user-question.js` **是 dsh 桥的生产代码**
(`plugins/dsh-mail-bridge/src/index.ts` 引用它)⇒ 两条必然冲突。
**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;可达性只能当**报告**,
不能当搬家判据。教训的形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** ——
我只看⻅了自己那条。

改动:
- `user-question.js` 与它的测试回到 `lib/`、`test/`(路径也与 dsh 侧一致),
  两边逐字节相同已复验;`check-shared-libs.sh` 退出码 0。
- `reach.mjs` 增加 `sharedLibNames()`:直接从 `check-shared-libs.sh` 的 `ALL_LIBS`
  读共用清单做豁免(不手抄常量),并把"进快照但本平台不可达"降级为**报告**。
- `layout-boundaries.test.mjs` 增加回归判据:共用模块必须留在 `lib/`、
  测试相对路径与 dsh 一致、两侧逐字节相同。
- 删掉 `reach.mjs` / `docs/DEV-TOOLING.md` 里那句**无据的机制说明**
  ("user-question 走前缀动态 import"):`localRefs` 的三条正则只认引号字面量,
  对模板字面量形状是**盲的** ⇒ 那句若为真,搬走的就是生产代码而两条判据都会绿。
  pi 读了 `src/` 下九个文件都找不到引用,我也确认是记忆偏差;理由改用 `addressing.js`
  (传递可达、`src` 直接引用数为 0)—— 它已足够证明"直接引用数不是可达性"。

顺带按 pi 的第二条建议:`deploy/check-deploy-drift.mjs` 判据 ① 把
**非运行时差异**摘出来(`jsonTestOnlyChange`,只豁免 `scripts.test` 一类字段,
只对"两边都在、仅内容不同"的文件生效)。理由:一条**永远黄、没人打算为它动手**的判据
唯一的下场是被学会忽略,那时真正的运行时漂移会被一起忽略。
⚠️ 摘的条件很窄 —— **把运行时差异误判成非运行时比恒黄更坏(那是假绿)**,
所以 `main`/`start`/`dependencies` 变了、或解析不了,一律仍算运行时;
纯函数加了六个反/正样本的判据(含三个"必须算运行时"的)。

(该文件同时有另一条会话的改动,未提交、我未触碰;本次只加了我这一段。)

验证:`npm test` 463/463;`check-shared-libs.sh` 退出码 0;`--self-check` 18 条全过。
This commit is contained in:
2026-09-14 20:11:58 +08:00
parent 2d936893f5
commit d616582e96
6 changed files with 197 additions and 18 deletions

View File

@ -134,13 +134,79 @@ export function diffTrees(repoFiles, snapFiles) {
};
}
export function diffSummary(d) {
/**
* `package.json` 里哪些改动是**非运行时**的:只有 `scripts.test` 这类"只有测试会跑"的
* 条目变了 ⇒ 这份快照跑起来的行为与仓库完全一致。
*
* ★ 为什么要分开(pi 评审 2026-09-14 提的):一条**永远黄、且没人打算为它动手**的判据,
* 唯一的下场是被学会忽略 —— 而那时真正的运行时漂移会被一起忽略。
* 实测当时就是这种局面:`package.json` 只差 `scripts.test`(`node --preflight …` →
* `node test/lib/…`),生产根本不跑它,却让 pi 组恒显"⚠️ 漂移"。
*
* ⚠️ 只认**明确属于测试**的键,别的(`main`/`start`/`dependencies`…)一律算运行时。
* 判据必须窄:把运行时差异误判成"非运行时"比恒黄更坏 —— 那是**假绿**。
*/
export function jsonTestOnlyChange(repoText, snapText) {
let a;
let b;
try {
a = JSON.parse(repoText);
b = JSON.parse(snapText);
} catch {
return { testOnly: false, keys: [] }; // 解析不了就不敢下结论,按运行时算
}
if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) {
return { testOnly: false, keys: [] };
}
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
const diffs = [];
for (const k of keys) {
if (JSON.stringify(a[k]) === JSON.stringify(b[k])) continue;
// 只豁免 scripts.test(以及打包工具惯用的 test:xxx 变体)
if (k === 'scripts' && a[k] && b[k] && typeof a[k] === 'object' && typeof b[k] === 'object') {
const sub = new Set([...Object.keys(a[k]), ...Object.keys(b[k])]);
const subDiff = [...sub].filter((s) => JSON.stringify(a[k][s]) !== JSON.stringify(b[k][s]));
const allTestOnly = subDiff.length > 0 && subDiff.every((s) => s === 'test' || s.startsWith('test:'));
if (allTestOnly) {
for (const s of subDiff) diffs.push(`scripts.${s}`);
continue;
}
}
diffs.push(k);
}
return { testOnly: diffs.length > 0 && diffs.every((k) => k.startsWith('scripts.test')), keys: diffs };
}
export function diffSummary(d, ctx = {}) {
return {
runtimeDrift: d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length,
docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length
docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length,
// `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。
testOnly: (ctx.repoDir && ctx.snapDir)
? testOnlyDrift(d, ctx.repoDir, ctx.snapDir)
: []
};
}
/** 逐字节不同、但差别**只落在"只有测试会跑"的字段**上的那些文件。
*
* 只看 `changed`(两边都在、内容不同)—— 一侧独有/缺失都是真的文件集变化,
* 不能靠读字段豁免。 */
function testOnlyDrift(d, repoDir, snapDir) {
const out = [];
for (const rel of d.changed.runtime) {
try {
const a = readFileSync(join(repoDir, rel), 'utf8');
const b = readFileSync(join(snapDir, rel), 'utf8');
const r = jsonTestOnlyChange(a, b);
if (r.testOnly) out.push({ path: rel, keys: r.keys });
} catch {
/* 读不到就按运行时算(宁可报出来) */
}
}
return out;
}
function processArgv(pid) {
try {
return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' ');
@ -248,16 +314,35 @@ function checkHost(spec) {
// 只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳。
// 想连那一跳一起判,得单独比 `git diff --stat`,别指望这一条。
const d = diffTrees(collectFiles(repoDir), collectFiles(linkPath));
const { runtimeDrift, docDrift } = diffSummary(d);
if (runtimeDrift === 0) {
pass('① 运行文件与仓库一致', docDrift ? `一致(另有 ${docDrift} 个文档差异,不影响运行)` : '逐字节一致');
const { runtimeDrift, docDrift, testOnly } = diffSummary(d, { repoDir, snapDir: linkPath });
// 把"只差测试脚本"这类差异从运行时漂移里摘出来单独说(pi 评审 2026-09-14):
// 一条**永远黄、且没人打算为它动手**的判据,唯一的下场是被学会忽略 ——
// 而那时真正的运行时漂移会被一起忽略。摘出来之后:真正的运行时漂移**照旧判红**,
// 非运行时差异则让这一条**通过并注明**(结论区也据此把该宿主判绿)。
//
// ⚠️ 摘的条件很窄(`jsonTestOnlyChange` 只豁免 `scripts.test` 一类字段),
// 而且只对"两边都在、仅内容不同"的文件生效;一侧独有的文件集变化照旧算运行时 ——
// **把运行时差异误判成非运行时比恒黄更坏,那是假绿。**
const testOnlyPaths = new Set(testOnly.map(t => t.path));
const runtimeOnly = [
...d.onlyRepo.runtime,
...d.onlySnap.runtime,
...d.changed.runtime.filter(f => !testOnlyPaths.has(f))
];
if (runtimeOnly.length === 0) {
const notes = [];
if (testOnly.length) {
notes.push(`${testOnly.length} 处非运行时差异(只差 ${testOnly.map(t => t.keys.join('/')).join('、')},生产不跑):${testOnly.map(t => t.path).join('、')}`);
}
if (docDrift) notes.push(`另有 ${docDrift} 个文档差异,不影响运行`);
pass('① 运行文件与仓库一致', notes.length ? notes.join(';') : '逐字节一致');
} else {
fail(
'① 运行文件与仓库一致',
`漂移 ${runtimeDrift} 处:${[
`漂移 ${runtimeOnly.length} 处:${[
...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`),
...d.onlySnap.runtime.map(f => `快照独有 ${f}`),
...d.changed.runtime.map(f => `内容不同 ${f}`)
...d.changed.runtime.filter(f => !testOnlyPaths.has(f)).map(f => `内容不同 ${f}`)
]
.slice(0, 5)
.join(';')}`