1. 从零搭建代码评审机器人的真实动机
代码评审这件事,做过团队协作的人都有体会。一个人写完功能,提交合并请求,然后眼巴巴等着同事来审。同事手头正忙,可能拖到第二天才看,看完提几条意见,改完再等一轮。一个不大的改动,来回折腾两三天是常事。更麻烦的是,有些问题其实很机械——变量命名不规范、函数太长、缺少边界判断、日志打得不合适——这些完全可以用工具自动查出来,却偏偏要占用人的时间。
我最初做这个项目的出发点很简单:把那些“人一眼能看出来、但每次都要重复说”的问题交给机器,让人专注于真正需要判断力的部分,比如架构设计是否合理、业务逻辑是否有漏洞、异常处理是否周全。这个想法并不新鲜,市面上也有不少静态分析工具,但实际用下来总有些不如意的地方:要么规则太死板,要么集成成本高,要么对开发者不够友好,报一堆问题但说不清楚为什么。
于是就有了这个名为 open-code-review 的项目。它的定位是一个轻量级、可扩展、对开发者友好的代码评审辅助工具。核心目标有三个:第一,能在代码提交阶段自动跑起来,不需要额外操作;第二,规则可配置、可扩展,团队可以根据自己的规范定制;第三,输出结果要清晰,告诉开发者“哪里有问题、为什么有问题、怎么改”,而不是甩一堆错误码。
这篇文章适合几类人看:如果你是小团队的技术负责人,正愁没有精力做代码评审;如果你是刚接触 CI/CD 的开发者,想了解怎么把自动化检查串进流水线;如果你对静态分析、AST 解析这些技术感兴趣,想自己动手做一个类似的东西——那接下来的内容应该对你有用。我会从整体设计讲到具体实现,把踩过的坑和绕过的弯路都摊开来说。
2. 整体架构与核心模块拆解
2.1 为什么选择“规则引擎 + 插件”的分层设计
一开始我图省事,把所有检查逻辑写在一个大文件里,用一堆 if-else 堆起来。结果不到两周就撑不住了:加一条新规则要改主流程,改完还得回归测试所有旧规则,生怕碰坏什么。更头疼的是,不同项目对规则的要求不一样,有的团队要求函数不超过 50 行,有的要求不超过 80 行,硬编码根本没法满足。
后来改成两层结构:底层是通用的解析和遍历能力,上层是独立的规则插件。每个规则是一个单独的模块,实现统一的接口,注册到规则引擎里。引擎负责读取配置、加载规则、执行检查、汇总结果。规则之间互不干扰,加新规则只需要新增一个文件,不用动引擎代码。
这个设计还有一个好处:规则可以按需启用。比如一个纯前端的项目,就不需要加载后端相关的检查规则;一个内部工具项目,可能对安全扫描的要求没那么高,可以关掉相关规则。配置驱动让工具变得灵活,而不是一刀切。
2.2 代码解析层:从源码到抽象语法树
要做静态检查,第一步是把源代码变成程序能理解的结构。最直接的方式是用正则表达式匹配文本,但这种方法非常脆弱——字符串里出现的关键字、注释里的代码片段,都会造成误报。我试过用正则查“未使用的变量”,结果把注释里提到的变量名也报出来了,开发者一看就觉得这工具不靠谱。
所以最终选择了基于抽象语法树(AST)的方案。简单说,就是把代码解析成一棵树,每个节点代表一个语法结构,比如函数声明、变量赋值、条件分支。遍历这棵树,就能准确地知道每个标识符出现在什么位置、扮演什么角色。
不同语言有不同的解析器。对于 JavaScript 和 TypeScript,可以用社区成熟的解析库;对于 Python,标准库里的 ast 模块就够用;对于 Go,官方工具链提供了完整的解析能力。我在项目里做了一个抽象层,把不同语言的解析结果统一成一套节点接口,这样上层的规则就不需要关心底层是什么语言,只需要按接口遍历就行。
这里有个细节值得注意:解析失败怎么办?有些代码可能语法不完整,或者用了某些实验性特性,解析器直接报错。我的处理策略是跳过无法解析的文件,记录一条警告,而不是让整个检查流程崩掉。毕竟代码评审工具是辅助,不能因为一个文件解析不了就阻塞整个提交流程。
2.3 规则引擎的执行流程与优先级管理
规则引擎的核心逻辑其实不复杂:读取配置,确定要跑哪些规则,然后对每个文件依次执行。但实际做起来有几个坑。
第一个坑是执行顺序。有些规则之间有依赖关系,比如“检测未使用变量”需要在“检测变量声明”之后跑,否则拿不到完整的声明信息。我的做法是给规则定义一个优先级字段,引擎按优先级排序后依次执行。高优先级的规则先跑,把基础信息准备好,低优先级的规则再基于这些信息做进一步分析。
第二个坑是性能。一个中等规模的项目可能有几千个文件,如果每个规则都独立遍历一遍 AST,开销会很大。优化方案是把多个规则合并到一次遍历中执行——遍历到某个节点时,把所有关心这个节点的规则都调用一遍。这样 AST 只需要遍历一次,速度提升非常明显。实测下来,一个包含约 2000 个文件的项目,从最初的 40 多秒降到了 8 秒左右。
第三个坑是错误隔离。某条规则内部抛异常了,不能影响其他规则。每条规则的执行都包在 try-catch 里,出错就记录日志并跳过,继续跑下一条。这样即使某条规则有 bug,也不会导致整个检查失败。
2.4 结果输出与集成方式的选择
检查结果最终要呈现给开发者,呈现方式直接影响使用体验。我见过一些工具,输出一大坨 JSON,开发者根本不想看。所以 open-code-review 默认输出人类可读的格式:按文件分组,每个问题标注行号、严重程度、问题描述和修改建议。
严重程度分三档:错误(必须改)、警告(建议改)、提示(仅供参考)。这样开发者可以优先处理错误级别的问题,不会被大量提示淹没。输出格式也支持切换,比如集成到 CI 流水线时可以用 JSON 格式,方便后续工具解析。
集成方式上,我提供了三种:命令行直接调用、Git 钩子自动触发、CI 流水线步骤。命令行适合本地开发时手动跑;Git 钩子可以在提交前自动检查,有问题直接拦截;CI 流水线则适合团队统一管控,确保合并到主分支的代码都经过检查。三种方式共用同一套配置和规则,行为一致。
3. 核心检查规则的实现细节与取舍
3.1 命名规范检查:从硬编码到可配置模式
命名规范是最基础也最常用的检查。最初我硬编码了一套规则:变量用小驼峰、常量用全大写下划线分隔、类名用大驼峰。结果推到团队里,前端组说他们的常量也用大驼峰,后端组说他们的私有变量习惯加下划线前缀。硬编码根本行不通。
改成配置驱动后,每个项目可以在配置文件里定义自己的命名模式。配置项包括:标识符类型(变量、常量、函数、类、接口等)、匹配模式(正则表达式)、严重程度、例外情况。比如:
naming: rules: - type: variable pattern: "^[a-z][a-zA-Z0-9]*$" severity: warning message: "变量名应使用小驼峰命名" - type: constant pattern: "^[A-Z][A-Z0-9_]*$" severity: warning message: "常量名应使用全大写下划线分隔"实现上,遍历 AST 时遇到声明节点,提取标识符名称和类型,然后逐条匹配配置中的规则。这里有个细节:有些标识符是框架约定的,比如 React 组件的 props 里常见的onClick、className,这些不应该被报错。所以配置里支持例外列表,匹配到例外就直接跳过。
还有一个容易忽略的点:解构赋值。const { userName, userAge } = data;这种写法,userName和userAge是变量声明,但它们的命名应该遵循什么规则?我的处理是把它当作变量处理,但允许配置单独的解构命名规则。因为有些团队对解构出来的变量命名有特殊约定,比如必须加前缀区分来源。
3.2 复杂度检测:圈复杂度与认知复杂度的双轨制
圈复杂度是衡量代码复杂度的经典指标,计算方式是决策点数量加一。每个 if、else if、for、while、case、catch、逻辑与或非,都算一个决策点。圈复杂度超过 10 就建议拆分函数,超过 20 就强烈建议重构。
但实际用下来,圈复杂度有个问题:它把所有决策点一视同仁。一个嵌套三层的 if-else 和一个平铺的 switch-case,圈复杂度可能一样,但前者明显更难理解。所以后来我又引入了认知复杂度作为补充。认知复杂度对嵌套结构有额外惩罚——嵌套越深,加分越多;连续的逻辑运算符也会累加;但 switch-case 这种平铺结构加分较少。
两条规则同时跑,输出时分别标注。开发者可以根据两个指标综合判断:如果圈复杂度高但认知复杂度不高,说明逻辑分支多但结构清晰,可以考虑用策略模式或查表法优化;如果两个都高,那基本确定需要重构了。
实现圈复杂度时,遍历 AST 统计决策节点数量即可,比较简单。认知复杂度的计算稍微麻烦一些,需要维护一个嵌套深度计数器,进入嵌套结构时加一,离开时减一,每个决策点的得分是基础分加上嵌套深度。逻辑运算符的处理更细:同一表达式里连续的&&或||算一个决策点,但混合使用&&和||要分别计算,因为混合逻辑更难理解。
3.3 重复代码识别:基于语法树的相似度比对
重复代码是代码质量的大敌,但检测起来比命名和复杂度难得多。最简单的方案是文本比对,但稍微改改变量名就失效了。所以必须基于语法树做结构比对。
我的方案是:把每个函数或方法的 AST 序列化成一条路径字符串,忽略具体的标识符名称和字面量值,只保留结构信息。然后计算不同函数之间的相似度。相似度超过阈值(默认 80%)就报重复。
具体实现分三步。第一步,对每个函数节点做规范化遍历,生成一个结构指纹。比如if (x > 0) { return a; }和if (y > 0) { return b; }会被规范化为相同的结构序列。第二步,用编辑距离或最长公共子序列算法计算两两之间的相似度。第三步,对相似度高的函数对进行聚类,避免同一个重复模式报出几十条结果。
这里有个性能问题:如果项目里有几千个函数,两两比对就是几百万次计算,太慢了。优化方案是先用哈希做粗筛——把结构指纹哈希成固定长度的值,只有哈希相同或相近的才进入精细比对。这样大部分不相似的函数在第一轮就被过滤掉了,实际计算量大幅降低。
还有一个取舍:多长的代码块才值得检测重复?太短的代码块(比如三五行)即使重复也无所谓,强行抽取反而增加抽象成本。所以默认只检测超过 10 行的函数或代码块,这个阈值可以在配置里调整。
3.4 安全扫描规则:常见漏洞模式的静态识别
安全相关的检查是后来加的,因为发现很多团队在代码评审时容易忽略安全问题。静态扫描不能替代专业的安全测试,但可以拦住一些低级错误。
目前覆盖的规则包括:硬编码的敏感信息(密码、密钥、令牌)、SQL 拼接、命令注入风险、不安全的随机数生成、日志中打印敏感数据等。这些规则的实现方式各不相同。
硬编码敏感信息检测用的是正则加熵值判断。单纯匹配“password”关键字会误报很多,比如变量名里包含 password 但值是从环境变量读的。所以结合了熵值计算:字符串的随机性越高,越可能是真实的密钥。同时排除掉明显的占位符,比如“your_password_here”、“xxx”、“todo”等。
SQL 拼接检测需要分析字符串拼接操作。如果发现 SQL 关键字和变量在同一个拼接表达式中出现,就报警告。更准确的做法是追踪变量的来源,看它是否来自用户输入,但这需要数据流分析,实现复杂度高很多。目前只做了浅层检测,误报率控制在可接受范围内。
命令注入检测类似,关注的是执行系统命令的函数调用,检查参数是否包含未经过滤的变量。这里有个经验:不要试图做到零误报,那会导致零召回。宁可多报几条让开发者自己判断,也不要漏掉真正的风险。
4. 集成到开发流程中的实操步骤
4.1 本地开发环境的安装与配置
安装方式我提供了两种:全局安装和项目内安装。全局安装适合个人开发者,装一次到处能用;项目内安装适合团队,把工具作为开发依赖写进 package.json 或 requirements.txt,确保每个人用的版本一致。
以 Node.js 项目为例,项目内安装的命令是:
npm install --save-dev open-code-review然后在项目根目录创建配置文件.codereview.yml。最小配置只需要指定要检查的目录和启用的规则集:
source: include: - "src/**/*.js" - "src/**/*.ts" exclude: - "**/*.test.js" - "**/node_modules/**" rules: naming: true complexity: true duplication: true security: true complexity: cyclomatic: warning: 10 error: 20 cognitive: warning: 15 error: 30配置文件支持继承,团队可以定义一个基础配置放在共享仓库里,各项目通过 extends 字段引用,再覆盖自己需要的部分。这样规范统一,又保留灵活性。
4.2 Git 钩子的接入与提交拦截策略
Git 钩子是最自然的集成点。在提交前自动跑检查,有问题直接拦截,避免脏代码进入仓库。我用的是 husky 加 lint-staged 的组合,只检查本次提交涉及的文件,速度很快。
配置方式是在 package.json 里加:
{ "husky": { "hooks": { "pre-commit": "lint-staged" } }, "lint-staged": { "*.{js,ts}": ["open-code-review --staged"] } }--staged参数让工具只检查暂存区里的文件,而不是整个项目。实测下来,即使项目有几千个文件,每次提交的检查时间通常在一两秒内,开发者几乎无感。
拦截策略上有个取舍:错误级别的问题直接阻止提交,警告和提示只展示不拦截。这样既保证了底线质量,又不会因为一些风格问题频繁打断开发节奏。如果开发者确实需要紧急提交,可以用--no-verify跳过钩子,但 CI 流水线里会再检查一遍,确保不会漏网。
4.3 CI 流水线中的检查步骤与结果展示
CI 流水线是最后一道防线。我在 GitHub Actions、GitLab CI、Jenkins 上都配过,思路一致:在代码合并前跑一次全量检查,结果作为合并请求的评论展示。
以 GitHub Actions 为例,工作流文件大概长这样:
name: Code Review on: [pull_request] jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: '18' - run: npm ci - run: npx open-code-review --format json --output review-result.json - uses: actions/upload-artifact@v3 with: name: review-result path: review-result.json跑完后,可以用一个额外的步骤把结果解析出来,以评论形式贴到合并请求上。这样评审人打开合并请求就能看到自动检查的结果,不用自己去翻 CI 日志。
这里有个经验:CI 里的检查结果要和本地保持一致。如果本地跑没问题、CI 跑出一堆问题,开发者会很困惑。所以配置文件的路径、规则的版本、解析器的版本都要锁定,确保环境一致。
4.4 团队协作中的规则协商与渐进式推行
工具再好,推不下去也是白搭。我经历过一次失败的推行:一开始就把所有规则开到最严,结果第一天就报了几百个问题,开发者直接放弃,把工具从流水线里删了。
后来调整了策略,分三步走。第一步,只开最基础的命名和复杂度检查,严重程度设为提示,不拦截任何提交。让团队先看到工具的存在,但不产生压力。第二步,收集反馈,把误报多的规则关掉或调整阈值,把大家认可的问题升级为警告。第三步,等团队习惯了工具的存在,再把关键规则升级为错误,开始拦截提交。
这个过程大概花了一个月,但效果很好。开发者逐渐认可了工具的价值,甚至会主动要求加新规则。规则协商也很重要——不是技术负责人一个人说了算,而是团队一起讨论哪些规则合理、阈值定多少合适。参与感带来了认同感。
5. 实际使用中踩过的坑与应对方案
5.1 误报率过高导致开发者失去信任
项目刚上线时,误报是最头疼的问题。命名规则把框架约定的属性名报了一堆,复杂度检测把自动生成的代码报了一堆,重复代码检测把相似的模板代码报了一堆。开发者看到满屏的红色警告,第一反应是“这工具不靠谱”,然后就不看了。
解决误报没有捷径,只能一条条分析、一条条修。我当时的做法是:收集一周内的所有误报,分类整理,找出共性问题。发现主要有三类:框架约定、生成代码、测试代码。针对框架约定,加了例外列表;针对生成代码,加了目录排除;针对测试代码,默认降低检查严格度。
还有一个技巧是提供“忽略”机制。开发者可以在代码里加一行注释,比如// codereview-ignore-next-line,让工具跳过下一行的检查。这给了开发者一个逃生通道,遇到确实不需要检查的情况可以自行处理,而不是被迫改代码或关掉整个规则。
5.2 大项目下的性能瓶颈与增量检查
项目规模上去之后,全量检查的时间越来越长。一个包含上万文件的项目,全量跑一次要几分钟,放在 CI 里还能接受,放在 Git 钩子里就太慢了。
增量检查是必须的。核心思路是只检查有变更的文件,以及依赖这些文件的文件。实现上,先用 Git 命令拿到本次变更的文件列表,然后根据模块依赖关系图,找出受影响的文件集合,只对这个集合做检查。依赖关系图可以基于 import/require 语句构建,缓存起来,每次变更时增量更新。
另一个优化是结果缓存。如果某个文件的哈希值没变,上次的检查结果可以直接复用,不需要重新解析和检查。缓存存在本地目录里,CI 环境下可以用持久化缓存。这两个优化加起来,大项目的检查时间从几分钟降到了十几秒。
5.3 多语言支持带来的解析器兼容问题
项目最初只支持 JavaScript,后来要加 Python 和 Go,问题就来了。不同语言的 AST 结构差异很大,统一接口的设计面临挑战。比如 JavaScript 的函数声明和 Python 的 def 语句,节点类型和属性名完全不同。
我的解决方案是加一层适配器。每种语言有一个适配器模块,负责把该语言的 AST 转换成统一的中间表示。规则引擎只操作中间表示,不直接接触原始 AST。适配器的工作量不小,但一次投入长期受益——加新语言只需要写一个适配器,规则可以复用。
还有一个坑是解析器的版本兼容。不同版本的解析器可能产生不同的 AST 结构,导致规则行为不一致。所以项目里锁定了解析器的版本,并且在 CI 里跑多版本测试,确保升级解析器时不会引入意外问题。
5.4 规则冲突与优先级仲裁
规则多了之后,冲突不可避免。比如一条规则说“函数不超过 50 行”,另一条规则说“函数应该包含完整的错误处理”,一个函数加了错误处理就超过 50 行了,两条规则打架。
这种冲突没有完美的自动解决方案,只能靠配置来仲裁。我给规则加了优先级和互斥标记。高优先级的规则先执行,如果某个问题已经被高优先级规则报过,低优先级规则可以选择跳过。互斥标记则用于明确冲突的规则对,配置里指定哪条规则优先。
更常见的做法是调整阈值。比如把函数长度限制从 50 行放宽到 80 行,给错误处理留出空间。阈值不是拍脑袋定的,而是根据项目实际情况统计出来的——分析现有代码的函数长度分布,取一个合理的分位数作为阈值,既不会太松也不会太严。
6. 规则扩展与二次开发的接口说明
6.1 自定义规则的注册与生命周期
内置规则不可能覆盖所有场景,所以开放了自定义规则接口。一个自定义规则就是一个模块,导出一个对象,包含元信息和检查函数。元信息包括规则名称、描述、严重程度、适用的语言;检查函数接收 AST 节点和上下文,返回问题列表或空数组。
注册方式有两种:配置文件里指定规则文件路径,或者把规则发布成包,通过包名引用。前者适合项目内部使用,后者适合跨项目共享。
规则的生命周期由引擎管理:加载时调用初始化钩子,检查前调用前置钩子,检查后调用后置钩子,卸载时调用清理钩子。大部分规则只需要实现检查函数,其他钩子是可选的。生命周期钩子的存在是为了支持一些高级场景,比如规则需要维护跨文件的状态,就可以在初始化时创建缓存,在检查时读写,在清理时释放。
6.2 规则测试框架与回归验证
自定义规则也需要测试,否则改着改着就出问题了。我提供了一个轻量的测试框架,用声明式的方式定义测试用例:输入一段代码,期望输出若干问题。框架会自动跑规则、比对结果、生成报告。
测试用例的写法很直观:
ruleTest({ rule: myCustomRule, cases: [ { name: "应该检测到未使用的变量", code: "const x = 1;", expect: [{ line: 1, message: "变量 x 未被使用" }] }, { name: "不应该报错已使用的变量", code: "const x = 1; console.log(x);", expect: [] } ] });回归验证是 CI 里的一个独立步骤。每次修改规则或引擎代码,都会跑一遍全量测试用例,确保没有破坏已有功能。测试覆盖率不追求 100%,但核心路径和边界情况必须覆盖。
6.3 与主流编辑器的联动思路
命令行和 CI 是主要使用方式,但编辑器内的实时提示能进一步提升体验。我做了 VS Code 扩展的原型,思路是把检查引擎跑在语言服务器里,编辑器打开文件时自动触发检查,问题以波浪线形式标注在代码上。
实现上,语言服务器监听文件的打开和修改事件,增量地把变更内容送给检查引擎,拿到结果后通过 LSP 协议推送给编辑器。为了不影响编辑流畅度,检查做了防抖处理——停止输入 500 毫秒后才触发检查。同时只检查当前打开的文件,不扫描整个项目。
这个扩展目前还在完善中,主要问题是启动时的初始化时间较长,因为要加载解析器和规则。优化方向是把解析器做成懒加载,只有真正需要检查某个文件时才加载对应语言的解析器。
7. 关于代码评审自动化的一些个人体会
做这个项目一年多,最大的感受是:工具能解决效率问题,但解决不了意愿问题。如果团队本身不重视代码质量,再好的工具也只是摆设。反过来,如果团队有质量意识,工具能帮他们省下大量重复劳动的时间,把精力集中在真正需要人判断的地方。
另一个体会是,规则不是越多越好。我见过一些团队把规则开到上百条,结果开发者每次提交都要花大量时间处理各种提示,反而降低了效率。真正有效的做法是精选一二十条核心规则,覆盖命名、复杂度、重复、安全这几个关键维度,其余的都设为提示级别,让开发者自行判断。
还有一点关于误报的体会:宁可漏报,不可误报。漏报只是少发现一个问题,误报却会消耗开发者的信任。信任一旦失去,再想挽回就很难了。所以每次加新规则,我都会先在小范围试跑,统计误报率,低于可接受水平才正式启用。
最后分享一个实用技巧:把检查结果和代码评审意见结合起来。自动检查报出的问题,可以在合并请求里自动生成评论,评审人只需要关注那些工具查不出来的问题。这样评审人的负担减轻了,评审质量反而提高了。我在几个项目里试过这个做法,评审时间平均缩短了三分之一左右。