Files
MailUI4Agents/plugins/pi-mail-bridge/test/lib/reach.mjs
JianFeeeee d616582e96 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 条全过。
2026-09-14 20:11:58 +08:00

183 lines
8.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* `lib/` 与 `test/lib/` 的边界:**从生产入口出发的 import 闭包**。
*
* # 为什么需要这条判据
*
* 2026-09-14 发现:`lib/tmp-space.mjs`、`lib/env-error.mjs`、`lib/session-fixtures.mjs`
* 这三个模块**只有测试用**,却住在 `lib/` 里 ⇒ 部署脚本 `cp -a "$SRC/." "$STAGING/"`
* 会把它们**打进生产快照**(`EXCLUDE` 只排掉 `test/`、`.git`、`node_modules/.cache`)。
* 后果不是"多几 KB",而是:
*
* - "漂移 N 处"这个数字**虚高**(其中两三处根本不是运行时代码);
* - 部署验收要核的哈希清单变长,而我们刚把"手抄哈希清单"定性为会过期的东西。
*
* # 规则(写成**可判定的**,不写成"约定")
*
* `lib/` = 从生产入口可达的模块(会进快照)
* `test/lib/` = 只被测试引用的模块(`test/` 不部署、也不被注册进套件)
*
* 判据:**`src/` 下任何文件都不得(直接或间接)引用 `test/` 下的任何文件。**
* 这是单向的 —— 测试引用 `lib/` 完全正常(本仓库就是这么做的)。
*
* ⚠️ 别把规则写成"被 `src/` **直接** import":实测 22 个 `lib/` 模块里有 4 个
* `src` 直接引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引用)、
* `user-question.js`(在 pi 侧**只被测试**引用 —— 但见下方豁免说明,它不能因此搬家)、`mail-session-id.js`、`crash-notify.mjs`。
* **直接引用数不是可达性**。所以这里真去走一遍图。
*
* ⚠️ 也别漏掉"按路径 fork 的子进程入口":`src/pool.mjs` 里是
* `fileURLToPath(new URL('./worker.mjs', import.meta.url))` —— worker 不是 `import`
* 进来的,但它是生产代码。因此种子 = `src/index.mjs` + 源码里 `new URL('./x.mjs', …)`
* 这类**按路径解析**的本地文件。
*
* 退出码:0 = 边界干净;1 = 有违规(`src/` 可达 `test/`);2 = 环境/前置问题。
*/
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { dirname, join, resolve, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
const HERE = dirname(fileURLToPath(import.meta.url)); // …/test/lib
const ROOT = resolve(HERE, '../..'); // 插件根
/** 从一份源码里取出它引用的**本地**文件(相对路径)。
* 三类都收:静态 `from './x'`、动态 `import('./x')`、以及按路径解析的
* `new URL('./x', import.meta.url)`(子进程入口就是这个形状)。 */
export function localRefs(src) {
const out = new Set();
const patterns = [
/\bfrom\s*['"](\.[^'"]+)['"]/g,
/\bimport\s*\(\s*['"](\.[^'"]+)['"]\s*\)/g,
/\bnew\s+URL\s*\(\s*['"](\.[^'"]+)['"]/g,
];
for (const re of patterns) {
for (const m of src.matchAll(re)) out.add(m[1]);
}
return [...out];
}
/** 把 `./x` 这种相对引用解析成一个真实存在的文件(补 .mjs/.js 后缀)。 */
function resolveRef(fromFile, ref) {
const base = resolve(dirname(fromFile), ref);
for (const cand of [base, `${base}.mjs`, `${base}.js`, join(base, 'index.mjs')]) {
if (existsSync(cand) && !cand.endsWith('/')) {
try {
if (readFileSync(cand) !== null) return cand;
} catch { /* 目录会抛 —— 跳过 */ }
}
}
return null;
}
/** 从种子出发走一遍 import 闭包,返回插件根下的相对路径集合。 */
export function reachable(seeds) {
const seen = new Set();
const queue = [...seeds];
while (queue.length) {
const f = queue.pop();
if (seen.has(f)) continue;
seen.add(f);
let src;
try {
src = readFileSync(f, 'utf8');
} catch {
continue;
}
for (const ref of localRefs(src)) {
const r = resolveRef(f, ref);
if (r && !seen.has(r)) queue.push(r);
}
}
return new Set([...seen].map((f) => relative(ROOT, f)));
}
/** 生产种子:部署脚本的 ENTRY + 源码里按路径解析的本地文件(子进程入口)。 */
export function productionSeeds() {
const entry = join(ROOT, 'src', 'index.mjs');
const seeds = [entry];
const srcDir = join(ROOT, 'src');
for (const f of readdirSync(srcDir)) {
if (!f.endsWith('.mjs')) continue;
const full = join(srcDir, f);
const src = readFileSync(full, 'utf8');
// `new URL('./worker.mjs', import.meta.url)` —— fork 的入口按路径找,不走 import
for (const m of src.matchAll(/\bnew\s+URL\s*\(\s*['"](\.[^'"]+)['"]/g)) {
const r = resolveRef(full, m[1]);
if (r) seeds.push(r);
}
}
return seeds;
}
/**
* 这个桥的 `lib/` 里**允许存在**"本平台不可达"的模块,只要它在**四桥共用清单**上。
*
* ★ 这条豁免是我踩出来的(2026-09-14)。我按"不可达就搬去 `test/lib/`"把
* `lib/user-question.js` 搬走了 —— 结果把 `deploy/check-shared-libs.sh` 打红两处:
* 共用模块缺失:plugins/pi-mail-bridge/lib/user-question.js
* 共用测试已分叉:test/user-question.test.mjs(opencode vs pi)
* 因为它**是 dsh 桥的生产代码**(`plugins/dsh-mail-bridge/src/index.ts` 引用它),
* 而 `lib/` 上挂着**两条相反的不变量**:
* ① 共用模块四方**逐字节同源**(`deploy/check-shared-libs.sh`,连相对路径一起钉);
* ② 本平台生产可达(本文件的规则)。
* 对 `user-question` 而言这两条必然冲突 —— 所以口径只能是:
* **`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**;
* 可达性适合当**报告**,不适合当搬家判据。
*
* 清单直接从 `check-shared-libs.sh` 里读(那才是它唯一的真相来源),
* 不手抄 —— 手抄的常量会过期。
*/
export function sharedLibNames() {
const script = join(ROOT, '..', '..', 'deploy', 'check-shared-libs.sh');
let text;
try {
text = readFileSync(script, 'utf8');
} catch {
return new Set(); // 读不到就不豁免(宁可报出来让人看,不要静默放过)
}
const m = /^ALL_LIBS="([^"]+)"/m.exec(text);
if (!m) return new Set();
return new Set(m[1].trim().split(/\s+/));
}
function main() {
const reach = reachable(productionSeeds());
const offenders = [...reach].filter((p) => p.startsWith('test/') || p.startsWith('test\\'));
console.log('生产可达性(从 src/index.mjs 出发,含按路径 fork 的子进程入口):');
const srcFiles = [...reach].filter((p) => p.startsWith('src/') || p.startsWith('lib/')).sort();
for (const p of srcFiles) console.log(` 可达 ${p}`);
// "进了快照但本平台不可达" —— 这是**报告**,不是违规(见 sharedLibNames 的说明)。
const shared = sharedLibNames();
const allLib = readdirSync(join(ROOT, 'lib')).filter((f) => /\.(mjs|js)$/.test(f));
const unreachable = allLib.filter((f) => !reach.has(`lib/${f}`));
const dead = unreachable.filter((f) => !shared.has(f.replace(/\.(mjs|js)$/, '')));
const sharedOnly = unreachable.filter((f) => shared.has(f.replace(/\.(mjs|js)$/, '')));
console.log(`\n src/ + lib/ 可达 ${srcFiles.length} 个;lib/ 总共 ${allLib.length} 个文件`);
if (sharedOnly.length) {
console.log(` 其中 ${sharedOnly.length} 个本平台不可达但在**四桥共用清单**上(必须留在 lib/):`);
for (const f of sharedOnly) console.log(` ${f}`);
}
if (dead.length) {
console.log(` ⚠ ${dead.length} 个既不可达、也不在共用清单上(进快照但不跑):`);
for (const f of dead) console.log(` ${f}`);
console.log(' —— 只是提示,不判红:它们可能是跨桥共用、也可能该删。');
}
if (offenders.length) {
console.error('\n✗ 生产代码引用了 test/ 下的文件:');
for (const p of offenders) console.error(` ${p}`);
console.error(' ⇒ 要么把它移到 lib/(如果生产真的需要),要么切断这条引用。');
process.exit(1);
}
console.log('\n✓ 生产代码没有引用 test/ 下的任何文件(边界干净)');
process.exit(0);
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main();
}