#!/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(); }