diff --git a/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs b/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs index 649602f..03c73a9 100644 --- a/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs +++ b/plugins/dsh-mail-bridge/test/persisted-cwd-not-found.test.mjs @@ -19,8 +19,12 @@ import { fileURLToPath } from 'node:url'; * 本仓已经为同一形状付过几次学费(判据自匹配、`read_thread` 会话作用域、 * 「取链里第一张证书」),所以这里用判据把区别固定下来。 * - * 判据钉的是**区分本身**(哪些 code 允许 create、哪些必须抛), - * 而不是"有没有 try/catch"——后者在错误修法下照样能通过。 + * 判据钉的是**区分本身 + 两支的顺序**,而不是"有没有 try/catch"—— + * 后者在错误修法下照样能通过。 + * + * ★ 2026-09-25(pi 复核时指出):原来这里只给**变异①**配了自检,而提交信息里 + * 声称"三个变异逐个验过" —— 那三个是**手跑**的,文件里**只钉住了一个**。 + * 手跑过一次 ≠ 以后还会红。现在三条变异 + 一条前置全部成为断言。 */ const HERE = dirname(fileURLToPath(import.meta.url)); @@ -47,6 +51,31 @@ 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; +} + +/** 判据整体:既要区分,也要顺序对 */ +const criterionPasses = src => distinguishes(src) && orderHolds(src); + test('★ persistedCwd 必须区分「不存在」与「读不出来」(只有前者能 create)', () => { assert.ok(SRC.indexOf(FN) >= 0, '找不到 persistedCwd —— 结构变了,判据要跟着改'); const body = codeOnly(blockAt(SRC, FN)); @@ -65,44 +94,79 @@ test('★ persistedCwd 必须区分「不存在」与「读不出来」(只有 '读失败必须抛出(让原文错误浮到调用方)—— 降级成 undefined 会让 bug ' + '从"报一句错"变成"静默走错分支",而日志里只剩 create 那句误导人的错。'); - // ③ 顺序/可达性:throw 不能被无条件提前(否则"不存在"也走不到 create) - const codeAt = body.indexOf(ABSENT); - const throwAt = body.indexOf('throw e'); - assert.ok(codeAt >= 0 && throwAt > codeAt, - '`throw e` 必须在 code 判断**之后** —— 反过来的话,连"确实不存在"也会抛,' + // ③ 顺序/可达性:throw 必须在 code 判断**之后** + assert.ok(orderHolds(SRC), + '`throw e` 必须排在 code 判断之后 —— 反过来的话,"确实不存在"也会抛,' + 'create 分支永远不可达(邮件会话再也建不出来)。'); // ④ 残余风险:catch 里不许出现无条件的 `return undefined` - // (写法上最容易的错:加了 code 判断,却把 return 留在最前面) const catchBlock = body.slice(body.indexOf('catch')); assert.doesNotMatch(catchBlock, /catch\s*\([^)]*\)\s*\{\s*return undefined\s*;?\s*\}/, 'catch 里不许无条件 `return undefined` —— 那等于把这次的修复又改回去了。'); }); /* - * 变异自检:判据本身要能红。 + * ── 变异自检:判据本身要能红 ────────────────────────────────────── * - * 不复用被测源码,而是喂给同一套断言"变异后的文本"—— 判据写松了(例如只找 - * `return undefined` 就算了)这里会立刻暴露。 + * 每条都遵循同一个形状(本仓踩过的坑): + * ① 变异必须**真的落在 persistedCwd 内** —— 用全局正则会命中文件里第一个无关的 + * `catch (e: any)`,变异没落下、判据反而全绿(自检变成恒真的假判据); + * ② 变异后 `criterionPasses` 必须为**假**(判据能发现这个回归); + * ③ 原样源码必须为**真**(前置那条断言保证,否则这三条全是假判据)。 */ -test('★ 判据自检:把修复回退成事故版本,本文件必须能发现', () => { - // 只在 persistedCwd **这个函数内部**换掉 catch —— 用全局正则会命中文件里 - // 第一个无关的 `catch (e: any)`(本仓实测踩过:变异没落在目标上, - // 自检于是变成恒真的假判据)。 + +/** 在 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 body = blockAt(SRC, FN); const bodyStart = SRC.indexOf('{', at) + 1; - const mutated = SRC.slice(0, bodyStart) - + body.replace(/catch \(e: any\) \{[\s\S]*\n \}/, 'catch {\n return undefined;\n }') - + SRC.slice(bodyStart + body.length); - assert.notEqual(mutated, SRC, '变异必须真的落下去(否则下面那条自检恒真,等于没写)'); - const mutatedBody = codeOnly(blockAt(mutated, FN)); - const distinguishes = body => new RegExp(`code\\s*===\\s*['"\`]${ABSENT}['"\`]`).test(body) - && /throw e\b/.test(body); - assert.equal(distinguishes(mutatedBody), false, - '把 catch 改回无条件的 `return undefined`(事故版本)后,"区分"条件必须为假 —— ' - + '若这里仍为真,说明判据松到发现不了这个回归。'); - // 反向:原样源码必须为真(否则这条自检恒假,等于没写) - assert.ok(distinguishes(codeOnly(blockAt(SRC, FN))), - '未变异的源码必须满足"区分"条件(否则这条自检恒假,等于没写)'); + 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 永远不可达。'); });