site logo

Marico's space

覆盖率只是主张,而非证据:面向 Agent 补丁的 Hunk 级账本

编程技术 2026-11-09 17:33:54 3

最近折腾 Agent(智能体)补丁审计,踩了几个坑,这篇把问题说清楚。测试全绿,只能证明测试通过了,不能证明哪些新增代码真正改变了结果,而且可能存在某个断言实际见证了新行为,只是没人说得清。Agent 补丁最容易利用这个空档:代码和测试一起生成,测试碰到了新增行,覆盖率于是变绿,门禁把数字当成了证据,最后直接合并。

这里要做的是一套小账本:读取 diff,把每个新增代码块与逐测试的覆盖率上下文关联起来,再根据“见证强度”给新增行评级。找不到有效见证的代码块,不是降级处理,而是直接拦住。

整个实现大约 80 行 Python。它读取 diff,将变更与每个测试的覆盖率上下文做关联,任何暂时无法分类的内容都按失败处理。下面会依次给出实现步骤、完整代码、四组校准用例,以及哪些项目不适合采用这套方案。

1. 真正的问题是覆盖率不可证伪

文件级覆盖率回答的是“这个文件有没有被导入”,却很少能回答“补丁新增的分支,是否被一个在分支出错时必然失败的测试执行过”。这是两个完全不同的问题。

Agent 会把这个问题放大,因为它经常在同一轮工作中同时生成代码和测试。一个生成出来的测试只要碰到了新增行,就能满足覆盖率要求,哪怕它几乎没有断言任何东西。门禁看到数字上涨,便把它称作证据。

所以,审计单位不应该是文件,也不应该是整个 Pull Request(合并请求),而应该是每一行新增代码,以及附着在这行代码上的见证。

2. 第一步:提取 Hunk,而不是文件

从 diff 开始。使用 --unified=0,可以直接得到精确的新增行范围,不带上下文噪声。

# evidence_ledger.py (part 1)
import json, re, subprocess, sys
from datetime import date
from pathlib import Path BASE = sys.argv[1] if len(sys.argv) > 1 else "origin/main"
HUNK = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@") def changed_lines(): diff = subprocess.run( ["git", "diff", "--unified=0", "--no-color", f"{BASE}...HEAD"], check=True, capture_output=True, text=True).stdout out, path = {}, None for line in diff.splitlines(): if line.startswith("+++ b/"): path = line[len("+++ b/"):] elif line.startswith("@@") and path: m = HUNK.match(line) start, n = int(m.group(1)), int(m.group(2) or 1) if n: # deletions are not new surface out.setdefault(path, set()).update(range(start, start + n)) return out

这里有一个刻意的限制:纯删除不会产生账目。删除行为由整个测试套件检查,这套账本只负责新增代码面。

3. 第二步:收集逐测试的覆盖率上下文

你需要知道第 42 行是被哪个测试执行的,而不只是知道它运行过。pytest-cov 可以通过动态上下文记录这种关系,再用 coverage json 导出。

pytest --cov=src --cov-context=test
coverage json --show-contexts -o coverage.json

此后,每一行都会带上类似 tests/test_pricing.py::test_rounds|run 的上下文字符串。匹配之前,需要去掉末尾的阶段后缀。

# evidence_ledger.py (part 2)
def contexts(path="coverage.json"): files = json.loads(Path(path).read_text())["data"]["files"] idx = {} for src, blob in files.items(): for lineno, ctxs in blob.get("contexts", {}).items(): for ctx in ctxs: idx.setdefault(src, {}).setdefault(int(lineno), set()).add(ctx.split("|")[0]) return idx

如果覆盖率报告里记录的是绝对路径,需要在 .coveragerc 中增加 [paths] 配置,让源文件路径与 diff 中的路径一致。路径归一化花十分钟,能省下排查空关联的一小时。

4. 第三步:给见证评级,而不是统计数量

见证的质量取决于它属于哪一类。登记表应该放在一个经过评审的独立文件里,而不是写在代码注释中。

见证类别 典型来源 权重 理由
property Hypothesis / stateful invariant 1.0 面对没人提前写下来的输入也能失败
test 确定性的单元测试或集成测试 1.0 固定一种已经观察到的行为
quarantined 位于不稳定测试登记表中的测试 0.0 它的结果不携带有效信息
unknown 未登记在册的测试 0.0 未分类测试会在无人察觉的情况下持续漂移

其中最关键的是 quarantined。如果一行代码只被冻结的不稳定测试覆盖,它就没有有效见证——那一声“绿”只是噪声。这里必须降级,不能照单全收。由 fixture(测试夹具)驱动的测试可以作为合法见证,但账本不会检查其 fixture 状态契约是否成立;需要配合仓库现有的 fixture 锁定检查一起使用。

# evidence_ledger.py (part 3)
WEIGHT = {"property": 1.0, "test": 1.0, "quarantined": 0.0} def grade(src, lines, ctx, registry): rows = {} for n in sorted(lines): tests = ctx.get(src, {}).get(n, set()) best = max((WEIGHT.get(registry.get(t, "unknown"), 0.0) for t in tests), default=0.0) rows[n] = (best, sorted(tests)) return rows

5. 第四步:执行门禁,并设置会过期的豁免

默认失败,但也要留一个泄压阀。孤立代码块只有获得书面豁免才能通过,豁免必须包含负责人和到期时间。豁免过期后,会自动恢复为失败。

# evidence_ledger.py (part 4)
REGISTRY, ALLOW = Path("evidence.json"), Path("allow_orphans.json") def main(): registry = json.loads(REGISTRY.read_text()) allow = json.loads(ALLOW.read_text()) if ALLOW.exists() else {} ctx, today = contexts(), date.today().isoformat() orphans, tolerated = [], [] for src, lines in sorted(changed_lines().items()): if not src.endswith(".py") or "/tests/" in src: continue for n, (weight, tests) in grade(src, lines, ctx, registry).items(): if weight > 0: continue key, grant = f"{src}:{n}", allow.get(f"{src}:{n}") if grant and grant["expires"] >= today: tolerated.append(key) else: orphans.append((key, tests or ["<none>"])) for key, tests in orphans: print(f"ORPHAN {key} witnessed_by={tests}") print(f"orphans={len(orphans)} tolerated={len(tolerated)}") return 1 if orphans else 0 if __name__ == "__main__": sys.exit(main())

输出故意做得很朴素:每个孤立代码块输出一行,最后再给一个汇总计数。越无聊的输出,越容易在 CI(持续集成)日志里被人真正读完。

6. 第五步:在成本最低的地方购买证据

一个孤立 Hunk(变更块)通常需要补两样东西中的一个:范围足够窄的单元测试,或者一个能够钉住该变更块原本要保持的不变量的 property(属性测试)。属性测试通常更便宜,而且能发现更多问题,所以可以先写。

披露说明:本文是 MonkeyCode 产品推广合作的一部分。

一个可落地的流程是:使用 MonkeyCode 的免费模型访问能力,根据 Hunk 本身提出候选不变量;再利用免费服务器方案运行账本和属性测试工具链,不必单独准备一台机器。这里描述的是可用性,不代表性能结论:我没有对本文涉及的模型做基准测试,模型生成的属性也只能作为候选方案,必须经过人工评审后才能进入登记表。实际依赖前,请确认当前服务条款。

# tests/test_pricing_properties.py — candidate drafted from a diff hunk
from decimal import Decimal
from hypothesis import given, strategies as st
from pricing import round_half_up @given(st.decimals(min_value=Decimal("-1000"), max_value=Decimal("1000"), places=2))
def test_rounding_never_decreases_with_input(amount): assert round_half_up(amount) <= round_half_up(amount + Decimal("0.01"))

这里有一个比其余注意事项都重要的限制:如果一个属性测试在补丁之前的父提交上也能通过,它就不是见证,只是装饰。登记为 property 之前,必须确认每个新属性都能在补丁前的代码树上失败。

7. 用四种已知结果校准账本

信任门禁之前,先证明它确实会失败。创建四个用完即弃的分支,并记录各自应有的结论。

用例 补丁 预期结果
A 新增一行代码,且由 test 见证覆盖 exit 0
B 新增一行代码,没有任何测试覆盖 exit 1,输出一个 ORPHAN
C 新增一行代码,只被 quarantined 测试覆盖 exit 1,权重被降级
D 用例 B 加上一个昨天已经过期的豁免 exit 1

如果用例 C 通过,说明登记表分类错了。如果用例 D 通过,说明日期比较写错了——一边是字符串、另一边是 date 对象时,这很常见。

8. 限制,以及哪些团队不适合使用

动态覆盖率上下文会拖慢测试套件,大型仓库必须为它预留时间。上面的脚本假设项目使用 Python,并依赖 coverage.py 生成的 JSON(JavaScript Object Notation)报告。它不检查被删除的行为、API(应用程序接口)兼容性,也不判断登记在册的 test 见证本身是否有意义。对于那些没有改动 .py 文件,却会改变运行时行为的配置、SQL(结构化查询语言)或模板变更,这套方案同样无能为力。

如果项目没有覆盖率采集能力、diff 经常涉及数百个文件,或者没人愿意负责维护豁免文件,就不要使用这套方案——一份无人负责的豁免清单,不出一个月就会变成永久白名单。首次落地时,可以只发布第一步,把 Hunk 映射打印出来跑一周。那份日志带来的启发,会比任何策略文档都多。

如果准备把整套账本建起来,我会按“提取器、上下文、登记表、门禁”的顺序推进,每个 Pull Request 只做一个环节。手里还没有沙箱环境时,MonkeyCode 的免费模型访问和免费服务器方案适合用来跑属性测试的草拟流程;无论是否使用它,提取器本身都值得先交付。