Files
MailUI4Agents/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs
JianFeeeee 73065664dd test(桥): 判据从「片段存在」收紧到「结构正确」—— 对抗性变异④找出我自己的洞
pi 复跑变异①报 `pass=1 fail=4`(我报 0/5)。两个数**都对**,但说的是**两种不同变异**:
  · 变体A(我上封跑的)整段替换成 `catch { return undefined }` ⇒ 0/6
  · 变体B(pi 跑的)保留 `catch (e: any)` 外形、只删 code 判断 ⇒ 1/5
两者的差别正是"catch 外形" —— 我上一封没把变异**写清楚**,这是我的表述问题。
事故版本的原文(`1de93fe^`)是 `} catch {\n return undefined;`,即**变体A**。

★ 更重要:顺着 pi 的复跑做**对抗性变异**,我找到了自己判据的一个真洞 ——

    } catch (e: any) {
      if (e?.code === ABSENT) { /* 什么都不做 */ }   ← 片段"在"
      return undefined;                              ← 读失败被降级 = **事故本身**
      throw e;                                       ← 不可达
    }

旧的 `distinguishes() && orderHolds()` 对**全绿**:code 比较在、`throw e` 在、
且 ABSENT 排在 throw 之前 —— 三条"**片段存在**"判据全过,而语义已经是事故。
⇒ "片段在不在"与"结构对不对"是**两种性质**(与 orderHolds 同族),必须分开表达。

本次收紧:
- 新增 `catchInner()`(按花括号取 catch 内层正文,不会误取函数体);
- 新增 `absentBranchReturnsUndefined()`:「不存在」那支必须**真的** `return undefined`
  (空转守卫 `{}` 不算);
- 新增 `unreachableThrowFree()`:`throw e` 必须**可达**(它前面与"不存在"之前
  不许有无条件的 `return undefined`);
- `criterionPasses` = 区分 && 顺序 && 上面两条结构判据;
- 新增测试 ④,并**先断言它骗得过旧判据**(`distinguishes` 真、`orderHolds` 真)——
  否则这条自检就不证明那个洞存在。

验证(对**真源码**改、逐字节还原):
  基线 6/6 | 变体A 0/6 | 变体B 1/5 | **变体C(洞)1/5**(旧判据下全绿)| 还原 6/6
门禁 `npx tsc && npm test`:403/403。
2026-09-25 04:45:44 +08:00

273 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { test } from 'node:test';
import { fileURLToPath } from 'node:url';
/*
* 「读不出来」不是「不存在」—— 这一行曾把 dsh 的邮件通道**整条弄断**。
*
* 事故(2026-09-19,DSH 升到 0.1.5-rc.2 后):`persistedCwd()` 的
* `catch { return undefined }` 把三种抛出情形压成同一个「磁盘上没有」,而调用方
* 只把 `undefined` 读作"可以 create"。于是格式迁移拒绝 → 当成不存在 → 走 create
* → 磁盘上**确实有**那个 id ⇒ `session "…" already exists` ⇒ 该会话的邮件
* 全部投不进去。40 个 `mail-*` 会话全部命中,且日志里只有 create 的错,
* **真正的读失败被那个 catch 吃掉了**。
*
* 为什么这个形状值得单独钉:它在"不报错的错"这一族里最典型 ——
* 读失败被降级成"没有",bug 就从"报一句错"变成"静默走错分支"。
* 本仓已经为同一形状付过几次学费(判据自匹配、`read_thread` 会话作用域、
* 「取链里第一张证书」),所以这里用判据把区别固定下来。
*
* 判据钉的是**区分本身 + 两支的顺序**,而不是"有没有 try/catch"——
* 后者在错误修法下照样能通过。
*
* ★ 2026-09-25(pi 复核时指出):原来这里只给**变异①**配了自检,而提交信息里
* 声称"三个变异逐个验过" —— 那三个是**手跑**的,文件里**只钉住了一个**。
* 手跑过一次 ≠ 以后还会红。现在三条变异 + 一条前置全部成为断言。
*/
const HERE = dirname(fileURLToPath(import.meta.url));
const SRC = readFileSync(join(HERE, '..', 'src', 'index.ts'), 'utf8');
/** 剥掉注释后再断言"代码里有什么"(本仓规范:判代码读剥离版,判理由读原文) */
const codeOnly = src =>
src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/[^\n]*/g, '$1');
/** 取某个函数/分支的正文(按花括号配对,不看窗口) */
function blockAt(src, needle) {
const at = src.indexOf(needle);
if (at < 0) return '';
const open = src.indexOf('{', at);
let depth = 0;
for (let i = open; i < src.length; i++) {
if (src[i] === '{') depth++;
else if (src[i] === '}') { depth--; if (depth === 0) return src.slice(open + 1, i); }
}
return '';
}
const FN = 'async function persistedCwd(';
/** dsh-session-query 的分类里,「确实不存在」是**唯一**允许 create 的那个 */
const ABSENT = 'SESSION_QUERY_SESSION_NOT_FOUND';
/** catch 里那把「读失败」与「不存在」分开的判据(抽出来,才能喂变异后的文本) */
function distinguishes(src) {
const body = codeOnly(blockAt(src, FN));
return new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`).test(body)
&& /throw e\b/.test(body);
}
/**
* 顺序判据:「不存在」的 return 必须排在 `throw e` **之前**。
*
* 单独一条,因为**它抓的是一种 `distinguishes()` 抓不到的错**:
* 把 `throw e` 提到 code 判断之前,代码**仍然区分了** code(正则照样命中),
* 但「确实不存在」也会抛 ⇒ create 永远不可达 ⇒ 邮件会话再也建不出来。
*/
function orderHolds(src) {
const body = codeOnly(blockAt(src, FN));
const cb = body.slice(body.indexOf('catch'));
const at = cb.indexOf(ABSENT);
const th = cb.indexOf('throw e');
return at >= 0 && th > at;
}
/**
* 取 `catch (...) { … }` 的**内层正文**(花括号配对,不会误取到函数体)。
*
* ★ 2026-09-25 新增:`distinguishes()` 那套是「**这些片段在不在**」,
* 而它能被一个**语义空转**的变体骗过 ——
*
* if (e?.code === ABSENT) { /* 什么都不做 *\/ }
* return undefined; ← 读失败也降级了(= 事故本身)
* throw e; ← 不可达
*
* 这个变体里 `code === ABSENT` 在、`throw e` 在、且 ABSENT 还排在 throw 前面
* ⇒ 旧判据**全绿**,而代码已经是"读失败静默变不存在"。这条洞是我自己在
* 对抗性变异里试出来的(不是被别人的例子提醒的),所以下面把判据从
* **"片段存在"** 收紧成 **"结构正确"**:守卫必须**真的**返回、"抛"必须**可达**。
*/
function catchInner(src) {
const body = codeOnly(blockAt(src, FN));
const ci = body.indexOf('catch');
if (ci < 0) return '';
const ob = body.indexOf('{', ci);
let depth = 0;
for (let i = ob; i < body.length; i++) {
if (body[i] === '{') depth++;
else if (body[i] === '}') { depth--; if (depth === 0) return body.slice(ob + 1, i); }
}
return '';
}
/**
* 「不存在」那一支必须**真的**返回 undefined。
*
* 允许 `if (c) return undefined;` 与 `if (c) { return undefined; }` 两种写法,
* 但**不接受**空转守卫(`{ }`)或不带 return 的守卫 —— 那正是上面那个骗过旧判据的变体。
*/
const absentGuard = () =>
new RegExp(`if\\s*\\(\\s*e\\?\\.code\\s*===\\s*['"\`]${ABSENT}['"\`]\\s*\\)\\s*(?:\\{\\s*)?return undefined\\s*;(?:\\s*\\})?`);
const absentBranchReturnsUndefined = src => absentGuard().test(catchInner(src));
/**
* 读失败必须**可达地**抛出:把「不存在」那一支摘掉之后,catch 里仍要有 `throw e`,
* 且它**前面不能有无条件的 `return undefined`**(那会让 throw 永不可达 ⇒ 又回到事故)。
*/
function unreachableThrowFree(src) {
const inner = catchInner(src);
const g = absentGuard().exec(inner);
if (!g) return false;
const before = inner.slice(0, g.index);
const after = inner.slice(g.index + g[0].length);
const th = after.indexOf('throw e');
if (th < 0) return false;
const between = after.slice(0, th);
return !/return\s+undefined/.test(before) && !/return\s+undefined/.test(between);
}
/** 判据整体:区分(片段)+ 顺序 + **结构**(守卫真的返回、抛真的可达) */
const criterionPasses = src => distinguishes(src) && orderHolds(src)
&& absentBranchReturnsUndefined(src) && unreachableThrowFree(src);
test('★ persistedCwd 必须区分「不存在」与「读不出来」(只有前者能 create)', () => {
assert.ok(SRC.indexOf(FN) >= 0, '找不到 persistedCwd —— 结构变了,判据要跟着改');
const body = codeOnly(blockAt(SRC, FN));
assert.ok(body.length > 50, 'persistedCwd 正文太短,八成是结构变了');
// ① 存在性判据:必须**只**对"确实不存在"这个 code 返回 undefined
assert.match(body, new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`),
`只有 ${ABSENT} 才允许当成"磁盘上没有"。`
+ '少了这个 code 判断,就是把"读失败"和"不存在"又压回同一个返回值 —— '
+ '那正是让 40 个会话的邮件全投不进去的那一行。');
assert.match(body, /return undefined/,
'「不存在」这一支仍要返回 undefined(它是调用方 create 的唯一依据)');
// ② 非存在性:读失败必须**抛出**,不许静默吞掉
assert.match(body, /throw e\b/,
'读失败必须抛出(让原文错误浮到调用方)—— 降级成 undefined 会让 bug '
+ '从"报一句错"变成"静默走错分支",而日志里只剩 create 那句误导人的错。');
// ③ 顺序/可达性:throw 必须在 code 判断**之后**
assert.ok(orderHolds(SRC),
'`throw e` 必须排在 code 判断之后 —— 反过来的话,"确实不存在"也会抛,'
+ 'create 分支永远不可达(邮件会话再也建不出来)。');
// ④ 残余风险:catch 里不许出现无条件的 `return undefined`
const catchBlock = body.slice(body.indexOf('catch'));
assert.doesNotMatch(catchBlock, /catch\s*\([^)]*\)\s*\{\s*return undefined\s*;?\s*\}/,
'catch 里不许无条件 `return undefined` —— 那等于把这次的修复又改回去了。');
// ⑤ ★ 结构判据(2026-09-25 补):光有"片段"不够,守卫必须**真的**返回、抛必须**可达**。
// 否则 `if (c) { /* 空转 */ } return undefined; throw e;` 这种变体会全绿而过 ——
// 它语义上**就是事故本身**(读失败静默变"不存在")。这条是我用对抗性变异自己试出来的。
assert.ok(absentBranchReturnsUndefined(SRC),
`「${ABSENT}」那一支必须**真的** ` + '`return undefined`(不能是空转守卫)——'
+ '否则"不存在"不再返回 undefined,调用方永远走不到 create。');
assert.ok(unreachableThrowFree(SRC),
'`throw e` 必须**可达**:它前面(以及"不存在"那支之前)不许有无条件的 `return undefined`。'
+ '一旦不可达,读失败就又静默降级成"不存在"了 —— 这正是事故的形状。');
});
/*
* ── 变异自检:判据本身要能红 ──────────────────────────────────────
*
* 每条都遵循同一个形状(本仓踩过的坑):
* ① 变异必须**真的落在 persistedCwd 内** —— 用全局正则会命中文件里第一个无关的
* `catch (e: any)`,变异没落下、判据反而全绿(自检变成恒真的假判据);
* ② 变异后 `criterionPasses` 必须为**假**(判据能发现这个回归);
* ③ 原样源码必须为**真**(前置那条断言保证,否则这三条全是假判据)。
*/
/** 在 persistedCwd 的**函数体内**做替换,并断言替换真的发生了 */
function mutateInside(fnBody, pattern, replacement) {
const next = fnBody.replace(pattern, replacement);
assert.notEqual(next, fnBody,
'变异没落在 persistedCwd 内(模式未命中)—— 这种"变异无声失败"会让自检恒真。'
+ `\n模式: ${pattern}`);
return next;
}
/** 把变异后的函数体装回整份源码,供 blockAt 复用同一套解析 */
function spliceBody(fnBody) {
const at = SRC.indexOf(FN);
const bodyStart = SRC.indexOf('{', at) + 1;
return SRC.slice(0, bodyStart) + fnBody + SRC.slice(bodyStart + blockAt(SRC, FN).length);
}
test('★ 自检前置:原样源码必须通过判据(否则下面三条全是假判据)', () => {
assert.ok(criterionPasses(SRC),
'未变异的源码不通过判据 ⇒ 下面的变异自检恒假,等于没写。'
+ '先修被测代码或判据,再看变异。');
});
test('★ 变异①:catch 改回无条件 return undefined(事故版本)⇒ 判据必须红', () => {
const body = mutateInside(blockAt(SRC, FN),
/catch \(e: any\) \{[\s\S]*\n \}/, 'catch {\n return undefined;\n }');
assert.equal(criterionPasses(spliceBody(body)), false,
'把 catch 改回无条件的 `return undefined` 后,判据必须为假 —— 若仍为真,它发现不了这个回归。');
});
test('★ 变异②:任何抛错都返回 undefined(catch 形状不变、也"看"了 code,但判断写反)⇒ 必须红', () => {
// 与①的区别:这是更**像**修复的错法 —— 仍然比较 code,只是结果写反。
// 只按"有没有 code 比较"写松的判据会被它骗过。
const body = mutateInside(blockAt(SRC, FN),
/if \(e\?\.code === 'SESSION_QUERY_SESSION_NOT_FOUND'\) return undefined;/,
'if (true) return undefined;');
assert.equal(criterionPasses(spliceBody(body)), false,
'任何抛错都返回 undefined 时,判据必须为假。');
});
test('★ 变异③:throw 提到 code 判断之前("不存在"也抛 ⇒ create 永不可达)⇒ 必须红', () => {
// ★ 这条**不改"是否区分"** —— `distinguishes()` 对它恒为真。
// 只有 `orderHolds()` 能抓住它,所以两条判据缺一不可:
// 我原来就是只写了"区分"、漏了顺序,才让这个变异第一次跑出全绿。
const body = mutateInside(blockAt(SRC, FN),
/(\n)( if \(e\?\.code === 'SESSION_QUERY_SESSION_NOT_FOUND'\) return undefined;)/,
'$1 throw e;$1$2');
const mutated = spliceBody(body);
assert.equal(distinguishes(mutated), true,
'(前提)这个变异**保留**了 code 比较 —— 否则它测的就不是"顺序"了,'
+ '而是退化成变异①,这条自检便不再证明 orderHolds 的必要性。');
assert.equal(orderHolds(mutated), false,
'`throw e` 提前后,顺序判据必须为假。');
assert.equal(criterionPasses(mutated), false,
'判据整体必须为假 —— 反过来的话,连"确实不存在"也会抛,create 永远不可达。');
});
test('★ 变异④(对抗性,我自己试出来的洞):守卫**空转** + 无条件 return undefined + 不可达 throw ⇒ 必须红', () => {
// 2026-09-25:pi 复跑后我顺手拿"语义等价但形状不像"的变体做对抗测试,发现旧判据有洞:
//
// } catch (e: any) {
// if (e?.code === ABSENT) { /* 什么都不做 */ } ← 片段"在"
// return undefined; ← 读失败被降级(= 事故本身)
// throw e; ← 不可达
// }
//
// 旧判据(distinguishes && orderHolds)对它**全绿**:code 比较在、throw e 在、
// ABSENT 还排在 throw 前面 —— 三条"片段存在"判据全过,而语义已经是事故。
// ⇒ "片段在不在"与"结构对不对"是两种性质,必须分开表达(与 orderHolds 同族的教训)。
let body = mutateInside(blockAt(SRC, FN),
/if \(e\?\.code === 'SESSION_QUERY_SESSION_NOT_FOUND'\) return undefined;/,
"if (e?.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { /* 空转:什么都不做 */ }");
body = mutateInside(body, /^(\s*)throw e;$/m, '$1return undefined;\n$1throw e;');
const mutated = spliceBody(body);
// 前提:这个变体**确实**骗得过旧的两条判据(否则它没测到那个洞)
assert.equal(distinguishes(mutated), true,
'(前提)变异④ 保留了 code 比较与 `throw e` 片段 ⇒ `distinguishes()` 仍为真。');
assert.equal(orderHolds(mutated), true,
'(前提)ABSENT 仍排在 `throw e` 之前 ⇒ `orderHolds()` 也为真。'
+ '正因如此,旧判据全绿 —— 这就是那个洞。');
// 新判据必须抓住它
assert.equal(absentBranchReturnsUndefined(mutated), false,
'守卫空转(`{ }` 而非 `return undefined`)时,结构判据必须为假。');
assert.equal(unreachableThrowFree(mutated), false,
'`throw e` 前出现无条件 `return undefined`(=> 不可达)时,结构判据必须为假。');
assert.equal(criterionPasses(mutated), false,
'判据整体必须为假 —— 否则"读失败静默变不存在"(事故本身)可以全绿通过。');
});