Files
HologramAgent-DSH-Memory-Port/docs/dev-notes-mutation-criteria.md
jianf 0e65dfee64 fix(check): checkpoint 全废时 runner 仍报通过 + 补回丢失的覆盖
缺口(评审复现,我独立复现确认)
上一条替换旧 README 判据时,把它的第二条断言一起带走了。
旧 test 同时守两件事:
  1) README 计数与实现一致      已判定不值得守,删
  2) checkpoints.js 至少注册一条 值得守,跟着 1 一起消失
grep 全仓:没有任何东西守 2。

实测后果(很安静)
把 helper 换成空操作、调用点全保留:
  npm run check  ->  0/0 checkpoints passed, exit=0
  npm test       ->  120 条全绿
即 checkpoint 全废、runner 报通过、测试一条不红。

修法:守结果,不守成因
scripts/checkpoints.js 结尾(if (failed > 0) 之前):
  if (results.length === 0) {
    console.error('no checkpoints were registered ...')
    process.exitCode = 1
  }
放 runner 而不是补回静态计数的理由:静态计数只能抓 helper 改名;
results.length === 0 抓的是结果,改名、被 stub、条件跳过都会红。

但 process.exit(0) 能绕过任何脚本内检查,所以另加一条外部断言
(test/phase-b-g-static.test.js)三层:
  - declared > 0                    helper 没被改名
  - 源码含 if (results.length === 0)
  - 该守卫确实设置 process.exitCode = 1
分工:runner 守结果,测试守 runner 还带着那道守卫。

实测
  0 条注册(stub)    -> no checkpoints were registered, exit=1
  条件跳过            -> 同上, exit=1
  正常态              -> 10/10 passed, exit=0
  变异:删 runner 守卫 -> not ok 6
  变异:helper 全改名  -> not ok 6

docs/dev-notes-mutation-criteria.md 增第 4 类
判据被替换时,旧判据的覆盖静默消失
- 形态:一条 test 守两件事
- 实测后果:0/0 passed / exit 0 / 120 全绿
- 守结果不守成因的四类成因对照表(含 process.exit(0) 是拦不住的那格)
- 自查问句:这条判据除了名字里那件事,还顺手守了什么
- 探针经验:不要用注释掉调用去验零注册,会变成 SyntaxError

验证
- npm test 120/120、npm run check 10/10
- npm test 3 次、npm run check 3 次(带 exit code 核对)全绿
- 无 .orig / .rej / /tmp/mut-* 残留
- 未 amend / 未 force push
2026-10-30 14:44:09 +08:00

11 KiB
Raw Blame History

变异判据的四类失效模式

来源:phase B(H 组 W1–W12)三轮返工中实际踩到、并由评审独立复现的坑。 四条都不是本任务特有的,任何写变异判据的人都会再踩。

共同点:它们都让判据绿着,而判据守的东西已经坏了。 这类问题比被测代码坏了更难发现, 因为失败的信号本身就是「一切正常」。


0. 一句话总纲

变异体必须让 lib/ 下的代码变坏。 一旦变异动的是 test/、或者断言的是测试里自己拼的局部函数,它就不是变异,是自测。

把这一条当硬规则执行,下面四条里前两条会被自动拦住。


1. 自测型假变异(犯得最多:四次)

形态

"变异"测试在 test/ 里自己拼一个替身,再断言替身的行为:

// ✗ 这不是变异
test('W11d†a: restoring the short-circuit breaks it', () => {
  const withShortCircuit = (hits) => (hits.length === 0 ? '' : renderRecall(hits))  // 自己拼的
  assert.equal(withShortCircuit(empty), '', 'the short-circuit does silence it')
})

这段一行 lib/ 代码都没碰。它永远不会随实现回滚而变红,却按「已覆盖一条变异」计数。

实测证据

变异(真改 lib/) 期望 实测
M-a 只回退 renderRecall 短路 W11d†a 红 W11d†a 绿,只有 W11d 红
M-b 只加回 contextForStep 短路 W11d†b 红 W11d†b 绿,只有 W11d 红

正确形态

真改源码 → 重新 import → 断言行为翻转 → 还原。

await withMutatedCopy('recall.js', [
  ['  if (lines.length === 0 && notice === \'\') return \'\'',
   '  if (lines.length === 0) return \'\' // MUTATED'],
], async (mod) => {
  const hits = []; hits.degraded = 'embed_error'
  assert.equal(mod.renderRecall(hits, 'x'), '', 'mutation makes it render nothing')
})

三条要求:

  1. 改的是 lib/,不是 test/。
  2. 锚点找不到时显式失败(assert.ok(text.includes(from))),否则源码一改名,变异静默失效。
  3. 改在副本上,见 §3。

自查问句

把 lib/ 完全回滚到上一个版本,这条测试会红吗? 不会 → 它是自测,删掉,别留在计数里。


2. 清单写死(判据的输入与代码脱节)

形态

判据遍历一份手写的清单,而清单本该覆盖的东西由代码决定:

const MODES = ['no_embedder', 'embed_error', 'aborted', 'vector_ready_unused',
               'future_mode_a', 'future_mode_b']   // ✗ 手写

future_mode_a/b 这类名字有误导性:它们测的是「掉进 default: 的未知模式」, 永远测不到「有人新写了一个 case」。而正常演进偏偏就是新写 case —— 那种时候 default: 和手写清单两头都管不到。

实测证据

在 lib/recall.js 里植入一个会撒谎的新分支:

case 'brand_new_mode':
  return '[Memory] New mode degraded; matches below may be incomplete.'   // 零命中时是谎话
版本 W11f W11f† W11f†b
写死 MODES 绿 绿 绿
源码扫描 红 绿 绿

写死版全绿,因为 brand_new_mode 不在数组里。

正确形态

清单从被测代码推导,而不是手抄:

const src = readFileSync(new URL('../lib/recall.js', import.meta.url), 'utf8')
const body = src.slice(src.indexOf('export function degradationNotice('))
const declared = [...body.slice(0, body.indexOf('\n}\n')).matchAll(/case '([^']+)':/g)]
  .map((m) => m[1])

★ 但「自动推导」不能替代「提取本身也要被守卫」

推导源必须是判据真正要管的那件事。case 'literal' 恰好就是「有哪些模式」的唯一事实源; 一旦 degradationNotice 改成表驱动(模式放进映射表),扫描就会静默扫到空数组而照样绿。

所以必须配两道:

守卫 作用 实测
declared.length >= 4 + 必需 mode 存在性 提取失效时响亮失败 把 case 'x' 全改成常量引用(语法合法、仍可导入)→ expected at least 4 case literals, found 0
W11f-a(guards the guard) 用变异塞一个新 case,断言扫描看得见它 塞 mode_added_by_the_future_test → 能取到

W11f-a 不是冗余:「扫描器坏掉」是这套判据唯一能静默失效的方式。

同一族的另一种:README 里的数字

README 曾写着「N tests / M checkpoints」,被手改过五次,两次是在 commit message 里漂的。 加守卫来盯着这个数字,成本高于数字本身的价值。

结论:删掉,不守着。 留下的守卫反过来断言「README 不得出现计数」:

[/\b\d+\s+tests\b/i, 'a test count'],
[/\b\d+\s+checkpoints\b/i, 'a checkpoint count'],
[/\b\d+\s*\/\s*\d+\s+checkpoints passed/i, 'a pasted pass-count'],

三种写法实测都能抓住。一个没有人验证的数字,给人一种精确的假象。

注意区分两类数字:

  • 手抄的运行计数(「119 tests」)——会漂,删。
  • 制品自身的内容量(「fixture 里有 45 个 case」)——由文件决定,但同样没有守卫, 所以也不要写进文档,改为指向文件本身。

3. 变异共享状态与并发撞车(ESM / 测试并行)

形态

node --test 默认并行跑测试文件(每个文件一个进程)。变异若直接改写共享的 lib/*.js:

// ✗ 与其它测试文件并发时不安全
writeFileSync(src, mutated)      // 别的文件此刻可能正在 import 它
const mod = await import(`${url}?mut=1`)
writeFileSync(src, original)

别的测试文件在变异窗口内 import,就读到中间态。

实测证据

  • 症状:约 1/30 偶发失败,看起来像「断言 flaky」而不是 harness 竞态。
  • 具体表现:W11d† 抓到的是未变异的模块,于是断言失败但原因完全错位。
  • 更隐蔽的一层:A1b(逐字节比对 recallBlocks 源码)、G2b/G5(扫 lib/ 全目录) 这三条专门读源码的判据,在就地变异下会读到被改坏的中间态。

正确形态

在副本上变异:

const dir = mkdtempSync(join(tmpdir(), 'mut-'))
cpSync(libDir, join(dir, 'lib'), { recursive: true })
cpSync(join(pkgRoot, 'package.json'), join(dir, 'package.json'))
cpSync(join(pkgRoot, 'node_modules'), join(dir, 'node_modules'), { recursive: true, dereference: false })
// 改 dir/lib/<file>,import dir/lib/<file>
// finally: rmSync(dir, ...)

要点:

  1. 复制 lib/ 整体(保留目录形状,相对 import 才能在副本内解析到被变异的兄弟模块)。
  2. 复制 package.json("type": "module")。
  3. node_modules 软链/保留符号链接(dereference: false),否则 peer 依赖解析不到。
  4. finally 里 rmSync,否则 /tmp 堆副本。
  5. 锚点匹配不到时显式失败,别静默跳过。

验证方式

  • 连续 30 次全量跑,0 失败(改前约 1/30 偶发)。
  • 跑完 ls -d /tmp/mut-?????? | wc -l 应为 0(无残留)。
  • 并行压力:a + g-static + h 三文件同跑 5 轮 0 红。

附带的 ESM 坑:package.json 里 "type": "module" 时,测试文件不能用 require(), 一律 await import('node:fs')。用 require 会报 Cannot determine intended module format(顶层 await 与 require 并存)。


4. 判据被替换时,旧判据的覆盖静默消失

形态

一条测试同时承担两件事。你删掉其中一件时,另一件的覆盖跟着没了—— 而没有任何东西会因为少了这个覆盖而变红。

本阶段真实发生:旧的 README 守卫同时含两条断言:

// ① README 的计数与实现一致(后判定不值得守)
assert.equal(Number(claim[1]), declared, ...)
// ② checkpoints.js 至少注册了一条  ← 值得守
assert.ok(declared > 0, 'no checkpoint calls found — did the helper get renamed?')

删①时把整条 test 换掉了,②一起消失。

实测后果(很安静)

把 helper 换成一个什么都不做的空操作(调用点全保留):

$ npm run check
0/0 checkpoints passed
$ echo $?
0                              ← 退出码 0
$ npm test
# tests 119 # pass 119 # fail 0   ← 全套仍全绿

checkpoint 全废了,npm run check 报「通过」,119 条测试一条都不红。

正确形态:守卫要守结果,不要守成因

补回一条静态计数只能抓「helper 改名」。放在 runner 里守结果,能覆盖所有成因:

if (results.length === 0) {
  console.error('no checkpoints were registered — did the helper get renamed or stubbed?\n')
  process.exitCode = 1
}
成因 runner 内守卫 静态计数
helper 改名 ✔ ✔
helper 被 stub 成空操作 ✔ ✘(调用点还在,计数不变)
条件跳过(if (skip) return) ✔ ✘
提前 process.exit(0) ✘(拦不住) ✘

最后一行是关键:process.exit(0) 绕过任何脚本内的检查。所以还要一条外部断言, 证明 runner 里仍然含有那道守卫:

assert.match(script, /if \(results\.length === 0\)/)
assert.match(script, /results\.length === 0[\s\S]{0,200}process\.exitCode = 1/)

自查问句

这条判据除了它名字里说的那件事,还顺手守了什么?

删/改之前,把它的断言逐条过一遍,对每一条问: 「这条如果不守了,谁会红?」——答案是「没人红」的那些,要么补新守卫,要么明确记下放弃。

附一条探针经验:验证「零注册」不要用「把 await checkpoint( 注释掉」—— 那会留下孤立的 return,报 SyntaxError: Illegal return statement,测的是语法。 用「换成一个仍存在但什么都不做的函数」,调用点保持不变,才是有效探针。


通用自查清单

写/改一条变异判据前,逐条过:

  • 变异体只改 lib/(改 test/ 的是自测)
  • 把 lib/ 回滚到上一版,这条测试会红
  • 锚点找不到时显式失败,不是静默 pass
  • 清单/枚举从代码推导,不手抄
  • 推导本身有长度下限 + 必需项断言;另有一条 guards-the-guard
  • 变异在副本上做,finally 清理,不碰共享树
  • 跑完确认无残留(.orig / .rej / /tmp/mut-*)
  • 连跑 ≥5 次(间歇性问题 3 次绿证明不了什么)
  • 文档里没有手抄的运行计数
  • 替换/删除旧判据时,逐条问过「这条覆盖现在谁守」

一句话记住四条

坑 一句话
自测型假变异 变异不碰 lib/,它就永远不会红。
清单写死 判据的输入和代码脱节时,绿的是记忆不是事实。
并发撞车 变异共享状态不能与代码并发跑 —— 改副本。
替换带走覆盖 一条判据往往守两件事;删一件时另一件不会有人提醒你。