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。
273 lines
14 KiB
JavaScript
273 lines
14 KiB
JavaScript
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,
|
||
'判据整体必须为假 —— 否则"读失败静默变不存在"(事故本身)可以全绿通过。');
|
||
});
|