裁定:F6 选 (a),措辞改写,不留互斥读法
原措辞「仍可按文本调出、不参与向量召回」读起来像互斥。实际是三层:
「按文本调出」= store 级无空间浏览,**不是生产召回路径**。
不改 F2 去迁就 F6 字面自洽的理由:F2 是 fusion.go:495 同款安全缺陷修复,
优先级高于 F6 的措辞。所以 F6 改成三段并列,每段一条断言,
**同一个用例内全断言**:
数据层:指纹变更后旧块仍在库中 —— 块数不减、按 fp 分组两行都在
浏览层:store 级不带空间参数的文本查询仍能取到旧块
召回层:带 fingerprint 的生产召回路径必须把旧块过滤掉(F2 承担)
为什么必须一个用例:只断言「过滤掉了」会漏掉数据被误删
(删库在召回层看起来就是「过滤正确」);只断言「能调出」会把 F2 挡回去。
F6 判据 3 条
F6 三段全断言 + 反向对照(当前空间块两层都能取到,
防止断言因为「什么都找不到」而假成立)
F6†data 变异:putBlock 顺手 purge 其它空间的行走道 ⇒ 数据层断言红
F6†recall 变异:去掉空间过滤器 ⇒ 召回层断言红
两条变异各自只咬自己那一段,与 F6†data/†recall 的分工一致。
★ F6†data 的变异写法踩了三次坑(据实记)
1. 只写 form(没真改代码)—— 那不是变异,是自测
2. 注入 SQL 里的转义引号把测试文件写成 SyntaxError
3. 最终用 String.fromCharCode(39) 拼引号 + 数组 join 构造注入文本,
避开嵌套模板字符串的转义地狱
裁定 2:A1b 降级要记账
A1b 原先逐字节锁死 recallBlocks 源码,F2 合法地要改它,已降级为
「不传 fingerprint 时行为不变」。但降级后的 A1b-behaviour 只跑 golden
**前 5 个** case,变异 A(非精确带分数微调)下它可能是绿的。
已在判据注释写明:**结论不由 A1b 单独给出,由 A2 兜**
(A2 走全部 45 例、比精确分数);A1b-behaviour 只是烟测,不是权威。
同步进 memo:docs/dev-notes-mutation-criteria.md 增第 5 条
「判据降级时,覆盖面必须一起交代」—— 这不是失效模式,是记账纪律。
自查问句:这条判据降级后,原本由它兜住的结论现在由谁兜?
答案必须是一个具体的、覆盖更广的判据名。
验证
- npm test 131/131、npm run check 10/10
- 干净副本(git archive + link-dsh)实数见后续消息
12 KiB
变异判据的四类失效模式 + 一条记账纪律
来源: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')
})
三条要求:
- 改的是
lib/,不是test/。 - 锚点找不到时显式失败(
assert.ok(text.includes(from))),否则源码一改名,变异静默失效。 - 改在副本上,见 §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, ...)
要点:
- 复制
lib/整体(保留目录形状,相对 import 才能在副本内解析到被变异的兄弟模块)。 - 复制
package.json("type": "module")。 node_modules软链/保留符号链接(dereference: false),否则 peer 依赖解析不到。finally里rmSync,否则/tmp堆副本。- 锚点匹配不到时显式失败,别静默跳过。
验证方式
- 连续 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,测的是语法。 用「换成一个仍存在但什么都不做的函数」,调用点保持不变,才是有效探针。
5. 判据降级时,覆盖面必须一起交代
不是失效模式,是一条记账纪律:判据被削弱时,削弱本身要写进判据注释, 否则后来人会把它当成完整证据。
本阶段实例:A1b 原本逐字节锁死 recallBlocks 源码,F2 合法地要改这个函数,
于是降级为「不传 fingerprint 时行为不变」。但降级后的 A1b-behaviour
只跑 golden 的前 5 个 case —— 变异 A(非精确带分数微调)下它可能仍是绿的。
所以注释里必须写明:
结论不由 A1b 单独给出,而由 A2 兜(A2 走全部 45 例、比精确分数)。
A1b-behaviour 只是 no-fingerprint 路径的廉价烟测,不是权威。
自查问句:
这条判据降级后,原本由它兜住的结论现在由谁兜? 答案必须是一个具体的、覆盖更广的判据名;答不出来就说明降级降过头了。
反例(同一纪律缺失的样子):只把测试改窄、不写「为什么不影响结论」, 后来人看到绿就以为全绿 —— 与「自测型假变异」的最终效果一样,只是路径不同。
通用自查清单
写/改一条变异判据前,逐条过:
- 变异体只改
lib/(改test/的是自测) - 把
lib/回滚到上一版,这条测试会红 - 锚点找不到时显式失败,不是静默 pass
- 清单/枚举从代码推导,不手抄
- 推导本身有长度下限 + 必需项断言;另有一条 guards-the-guard
- 变异在副本上做,
finally清理,不碰共享树 - 跑完确认无残留(
.orig/.rej//tmp/mut-*) - 连跑 ≥5 次(间歇性问题 3 次绿证明不了什么)
- 文档里没有手抄的运行计数
- 替换/删除旧判据时,逐条问过「这条覆盖现在谁守」
- 判据被降级/收窄时,注释里写明「结论现在由谁兜」
一句话记住五条
| 坑 | 一句话 |
|---|---|
| 自测型假变异 | 变异不碰 lib/,它就永远不会红。 |
| 清单写死 | 判据的输入和代码脱节时,绿的是记忆不是事实。 |
| 并发撞车 | 变异共享状态不能与代码并发跑 —— 改副本。 |
| 替换带走覆盖 | 一条判据往往守两件事;删一件时另一件不会有人提醒你。 |
| 降级不记账 | 判据收窄后,原本由它兜的结论必须有新名字接手,且写进注释。 |