你的 Agent 提交的改动里,有些代码行根本没有测试跑过。合并之前,可以用 diff-cover 把它们找出来

Dev.to AI 2026-10-25T09:42:32.800408

书籍:《AI Agents 口袋指南》系列:给开发者的口袋书 —— 8 本小册子,涵盖 agent、RAG、prompt、可观测性、事件、数据库和系统设计 —— 整个系列
我的项目:Hermes IDE | GitHub —— 一款为使用 Claude Code 及其他 AI 编码工具的开发者打造的 IDE
我:xgabriel.com | GitHub

你的 agent 写完了退款接口,回话说测试都通过了。你去查了,确实如此。所有测试全绿,项目的覆盖率百分比几乎没动。

但这两件事都不能告诉你:agent 刚写的那段代码,究竟有没有被任何测试执行过。整个测试套件可以全部通过,而新加的那个 if (order.status !== 'paid') 分支一次都没被执行。项目级的覆盖率数字同样会把它藏起来:在一个大型代码库里,新增的几十行对总数的影响微乎其微。

你真正想回答的问题要具体得多:这个分支改动的那些行里,哪些没有测试跑过?

diff-cover 干的正是这件事。它读一份覆盖率报告,读 git diff,然后报告两者交汇处的那些行的覆盖情况。它以 Apache 2.0 协议开源,10.6.0 版本于 2026 年 9 月 22 日发布到 PyPI。

diff-cover 度量什么,又跳过什么

diff-cover 接受的报告格式包括 Cobertura、Clover、JaCoCo XML 和 LCov,所以大多数测试运行器都能给它喂数据(见 README)。

它把你的当前分支和一个对比分支作比较,默认是 origin/main。默认的范围写法是 ...,意思是「你的分支从分叉点之后产生的改动」,而不是两个分支之间的全部差异。

工作区里的改动也算数。默认情况下,已暂存和未暂存的修改都会包含进来。未跟踪的文件不算,除非你传 --include-untracked。

这个参数在跟 agent 配合时很关键:agent 新建了一个文件却从没 git add 过,那它就是未跟踪文件;不传这个参数,它的行根本不在 diff 里。

第二个坑更隐蔽。当 diff 里的某个文件在覆盖率报告中找不到时,diff-cover 会认为它没有任何可度量的行,也不会报出任何违规。在它的 reporter 源码里能看得很清楚:如果没有匹配到任何内容,就是 violations = set()。

一个全新的模块,如果没有被任何测试导入,可能压根不会出现在报告里,于是它对覆盖率百分比的贡献为零——不管好还是坏。

覆盖率工具必须列出所有源文件,不管这些文件有没有测试。下面两种配置都能做到这一点。

JavaScript:用 Vitest 生成 lcov 报告

先安装覆盖率提供器,具体命令见 Vitest 覆盖率指南。diff-cover 是 Python 工具,即便项目用的是 JavaScript,也需要 Python 3.10 或更高版本(PyPI):

npm i -D @vitest/coverage-v8
pipx install diff-cover

Vitest 默认支持的报告器有 text、html、clover 和 json,唯独没有 lcov(见配置参考)。先加上它,同时设置 include——因为「默认情况下 Vitest 只显示测试运行时被导入的文件」(见指南):

// vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    coverage: {
      provider: 'v8',
      reporter: ['text', 'lcov'],
      include: ['src/**/*.{ts,tsx}'],
    },
  },
})

报告会输出到默认的 ./coverage 目录,lcov 报告器生成的文件叫 lcov.info(参考 istanbul-reports)。如果 .gitignore 里还没有 coverage/,记得加进去。

然后跑测试,再做差异检查:

npx vitest run --coverage

diff-cover coverage/lcov.info \
  --compare-branch=main \
  --include-untracked \
  --show-uncovered \
  --fail-under=90

--compare-branch=main 指向你本地的 main 分支,也就是 agent 任务通常的起点。默认值是 origin/main,它可能领先也可能落后于本地分支。--show-uncovered 会直接在控制台打印出未覆盖的行。--fail-under=90 让命令在改动行的覆盖率不达标时返回退出码 1,并打印「Failure. Coverage is below 90%.」(源码)。这个退出码就是你的关卡。

控制台报告沿用仓库里的一个简短模板(控制台模板)。简化一下,尖括号处填入实际值,结构大致如下:

-------------
Diff Coverage
Diff: <对比范围>
-------------
<文件> (<百分比>%): Missing lines <n>,<n>
-------------
Total:   <n> lines
Missing: <n> lines
Coverage: <百分比>%
-------------

每个有改动的文件对应一行 <文件>。

Python:coverage.py 的 XML 报告

coverage.py 会帮你跑 pytest,快速上手文档里写得很清楚。记得传 --source 参数:文档说指定 source「能让 coverage.py 报告未被执行的文件」,正好堵上前面提到的「文件漏报」坑。coverage xml 默认生成 coverage.xml(命令文档):

pipx install diff-cover

coverage run --source=src -m pytest

coverage xml
diff-cover coverage.xml \
  --compare-branch=main \
  --include-untracked \
  --show-uncovered \
  --fail-under=90

coverage.py 当前版本是 7.16.2,2026 年 9 月 27 日发布(文档)。两种语言下 diff-cover 的参数完全相同,只是报告路径不同。

把未覆盖的行交还给 Agent

门禁失败只是第一步。Agent 还需要知道该补哪些行的测试,而且要能用它能直接上手改的形式告诉它。

diff-cover 可以在输出控制台报告的同时再写一份 JSON 报告。控制台报告仍然会打印,--format 只控制额外生成的文件。src_stats 下的每个条目都有一个 violation_lines 列表,记录本次变更中没有测试跑到的行(报告生成器)。

还有一条捷径得堵上。Agent 想让门禁变绿,最省事的办法就是加一条覆盖率忽略注释:Vitest 里写 /* v8 ignore next -- @preserve */(指南),coverage.py 里写 # pragma: no cover(文档)。所以脚本要先把新增的这类注释拦下来:

#!/bin/sh
# scripts/diff-gate.sh
set -e
base=$(git merge-base main HEAD)
pat='pragma: no cover|v8 ignore|istanbul ignore'
self=':(exclude)scripts/diff-gate.sh'
if git diff "$base" -- . "$self" | grep -qE "^\+.*($pat)"; then
  echo "New coverage-ignore comment in this diff" >&2
  exit 1
fi

npx vitest run --coverage
diff-cover coverage/lcov.info \
  --compare-branch=main \
  --include-untracked \
  --show-uncovered \
  --fail-under=90 \
  --format json:diff-cover.json

git diff "$base" 覆盖了已跟踪文件的已提交改动和未提交改动。脚本把自己排除在外,因为它的 pat= 那一行本身就包含了它要搜索的所有模式。但全新未跟踪文件里的注释会溜过去,所以新文件还得自己看一眼。

如果是 Python 项目,把 npx vitest 那行换成 coverage.py 的两行命令,并让 diff-cover 指向 coverage.xml 即可。

门禁失败时,用 jq 把 JSON 转成一份清单:

jq -r '
  .src_stats | to_entries[]
  | select(.value.violation_lines | length > 0)
  | "- \(.key): lines "
  + (.value.violation_lines | map(tostring) | join(", "))'

diff-cover.json 会为每个仍有未覆盖变更行的文件打印一条要点。把这份清单粘贴到提示词里,并说明什么算完成、什么不算数:

diff-cover 发现这些变更行没有任何测试执行到:

请添加测试来执行这些行,并对它们的行为做断言。

不要为了通过而删除代码或添加覆盖率忽略注释。

如果某行代码无法测试,就明确标注它并说明原因。保留这最后一行是因为有些行确实没法合理测试,你更希望听到这个事实,而不是拿到一个调用了函数却什么都没断言的测试。在 Hermes 里,我为每个 agent 设置一道门禁(我构建了 Hermes IDE),所以读这一节时请记住这一点。在 Hermes 中,⌘N(Windows 和 Linux 上是 Ctrl+Shift+N)会让每个任务在自己的 hermes/ 分支和独立 worktree 上启动,任务本身作为 agent 的第一个提示词(见 README)。Hermes 2.3.4 里的 ⌘N 新建任务面板:任务、Claude Code、orders-api 项目、新建的 hermes/ worktree、Accept edits、模型与 effort,以及“Hermes 将运行”的 claude 命令预览。README 还提到,你可以用自己的检查来决定任务何时完成。在 .hermes/worktree.toml 中为每个新任务设置默认检查,用 done_when(recipe 文档):

# .hermes/worktree.toml
setup = [ "npm ci" ]
done_when = [ "sh scripts/diff-gate.sh" ]

setup 在新的 worktree 中运行,这样任务就有自己的 node_modules 供门禁使用。Hermes 第一次看到这个文件时,会显示命令并在运行任何东西之前先询问(recipe 文档)。diff-cover 本身需要安装在你的机器上。门禁失败时,把 jq 列表和上面的提示词自己粘贴到 agent 的终端里:Hermes 从不自己往终端里输入内容(见 README)。每个任务有自己的 worktree 和分支,所以门禁测量的是一个 agent 的 diff。把同一张工单同时交给 Claude Code 和 Codex,你会得到两份 diff-cover 报告,每个分支一份,而不是两个 agent 工作混在一起的一个数字。Hermes 2.3.4 里 Review Desk 的 Worktrees 标签页:orders-api、web-app 和 orders-types 各有一个 Hermes worktree,位于 ~/code 下,显示各自的 hermes/ 分支、任务、存在时长和磁盘占用。有一处需要手动设置:Land 会提交、打开 pull request,或合并回任务启动时的分支(见 README),但我没找到有文档记载的变量能把那个分支名传给检查。脚本里写的是 main。如果你从其他分支启动任务,就在那里改。

Hermes 免费,源代码在 BSL 1.1 下开放,支持 macOS、Windows 和 Linux。可以下载,或在 GitHub 上读代码。

它不隶属于 Anthropic、OpenAI、Nous Research,也不属于任何 agent 厂商。如果你想在 CI 里做同样的关卡,上面那些命令在任何流水线里都能用。

关卡通过,并不代表什么都被证明了

代码被执行,不等于被检查过。一个只调用新函数、却一条断言都不写的测试,照样能把这个函数的每一行都算成"已覆盖"。上面的提示词要求写断言,但测试还是得你自己看。要抓出"跑了代码却不做检查"的测试,得靠变异测试(mutation testing)。

diff-cover 只数行。一行里塞了三元表达式的两边,可能被算作覆盖,其实有一边从没执行过。报告有多完整,取决于你喂进去的输入。不在 include 或 --source 范围内的文件,对 diff-cover 来说等于不存在。自动生成的代码和测试夹具(fixture)可能需要用 --exclude 排除,它接受 fnmatch 通配模式(见 README)。

在 CI 里,用来比较的分支通常是 origin/main,而浅克隆(shallow checkout)下来的仓库可能没有这个分支。diff-cover 的 README 里有一条排错说明,专门讲 ambiguous argument 'origin/main...HEAD' 这个报错:运行前先把该分支 fetch 下来。

阈值要有意识地选。agent 会写大量新代码行,对"agent 刚写的这些行"要求 90%,比对整个项目要求 90% 严得多。我自己没在团队的仓库里跑过这道关卡,所以把 90 当作起点,读过几份报告之后再调整。

如果这篇对你有用

给 agent 自己的 diff 加一道覆盖率关卡,只是众多护栏中的一条:一个 agent 无法争辩的检查、一个清晰的完成定义,以及把失败结果当作下一条提示词喂回去的机制。《AI Agents Pocket Guide》讲的就是这些循环、验证,以及给真正改代码的 agent 加护栏的做法。它是《Pocket Guides for Developers》系列 8 本小书之一,主题覆盖 agent、RAG(检索增强生成)、提示词、可观测性、事件、数据库和系统设计。如果你同时跑多个 agent,Hermes IDE 会给每个任务单独开一个分支,并让这道关卡来决定任务什么时候算完成。

Book: AI Agents Pocket Guide | The series: Pocket Guides for Developers——8 本小册子,涵盖 agents、RAG、prompts、可观测性、事件、数据库和系统设计——the whole series | My project: Hermes IDE | GitHub——为使用 Claude Code 及其他 AI 编码工具的开发者打造的 IDE | Me: xgabriel.com | GitHub

你的 agent 写完退款接口,回来报告说测试全过了。你亲自验了一遍,确实如此。每一项测试都是绿油油的,项目的覆盖率百分比也几乎纹丝不动。这两件事——测试全绿和覆盖率不变——单独看都没问题,合在一起却可能藏着大坑。

查看原文