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 全绿。
138 lines
5.7 KiB
JavaScript
138 lines
5.7 KiB
JavaScript
#!/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();
|
||
}
|