open-code-review:用声明式规则引擎和 Tree-sitter 实现高效代码审查 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-review4.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/checkoutv4 with: fetch-depth: 0 - name: Run open-code-review uses: open-code-review/actionv1 with: config: .open-code-review.yaml token: ${{ secrets.GITHUB_TOKEN }} - name: Upload report uses: actions/upload-artifactv4 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-credentialwarning 级别function-complexity、deep-nesting、missing-error-contextinfo 级别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-reviewreviewer 打开 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。这种能力会大大提升工具的说服力但涉及问题跟踪系统数据接入以及隐私和安全边界约束实现起来需要格外谨慎。这三个方向我目前都在探索中前两个已经有初步的代码原型第三个还停留在设计讨论阶段。开源项目最大的魅力就在于此它不是一个静态交付物而是一个可以持续演进的东西。你要是有兴趣参与欢迎来看代码或者带着自己的规则集来直接做贡献。工具本身永远不完美但大家一起迭代总会越来越接近脑子里那个理想形态。