[控场AI]
· 11 分钟阅读· 5,760 字

Agent补丁测试没覆盖改动行?Diff覆盖率门禁方案

Agent补丁测试没覆盖改动行?Diff覆盖率门禁方案

用 diff 覆盖率门禁按 hunk 裁决 Agent 补丁改动行是否被测试执行,拒绝合并未执行代码。

文章指出文件级覆盖率是统计平均值,无法回答 Agent 生成的补丁改动行是否被任何测试执行这一关键问题。作者提出一种轻量级 diff 覆盖率门禁:从 git 差异中提取每个 hunk 的新增行号范围,与 coverage.py 报告的 executed_lines 求交集,按 hunk 输出 EXECUTED、PARTIAL、SILENT、UNKNOWN、ALLOWED 五种裁决,并据此决定是否阻止合并。门禁在 CI 中以严重度排序控制失败阈值,用 --excluded-tests 将 flaky 隔离导致的 SILENT 降级为 UNKNOWN 以避免虚假阻止。文章还讨论了每种裁决的证明边界、工具链限制、播种门禁的方法以及不适用场景。

AI Agent 生成的代码补丁通过测试套件,只说明运行没有崩溃,并不代表测试执行了补丁改动的行。这个盲区在 Agent 编程场景中被显著放大:模型可能添加一个防御性 except 分支、放宽类型检查或插入一个从未被测试过的提前返回。文件级覆盖率是平均值,91% 的文件覆盖率可能完全掩盖一个全新未执行的 except 分支。本文介绍一种轻量级 diff 覆盖率门禁:将补丁改动行与测试实际执行行求交集,按 hunk 逐个裁决,拒绝合并无人执行的代码块。

diff覆盖率门禁概念图

为什么文件级覆盖率不够用

文件覆盖率是一个统计平均值。一个文件显示 91% 覆盖率时,其中可能包含一个完全未被执行的 except 分支,而数字几乎不会变动。Agent 恰好擅长生成这种形态的补丁:一小段防御性逻辑、一个扩宽的类型检查、一个模型想象出来但从未验证的提前返回。

评审时真正需要回答的问题是:有没有任何测试执行了我即将批准的这几行代码? 按补丁范围测量行执行情况,是回答这个问题的最小单位。

收集改动行范围

第一步是从 git 差异中提取每个 hunk 的新增行号范围。三个关键参数配合使用:

  • -U0 保持 hunk 最小化,避免一行改动拖入 40 行未触及的上下文
  • --diff-filter=d 跳过已删除文件,它们没有可执行的行
  • 三点范围 base...HEAD 基于合并基(merge base)做差异,而非与漂移后的 main 分支比较
import re
import subprocess

def changed_ranges(base: str) -> dict[str, list[tuple[int, int]]]:
    diff = subprocess.run(
        ["git", "diff", "-U0", "--no-color", "--diff-filter=d", f"{base}...HEAD"],
        capture_output=True, text=True, check=True,
    ).stdout
    ranges: dict[str, list[tuple[int, int]]] = {}
    path = None
    for line in diff.splitlines():
        if line.startswith("+++ b/"):
            path = line[len("+++ b/"):]
            ranges.setdefault(path, [])
        elif line.startswith("@@") and path:
            m = re.search(r"\+(\d+)(?:,(\d+))?", line)
            start, count = int(m.group(1)), int(m.group(2) or 1)
            if count:  # 纯删除产生零长度 hunk,跳过
                ranges[path].append((start, start + count - 1))
    return ranges

纯删除产生零长度 hunk,因此被过滤掉;重命名文件落在新路径下,与覆盖率报告路径对齐。输出结构为 文件 -> [(起始行, 结束行), ...],计算成本足够低,每次推送都可以重新运行。

合并基(merge base) 是 git merge-base 命令计算出的两个分支的最近共同祖先提交。使用三点范围 base...HEAD 时,Git 比较的是 merge-base(base, HEAD) 与 HEAD 之间的差异,而不是 base 分支当前尖端与 HEAD 的差异。这样做的原因是:当 base(如 main)在特性分支创建后继续前进时,两点范围 base..HEAD 会把 main 上的新提交也纳入差异,产生大量与本次补丁无关的改动行,污染覆盖率门禁的判定范围。三点范围只关注特性分支自身引入的变更,是代码评审场景下更精确的 diff 语义。

收集已执行行

使用覆盖率工具以分支模式运行一次测试,并限定在源码目录:

python -m coverage run --branch --source=src -m pytest -q tests/
python -m coverage json -o coverage.json

coverage json 为每个文件输出 executed_lines 和 missing_lines。省略 --source 会导致交集计算为 conftest.py、fixtures 和未改动的测试辅助文件买单。分支覆盖率并非所有工具链默认开启,如果关闭,一个只走了一半的 if 会报告为完全覆盖。

分支覆盖率(branch coverage) 与行覆盖率互补:行覆盖率只记录某一行是否被执行,而分支覆盖率还记录条件语句的每个分支(如 if 的真假两侧、except 的触发与未触发)是否都被走到。在 Agent 编程场景中,模型添加的防御性 except 分支往往只在异常路径下执行,行覆盖率可能因为 try 块被执行而将整个结构标记为部分覆盖,但分支覆盖率能明确暴露该 except 体从未运行。coverage run --branch 开启此模式后,coverage json 输出中会额外包含 executed_branches 和 missing_branches 字段,diff 门禁可据此对 hunk 做更严格的裁决。

交集分类与裁决

每个 hunk 得到且仅得到一个裁决,门禁依据裁决行动,而不是百分比。

import fnmatch
import json
import sys

SEVERITY = ["EXECUTED", "ALLOWED", "UNKNOWN", "PARTIAL", "SILENT"]

def classify(path, span, ran, allowlist):
    lines = set(range(span[0], span[1] + 1))
    if any(fnmatch.fnmatch(f"{path}:{span[0]}", pat) for pat in allowlist):
        return "ALLOWED"
    hit = lines & ran
    if not hit:
        return "SILENT"
    return "EXECUTED" if hit == lines else "PARTIAL"

五种裁决及默认合并规则如下:

裁决含义默认合并规则
EXECUTED所有改动行都被某个测试执行通过;检查断言
PARTIAL部分改动行从未执行阻止合并
SILENT没有任何改动行被执行阻止合并
UNKNOWN运行排除了部分测试,结果不确定重新运行被排除的子集
ALLOWED显式、有主、经过评审的例外通过,需记录负责人

一个添加重试分支但从未测试的补丁,输出示例:

EXECUTED  src/ledger/charge.py:41-48
PARTIAL   src/ledger/charge.py:52-61
SILENT    src/ledger/charge.py:64-70

1 hunk(s) at or above PARTIAL

Hunk(差异块) 是 unified diff 格式中由 @@ ... @@ 头部标识的一段连续变更区域。每个 hunk 包含若干上下文行、删除行(以 - 开头)和新增行(以 + 开头)。在 -U0 模式下,上下文行被压缩为零,hunk 仅包含实际增删的行,因此每个 hunk 的新增行号范围直接对应补丁引入的可执行代码区间。按 hunk 而非按文件裁决的意义在于:一个文件可能同时包含已测试的修改和未测试的修改,文件级通过/失败会丢失这种粒度;hunk 级裁决让门禁精确指出“哪一块新增逻辑没有被任何测试触达”,评审者可以直接定位到具体行号。

在 CI 中设置门禁

严重度排序是整个枚举设计的核心。--fail-on PARTIAL 会同时拦截 PARTIAL 和 SILENT;--fail-on SILENT 只拦截完全没有被触达的 hunk。建议先以严格模式运行一个冲刺周期,再判断 PARTIAL 在你的代码库中是噪音还是真实信号。退出码使用 2,以便在多数 CI 系统中与测试运行崩溃(退出码 1)区分开。

python diffcov.py --base origin/main --fail-on PARTIAL --excluded-tests 0

每种裁决不能证明什么

EXECUTED 证明某行被执行过,但不证明任何测试对该行效果做了断言。一个只调用函数并检查“没有抛出异常”的测试,会将内部每个行标记为 EXECUTED。应把裁决视为阅读断言的许可,而非断言存在的证据。

PARTIAL 通常是最有信息量的行:分支只走了一边,或循环体仅被空 fixture 进入。ALLOWED 是一种声明而非事实——每个模式都需要负责人和理由,像代码一样接受评审。保持模式行级作用域,src/migrations/* 这种写法会让白名单把门禁变成装饰。

工具链限制真实存在:C 扩展不上报任何内容,子进程代码不上报,异步任务体可能在覆盖率关闭后才执行。只在真实设备上运行的代码将永远显示 SILENT。

覆盖率与测试执行关系

Flaky 测试(不稳定测试) 指在相同代码和环境下,多次运行结果不一致的测试——时而通过、时而失败,通常由时序依赖、共享状态、网络或随机数据引起。在覆盖率门禁的上下文中,flaky 测试的隔离策略会直接影响裁决的可信度:被排除的测试不再执行,其覆盖的行在本次运行中表现为未执行,从而将原本 EXECUTED 的 hunk 降级为 SILENT。如果门禁不区分“代码真的没被测试”和“测试被临时排除”,就会产生大量误报,迫使团队放宽阈值或绕过门禁。--excluded-tests 参数的作用正是将这种不确定性显式建模为 UNKNOWN,要求合并前对被排除的子集做完整重跑,而不是让隔离策略悄悄腐蚀门禁的权威性。

隔离不稳定测试的交互

当某个 flaky 测试被排除出运行集时,覆盖率会静默下降。一个原本只由该测试覆盖的 hunk 现在显示 SILENT——门禁没有错,是运行不完整。这就是 --excluded-tests 将 SILENT 降级为 UNKNOWN 而非直接失败的原因:诚实的“我不知道”胜过虚假阻止,后者会教评审者绕过门禁。

从测试运行器显式传入排除数量,并要求在合并前对被隔离子集做完整重跑。反向失败更糟:绝不能让广泛隔离导致 UNKNOWN 成为默认值。如果超过少量 hunk 返回 UNKNOWN,你拥有的不是一次覆盖率运行,而是一个跳过测试的习惯。

播种门禁与工具选择

一个从未见过失败的门禁只是装饰。你需要一批补丁流——有些正确,有些包含未测试分支——来验证它拦截了该拦截的、放行了该放行的。用免费模型层级生成候选补丁是合理用法:要求一个行为变更加测试,然后只运行门禁并记录出现的裁决。此时模型是 fixture 生成器而非预言机;弱样本只会产生容易的 EXECUTED 行,这本身也是有用的校准点。

MonkeyCode 的免费模型访问和免费服务器选项恰好对应这个循环的两个后勤环节:候选生成和干净检出执行。覆盖率任务必须在与 CI 相同工具链的全新检出上对每个候选运行,否则门禁恰好在队列繁忙时被跳过。需遵守两条约束:将运行器视为临时的(每次运行重新生成 coverage.json,不存储不可复现的内容),并固定工具链版本(运行时、覆盖率工具和测试运行器),因为不同版本产生的裁决与 CI 结果不可比。

哪些场景不适合使用

  • 改动行大部分是生成代码的仓库:锁文件、protobuf 输出、迁移文件。白名单会吞掉门禁,报告无人阅读。
  • 被测语言没有行覆盖率支持的工具链。部分插桩会产生自信但错误的 SILENT 行。
  • flaky 率过高导致多数运行以部分覆盖结束的团队。先修复隔离预算,这个门禁会放大已有噪音。
  • 打算把数字当作质量指标的人。它是针对 diff 的合并决策,不是个人或仓库的评分。

脚本不到九十行,几秒内运行完毕,因为最昂贵的部分——测试运行——本来就在发生。变化在于:报告现在明确列出了 Agent 改动但未执行的精确行,而不是把它们藏在绿色对勾里。

#diff覆盖率#Agent补丁测试#代码覆盖率门禁#CI质量门禁#增量覆盖率#AI编程测试#覆盖率工具
分享:

相关推荐