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