/* 跨桥一致性判据:`parent_from` 方向判据必须在**每个**桥真正生效(2026-10-02)。 # 这个缺陷是怎么活下来的 b0c8719(2026-09-30)给 `inboundHeadline` 加了 `parentFrom`/`selfName` 两个参数, 用来分辨「回的是你那封」和「多方线索里的他人续谈」——单向续信链(8 封全是 对方发来的)同样满足 `in_reply_to`,不判方向就会被逐封宣称「回的是你那封」, 模型于是把它当新任务处理。pi 桥接了,**另外两个桥没接**: pi parentFrom: data?.parent_from ✓ dsh (从不传) ✗ 3 处调用点 opencode (从不传) ✗ 1 处调用点 判据函数在 `lib/relay-policy.js` 里对三个桥是**同一份拷贝**,看起来测过了; 但「函数支持」≠「调用方传了」。dsh 还多一层:它有一份**手写**的 `lib/relay-policy.d.ts`,缺这两个字段 ⇒ tsc 报 TS2353 ⇒ 调用方即使想传 也传不过类型检查 —— 于是那行判据在 dsh 上是被类型系统**主动拦住**的死代码。 # 为什么这值得单独一格判据 三个桥各自都有 relay-policy 的单元测试,且全绿 —— 因为它们测的是 `inboundHeadline` 这个**纯函数**,没有一个测「桥真的把参数传进去了」。 纯函数测试与接线测试是两件事,缺后者就等于没测。 用法:在仓库根跑,见 go 之外的 `node deploy/check-parent-from.mjs`。 */ // 这是 .mjs —— ESM。(初版写了 require,跑起来直接 ReferenceError; // 与其它 deploy/check-*.mjs 保持一致用 import。) import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); /** 每个桥:入口文件 + 期望出现 parentFrom 传参的次数。 * 次数写死而不是「>0」:新增调用点时忘了传参,要能被发现而不是被静默接受。 */ const BRIDGES = [ { name: 'pi', files: ['plugins/pi-mail-bridge/src/turn.mjs'], expect: 1 }, { name: 'dsh', files: ['plugins/dsh-mail-bridge/src/index.ts'], expect: 3 }, { name: 'opencode', files: ['plugins/opencode-mail-bridge/index.js'], expect: 1 }, ]; const PASS = (m) => console.log(` 通过 ${m}`); const FAIL = (m) => { console.log(` 失败 ${m}`); failures.push(m); }; const failures = []; console.log('判据:三个桥都必须把 parent_from 传给 inboundHeadline'); for (const b of BRIDGES) { for (const rel of b.files) { const full = path.join(REPO, rel); if (!fs.existsSync(full)) { FAIL(`${b.name}: 文件不存在 ${rel}`); continue; } const src = fs.readFileSync(full, 'utf8'); // ① 每个 inboundHeadline 调用点都必须带 parentFrom const calls = [...src.matchAll(/inboundHeadline\(\s*\{([\s\S]*?)\n\s*\}\)/g)]; if (calls.length === 0) { FAIL(`${b.name}: 没找到 inboundHeadline 调用点(判据本身失效?)`); continue; } const missing = calls.filter((c) => !/parentFrom\s*:/.test(c[1])); if (missing.length) { FAIL(`${b.name}: ${missing.length}/${calls.length} 个 inboundHeadline 调用点没传 parentFrom ` + `⇒ 单向续信链会被误判成回信`); } const have = calls.filter((c) => /parentFrom\s*:/.test(c[1])).length; if (have !== b.expect) { FAIL(`${b.name}: 实传 ${have} 处,期望 ${b.expect} 处(新增/漏改调用点)`); } if (!missing.length && have === b.expect) { PASS(`${b.name}: ${calls.length} 个调用点全部传了 parent_from(${rel})`); } // ② 传了 parentFrom 就必须传 selfName,否则判据永远不成立 const noSelf = calls.filter((c) => /parentFrom\s*:/.test(c[1]) && !/selfName\s*:/.test(c[1])); if (noSelf.length) { FAIL(`${b.name}: ${noSelf.length} 处传了 parentFrom 却没传 selfName ` + `⇒ parentFrom === selfName 永不成立,判据形同虚设`); } } } // ③ 判据函数本身必须真的认这两个参数(三份拷贝各一份) for (const lib of [ 'plugins/pi-mail-bridge/lib/relay-policy.js', 'plugins/dsh-mail-bridge/lib/relay-policy.js', 'plugins/opencode-mail-bridge/lib/relay-policy.js', ]) { const full = path.join(REPO, lib); if (!fs.existsSync(full)) { FAIL(`判据函数缺失 ${lib}`); continue; } const src = fs.readFileSync(full, 'utf8'); if (!/parentFrom\s*===\s*selfName/.test(src)) { FAIL(`${lib}: 方向判据 parentFrom === selfName 不在(调用方传了也没用)`); } else { PASS(`${lib}: 方向判据在`); } } // ④ dsh 的手写 .d.ts 必须声明这两个字段,否则 tsc 会拦住调用方 { const full = path.join(REPO, 'plugins/dsh-mail-bridge/lib/relay-policy.d.ts'); if (fs.existsSync(full)) { const src = fs.readFileSync(full, 'utf8'); // ★ 只认**字段声明**,不能只搜字段名。 // 初版用 `\bparentFrom\b` 全文件搜 —— 撤掉 `parentFrom?: string;` 后 // 注释文字里还留着这个词 ⇒ 照样匹配 ⇒ 变异 ③ 不红(假绿,已实测)。 // 判据本身就是这次事故的一部分,所以这里必须真的能判。 const missing = ['parentFrom', 'selfName'].filter((f) => !new RegExp(`^\\s*${f}\\??\\s*:\\s*string\\s*;`, 'm').test(src)); if (missing.length) { FAIL(`dsh relay-policy.d.ts 缺声明 ${missing.join(', ')} ` + `⇒ TS2353 会让调用方传不了参(这正是它一度是死代码的原因)`); } else { PASS('dsh relay-policy.d.ts 声明与实现一致'); } } } console.log(failures.length ? `\n✗ ${failures.length} 处不通过` : '\n✓ 全部通过'); process.exit(failures.length ? 1 : 0);