1. 从代码评审的痛点说起:为什么会有 open-code-review 这个项目
先交代一下背景:我做开源项目维护有三四年了,带过小团队,也参与过几个社区活跃度还不错的仓库。open-code-review 这个项目不是灵光一现,而是被逼出来的。
我观察到的核心问题是:代码评审这件事,大多数人做得并不到位。不是大家不重视,而是常规的 review 流程里藏着太多低效环节。我见过太多 PR 挂着两三天没人理,也见过 reviewer 打开 diff 之后只回一句"LGTM"就关掉,更常见的是 review 意见集中在代码风格、命名这类琐碎问题上,真正的逻辑缺陷反而没人提。
这可能也是很多人对代码评审的误解——以为评审就是"看一遍有没有 bug"。但实际上,一次有效的评审需要回答的问题非常多:这段改动影响哪些模块?有没有破坏现有契约?有没有引入安全风险?测试覆盖够不够?边界条件有没有处理?这些问题靠人肉逐个回答,成本极高,而且随着 PR 变大,注意力会明显下降。
open-code-review 想解决的就是这个矛盾。它不是一个替代人工评审的 AI 神器,也不是什么重量级平台,而是一个可以嵌入现有仓库的自动化审查助手,专门做"第一道过滤":把机械性、重复性的检查全部吃掉,把真正需要人判断的问题留给 reviewer。项目本身完全开源,规则可配置,接入成本控制在半小时以内。
这个项目适合谁?如果你维护的仓库有一定规模,PR 数量开始变多,或者团队里 review 质量参差不齐,那它大概率能帮上忙。如果你只是想给自己的小项目加一层保护,它也足够轻量,不会喧宾夺主。
2. 我把 open-code-review 做成了什么样子
2.1 核心设计:让审查规则可声明、可扩展
动手之前我列了几个设计原则,第一条就是"规则必须可声明"。
我不想把审查逻辑写死在代码里。比如"检查函数是否过长"这种事,如果我用 if 语句硬编码进源码,那么每加一条规则都要改主程序、发版、升级,维护成本会越滚越高。所以 open-code-review 的规则全部做成声明式配置,用 YAML 描述,运行时加载,加规则不需要动主程序。
配置大概是这种感觉:
rules: - name: "function-complexity" enabled: true level: "warning" engine: "tree-sitter" pattern: | (function_declaration body: (compound_statement) @body) condition: metric: "cyclomatic_complexity" threshold: 10 - name: "debug-statement-check" enabled: true level: "error" engine: "regex" include_files: - "*.go" - "*.py" exclude_files: - "**/vendor/**" pattern: "console\\.(log|debug)\\(|fmt\\.Print"这个设计的核心是"规则与执行引擎解耦"。规则只描述"我要检查什么",至于怎么解析代码、怎么计算复杂度、怎么定位行号,全部交给底层的引擎去处理。以后社区想加新语言支持,只需要扩展引擎,不需要改规则格式。
2.2 技术选型:为什么用 Go 而不是 Python 或 Node
这个项目我一开始用 Python 写了第一版原型,跑通之后又推倒重写了。原因很实际:Python 在解析大型代码仓库时内存占用太难看,而且分发部署需要解释器环境,用户在 CI 里接入的时候会多出不少麻烦。
后来切到 Go,主要有几个考量:
第一,静态编译成单个二进制,用户下载下来就能跑,不需要装任何依赖。这一点对 CI 场景特别友好,Docker 镜像也能控制在很小的体积。
第二,Go 的标准库对并发支持非常顺手。代码审查本质上可以并行化,一个仓库里的文件彼此独立,goroutine 一开,检查速度几乎线性扩展。实测下来,几百个文件的仓库,规则跑完也就是几十秒的事。
第三,Tree-sitter 的 Go 绑定已经很成熟。Tree-sitter 这个增量解析库支持几十种语言,能够输出带精确位置信息的语法树,用它来写代码结构类规则比正则表达式可靠太多。
关于解析方案,我也对比过几个方向:
| 方案 | 优点 | 缺点 | 我的结论 |
|---|---|---|---|
| 正则表达式 | 简单直接 | 无法理解代码结构,误报率高 | 只用来做关键词类检查 |
| Tree-sitter | 增量解析、多语言、位置精确 | 需要安装 grammar | 主要解析引擎 |
| 各语言官方 AST | 语义准确 | 每个语言都要接一套,成本高 | 特定深度检查用 |
| 文本 diff 分析 | 轻量快速 | 只能看改动,无法看上下文 | 作为辅助信息源 |
实际开发中,我发现规则类型可以分成三类:文本类规则用正则就够了,比如禁止提交调试代码、检测密钥痕迹;结构类规则必须用 Tree-sitter,比如检查函数复杂度、嵌套深度、未处理错误;还有一类是"跨文件契约规则",这类最复杂,我放到后面版本里才实现,比如检查某个函数签名变更后有没有同步更新所有调用方。
2.3 项目结构一览
仓库本身的目录设计不算复杂,但分层比较清晰:
open-code-review/ ├── cmd/ # 入口命令 │ └── review/ # 主命令 ├── internal/ │ ├── config/ # 配置加载与校验 │ ├── engine/ # 规则执行引擎 │ ├── parser/ # 统一语言解析接口 │ ├── analyzers/ # 内置分析器 │ ├── reporter/ # 报告输出 │ └── vcs/ # Git 相关信息收集 ├── rules/ # 内置规则集 │ ├── builtin-go.yaml │ ├── builtin-python.yaml │ └── builtin-js.yaml ├── docs/ └── examples/internal/parser是一个统一接口层。不同的语言接入不同的 Tree-sitter grammar,但对上层暴露的是统一的语法树模型。这样规则引擎不需要关心具体语言细节,只需要在统一模型上做模式匹配和指标计算。
有朋友问我为什么不直接用现成的 CodeQL 或者 Semgrep。答案很直接:它们确实强大,但各自有各自的约束。CodeQL 的语义分析能力顶级,可它的规则语言有一定学习门槛,而且对仓库体量小的项目来说略显笨重;Semgrep 的规则生态很好,不过它的工作模式是"先有规则库再匹配"的路线,我想更细粒度地控制审查上下文。与其在别人的框架里迁就,不如做一个轻量的、贴合自己流程的引擎。开源的意义就在这里,你不需要说服所有人用它,对的人自然会来。
3. 核心工作流程:从一次 PR 到一份审查报告
3.1 触发与上下文收集
open-code-review 的默认工作模式是"差异审查",也就是只对 PR 中变更的内容做检查,而不是对整个仓库跑一遍全量扫描。这个决策花了我不少心思。
全量扫描的好处是覆盖面广,但问题也很明显:第一,MR 越大,扫描时间越长,很难控制在开发者可以接受的反馈周期内;第二,reviewer 真正关心的是这次改动引入了什么问题,而不是历史代码里本来就存在的问题。如果你在每次 PR 里都把旧账翻出来,开发者很快会烦。
差异审查的实现思路是:拿到两个 commit 之间的 diff,找出变更文件和每个文件里变更的具体行号,然后用这个信息去指导后面的检查。对于差异范围内的内容,跑全量规则;涉及函数级检查时,会往上下文扩展几行代码,确保能拿到完整的函数体。
我在实现里专门做了一个VCSContext模块,用来收集这些信息:
type ChangeContext struct { FilePath string OldCommit string NewCommit string AddedLines []int // diff 中新增的行号 RemovedLines []int // 删除的行号 }这个模块封装了对 Git 命令的调用,包括读取 diff、解析 hunk、定位文件路径。有意思的是,Git 的 diff 格式在不同平台上表现有细微差异,尤其是换行符和文件名转义的问题,我在测试阶段踩了几个坑,后面再细说。
3.2 规则引擎与静态分析插件的协作
规则引擎是整个项目的心脏。它的工作流程分为三个阶段:加载规则、匹配代码、生成问题列表。
加载规则阶段,引擎会读取配置文件里的每一条规则,检查语法合法性,解析 include/exclude 文件模式,然后把规则注册到对应的执行器上。这里的注册机制我优化过好几轮,最初版本用的是简单的顺序执行,每条规则遍历所有文件,后来发现文件多了以后性能很差。改成按文件分发之后,每个文件只过一遍,但规则在文件维度上依次执行,效率提升明显。
匹配阶段做的事情是:把文件的源码解析成语法树,然后在语法树上执行规则定义的模式匹配。Tree-sitter 的 query 语法和 CSS 选择器有几分相似,可以很方便地匹配特定语法结构。举个例子,下面的 query 可以匹配所有没有 else 分支的 if 语句:
(if_statement consequence: (compound_statement) alternative: ) @if-without-else这只是 token 查询的结果,真正判断"有没有问题"还需要结合上下文。比如一个函数里只有一个不带 else 的 if,多半是正常的;但如果函数里连续出现五六个这样的 if,就可能说明逻辑分支太多,值得 reviewer 留意。这种"从语法匹配到语义判断"的过程,我用一个叫aggregation的机制实现:规则可以定义对匹配结果的聚合条件,比如"同一函数内匹配次数超过 N",满足条件才上报。
生成问题列表时,每条问题记录要包含足够多的信息:文件路径、行号、列号、问题级别、规则名称、匹配到的代码片段、建议文案。这份信息会直接透传到报告里,reviewer 拿到后不需要再翻代码定位,直接点进去看就行。
3.3 审查报告的生成与输出
报告格式我支持三种:纯文本、GitHub Markdown、JSON。前两种给人看,JSON 给脚本和上层系统用。
Markdown 报告的设计原则是"先结论后细节"。最上方是总结性统计,包括本次检查覆盖了多少文件、发现了多少个问题、分布级别如何;然后按文件列出问题详情,每个问题包含行号、规则、原因、建议;最后附上一个修正示例,方便开发者快速理解怎么改。
报告里每种审查结果的级别还没有。这里说个经验:千万不要把所有问题都标成 error。如果规则把"建议优化"和"必须修复"混在一起,开发者会逐渐麻木,真正要紧的错误反而被淹没。我默认的分级是 error 对应一定会导致 bug 或安全风险的问题,warning 对应代码异味或潜在隐患,info 对应风格建议。团队可以根据自己的容忍度调整。
JSON 输出的结构走的是 Schema 化设计,字段名固定、类型明确,方便别人二次开发。我在项目里预留了一个--format json参数,本来只是想方便自己调试,结果后来被好几个用户拿去接自己的仪表盘,算是意外收获。
4. 在自己的仓库里接入 open-code-review
4.1 最小化接入:一个配置文件搞定
接入门槛是开源工具的生命线。如果配置过程超过十分钟,很多人就会放弃。所以我把最基础的接入流程压缩到了三步:下载二进制、写配置文件、跑一次命令。
二进制可以从 Release 页面下载,也可以直接用 Docker 镜像。配置文件默认查找.open-code-review.yaml,放仓库根目录即可。一个最小的配置文件长这样:
version: "1.0" languages: - go - python diff_base: "origin/main" rules: - include: "builtin-go.yaml" - include: "builtin-python.yaml" reporter: format: "markdown" output: "review-report.md"接好后本地跑一次:
open-code-review --repo . --config .open-code-review.yaml如果配置正确,它会输出一行"审查完成"和报告文件路径。这里说一个我特意设计的小细节:默认情况下,如果审查没有发现任何 error 级别问题,命令的退出码是 0;发现问题时退出码是 1。这个设计是为了方便大家直接接到 CI 的fail_ci_please逻辑里,但又给了足够的灵活性——你想让 warning 也阻断流水线的话,加个--strict参数就行。
4.2 接入 CI 流水线
本地跑通之后,接入 CI 就是顺水推舟的事。我以 GitHub Actions 为例,流水线大概是这样的:
name: code-review on: pull_request: types: [opened, synchronize] jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Run open-code-review uses: open-code-review/action@v1 with: config: .open-code-review.yaml token: ${{ secrets.GITHUB_TOKEN }} - name: Upload report uses: actions/upload-artifact@v4 with: name: review-report path: review-report.md这里有个关键的坑:diff 分析需要完整的 Git 历史,所以 checkout 时必须设置fetch-depth: 0。我见过不少用户第一次接入时报"找不到 diff base",八成都是因为 Git 历史被浅克隆截断了。
如果在 GitLab CI 或 Jenkins 里接,原理也一样,本质上就是把仓库拉下来、执行二进制、把退出码映射到流水线状态。GitLab CI 里可以直接用rules和allow_failure控制是否阻断合并请求。
还有一个很多人会问的问题:审查任务应该放在 CI 里同步跑,还是放到后台异步跑?我的建议是,如果规则集比较轻量,检查时间在 30 秒以内,同步跑没问题,开发者提交 PR 后几分钟内就能看到结果;如果规则很重,比如加了深度的数据流分析,那还是丢到异步队列里跑,避免让开发者干等。open-code-review 的设计倾向于轻量同步,深度分析我留给了服务端版本,这是后话。
4.3 规则集示例与调优思路
内置规则集我按照语言分了几个文件,每个文件里的规则按风险等级排了序。拿builtin-go.yaml来说,里面包含的规则有:
- error 级别:
error-return-unchecked、goroutine-without-recover、hardcoded-credential - warning 级别:
function-complexity、deep-nesting、missing-error-context - info 级别:
naming-convention、redundant-type-decl
调优规则集这件事,我的经验是"让数据说话"。跑完一次审查后,你可以分析各类规则的命中率:如果一类规则在连续几十个 PR 里零命中,说明它要么写得好,要么根本没有相关代码路径,这时候可以考虑把它关掉或降级,让报告更聚焦。
另一个思路是按目录细分规则。比如internal/目录下要求所有函数都有注释,而cmd/目录下不要求;协议模块只跑安全类规则,工具类模块可以放宽。这种目录级别的规则控制,用配置文件里的include_files/exclude_files就能实现。
我还想强调一点:规则集的目的是辅助人,不是替代人。遇到规则误报时,千万不要想着一股脑把规则改严或者改松,而是先分析误报的模式。如果某条规则在大多数情况下都准确,只是个别模式判断不了,那就把这条规则设置成 warning,加一个排除条件;如果这条规则连一半的准确率都达不到,建议直接删掉。一个高质量的规则集,应该是每条规则拉出来都有存在理由的,宁缺毋滥。
5. 实测两个月后,我发现的问题和修正
5.1 误报率最高的三类检查
工具上线到我们自己仓库之后,运行了两个多月,积累了不少真实数据。我花了整整一个周末把历史 issue 里的误报报告翻了一遍,总结出误报率最高的三类检查。
第一类是"硬编码密钥检测"。这类规则通过正则匹配形如password = "..."或者api_key=...的内容,本意是防止敏感信息入库。但实际运行中误报率非常高,因为很多测试文件里故意写了假的密钥,还有一些模板代码里用占位符代替真实值。我不可能让规则学会理解业务逻辑,所以最后用了两个改进:一是对测试文件里的匹配结果自动降级为 info 级别;二是增加了一个"已知占位符列表",比如your-api-key、change-me、xxxxx这种值直接放行。这样处理之后,误报率从原来的 40% 降到了 5% 左右,开发者也不再被垃圾报告刷屏。
第二类是"未检查的错误返回值"。这个规则在 Go 项目里尤其多,比如fmt.Sprintf也被算作"可能有错误返回"。但现实里fmt.Sprintf根本不可能返回错误,常规项目里没人会检查它的返回值。这类问题本质上是规则语义不够精确。我调整了内置规则,把标准库里已知的"必成功"函数列表加了进去,同时允许用户在配置里追加自己的忽略列表。
第三类是"结构复杂度告警"。规则按圈复杂度超过 10 就报警,但碰到那种由协议定义生成的代码,动辄一个函数几百行,复杂度和圈复杂度都很高,可这不是人的问题,是生成器生成的代码本来就这样。处理方案是增加了一个generated-file识别机制,检测文件头部的"Code generated ... DO NOT EDIT"标注,凡是命中这个标注的文件直接跳过结构类检查。这一类处理完之后,报告的信息密度高了一截。
5.2 性能优化:从 3 分钟到 40 秒
性能问题是在接入一个中等规模仓库之后暴露出来的。那个仓库有 3000 多个 Go 文件,第一次跑全量规则花了将近三分钟,开发者在 CI 里等得痛苦。
我做了三件事把耗时压到 40 秒左右。
第一件事是引入文件级并行。之前规则是按文件遍历,但每个规则都会重新读取文件内容。我把文件读取和解析结果做了缓存,同一次运行中,多个规则可以共享同一个文件的解析结果。这一项优化大概省掉了 30% 的时间。
第二件事是增量解析。Tree-sitter 支持增量解析,可以在上一次解析结果的基础上只重新解析变更部分。但实际实现时我发现,完全依赖增量解析的边界条件很多,容易出 bug,所以最后采用的是"整文件解析 + 结果缓存"策略。就是说,对于一个文件,如果它在多个 commit 之间没有变化,解析结果可以直接复用。在 PR 场景下,大部分文件都没有变化,这个缓存命中率非常高,省下的时间非常可观。
第三件事是对规则执行流程做了优先级调度。error 级别的规则先跑,warning 和 info 级别的规则可以分到另一个 goroutine 池里去跑。CI 流水线里可以配置 error 检查先行完成、warning 检查延后输出。这样即便 warning 检查跑得慢一点,也不影响 PR 合入的决策。
性能优化这件事给了一个很深的体会:工具做出来是一回事,在真实场景里能跑得顺畅是另一回事。很多开源工具功能很强大,可是用起来很卡,最后大家就不用了。反馈速度本身就是功能的一部分。
5.3 团队协作模式随之发生的变化
这个工具的引入,意外地改变了我所在团队的一些协作习惯。
以前 review PR 的流程是:有人提交 PR,然后等一个或两个 reviewer 从头到尾看一遍代码。这个流程最大的问题是"从头到尾看一遍"这件事,不同人做出来的效果差异巨大。有人会在意逻辑正确性,有人只关心命名规范,还有人可能自己也很忙,随便扫一眼就过了。
现在有了 open-code-review,reviewer 打开 PR 时先看报告,电子化的检查结果已经帮他们把机械性的问题筛出来了。reviewer 的注意力集中在报告中标记的 error 和 warning 上,讨论质量提升非常明显。以前经常出现"reviewer 和作者在缩进风格上争论十轮"的情况,现在这类问题根本不会出现在人肉 review 环节。
还有一个有意思的现象:开发者提 PR 之前,会先在本地跑一遍工具,把自己代码里的低级问题修掉,再提交。相当于是把一部分 review 的工作前置到了开发阶段。我一开始完全没想到会出现这种用法,但它恰好印证了一个道理——开发者不是不想修问题,而是不知道自己有哪些问题。工具把问题提前暴露出来,开发者自然会修。
当然,这个工具也带来了一些争论。有同事觉得 machine review 的报告有时候像"开卷考试",开发者在提交前把所有 error 清零,过程变得有点像为了通过检查而写代码。我的回应是:如果工具能保证检查项覆盖常见风险,那么"为了通过检查而写代码"并不是坏事,至少比把风险留给线上事故强。真正需要警惕的是,团队不能把工具当成代码质量的唯一来源,重要模块该做的人肉 review 还是得做。
6. 如果你也想做类似的工具,我的建议
6.1 先解决自己的问题,再做通用化
很多人问我要不要做一个开源工具,我都会先问一句:你自己有没有被这个问题折磨超过半年?如果没有,你大概率会在开发到一半的时候失去动力。
open-code-review 之所以能坚持下来,是因为它从一开始就是我自己工作流的刚需。我被"reviewer 忘记检查错误处理"这类问题坑过很多次,被"搜索 API 变更没有通知所有调用方"这类问题坑过很多次。工具解决的每一类问题,都是我自己或者身边同事真实踩过的坑。这些场景打磨出来的功能,放出去才不会显得空。
如果你决定做类似的项目,建议按这个顺序推进:先写成脚本,在你自己仓库里跑一个月;确认脚本化验逻辑没有大问题后,再把脚本重构成正式项目;最后才是设计配置格式、编写文档、开放贡献模板。一上来就想着做一个平台,大概率会陷入"功能越做越多、却没人用"的困境。
6.2 规则引擎是最值得投入的部分
如果只允许我给一个建议,那就是:把最多的精力投入到规则引擎和规则生态上,而不是外围功能。
外围功能,比如报告界面、在线编辑器、Webhook 集成,确实很吸引眼球,但它们不是核心价值。核心价值是"能不能准确、高效地发现代码里的问题"。如果你的规则引擎不能精准匹配目标问题,报告做得再华丽也没用。
规则引擎的设计上,我认为最值得花时间的是"规则与代码匹配的能力"。这包括两部分:一部分是语法结构匹配能力,另一部分是语义判断能力,比如是否跨越多个文件、是否关联到特定配置项。语法结构匹配借助 Tree-sitter 这类解析器可以做得很好;语义判断目前没有银弹,只能一层层往上叠,先从简单的数据流分析做起,再逐步补充函数调用链、变量传播之类的分析能力。
规则生态也很关键。开源工具的生命力往往不在核心代码,而在围绕它生长的规则集。我开放了规则仓库的提交通道,任何人都可以提交自己编写的规则文件。为了让这些规则有品质保证,我设置了资源路径审查,每个规则文件必须有对应的示例和预期结果,凡是提供不出用例的规则一律不合并。
6.3 后续扩展方向
open-code-review 现在能覆盖的核心能力,是代码结构、文本模式、基础指标这三类检查。这离我理想中的"智能代码评审助手"还有明显距离,后面有几个方向值得深入。
第一个方向是数据流分析。比如检查"用户输入是否经过校验就进入了危险函数",这种检查需要跨函数、跨文件的追踪能力。当前版本的 Tree-sitter 语法树只能提供静态结构,要实现真正的数据流分析,恐怕要引入更重的分析框架。
第二个方向是语义化的变更影响分析。什么意思呢?如果一个 PR 修改了某个函数的签名,能不能自动列出所有调用点,并逐个评估调用点是否需要同步修改?这个功能做出来以后,reviewer 就不用自己去全局搜索调用点了,工具直接告诉他"这有 15 个调用点需要关注"。
第三个方向是历史知识沉淀。如果工具能记住过去几个月的 review 记录,那么当一个新的 PR 触发了类似模式时,就可以提示"这个模式之前在本仓库引发过一次线上事故,详情见 issue #1234"。这种能力会大大提升工具的说服力,但涉及问题跟踪系统数据接入,以及隐私和安全边界约束,实现起来需要格外谨慎。
这三个方向我目前都在探索中,前两个已经有初步的代码原型,第三个还停留在设计讨论阶段。开源项目最大的魅力就在于此,它不是一个静态交付物,而是一个可以持续演进的东西。你要是有兴趣参与,欢迎来看代码,或者带着自己的规则集来直接做贡献。工具本身永远不完美,但大家一起迭代,总会越来越接近脑子里那个理想形态。