From d616582e96a3e0c0b8009fe06605061794e818ca Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 20:11:58 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=9B=9E=E6=BB=9A=20user-question.js=20?= =?UTF-8?q?=E9=82=A3=E4=B8=80=E6=90=AC=EF=BC=88=E5=AE=83=E6=8A=8A=20check-?= =?UTF-8?q?shared-libs=20=E6=89=93=E7=BA=A2=E4=B8=A4=E5=A4=84=EF=BC=89?= =?UTF-8?q?=EF=BC=8C=E5=B9=B6=E6=8A=8A=20drift=20=E7=9A=84=E9=9D=9E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E5=B7=AE=E5=BC=82=E6=91=98=E5=87=BA?= =?UTF-8?q?=E6=9D=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 条全过。 --- deploy/check-deploy-drift.mjs | 99 +++++++++++++++++-- docs/DEV-TOOLING.md | 12 ++- .../{test => }/lib/user-question.js | 0 .../test/layout-boundaries.test.mjs | 45 ++++++++- plugins/pi-mail-bridge/test/lib/reach.mjs | 57 +++++++++-- .../test/user-question.test.mjs | 2 +- 6 files changed, 197 insertions(+), 18 deletions(-) rename plugins/pi-mail-bridge/{test => }/lib/user-question.js (100%) diff --git a/deploy/check-deploy-drift.mjs b/deploy/check-deploy-drift.mjs index 375274c..2d84ea7 100644 --- a/deploy/check-deploy-drift.mjs +++ b/deploy/check-deploy-drift.mjs @@ -134,13 +134,79 @@ export function diffTrees(repoFiles, snapFiles) { }; } -export function diffSummary(d) { +/** + * `package.json` 里哪些改动是**非运行时**的:只有 `scripts.test` 这类"只有测试会跑"的 + * 条目变了 ⇒ 这份快照跑起来的行为与仓库完全一致。 + * + * ★ 为什么要分开(pi 评审 2026-09-14 提的):一条**永远黄、且没人打算为它动手**的判据, + * 唯一的下场是被学会忽略 —— 而那时真正的运行时漂移会被一起忽略。 + * 实测当时就是这种局面:`package.json` 只差 `scripts.test`(`node --preflight …` → + * `node test/lib/…`),生产根本不跑它,却让 pi 组恒显"⚠️ 漂移"。 + * + * ⚠️ 只认**明确属于测试**的键,别的(`main`/`start`/`dependencies`…)一律算运行时。 + * 判据必须窄:把运行时差异误判成"非运行时"比恒黄更坏 —— 那是**假绿**。 + */ +export function jsonTestOnlyChange(repoText, snapText) { + let a; + let b; + try { + a = JSON.parse(repoText); + b = JSON.parse(snapText); + } catch { + return { testOnly: false, keys: [] }; // 解析不了就不敢下结论,按运行时算 + } + if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) { + return { testOnly: false, keys: [] }; + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]); + const diffs = []; + for (const k of keys) { + if (JSON.stringify(a[k]) === JSON.stringify(b[k])) continue; + // 只豁免 scripts.test(以及打包工具惯用的 test:xxx 变体) + if (k === 'scripts' && a[k] && b[k] && typeof a[k] === 'object' && typeof b[k] === 'object') { + const sub = new Set([...Object.keys(a[k]), ...Object.keys(b[k])]); + const subDiff = [...sub].filter((s) => JSON.stringify(a[k][s]) !== JSON.stringify(b[k][s])); + const allTestOnly = subDiff.length > 0 && subDiff.every((s) => s === 'test' || s.startsWith('test:')); + if (allTestOnly) { + for (const s of subDiff) diffs.push(`scripts.${s}`); + continue; + } + } + diffs.push(k); + } + return { testOnly: diffs.length > 0 && diffs.every((k) => k.startsWith('scripts.test')), keys: diffs }; +} + +export function diffSummary(d, ctx = {}) { return { runtimeDrift: d.onlyRepo.runtime.length + d.onlySnap.runtime.length + d.changed.runtime.length, - docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length + docDrift: d.onlyRepo.doc.length + d.onlySnap.doc.length + d.changed.doc.length, + // `testOnly` 只在给出两棵树根目录时才有意义(要读原文比对字段)。 + testOnly: (ctx.repoDir && ctx.snapDir) + ? testOnlyDrift(d, ctx.repoDir, ctx.snapDir) + : [] }; } +/** 逐字节不同、但差别**只落在"只有测试会跑"的字段**上的那些文件。 + * + * 只看 `changed`(两边都在、内容不同)—— 一侧独有/缺失都是真的文件集变化, + * 不能靠读字段豁免。 */ +function testOnlyDrift(d, repoDir, snapDir) { + const out = []; + for (const rel of d.changed.runtime) { + try { + const a = readFileSync(join(repoDir, rel), 'utf8'); + const b = readFileSync(join(snapDir, rel), 'utf8'); + const r = jsonTestOnlyChange(a, b); + if (r.testOnly) out.push({ path: rel, keys: r.keys }); + } catch { + /* 读不到就按运行时算(宁可报出来) */ + } + } + return out; +} + function processArgv(pid) { try { return readFileSync(`/proc/${pid}/cmdline`, 'utf8').split('\0').filter(Boolean).join(' '); @@ -248,16 +314,35 @@ function checkHost(spec) { // 只覆盖「仓库→快照」这一跳,覆盖不到「HEAD→工作区」那一跳。 // 想连那一跳一起判,得单独比 `git diff --stat`,别指望这一条。 const d = diffTrees(collectFiles(repoDir), collectFiles(linkPath)); - const { runtimeDrift, docDrift } = diffSummary(d); - if (runtimeDrift === 0) { - pass('① 运行文件与仓库一致', docDrift ? `一致(另有 ${docDrift} 个文档差异,不影响运行)` : '逐字节一致'); + const { runtimeDrift, docDrift, testOnly } = diffSummary(d, { repoDir, snapDir: linkPath }); + // 把"只差测试脚本"这类差异从运行时漂移里摘出来单独说(pi 评审 2026-09-14): + // 一条**永远黄、且没人打算为它动手**的判据,唯一的下场是被学会忽略 —— + // 而那时真正的运行时漂移会被一起忽略。摘出来之后:真正的运行时漂移**照旧判红**, + // 非运行时差异则让这一条**通过并注明**(结论区也据此把该宿主判绿)。 + // + // ⚠️ 摘的条件很窄(`jsonTestOnlyChange` 只豁免 `scripts.test` 一类字段), + // 而且只对"两边都在、仅内容不同"的文件生效;一侧独有的文件集变化照旧算运行时 —— + // **把运行时差异误判成非运行时比恒黄更坏,那是假绿。** + const testOnlyPaths = new Set(testOnly.map(t => t.path)); + const runtimeOnly = [ + ...d.onlyRepo.runtime, + ...d.onlySnap.runtime, + ...d.changed.runtime.filter(f => !testOnlyPaths.has(f)) + ]; + if (runtimeOnly.length === 0) { + const notes = []; + if (testOnly.length) { + notes.push(`${testOnly.length} 处非运行时差异(只差 ${testOnly.map(t => t.keys.join('/')).join('、')},生产不跑):${testOnly.map(t => t.path).join('、')}`); + } + if (docDrift) notes.push(`另有 ${docDrift} 个文档差异,不影响运行`); + pass('① 运行文件与仓库一致', notes.length ? notes.join(';') : '逐字节一致'); } else { fail( '① 运行文件与仓库一致', - `漂移 ${runtimeDrift} 处:${[ + `漂移 ${runtimeOnly.length} 处:${[ ...d.onlyRepo.runtime.map(f => `仓库独有 ${f}`), ...d.onlySnap.runtime.map(f => `快照独有 ${f}`), - ...d.changed.runtime.map(f => `内容不同 ${f}`) + ...d.changed.runtime.filter(f => !testOnlyPaths.has(f)).map(f => `内容不同 ${f}`) ] .slice(0, 5) .join(';')}` diff --git a/docs/DEV-TOOLING.md b/docs/DEV-TOOLING.md index 3bcaa74..d01b8db 100644 --- a/docs/DEV-TOOLING.md +++ b/docs/DEV-TOOLING.md @@ -205,8 +205,16 @@ bash deploy/prune-deploy-artifacts.sh --self-check # 判据自检(16 项, 判据在 `test/lib/reach.mjs`(真走 import 闭包,含按路径 fork 的子进程入口) 与 `test/layout-boundaries.test.mjs`。 ★ 别写成"被 `src/` **直接** import":实测 22 个 `lib/` 模块里 4 个 `src` 直接引用数为 0 - (`addressing.js` 被 `lib/inbox-format.js` 引、`user-question.js` 走前缀动态 import、 - 另两个谁都不用)—— **直接引用数不是可达性**。 + (`addressing.js` 被 `lib/inbox-format.js` 传递引用、`mail-session-id.js`、`crash-notify.mjs`、 + `user-question.js`)—— **直接引用数不是可达性**,所以判据真走图。 + ★★ 但这条例外更要紧:**`lib/` 首先是四桥共用命名空间,其次才是"本平台可达"**。 + `user-question.js` 在 pi 侧只被测试引用、却在**四桥共用清单**上 + (`deploy/check-shared-libs.sh` 的 `ALL_LIBS`,它是 **dsh 桥的生产代码**)。 + 按"不可达就搬走"处理它,会同时打红两处(共用模块缺失 + 共用测试已分叉), + 而且**下一次部署会静默把它从生产快照里删掉**。 + 所以可达性只能当**报告**,不能当搬家判据 —— 判据里读共用清单做豁免。 + (教训的完整形状:**一条新判据上线时,先找它可能与哪些既有不变量冲突** —— + 这里两条不变量方向相反,而我只看见了自己那条。) ### 索引(实证在各自文件头注释里,此处不复述) diff --git a/plugins/pi-mail-bridge/test/lib/user-question.js b/plugins/pi-mail-bridge/lib/user-question.js similarity index 100% rename from plugins/pi-mail-bridge/test/lib/user-question.js rename to plugins/pi-mail-bridge/lib/user-question.js diff --git a/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs b/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs index 77ae760..74b5244 100644 --- a/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs +++ b/plugins/pi-mail-bridge/test/layout-boundaries.test.mjs @@ -1,10 +1,10 @@ import assert from 'node:assert/strict'; import test from 'node:test'; -import { readFileSync, readdirSync } from 'node:fs'; +import { readFileSync, readdirSync, existsSync } from 'node:fs'; import { fileURLToPath } from 'node:url'; import { dirname, join, relative, resolve } from 'node:path'; -import { reachable, productionSeeds } from './lib/reach.mjs'; +import { reachable, productionSeeds, sharedLibNames } from './lib/reach.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); // …/test const ROOT = resolve(HERE, '..'); @@ -41,6 +41,47 @@ test('★`test/lib/` 里的模块只被测试引用(别把运行时模块藏 assert.deepEqual(wrong, [], `这些模块生产可达,不该放在 test/lib/:${wrong.join('、')}`); }); +test('★共用模块必须留在 `lib/`:我把它搬走会打红两条不变量', () => { + // 回归判据。2026-09-14 我按"本平台不可达 ⇒ 搬去 test/lib/"把 + // `lib/user-question.js` 搬走了,结果: + // 共用模块缺失: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/` 上挂着两条方向相反的不变量** —— + // ① 共用模块四方逐字节同源(连相对路径一起钉);② 本平台生产可达。 + // 我只看见了自己那条,于是"按我的规则推断出的正确动作"恰好破坏了另一条。 + // 判据所以长成这样:对**共用清单上的**模块,"本平台不可达"不构成搬家理由。 + const shared = sharedLibNames(); + assert.ok(shared.has('user-question'), 'ALL_LIBS 里应当有 user-question'); + + // 本平台侧:文件必须在 lib/,且测试的相对路径必须与它同源。 + assert.ok( + existsSync(join(ROOT, 'lib', 'user-question.js')), + 'lib/user-question.js 必须留在 lib/(它是 dsh 桥的生产代码,且要四方同源)' + ); + assert.ok( + !existsSync(join(HERE, 'lib', 'user-question.js')), + '不该出现在 test/lib/' + ); + assert.match( + readFileSync(join(HERE, 'user-question.test.mjs'), 'utf8'), + /from '\.\.\/lib\/user-question\.js'/, + '测试的相对路径也要跟 dsh 侧一致(同源是连路径一起钉的)' + ); + + // 逐字节同源(对 dsh 那一侧;这是被打破的那条不变量的直接判据)。 + const peer = join(ROOT, '..', 'dsh-mail-bridge'); + if (existsSync(join(peer, 'lib', 'user-question.js'))) { + assert.equal( + readFileSync(join(ROOT, 'lib', 'user-question.js'), 'utf8'), + readFileSync(join(peer, 'lib', 'user-question.js'), 'utf8'), + 'pi 与 dsh 的 user-question.js 必须逐字节一致' + ); + } +}); + test('★测试文件之间不许互相 import(会二次注册整套用例)', () => { // `node --test` 默认每个文件一个子进程,而模块导入是**进程内**的 ⇒ // 从另一个 `.test.mjs` 取东西,会把那个文件的用例在引用者进程里**再注册一遍**。 diff --git a/plugins/pi-mail-bridge/test/lib/reach.mjs b/plugins/pi-mail-bridge/test/lib/reach.mjs index f097cdf..e90a382 100644 --- a/plugins/pi-mail-bridge/test/lib/reach.mjs +++ b/plugins/pi-mail-bridge/test/lib/reach.mjs @@ -22,7 +22,7 @@ * * ⚠️ 别把规则写成"被 `src/` **直接** import":实测 22 个 `lib/` 模块里有 4 个 * `src` 直接引用数是 0 —— `addressing.js`(被 `lib/inbox-format.js` 引用)、 - * `user-question.js`(走前缀动态 import)、`mail-session-id.js`、`crash-notify.mjs`。 + * `user-question.js`(在 pi 侧**只被测试**引用 —— 但见下方豁免说明,它不能因此搬家)、`mail-session-id.js`、`crash-notify.mjs`。 * **直接引用数不是可达性**。所以这里真去走一遍图。 * * ⚠️ 也别漏掉"按路径 fork 的子进程入口":`src/pool.mjs` 里是 @@ -110,6 +110,37 @@ export function productionSeeds() { 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\\')); @@ -117,9 +148,24 @@ function main() { 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} 个文件`); + + // "进了快照但本平台不可达" —— 这是**报告**,不是违规(见 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/ 下的文件:'); @@ -127,8 +173,7 @@ function main() { console.error(' ⇒ 要么把它移到 lib/(如果生产真的需要),要么切断这条引用。'); process.exit(1); } - console.log('✓ 生产代码没有引用 test/ 下的任何文件(边界干净)'); - if (testFiles.length === 0) console.log('(本次闭包里没有 test/ 文件)'); + console.log('\n✓ 生产代码没有引用 test/ 下的任何文件(边界干净)'); process.exit(0); } diff --git a/plugins/pi-mail-bridge/test/user-question.test.mjs b/plugins/pi-mail-bridge/test/user-question.test.mjs index 29a5ab5..bcfec38 100644 --- a/plugins/pi-mail-bridge/test/user-question.test.mjs +++ b/plugins/pi-mail-bridge/test/user-question.test.mjs @@ -20,7 +20,7 @@ import { hasOptions, optionLabels, questionTitle, -} from './lib/user-question.js'; +} from '../lib/user-question.js'; test('单问题单选项:忠实映射问题与选项', () => { const flat = flattenQuestions([{