Files
MailUI4Agents/plugins/pi-mail-bridge/test/lib/reach.mjs
JianFeeeee fb85a8728d refactor(pi-bridge): 定下 lib/ 与 test/lib/ 的边界 —— 三个测试侧模块原来会随部署进 /opt
pi 复核后指出:`lib/` 会被 `cp -a "$SRC/." "$STAGING/"` **整份打进生产快照**
(排除清单只有 `test/`、`.git`、`node_modules/.cache`),而我们那三个测试侧模块
(`tmp-space.mjs`、`env-error.mjs`、`session-fixtures.mjs`)都住在 `lib/` 里。
后果不是几 KB,而是"漂移 N 处"这个数字**虚高**、哈希清单变长 ——
而"手抄哈希清单"正是我们刚定性为会过期的东西。

## 规则写成**可判定的**,不写成约定

    lib/      = 从生产入口可达的模块(会进快照)
    test/lib/ = 只被测试引用的模块(test/ 不部署、也不被注册进套件)

`test/lib/reach.mjs` 真去走一遍 import 闭包(种子 = `src/index.mjs` +
源码里 `new URL('./x.mjs', import.meta.url)` 这类**按路径 fork 的子进程入口**)。

★ 顺带纠正 pi 的规则表述:他写的是"被 `src/` import",但实测 22 个 `lib/` 模块里
有 4 个 `src` **直接**引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引)、
`user-question.js`(走前缀动态 import)、`mail-session-id.js`、`crash-notify.mjs`。
**直接引用数不是可达性**,所以判据真走图而不是 grep。
★ 也纠正他的排除清单名字:脚本里没有 `EXCLUDE_DIRS` 这个变量,就是一条 `rm -rf`。

## 本规则多抓到一个 pi 没发现的

`lib/user-question.js` 也是**只被测试引用**(只有 `test/user-question.test.mjs` 用它)
⇒ 同样会进快照。已一并移到 `test/lib/`。剩下 `mail-session-id.js` 与
`crash-notify.mjs` 是**谁都不用**(生产与测试都不可达)—— 那是遗留物,
不动它们(不属本次范围),但记录在此。

## 新增:因果**无关**的运行期判据

`test/lib/run-suite.mjs`:跑套件并从**同一次运行的 TAP**里数结果行,任何用例名
出现两次就红。为什么需要:静态那条(测试文件不许互相 import)只能发现**已知成因**。
实测跨文件重名**不会被 runner 拦**:两个文件各写一个同名用例 ⇒
`# tests 2 / # pass 2 / # fail 0`,两句 `ok`,零警告。

判据锚在 `^(ok|not ok) <n> - <名字>`(**结果行**),不是"名字出现过"——
pi 先前那条 `grep -c '<名字>'` 给 4 是因为 TAP 里名字既出现在 `# Subtest:` 头、
又出现在结果行,**2 倍效应 + 2 倍噪声恰好同值**,若行种类是 3 就会把两次读成三次。
本脚本自带 `--self-check`(干净样本放行 / 重复样本点名 / 只出现在头里的不算重复 /
名字含 `#` 不被截断)。

`package.json` 的 `test` 改为:
    node test/lib/env-preflight.mjs && node test/lib/run-suite.mjs

## 判据全进套件

`test/layout-boundaries.test.mjs`(新):生产可达性不碰 `test/`、`test/lib/` 里不许藏
运行时模块、测试文件不许互相 import、`npm test` 必须接上 run-suite 那一层。
原来放在 `env-guard.test.mjs` 里那条"夹具不在测试文件里"已移到这里(集中边界判据)。

## 变异自检(两条都实测红了才留下)

- 造一个与巨行用例**同名**的探针文件 ⇒ `npm test` exit 1 并点名
  `2× ★巨大的 message 行不进内存也不影响解析`;
- 往 `src/gateway.mjs` 加一行指向 `test/lib/run-suite.mjs` 的真 import ⇒
  边界判据红并指出 `生产可达了测试代码:test/lib/run-suite.mjs`。
  两条探针均已删除、`src/gateway.mjs` 用 `git checkout` 还原并 `cmp` 校验一致。

顺带修一处路径:`env-guard.test.mjs` 里 `PREFLIGHT` 仍指向旧的 `test/env-preflight.mjs`
(前置脚本已移入 `test/lib/`)。

验证:`npm test` **462/462**、结果行重复检查 0 个重名、set 全绿。
2026-09-14 20:00:16 +08:00

138 lines
5.7 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`(走前缀动态 import)、`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;
}
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}`);
const testFiles = [...reach].filter((p) => p.startsWith('test/'));
console.log(`\n src/ + lib/ 可达 ${srcFiles.length} 个;lib/ 总共有 ${
readdirSync(join(ROOT, 'lib')).length} 个文件`);
if (offenders.length) {
console.error('\n✗ 生产代码引用了 test/ 下的文件:');
for (const p of offenders) console.error(` ${p}`);
console.error(' ⇒ 要么把它移到 lib/(如果生产真的需要),要么切断这条引用。');
process.exit(1);
}
console.log('✓ 生产代码没有引用 test/ 下的任何文件(边界干净)');
if (testFiles.length === 0) console.log('(本次闭包里没有 test/ 文件)');
process.exit(0);
}
if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
main();
}