ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

自建代码审查工具 open-code-review:从人肉 Review 到语义分析自动化

自建代码审查工具 open-code-review:从人肉 Review 到语义分析自动化

聊到代码审查,很多团队的现状其实是这样的:Code Review 主要靠人肉,合并请求一挂就是半天,reviewer 要么忙得没空看,要么扫一眼就 Approve,等代码上了生产出了问题才回过头来懊恼。我今年在维护一个中大型项目时,被这种“人肉审查”卡得实在难受,于是抽空折腾了一个开源的代码审查工具,名字就叫 open-code-review。它的核心思路并不复杂:把代码审查从“等人看”变成“机器先看一遍、人再看重点”,通过差异分析、规则引擎和语义分析,在 MR 提交的第一时间自动生成结构化的审查意见,让 reviewer 不用从头到尾逐行读 diff,而是直接去看机器标注的风险点和有争议的地方。对于项目里有几十号开发、每天几十个 MR 的团队来说,这东西能省下的时间非常可观。这篇博文我就把整个项目的设计思路、核心实现、部署接入和一些实际踩坑经验完整分享一下,适合想自建代码审查体系的开发者、对工程效率工具感兴趣的技术负责人,也适合刚接触这类工具的读者照着搭一套。

1. 为什么要自建代码审查系统:团队里的真实痛点

1.1 人工审查的瓶颈在哪里

先说一个我观察了很久的现象:大部分人做 Code Review 的时候,真正花在“思考代码逻辑”上的时间,其实不到三分之一。剩下三分之二的时间,都耗在“看 diff 上下文”“找变更文件”“判断这行改动会不会影响别的模块”这些重复劳动上。尤其当 MR 改动了几十个文件,reviewer 打开页面看到一屏红色绿色,第一反应往往是头疼,然后快速滑动滚轮,最后只对几个大文件留下几句不痛不痒的评论。

这种模式的瓶颈不是人不够聪明,而是人的注意力本身就是稀缺资源。一个 200 行以内的小 MR 还好,一旦到了 500 行以上,审查质量就直线下降。我统计过团队最近三个月的 MR 数据,超过 300 行的 MR,平均每个文件被提出有效问题的概率不到 20%,也就是说大部分改动其实是在“裸奔”状态下合入主干的。这不是某个人态度有问题,而是流程设计上就存在漏洞:没有在第一轮用机器做一遍基础的、确定性的检查,把人的精力留给真正需要判断力的地方。

1.2 open-code-review 解决的问题

open-code-review 要解决的就是上面说的“确定性问题”和“注意力分配”问题。它能做的第一件事,是在 MR 创建的时候自动把 diff 拉下来,解析出每个文件的新旧版本,算出新增行、删除行、修改行,然后基于这些变化去做分析。

举个例子,一个函数从“接收两个参数”改成“接收三个参数”,如果只靠人眼,你可能要看到调用处的报错才能反应过来。但 open-code-review 可以在分析阶段就发现:这个函数的签名变了,而某个调用点没跟着改,于是自动在该调用点下打一条评论,提示“函数入参数量不匹配”。这种检查不需要人思考,机器做得又快又准。另外,它可以自动识别高风险文件——比如改动涉及鉴权、支付、数据库事务、并发锁等逻辑,就会把这些文件标记为“建议必须人工重点审查”,并附上理由。

我一开始做这个项目的时候也犹豫过,现在市面上有 CodeRabbit、Snyk 这些商业工具,为什么还要自己写一个?后来想明白了:商业工具通常是黑盒,规则不透明,而且很难跟团队内部的规范完全对齐。自建工具最大的价值,是规则、阈值、输出格式全部掌握在自己手里,今天发现一个新的坏味道模式,明天就能写进规则库里,立刻生效。

1.3 和现有方案的对比选择

如果你也在考虑引入代码审查自动化,我建议你先想清楚一个核心问题:你要的是“审查意见的数量”,还是“审查意见的质量”?如果只追求数量,商业工具确实开箱即用;但如果你希望工具能真正贴合团队的编码规范、能处理私有仓库里的特殊逻辑,那自建一套规则引擎反而更省心。

方案上手成本规则可定制性数据隐私与内部规范对齐
商业 AI 审查工具低中弱(代码出库)一般
纯人工 Review低高强强
open-code-review 自建中高强(全本地)强

我最终选择自建,还有一个原因是隐私。团队里有一部分项目是客户私有化部署的,代码绝不能出内网。open-code-review 可以完全跑在本地或内网 CI 服务器上,不需要把代码传给任何第三方服务,这一点在 To B 项目里是刚需。

2. 整体架构与核心设计思路

2.1 系统模块划分

整体架构我拆成了四个模块:变更采集模块、差异解析模块、规则分析模块和报告输出模块。这四个模块各自独立,通过标准的数据结构串起来,这样做的好处是后续想换掉任何一块,都不用动其他代码。

变更采集模块负责跟 Git 仓库打交道,执行 diff 命令,拿到两次提交之间的变更列表。差异解析模块则把 diff 文本解析成结构化数据——哪个文件的哪一行新增了什么、删除了什么、上下文是什么函数,这些信息都会被打上标记。规则分析模块是核心,它拿到结构化变更数据后,会遍历所有激活的规则,逐条进行匹配。报告输出模块比较简单,就是把分析结果整理成 Markdown 或 JSON,方便回写到 Git 平台的评论区。

我用 Python 写这个项目,原因很朴素:Python 在文本处理和 AST 解析这块生态太成熟了。tree-sitter 可以直接把多种语言解析成语法树,不需要每个语言单独写一套 parser。整个系统的入口是一个命令行工具,支持open-code-review review --base main --head feature这样的调用方式,方便在 CI 里直接跑。

2.2 为什么选择基于语义分析而不是纯正则匹配

刚开始我也走过弯路,第一版用了大量的正则表达式来匹配“可疑代码”。比如查TODO、FIXME、调试用的print,确实简单粗暴。但正则很快暴露了局限:它只能在文本层面做匹配,完全看不懂代码结构。

举一个我实际遇到的例子。一个正则规则想查“所有直接调用dangerous_call()的地方”,用正则dangerous_call\s*\(确实能匹配到调用点。但如果这个函数被包了一层:wrapper(() => dangerous_call()),正则只能匹配到内层,却没法判断外层是不是已经做了异常处理。这时候就需要语义分析,看 AST 节点,看这个调用点的父节点是什么类型的语句,有没有被try-catch包裹。

tree-sitter 提供了完整的语法树,我可以拿到每个函数调用的精确位置、参数列表、函数签名,甚至能查到某个变量在当前作用域里是怎么定义的。这就能做很多更智能的检查,比如变量遮蔽检测:内层作用域定义了一个跟外层同名的变量,在大型项目里这种问题很难一眼看出来,但语法树一对比就清清楚楚。

2.3 设计时重点考虑的场景

在设计规则分析模块的时候,我明确锁定了三个高频场景优先支持。

第一个是接口变更波及分析。函数签名变了,调用点没跟着改,这个问题在多人协作的项目里几乎每天都会出现。通过 AST 解析所有函数定义和调用关系,一旦发现某个被引用多次的函数签名发生变化,就自动去查调用点,把不匹配的列出来。

第二个是配置项硬编码检测。团队内部规范要求敏感信息必须走配置中心,但总有人图省事直接在代码里写死。这个用语义分析做也很方便:先通过 AST 找字符串常量,再跟规则库里的敏感变量名(比如password、api_key、secret)做匹配,命中就报警。

第三个是错误吞没检测。空except: pass或者catch (Exception e) { }这种写法,是线上问题排查时的噩梦。解析语法树后,如果发现异常处理块里面没有任何日志输出或者重新抛出的逻辑,就直接标记为“错误被吞没”。

这三个场景覆盖了团队里大部分“机器能发现的低级问题”,把人的精力真正解放出来。

3. 核心实现细节解析

3.1 变更采集与差异计算

变更采集的输入是 Git 仓库和两个 commit 引用。实际执行的时候,我用的是git diff加--unified=0参数,这样输出的 diff 只包含变更行,不包含上下文,解析起来更轻量。但这里有个坑:没有上下文的 diff 在做语义分析时不够用。比如我要判断一个删除行是不是位于某个 try 块内部,必须拿到这个删除行附近的完整代码上下文。

所以我实际采集了两层数据:第一层用git diff --name-status拿变更文件列表,第二层对每个变更文件单独执行git diff -U10 <base> <head> -- <file>,保留足够的上下文行。然后再用 tree-sitter 把新旧版本的文件都解析成语法树,这样每一个变更行都能对应到语法树上的具体节点,能知道它属于哪个函数、哪个类、哪个控制流块。

差异计算的另一个容易忽略的点是行号映射。git 的 diff 输出里有@@ -12,7 +12,9 @@这种 hunks 头,需要正确解析出新旧行号之间的映射关系。我维护了一个双向映射表:旧文件第 12 行到第 18 行,对应新文件第 12 行到第 20 行。分析引擎查询“新文件第 15 行发生了什么变化”时,可以立刻通过映射表找到对应的旧文件行号,以及这行的变更类型是新增还是删除。

3.2 规则引擎的设计与实现

规则引擎是整个项目里最有意思的部分。我把每条规则设计成两个部分:元信息和检测函数。元信息包含规则名称、适用语言、风险等级、描述文本;检测函数则是一个接收语法树节点和变更信息、返回命中告警的函数。规则文件用 YAML 或 Python 模块来定义,团队想加规则,不用改主程序,只需要新增一个规则文件并注册即可。

举个例子,空异常捕获规则的检测函数逻辑大致是这样:遍历语法树中所有try语句,找到它的except或catch子句,检查该子句内部是否有日志调用、是否有raise、是否有返回错误值的语句。如果子句体为空或者只有pass,就命中规则。

规则分析完成后,每条告警会带上文件路径、行号、规则 ID、严重级别和人类可读的描述。这个数据结构是统一的,后续无论是输出成 Markdown 表格,还是转成 GitLab 评论,或者是发到企业微信机器人,都很方便。

3.3 报告生成与审查体验优化

报告输出我做了两个版本:本地命令行版本输出到终端和 Markdown 文件,CI 版本则会读取GITLAB_TOKEN或GITHUB_TOKEN,直接把审查意见以评论的形式发到 MR 对应的 diff 行上。这样开发者不用跑到 CI 日志里翻结果,直接在 MR 页面就能看到“第 45 行这里可能有空异常捕获”。

这里有一个产品细节我觉得值得分享:告警不要全部冲到 MR 评论里,一定要做降噪和分级。我设置了三个级别:Block(阻断)、Warning(警告)、Info(提示)。Block 级别的意见会直接标记为“必须处理”,比如接口调用不匹配、密钥硬编码;Warning 级别需要人工确认,比如空异常捕获;Info 级别则只是提示,比如“这个函数过于复杂,建议拆分”。

为什么会这样设计?因为我发现,如果机器每次在 MR 上挂 30 条评论,开发者的第一反应是“这工具疯了”,然后把所有评论一键忽略。反过来,如果平均只挂 3 到 5 条,而且每条都言之有理,大家才愿意认真看。所以我在报告模块里专门做了一个聚类去重逻辑:同一个文件里相同规则的相邻命中,合并成一条,避免刷屏。

4. 实操部署与接入 CI/CD 流程

4.1 环境准备与安装步骤

open-code-review 依赖 Python 3.10 以上版本,还需要 Git 和 tree-sitter 对应的系统库。安装很简单,直接从源码仓库 clone 下来后执行pip install -r requirements.txt即可。我建议用虚拟环境,别直接装到系统 Python 里,不然之后升级依赖会遇到一堆莫名其妙的版本冲突。

需要的核心依赖我列一下:tree-sitter、tree-sitter-language-pack、PyYAML、requests。tree-sitter-language-pack 这个包很关键,它把常见语言的 grammar 都打包好了,不用自己逐个去编译。我第一次折腾的时候不懂,一个个去装 grammar C 扩展,折腾了大半天,后来发现一个包就能搞定。

安装完成后,执行open-code-review --help能看到命令行工具已经可用。这里有个小提示:如果是在 CI 环境里用,建议把依赖固化成 lock 文件,避免某一天某个依赖库更新导致行为变化。我自己就被这个坑过一次,一个 grammar 版本升级后解析结果变了,导致同一个 MR 前后两次审查意见不一致。

4.2 快速启动与本地验证流程

安装好后,先在本地仓库里跑一遍验证效果。我通常的做法是开一个测试分支,故意改几个有明显问题的代码片段:比如加一个空异常捕获、硬编码一个密钥、改一个函数签名但不改调用点。然后执行:

open-code-review review --base main --head test-branch --format markdown --output report.md

命令跑完后,打开report.md看结果。如果规则没问题,你会看到每条问题记录都带文件路径、行号和具体描述。遇到没有命中预期规则的情况,先用--debug参数跑一遍,查看解析器是否把目标代码正确识别成了对应的语法节点,大部分规则失效的问题都出在这一步。

我还建议在本地用一个小脚本模拟 CI 环境,保证环境变量注入后行为一致。因为 CI 里的工作目录往往是干净的 clone,没有本地未提交的更改,执行 diff 的结果跟本地可能会有差异。我遇到过一种情况:本地因为有一些未提交改动,导致 diff 范围异常,工具把不该审查的文件也拉进来了,审查报告里出现了一堆跟本次 MR 无关的告警。

4.3 接入 GitLab CI 和 GitHub Actions

接入 CI 是整个项目落地最关键的环节。我现在主要用 GitLab CI,流程很简单:在.gitlab-ci.yml里加一个 stage,MR 创建或者更新时触发,跑一个自定义镜像里的审查脚本,最后把生成的报告提交到 MR 评论区。

GitLab CI 的核心配置大概是这样的:

code-review: stage: review image: python:3.11-slim only: - merge_requests script: - pip install -r requirements.txt - open-code-review review --base $CI_MERGE_REQUEST_TARGET_BRANCH_SHA --head $CI_MERGE_REQUEST_SOURCE_BRANCH_SHA --gitlab-token $GITLAB_TOKEN --project-id $CI_PROJECT_ID --mr-iid $CI_MERGE_REQUEST_IID artifacts: paths: - report.md

GitHub Actions 的接入思路完全一致,只是环境变量名不同。要注意的一点是:GitLab CI 里$CI_MERGE_REQUEST_TARGET_BRANCH_SHA这种变量,指向的是分支的最新 commit,而不是分支名。用 commit SHA 做 diff 比用分支名更可靠,因为分支名是动态的,前后两次流水线拉到的 commit 可能不一样,导致审查结果对不上。

当然,这里有一个前提:代码仓库必须允许 CI 运行时有权限读取 MR 的元数据并调用平台 API 写评论。GitLab 需要在 CI 里配置一个具有api权限的 token,GitHub 则需要配置GITHUB_TOKEN并赋予pull-requests: write权限。权限这块别给太大,最小够用就行,毕竟 token 泄露的后果谁都不想面对。

5. 规则自定义与模型调优

5.1 规则引擎的自定义配置实践

默认规则集只是一个起点,真正让工具发挥价值的是自定义规则。团队内部的规范五花八门,比如“禁止直接 new Date(),必须使用统一时间工具类”“所有 RPC 调用必须设置超时时间”“日志必须包含 traceId”。这些规范写成规则,其实都逃不出几类模式:查 AST 节点类型、查函数调用名、查某个作用域的属性访问。

规则文件我设计成 YAML 格式,看起来是这样的:

- id: no-raw-date name: 禁止直接使用 Date 构造器 languages: [javascript, typescript] severity: warning message: 请使用 utils/time.ts 中的统一时间函数替代直接 new Date() pattern: call_expression filter: function_name: Date

有了这种自定义能力,规则库是可以积累的。团队里每出现过一次线上事故,我们就复盘它能不能通过静态规则提前发现。能,就把它变成一条新规则。这是我认为自建工具最大的长期价值——它是一个会成长的过程资产。

不过自定义规则也有主板,写规则你一定得理解语法树节点的结构。我建议先用 tree-sitter 的 playground 把目标代码解析出来,看节点长什么样,再写过滤条件。别指望一次写对,规则调试基本是个反复试错的过程,我第一条规则就改了四版才稳定。

5.2 结合大模型生成智能审查意见

静态规则做到后面,你会发现有些问题它确实抓不到,比如“这个函数名字叫 fetchData,但里面实际在写文件”,这种语义层面的问题需要真正理解代码意图。为了补上这块短板,我在 open-code-review 里加了一个可选的“大模型分析”模块,通过调用大模型 API,把变更文件的摘要和关键 diff 发送给模型,让它生成自然语言审查意见。

这个模块的接入方式是配置llm段,指定 provider 和模型名称。分析流程上,我先用静态规则筛掉低价值文件,只把“高风险文件”和“变更超过 100 行的文件”送给模型,这样能控制 token 消耗,又不至于漏掉大改动。模型返回的意见会经过后处理,和静态规则的结果合并输出。

用下来我的感受是:大模型对“代码坏味道”的嗅觉确实敏锐,比如“这个方法职责太多了,建议拆分”“这段删除操作没有幂等保护”,这类意见是静态规则写不出来的。但也有翻车的时候,模型偶尔会幻觉出一些不存在的变量,所以在报告里我会把大模型意见标记为“AI 建议”,不设 Block 级别,只作为参考。

5.3 控制误报率的几个实用技巧

误报率是代码审查工具的生死线。最开始我在团队里推广的时候,被人吐槽最多的就是“这工具太吵了”,几乎每周都有人在群里艾特我说某个规则误报了。后来我总结出三个控制误报率的技巧。

第一,规则必须支持排除路径和文件。比如某些生成代码目录(client/models、migrations),本来就是自动生成的,审查毫无意义。我在配置里加了exclude_paths和include_paths两个选项,优先级高于规则本身。

第二,减少规则里的“凭感觉”判断。比如“函数太长”这种复杂度规则,一开始我定的是“超过 50 行就报警”,结果大量既有代码命中,开发体验极差。后来改成“新变更新增的代码导致函数超过 80 行”才报警,只针对增量,不针对存量,大家才觉得合理。

第三,建立误报反馈渠道。每一条告警都允许开发者标记“忽略”或“误报”。我每月拉一次数据,看看哪些规则被忽略率超过 30%,这种规则要么修改,要么直接下线。规则不维护,迟早变成一支报警大队。

6. 常见问题与实战排查记录

6.1 常见问题速查表

我把实际使用中高频出现的问题整理成了速查表,方便后来者排查。

现象可能原因处理方式
审查结果为空diff 范围错误,base 和 head 指向了同一 commit检查 CI 里传入的 SHA 变量是否正确
评论没有发到 MR 上token 权限不足,或者项目 ID 配错确认 token 有 api 权限,核对 project ID
tree-sitter 解析报错语言 grammar 版本过旧升级 tree-sitter-language-pack 到最新版
同一个 MR 重复告警每次 push 都触发,没有做去重在报告模块里按 commit SHA 缓存结果
大模型分析超时并发请求太多,或 diff 太大限制单次分析的 diff size,加入超时重试

6.2 三个让我印象深刻的坑

第一个坑是 diff 上下文不完整导致报告“张冠李戴”。当时我用--unified=0拿精简 diff 做分析,发现某条告警指错了行号。排查半天才发现,没有上下文的 diff 在解析和谐映射时容易错位,尤其是文件中有大量空行变动的时候。后来我改成保留 10 行上下文,还专门为行号偏移写了单元测试,这个问题才算解决。

第二个坑是语法树缓存失效。一开始我在长驻进程里缓存了所有文件的 AST,明显提升了大仓库的分析速度。但 Git 分支切换后,文件内容变了,缓存没跟着失效,导致分析结果还是旧版本代码的。现在我的策略是:以 diff 中每个文件的 head commit SHA 作为缓存 key,一旦 SHA 不匹配就重新解析,彻底避免了脏缓存。

第三个坑跟 CI 环境有关。GitLab CI 默认的工作目录是浅克隆,历史只有最近一次提交。执行git diff base head时,如果 base 所处的 commit 不在浅克隆范围内,就直接报错。我甚至在流水线日志里看到过“fatal: bad object”这种让人一头雾水的错误。解决方法是在 CI 配置里加上GIT_DEPTH: 0或者使用git fetch --unshallow,确保完整克隆。

6.3 下一步可以怎么扩展

open-code-review 目前的版本已经满足了我们团队的日常需求,但我心里清楚还有很多可以扩展的地方。第一个方向是支持多语言的语言服务器协议(LSP),这样可以直接复用编辑器的诊断能力,比如 TypeScript 的 type check 结果、Python 的 mypy 结果,都可以汇总到审查报告里。

第二个方向是做增量规则学习。现在规则还是靠手动积累,我理想的状态是:工具能从历史告警和人工标注的误报里自动总结规律,推荐新的规则组合。这个工程量大一些,但方向是对的。

第三个方向是打通更多平台和通知渠道。目前只接入了 GitLab 和 GitHub,但很多团队用的是自研代码平台,或者需要把审查结果同步到飞书、钉钉、企业微信。报告模块的输出是标准 JSON,理论上接一个新渠道只是写一个推送函数的事。

最后再分享一点实际操作中的体会

如果你正在犹豫要不要自建这样一个工具,我的建议是先小范围试起来。不用一上来就把所有规则、所有语言都配齐,选一个核心仓库、选三种左右的高价值规则,跑两周看看效果。我见过太多团队想一步到位,结果把自己卷进复杂的配置里,最后什么也没落地。工具是服务于流程的,审查工具最大的作用不是替代人,而是让人把注意力放到真正有价值的事情上。动手搭一套,跑起来,再慢慢调,你会发现它的价值会随着规则的积累越来越明显。

RELATED READING

延伸阅读

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