> 对抗夹具的覆盖单位是"夹具 + 参数",不是夹具——第 27 条子原因只在加了一个 flag 之后才响

Research Tools Intermediate 2026-10-12 06:18 2026-10-12
#testing #coverage #self-check #fixtures #dead-code #static-analysis

一个论文可读性检查器有 9 项检查。它的自检先只要求"每项检查至少命中一次", 后来发现这个粒度抓不住东西:caption 这一项同时覆盖太短和缺失两个分支, 其中一个烂掉,另一个照样把计数顶在 1,自检继续绿,而它在真论文上已经瞎了一半。

// TABLE_OF_CONTENTS

结论先行

一个论文可读性检查器有 9 项检查。它的自检先只要求"每项检查至少命中一次", 后来发现这个粒度抓不住东西:caption 这一项同时覆盖太短和缺失两个分支, 其中一个烂掉,另一个照样把计数顶在 1,自检继续绿,而它在真论文上已经瞎了一半。

修法是把子原因显式登记:REASONS 列出 27 条 (检查, 消息正则, 子原因 slug),--json 给每条发现打上标签, 自检在对抗夹具上断言两件事 —— 每条登记的原因都要响,且不能出现 UNREGISTERED。

我按文档描述跑了一遍这个地板,用它自己的三个对抗夹具:

tripwire.tex      20 findings
tripwire_b.tex    11 findings
tripwire_c.tex     2 findings
declared 27   fired 26   UNREGISTERED 0
never fired: ['teaser/kind-exempt']

26/27。 差的那一条不是死代码。它是一条只能由参数触发的原因: "这类论文不需要 teaser 图" 要有一个论文种类才会说出口。 去翻自检脚本,它跑的不是三次,是四次:

runs = [(f, []) for f in sorted(glob.glob("fixtures/tripwire*.tex"))]
runs.append(("fixtures/tripwire_b.tex", ["--kind", "benchmark"]))

同一个文件,换一个 flag,再跑一次。补上这一次:

跑法 27 条里响了 没响的
3 个夹具,不带参数 26 teaser/kind-exempt
+tripwire_b.tex --kind benchmark 27 无

为什么

对抗夹具的直觉是"造一份踩满所有检查的输入"。但一个检查的分支不只由输入选, 也由调用参数选。--kind 这类参数在代码里就是一条 if, 它背后那条路径没有任何输入能走到 —— 不管夹具写得多脏。

于是覆盖的单位必须是 (夹具, 参数) 这个二元组。按文件数夹具会得到一个 系统性偏低的覆盖率,而缺的恰好是"只在某种模式下才生效"的分支 —— 那通常是豁免、降级、兼容开关这类让工具闭嘴的分支。 豁免路径是最该被钉住的:它写错了,表现是工具安静,而安静看起来像通过。

这里还有一个值得单独记的设计:REASONS 用消息正则去认领发现。 它看着脆(改一句措辞就不匹配了),但两条断言合起来把两个方向都封住了 —— 改了措辞 → 发现变成 UNREGISTERED → 红; 删了代码 → 登记的原因没响 → 红。 措辞和代码必须同时改,而且自检会说是哪一边先漏。 它第一次跑就抓出两条指向已不存在代码的登记原因。

这条和 用 any() 写的回归测试是假保证——它只要求最容易过的那个夹具通过 是一个家族:那条讲"任意一个夹具过了就算过" 是假保证;这条讲即使改成"每个都要过",枚举的维度选错了照样是假保证。 而 手写的自检清单永远是全绿的——它只包含你刚修过的东西 是同一问题的上一级 —— 手写清单只包含你刚修过的东西。三条的共同点: 覆盖率的分母是人选的,而人会选自己想得到的那一维。

怎么用

把"还有哪些 flag 会改变控制流"当成覆盖维度列出来。 一条实用的近似:对每个在代码里出现在 if 条件里的 CLI 参数, 问"有没有一次自检带着它跑"。带不带 --kind / --only / --strict / --assume 是不同的程序。

输入和参数要在报告里分别记。 上面那张表之所以能一眼看懂, 是因为我把两次跑的差集打出来了。如果只打一个"27/27 通过", 下次有人简化自检、顺手把那个带 flag 的第四次跑删掉,计数会掉到 26, 而 26 和 27 的区别没人记得意味着什么 —— 所以断言里要写期望的具体集合, 不是期望的数量。

警惕只有一个夹具能触发的原因是互斥的信号。 这个项目需要三个 对抗夹具,理由是有些原因天然互斥(一篇论文不可能同时"豁免了 teaser" 和"缺 teaser")。互斥的原因不能靠把夹具写得更脏来覆盖, 只能靠多一个夹具或多一组参数 —— 发现自己在给夹具加互相矛盾的内容时, 应该停下来加文件,不是继续加内容。

出处

~/Tools/nonexpert-review:nonexpert_review.py:460(REASONS 的 27 条登记)、 CALIBRATION.md 「地板,在子原因粒度上」一节、 fixtures/tripwire.tex / tripwire_b.tex / tripwire_c.tex; 自检的四次跑在 ~/Tools/skill-backend-check.sh:1755-1776。 上面两次覆盖统计是我自己在 2026-10-11 跑的 (check <fixture> --json,再按 findings[].reason 和 REASONS 求差集), 工具版本 2026-09-25.1。