ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于diff的代码审查工具:从原理到CI集成实践

基于diff的代码审查工具:从原理到CI集成实践

1. 我到底为什么又写了一个代码审查工具

1.1 先说说手动 review 里最磨人的那部分

我是一个开源项目的维护者,仓库不大不小,刚好卡在“一个人忙得过来,但 PR 多起来就烦躁”的规模。每天打开 GitHub 的 Pull Requests 列表,我一眼就能看出哪些 PR 是认真做了自测的,哪些只是把代码从编辑器里复制过来就点了 create pull request。最典型的例子是:函数名把复数写成单数,import顺序乱得没法看,日志级别全是print(),明明项目里统一用了 Pydantic 却有人传 dict 进来。这些不是架构问题,不是逻辑问题,甚至很难算“bug”,但它们每一件都需要我打开文件、看上下文、写评论、等提交人改掉、再重新 review。一个下午就这么没了。

但是真让我绝望的不是这些小事,而是这些小事里的“重复性”。我几乎是在用人工的方式做一台正则机器该做的事情。每次看到同类问题,我都有同一个念头:这种东西不应该是人肉查的。我需要的不是又一个 linter,因为 linter 管的是整个文件,而 review 的时候我只关心这次改动动了哪几行;我更想要的是一套“挂在 diff 上的审查助手”,它能看到我改了哪里、动了哪些符号、有没有把不该来的代码顺手带进来,然后在我点开 PR 之前先替我过一遍。

1.2 现有工具和我的需求之间差了什么

当时我不是没试过别的方案。GitHub 自带 code scanning、CodeQL 的规则确实很强大,但它的配置成本和学习曲线摆在那里,属于“三个月用一次”的工具。市面上也有一些能把 AI 拉进 code review 的 SaaS 服务,但它们的问题是代码要出网,我对这个比较敏感,而且价格也不便宜。更现实的一个痛点是:我希望 review 规则能跟着自己团队的项目规范走,比如“核心包不允许新增第三方依赖”“错误处理必须返回项目自定义的AppError”“所有文件名都应该是小写下划线”。这些东西并不通用,别人帮不了我,必须自己写。

于是我把需求收敛成了三点:第一,工具必须能只看 diff,而不是整个仓库,这样跑得才快、噪音才少;第二,规则必须能通过插件方式扩展,我用 Python 写,那么规则插件最好也是 Python 函数;第三,输出必须既给人看又给 CI 用,最好能直接生成 Markdown 报告或者小规模自定义 annotations。按这个思路,我折腾出了一个人的开源项目,名叫open-code-review。

项目的定位,是一个“跑在本地跑在 CI 里的轻量代码变更审查器”。它不替代人工 review,但它可以代替你去翻那些一眼就能看到底的细节问题,把你从琐碎里解放出来,让你把脑力留给真正的设计评审。如果你也在维护一个有点规模的仓库,或者你团队里想要统一但一直没统一的代码审查基线,这个工具的使用思路应该是能给你一些启发的。

1.3 三个设计原则:diff 输入、插件式检查器、可解释输出

动手写代码前,我先把硬约束列了出来。第一原则是open-code-review只基于 diff 运行。我会把基础分支和目标分支之间的改动解析成结构化数据,所有检查器都只看到这些改动,而不是整个仓库文件。第二原则是插件式检查器。工具自己带的规则库很少,因为我清楚每个团队都有自己的偏好,统一内置一套“全人类都应该这么写”的规则是胡说八道。所以核心引擎负责解析、上下文收集和报告输出,规则全部作为插件存在。第三原则是输出必须可解释。检查器输出的是“哪个文件、哪一行、为什么报、参考文档是什么”,而不是一个干巴巴的错误码。做到这一点,后续无论是人工处理还是接入 CI 门禁,都方便得多。

有了这三个原则,我在后面写代码时就不用老纠结架构了。

2. open-code-review 的架构:把 diff 变成一条审查流水线

2.1 以 diff 为中心的设计,到底好在哪

如果你之前写过静态分析工具,你大概率是从“分析整棵 AST”入手的。但如果目标是代码审查,一上来就分析全库反而会导致两个问题:第一是性能太差,一个大仓库全量解析动不动就几十秒,放在 CI 里无法接受;第二是误报率奇高,因为那些“本来就在仓库里存在的问题”会一起蹦出来,把真正的增量问题淹没掉。

open-code-review把 diff 作为一切检查的输入源头。它会先调用 git 拿到改动的文件列表和每个文件的具体行号范围,然后让各个检查器只针对这些行号去定位问题。比如一个检查器叫no-debug-print,它不会检查整个项目有没有console.log,它只会检查本次改动新增的代码里有没有console.log。这就非常符合人工 review 的习惯:你不是去翻整个文件,你是看着绿绿红红的 diff 判断这段新代码是否合格。这个思路救了我很多次,尤其在大规模重构的 PR 中,仓库里原本的旧问题一大堆,而我只关心这次联调动了什么。

为了确保检查器拿到的“改动上下文”足够丰富,引擎还会计算每个 hunk 的前后文。也就是说,虽然检查器只检查新增行,但它可以看到这个新增行所在的函数名、类名、是否是函数签名的一部分,这能帮助规则做出更合理的判断,减少指鹿为马的情况。

2.2 检查器到底能拿到哪些信息

很多人会问:如果只看 diff,是不是能做的事太少?比如说某个改动会导致类型错误,但类型信息藏在另一个文件里,光看 diff 根本不知道。我的回答是:不要试图让检查器取代编译器,它能拿到的信息,既能保证“快”,又能保证“够用”。

open-code-review传给检查器的结构大致包含三类信息。第一类是文件信息,包括路径、语言类型、当前分支上的相对路径;第二类是 diff hunk 信息,包含每个 hunk 的旧行号新行号、改动类型(新增、删除、修改)、前后若干行的原文;第三类是轻量符号表,引擎会对改动涉及的目标文件做一次快速 AST 扫描,提取出当前文件里的函数、类、变量定义和 import 关系,但不做跨文件的类型推导。这就是整个上下文容器的范围,听起来朴素,但对绝大多数规则来说已经够了。

举个例子,我写过一个规则叫no-sleep-in-tests,它会抓time.sleep(的出现。仅凭 diff hunk,它就能判断这个新增调用出现在测试目录还是生产代码目录。又比如规则avoid-todo-leak,它会在 App 代码的新增行里匹配TODO注释。这些都不是什么高深能力,但组合起来之后,覆盖面相当可观。

2.3 所有诊断信息统一成同一种数据结构

检查器的执行结果会统一转换成诊断对象。这个对象包含file_path、line、column、severity、message、rule_id、suggestion和confidence。我特别重视confidence这个字段,因为代码审查不是编译报错,它本身就是概率性的;同一段代码,可能在不同团队里有不同风格,所以一个置信度字段能帮助下游决定是该拦截还是仅提醒。

统一的诊断结构带来的最大好处是“输出层可以随便换”。引擎底层只需要收集一个个 Diagnostic 对象,然后在渲染阶段把它们转成 Markdown、JSON 或 SARIF。我最初只做了 JSON 输出,后来因为要在 GitHub Actions 里展示 annotations,所以补了 SARIF 渲染器。如果没有统一的结构,这种扩展会非常痛苦。

2.4 为什么选择了 Python 而不是 Go 或 Rust

选语言这件事被朋友问过很多次。其实我最开始也考虑过 Go,毕竟单二进制部署确实很香,但最后还是选了 Python。原因很现实:我是用 Python 做日常脚本和维护的,遇到新规则需求,我可以用最短时间写出来。另外 Python 内置了difflib,第三方的tree_sitter绑定也用得顺手,生态足够让我快速迭代。

当然,坏处也明显:分发依赖 Python 环境,跑起来比 Go 慢。但考虑到这个工具定位是“CI 中的前置过滤网”,而不是“大规模静态分析集群”,Python 的性能完全足够。如果你也想做类似的东西,我的建议是:除非你一开始就知道性能和分发是硬指标,否则选择自己最熟的语言去把逻辑跑通,远比选一个看起来很酷的语言然后中途放弃重要。

3. 拿一条真实提交把流程跑通,你才知道它值不值

3.1 安装与初始化配置

我习惯把工具做成标准的 Python CLI。安装就是常见的pip install open-code-review,然后命令行里执行open-code-review init,它会生成一个open-code-review.toml作为默认配置。这个配置文件负责三件事:声明要启用哪些规则、告诉引擎要忽略哪些路径、以及设置规则的参数。

一个最小配置大概长这样:

[core] reporter = "markdown" root_dir = "." ignore_paths = ["vendor/", "dist/", "node_modules/"] [checkers] enabled = [ "no_debug_print", "no_sleep_in_tests", "avoid_todo_leak", "require_error_handler", ] [checkers.no_sleep_in_tests] test_paths = ["tests/", "src/__tests__/"]

配置文件的格式本身没什么魔力,但设计它的过程让我意识到一件事:规则参数最好是显式的。像test_paths这种字段,如果我写死在规则里,那么换一个项目整个规则就得复制一遍;但如果把它放出来,规则就能做到“一码多用”。这正是插件化框架需要优先考虑的:把可变的部分暴露给开发配置,而不是写进代码里。

3.2 命令行参数的设计取舍

命令行主入口是open-code-review review。我通常会传两个参数:--base和--head,或者在不传参数时自动用origin/main和当前分支做 diff。还有--format用于指定输出格式,--output指定输出文件,--fail-on用于设置门禁级别,比如error或warning。

我还加了一个很有意思的参数叫--context-size,用来控制每个检查器拿到的前后文行数,默认是 3 行。有人可能会觉得这参数太底层了,但实际使用中非常有用。比如写“检测日志里是否含用户敏感信息”的规则时,上下文行数多一点才不至于把多行拼接的日志漏掉;而在性能敏感的大仓库上,把上下文缩到 1 行能明显提速。

命令长这样:

open-code-review review --base origin/main --format markdown --output report.md

如果审查结果里没有任何问题,进程退出码是 0;如果存在 error 级别问题,退出码是 1。这个语义很简单,任何 CI 脚本都能直接吃。

3.3 一个真实场景下跑出来的报告

我拿一个改造前的旧模块测试过。改动内容非常典型:有人在一个工具函数里加了print(f"debug: {data}")来排查问题,然后忘记删;同时他在测试文件里写了一个time.sleep(2)来等异步结果。这些都逃过了普通 linter 的眼睛,因为 linter 默认运行在整个文件上,并没有“这条调试语句是这次变更新增的”这个维度。

跑一次open-code-review后,Markdown 报告里会出现这样的片段:

| rule_id | file | line | severity | message | | --- | --- | --- | --- | --- | | no_debug_print | src/utils/helpers.py | 42 | error | 新增代码中包含了调试用 print,请删除或用日志对象替代 | | no_sleep_in_tests | tests/test_async_utils.py | 18 | warning | 测试代码中出现固定 sleep,容易造成 CI 不稳定,建议使用 wait 条件 |

这个报告让我眼前一亮的地方不是它抓到了问题,而是它抓到了“新增代码里的问题”。如果我人工 review,也要把 diff 移到那一行才能发现。而现在机器人已经帮我在提交阶段把所有这个泪点扫过一遍了。

3.4 自动修复与手动 apply 的边界

为了一些简单规则,我还实现了open-code-review fix子命令。它能够对诊断对象里的suggestion字段做“机器可读补丁”处理。比如去掉print(行、把time.sleep替换成wait_until这类明确的转换。但修复原则非常克制:只修“语义百分百不会变”的改动,其余一律只能给出建议文案,绝不自动改。

我自己在设计中坚持了一条原则:自动修复属于“锦上添花”,不属于核心功能。因为代码审查的任务场景非常复杂,同一个代码片段可能在团队里有特殊含义,盲目自动修复比不修更可怕。如果你也在做类似工具,我建议你一开始不要做自动修复,先把诊断能力做扎实,后面再考虑“哪些建议可以安全地自动执行”。

4. 接入 GitHub Actions:从本地扫描到 PR 自动评论

4.1 一个实际在用的 workflow 长什么样

本地跑通了,下一步自然是让它进 CI。我不想每次手动拉分支跑命令,而是希望每次 PR 打开或更新时,工具自动对两个分支之间的 diff 执行审查,然后以机器人的身份在 PR 底下留言。

我使用的是 GitHub Actions。一个最小可用的 workflow 我贴在这里:

name: open-code-review on: pull_request: types: [opened, synchronize, reopened] jobs: code-review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/setup-python@v5 with: python-version: '3.11' - run: pip install open-code-review - run: open-code-review review --base origin/main --format markdown --output report.md - name: Read report id: report run: | echo "report_md=$(cat report.md)" >> "$GITHUB_OUTPUT" - name: Comment PR uses: actions/github-script@v7 with: script: | const body = process.env.REPORT_MD; await github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: body, }); env: REPORT_MD: ${{ steps.report.outputs.report_md }}

关键点是actions/checkout一定要加fetch-depth: 0,否则没有完整 git 历史,git diff拿不到正确的 base 和 head。这一点我踩过坑,第一次没写 fetch-depth,结果工具永远拿到的是半截 diff,报了一堆莫名其妙的错误。

4.2 用GITHUB_TOKEN评论 PR,还是用独立 bot?

我见过很多自动评论工具会要求你单独申请一个 GitHub App 或者 bot 账号,理由是可以绕过GITHUB_TOKEN的权限限制。但对代码审查这个场景来说,GITHUB_TOKEN完全够用,而且它不需要额外配置密钥,安全上也省心。唯一需要注意的是:用GITHUB_TOKEN发评论时,评论者显示为 “github-actions[bot]”,无法在评论里 @ 具体的人。如果你想在评论里提到提交者,必须在脚本里显式读取 PR 的提交人信息再拼进 Markdown,否则大家只能看到一条匿名机器人回复。

另外,如果希望 PR 不被机器人刷爆,我会建议把汇总评论而不是逐行内联评论作为默认策略。原因在于逐行评论在某些仓库里被 REVIEWERS 视为“噪音”,而且一旦提交人针对每条评论做了回复,评论区会变得非常长。汇总评论恰好能一屏展示所有问题,适合用来粗筛。

4.3 汇总评论和内联评论,我的最终取舍

这里我算是想明白了:关键不是内联好不好,而是你要解决什么问题。如果你团队的目标是“合并代码前必须把所有 block 问题改掉”,那汇总评论足够了,因为提交人可以对照清单逐项修;如果你希望机器人直接对某一行提出具体修改建议,那内联评论更高效。

open-code-review本身就支持输出github-action格式,也就是把诊断对象映射成 GitHub 的 workflow command:

::error file=src/utils/helpers.py,line=42,title=no_debug_print::新增代码中包含了调试用 print

这种格式可以直接被 GitHub 解析成 PR 文件行上的 annotation,不需要调用任何 API。我在实践中是“内联 annotation + 汇总评论”组合用:内联负责定位问题,汇总用来写总结意见。二者互相补充,体验反而最好。

4.4 把它当门禁用的时候,务必要留后门

把 open-code-review 集成到 CI 之后,我一度想过:如果 error 级别问题存在就不允许合并,是否过于严厉?后来发现,过度严格的门禁一定会有人想办法绕过,最常见的办法是直接 close 再开一个新的 PR,把旧评论全部洗掉,毕竟 review 工具不是强制流程。

所以我最后采用的是“warning 不拦截,error 拦截,但允许维护者手动 override”.在 workflow 中我加了一个判断,只有 PR 改动者不包括在某个admin-paths列表时才启用阻断。这种方式显然不是完美的自动门禁,但它在“自动化”和“人的可干预性”之间找到了一种平衡。你可以在自己的工具里复制这个模式:让门禁策略是可配置的,而不是硬编码成一个二选一。

5. 自定义检查器的真实开发流程:从需求到落地

5.1 检查器的三步约定

open-code-review的插件体系非常简单,约定大于配置。每个检查器是一个 Python 函数,它接收一个ReviewContext,返回一个Diagnostic列表。三段式结构,非常容易记忆。

from open_code_review import register_checker, ReviewContext, Diagnostic def check(context: ReviewContext) -> list[Diagnostic]: diags = [] for file_change in context.changed_files: if not file_change.path.endswith(".py"): continue # 检查逻辑 return diags register_checker("my_custom_rule", check, defaults={"severity": "warning"})

这个设计我尽力让它保持直白。没有复杂的事件系统,没有装饰器注册链,就是一个普通函数挂到register_checker上。好处是新人接手自定义规则几乎零成本,坏处是表达能力有限,但“有限”有时候反而是控制的优点。

5.2 用一个真实需求做例子:禁止某个依赖不经过批准升级

我举一个实际团队里会用到的例子:管理员希望锁定requests库的版本,不允许 PR 直接把版本从 2.28 升到 2.31,除非有人在审查配置里显式声明允许。

规则逻辑很简单:对每个变更文件过滤出requirements.txt或pyproject.toml,再匹配requests相关的依赖行。如果这行是新增的,且版本号不在allowed_versions列表里,就返回一条 error 诊断。

import re from open_code_review import register_checker, ReviewContext, Diagnostic ALLOWED_REQUESTS = {"2.28.0", "2.28.1"} def check_requests_pinned(context: ReviewContext) -> list[Diagnostic]: diags = [] for fc in context.changed_files: if fc.path not in ("requirements.txt", "pyproject.toml"): continue for hunk in fc.hunks: for line in hunk.added_lines: if line.startswith("requests") and "requests" in line: version = re.search(r"requests[^\d]*([\d.]+)", line) if version and version.group(1) not in ALLOWED_REQUESTS: diags.append(Diagnostic( file_path=fc.path, line=hunk.new_start + hunk.added_lines.index(line), severity="error", message="requests 库版本未在允许升级列表中,如需升级请联系维护者", rule_id="requests_pinned", suggestion=f"使用允许的版本之一: {', '.join(sorted(ALLOWED_REQUESTS))}", )) return diags register_checker("requests_pinned", check_requests_pinned, defaults={"severity": "error"})

这个例子最值得看的其实不是正则本身,而是“为什么规则只关心新增行”。如果旧版本本来就不符合规范,升级 PR 没动它,那处理它只会制造噪音。这一点思路,可以说是我整个工具的哲学。

5.3 给规则写单元测试的三种姿势

规则一旦多了,回归测试就成了刚需。open-code-review内置了一个小型的测试辅助模块,让你可以不用启动完整 CLI,就能直接调用检查器。

第一种姿势是造一个最简单的ReviewContext,只包含一个 hunk。第二种是使用真实仓库 fixture,跑完整流程然后快照输出。第三种是针对纯函数单测,比如把过滤逻辑抽出来单独测。

def test_deny_requests_upgrade(): context = make_context_from_diff( """ diff --git a/requirements.txt b/requirements.txt --- a/requirements.txt +++ b/requirements.txt @@ -10 +10 @@ -requests==2.28.1 +requests==2.31.0 """ ) result = check_requests_pinned(context) assert len(result) == 1 assert result[0].rule_id == "requests_pinned"

如果你也想做类似项目,我强烈建议早一点把这种测试通路铺好。规则引擎最怕的不是逻辑写得差,而是改了一个通用辅助函数后几十个规则一起挂,而你只能靠盲摸去定位。

5.4 避免重复造轮子:内置规则怎么复用

我最初设计了一些内置规则,但内置规则数量很少。不是我不想内置,而是如果内置规则和自定义规则用完全不同的代码路径,那自定义的灵活性就会大打折扣。因此我把内置规则也写成插件,放在open_code_review.contrib.rules里。用户可以直接继承一个基础规则类,也可以在自己规则文件里配置内置规则的参数。

比如说你只需要把no_debug_print的检测对象从print扩展到pprint,你不需要复制代码,而只需要在配置里传一个:

[checkers.no_debug_print] expressions = ["print", "pprint"]

这个参数会进入诊断对象里的rule_meta,让大家一眼就能看出该规则的实际配置。规则复用做到这个程度,团队内部定制成本基本就降到最低了。

6. 项目用了大半年之后,我总结出的几条实用经验

6.1 误报率永远比漏报率更值得关注

工具上线后,我收到的第一个反馈不是“抓得好”,而是“它怎么连这种东西都报”。如果有人对工具产生了不信任,后面再想让团队接受就难了。所以我在设计上刻意让多数规则默认朝着“保守”走,拿不准的问题只报 warning,不报 error;只对新增行做报告,不对历史坏账多做纠缠。

有个很典型的例子:no_debug_print在开发期经常被误报,因为有人会写print在业务逻辑里并不是调试,而是命令行工具的正常输出。后来我给规则加了一个目录黑名单,默认对cli/和scripts/目录放行,这才把误报率降到可接受范围。没有完美的规则,只有愿意倾听反馈并主动调整规则边界的维护者。

6.2 它和 linter 不是替代关系,而是上下层关系

有人会问:我项目里已经有ruff、ESLint、golangci-lint了,为什么还要用open-code-review?我的回答是,它不是 linter 的替代品,而是 linter 的“调度器”和“过滤器”。linter 的扫描范围是整个文件,open-code-review会先把变更行提取出来,再在变更行上精准地执行所有规则,因此即使底层复用了一些开源 lint 引擎的规则,也能跑得比“全量 lint 一遍再 diff 结果”更快、更准确。

我在实际项目里是三层流水线:第一层是 pre-commit 钩子里的快速 lint,第二层是open-code-review的 diff 审查,第三层才是人工的架构评审。每一层负责的事都不一样,大概率可以做到不重叠。

6.3 大 PR 和超大重构,是这个工具目前最大的软肋

如果 PR 里改了 500 个文件,且大部分文件都是机械变更,比如加函数签名参数、换包名、调缩进,那么基于 diff 的引擎会被大量 hunk 淹没,性能会明显下降。为此我做了一个硬性限制:默认只处理前 200 个变化文件,超出部分会在报告里提示“本次审查为部分覆盖”。

这个局限让我意识到,自动代码审查工具能解决的最舒服的场景是“小步快跑的日常 PR”,而不是“蓄力三个月的一次大重构”。后者更适合交给人力去 review,因为语义上的完整性判断,目前还是人比工具强。如果你想在自己的项目里采用同类工具,我给你的建议是:先在小而频繁的 PR 里使用,不要指望它一次解决巨型重构。

6.4 从 open-code-review 延伸出来的两个方向

这半年里,我从项目里剥离出了两个值得继续的方向。第一个是把它内置的规则接口做成独立的规则包仓库,允许团队上传自己的规则,然后通过open-code-review install-rules <package>安装到本地配置。这样可以形成一个规则市场,大家就不用把自己辛苦调出来的规范藏在公司代码库里。第二个是把诊断对象导出成与 AI 对话友好的结构化 JSON,未来可以让大模型基于这些诊断结果做更深层的语义审查,比如“这个问题是否与函数的设计意图相冲突”。

我目前已经把这些想法整理进项目 roadmap,但还没正式做到。如果你感兴趣,可以在你自己的工具里试试类似思路:先把规则引擎做薄,把规则包做厚,后续再叠加高级分析能力。这样做的最直接好处是,即使某一天你想换掉底层分析引擎,上面的规则和下游的评论流程都还能继续用。

对我个人而言,open-code-review带来的最大改变,是让我重新理解了自己在 review 里到底该干什么。琐碎的机械检查交给脚本之后,我反而愿意多花时间看调用关系、设计边界和测试覆盖率——这些才是一个维护者真正不可替代的价值。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进