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 条全过。
183 lines
8.1 KiB
JavaScript
183 lines
8.1 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`(在 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();
|
||
}
|