fix: 回滚 user-question.js 那一搬(它把 check-shared-libs 打红两处),并把 drift 的非运行时差异摘出来
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 条全过。
This commit is contained in:
@ -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` 取东西,会把那个文件的用例在引用者进程里**再注册一遍**。
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,176 +0,0 @@
|
||||
/**
|
||||
* `ask_user_question` ↔ AgentMail 询问邮件 的双向映射(纯函数)。
|
||||
*
|
||||
* # 为什么需要它
|
||||
*
|
||||
* DSH 的 `ask_user_question` 走 `ctx.userQuestions` 这个 UI seam,而邮件驱动的
|
||||
* 会话**没有本地 UI**。不桥接的后果是模型主动提问后永久挂死:`ask()` 的
|
||||
* promise 永远不 resolve,那一轮工具调用卡在那里,人却什么也看不到。
|
||||
*
|
||||
* # 两个模型的形状差异
|
||||
*
|
||||
* DSH:`{ questions: [{ id, question, header?, options?: [{label, description?}],
|
||||
* multiSelect? }] }` → `{ answers: [{ id, selected[], custom? }] }`
|
||||
* - 一次可以问**多个**问题,每个问题可有自己的选项与多选语义
|
||||
* - 答案按问题 id 回填,选项用 label 字符串
|
||||
*
|
||||
* AgentMail:一封询问邮件 = 一个问题 + 一个选项列表 + 一个 multi_select 标志
|
||||
* - 只有单问题结构,因此多问题时必须摊平
|
||||
*
|
||||
* # 摊平策略(多问题时)
|
||||
*
|
||||
* 选项取所有问题 label 的并集(去重、保持首次出现顺序),并把每个问题的
|
||||
* 原文、选项与说明枚举进 context 正文。回信时按「label 属于哪个问题」把
|
||||
* 选择分配回去。这比「一问一封邮件」简单得多 —— 后者要等人分别回复多封
|
||||
* 才能凑齐一次 `ask()` 的答案,而 `ask()` 是**单次**调用,凑不齐就还是挂死。
|
||||
*
|
||||
* # fail closed
|
||||
*
|
||||
* 认不出的答案一律不猜测;没有匹配到任何选项的问题返回空选择 + 把自由文本
|
||||
* 放进 custom,而不是随便挑一个 label 放行。
|
||||
*/
|
||||
|
||||
/** 问题是否带可选项。 */
|
||||
export function hasOptions(question) {
|
||||
return Array.isArray(question?.options) && question.options.length > 0;
|
||||
}
|
||||
|
||||
/** 一个 DSH 问题的可选项 label 列表(保序)。 */
|
||||
export function optionLabels(question) {
|
||||
if (!hasOptions(question)) return [];
|
||||
return question.options
|
||||
.map((o) => (typeof o === 'string' ? o : o?.label))
|
||||
.filter((l) => typeof l === 'string' && l.length > 0);
|
||||
}
|
||||
|
||||
/** 一个问题的展示标题:header 有就用它做前缀,否则只用 question。 */
|
||||
export function questionTitle(question) {
|
||||
const header = typeof question?.header === 'string' ? question.header.trim() : '';
|
||||
const text = typeof question?.question === 'string' ? question.question.trim() : '';
|
||||
if (header && text) return `${header}: ${text}`;
|
||||
return header || text || '(未提供问题)';
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 DSH 的 questions 摊平成一封 AgentMail 询问邮件的正文与元数据。
|
||||
*
|
||||
* @param {Array<object>} questions DSH AskUserQuestionItem[]
|
||||
* @returns {{ question: string, options: string[], context: string, multiSelect: boolean }}
|
||||
*/
|
||||
export function flattenQuestions(questions) {
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
if (list.length === 0) {
|
||||
throw new Error('ask_user_question 至少需要一个 question');
|
||||
}
|
||||
|
||||
const lines = [];
|
||||
const options = [];
|
||||
const seen = new Set();
|
||||
let anyMulti = false;
|
||||
|
||||
list.forEach((q, i) => {
|
||||
const title = questionTitle(q);
|
||||
lines.push(`${i + 1}. ${title}`);
|
||||
const detail = typeof q?.detail === 'string' ? q.detail.trim() : '';
|
||||
if (detail) lines.push(` ${detail}`);
|
||||
|
||||
const labels = optionLabels(q);
|
||||
if (labels.length > 0) {
|
||||
lines.push(` 可选项:${labels.join(' / ')}${q.multiSelect ? '(可多选)' : ''}`);
|
||||
for (const label of labels) {
|
||||
if (!seen.has(label)) {
|
||||
seen.add(label);
|
||||
options.push(label);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push(' (请直接填写回答)');
|
||||
}
|
||||
if (q?.multiSelect === true) anyMulti = true;
|
||||
});
|
||||
|
||||
// 多问题时必须允许多选:不同问题的选项要能一起勾选。
|
||||
const multiSelect = list.length > 1 ? options.length > 0 : anyMulti;
|
||||
|
||||
const question = list.length === 1 ? questionTitle(list[0]) : `${list.length} 个问题待回答`;
|
||||
const context = [
|
||||
list.length === 1 ? '' : '模型提出了多个问题,请在「回复」里一并回答:',
|
||||
...lines,
|
||||
'',
|
||||
options.length > 0
|
||||
? '可直接勾选下方的选项;补充说明写在备注里。'
|
||||
: '这题没有预设选项,请把回答写在备注里。',
|
||||
].filter((l) => l !== '').join('\n');
|
||||
|
||||
return { question, options, context, multiSelect };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把人类的决策回写成 DSH 的 answers[]。
|
||||
*
|
||||
* @param {Array<object>} questions 原始 DSH questions(回填 id 用)
|
||||
* @param {string} decision 人类选的选项原文(多选时前端用换行分隔)
|
||||
* @param {string} [note] 自由文本/备注
|
||||
* @returns {{ answers: Array<{id: string, selected: string[], custom?: string}> }}
|
||||
*/
|
||||
export function answersFromDecision(questions, decision, note) {
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
const labels = String(decision || '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const custom = typeof note === 'string' ? note.trim() : '';
|
||||
|
||||
// 单问题:忠实映射(选项 → selected,备注 → custom)。
|
||||
if (list.length === 1) {
|
||||
const q = list[0];
|
||||
const id = String(q?.id ?? '0');
|
||||
if (!hasOptions(q)) {
|
||||
// 无选项题:人类把答案写在决策文本或备注里,都属于「自由文本回答」。
|
||||
const text = custom || labels.join('\n');
|
||||
return { answers: [{ id, selected: [], ...(text ? { custom: text } : {}) }] };
|
||||
}
|
||||
return {
|
||||
answers: [{
|
||||
id,
|
||||
selected: labels,
|
||||
...(custom ? { custom } : {}),
|
||||
}],
|
||||
};
|
||||
}
|
||||
|
||||
// 多问题:按 label 归属把选择分配给各自的问题;备注归给第一个问题。
|
||||
let customUsed = false;
|
||||
const answers = list.map((q, i) => {
|
||||
const id = String(q?.id ?? String(i));
|
||||
const labels_q = optionLabels(q);
|
||||
const selected = labels.filter((l) => labels_q.includes(l));
|
||||
let qCustom;
|
||||
if (custom && !customUsed) {
|
||||
qCustom = custom;
|
||||
customUsed = true;
|
||||
}
|
||||
// 无选项题且人没写备注:退而把决策文本整段给它(否则它的答案永远是空的)。
|
||||
if (qCustom === undefined && !hasOptions(q) && note === undefined) {
|
||||
const text = labels.join('\n');
|
||||
if (text) qCustom = text;
|
||||
}
|
||||
return { id, selected, ...(qCustom ? { custom: qCustom } : {}) };
|
||||
});
|
||||
|
||||
return { answers };
|
||||
}
|
||||
|
||||
/**
|
||||
* 决策是否「什么都没答」——用来在提交前拦住空回答(不把空答案喂给模型)。
|
||||
*
|
||||
* 允许多选时空 selected 但有 custom 也算答了;两者都空才算没答。
|
||||
*/
|
||||
export function isBlankAnswer(questions, decision, note) {
|
||||
const labels = String(decision || '').split('\n').map((s) => s.trim()).filter(Boolean);
|
||||
const custom = typeof note === 'string' ? note.trim() : '';
|
||||
if (labels.length > 0 || custom) return false;
|
||||
// 全部问题都没有选项、人也没写字 → 确实什么都没答
|
||||
const list = Array.isArray(questions) ? questions.filter(Boolean) : [];
|
||||
return list.some((q) => hasOptions(q)) || list.length === 0;
|
||||
}
|
||||
@ -20,7 +20,7 @@ import {
|
||||
hasOptions,
|
||||
optionLabels,
|
||||
questionTitle,
|
||||
} from './lib/user-question.js';
|
||||
} from '../lib/user-question.js';
|
||||
|
||||
test('单问题单选项:忠实映射问题与选项', () => {
|
||||
const flat = flattenQuestions([{
|
||||
|
||||
Reference in New Issue
Block a user