首页 / 资讯中心 / 文章详情

自建轻量代码审查工具:基于语法树与增量审查的工程实践

自建轻量代码审查工具:基于语法树与增量审查的工程实践 ★ FEATURED ARTICLE
1. 为什么我最终选择自建代码审查工具团队痛点与方案权衡先说说我所在的团队背景。我们是一个十人左右的后端小组主要语言是Python和TypeScript代码量不算夸张但仓库多、迭代快几乎每天都有十几个PR在流转。代码审查这件事说重要是真重要说痛苦也是真痛苦。最典型的场景是核心模块的PR挂着三天没人review等凑齐了两个approve分支早跟主干差了几十个commit而一些低风险的文件改动反而因为reviewer刚好在线两分钟就被合进去了。审查质量完全取决于运气和当天的心情。另一个痛点是风格类问题反复在review里出现——有人习惯用dict.get有人偏要用try/except有人会在调试完忘了删breakpoint()这些本可以自动检查的问题白白消耗了维护者的注意力。这个阶段我调研过几类现成方案。一类是SonarQube这类重量级平台功能确实全但部署和维护成本摆在那里小团队用起来有杀鸡用牛刀的感觉而且规则库默认面向全语言通用场景和团队自身的代码习惯贴合度不够。另一类是ReviewDog配合各种linter思路很好但它本质上还是在跑现成工具链对业务语义层面的检查支持有限。CodeQL的思路我很认可——把代码当成数据来查询但它的学习曲线和查询语言门槛对普通团队成员来说真的不太友好。后来我决定自己动手做一个轻量工具也就是这个open-code-review。目标很明确不追求取代人类reviewer而是把审查流程里那些机械、重复、靠人肉记忆的部分全部自动化掉让人的注意力集中在真正需要判断力的地方。这个工具不需要装服务端、不需要维护数据库一个命令行就能跑完一轮审查也可以直接嵌进CI流水线里。2. 整体架构设计规则引擎、增量审查与三轮流水线open-code-review的核心架构设计其实经历了三轮迭代才稳定下来。第一版我用了纯正则做检查简单直接但很快发现误报率太高——比如检查console.log残留字符串里的日志文案会被误判模板字符串里的嵌套写法也常漏检。第二版引入了AST解析准确率上来了但整库解析在大型仓库上性能撑不住一次全量检查要跑近两分钟体验很差。最后我确立了现在这个架构基于语法树的精准匹配 增量文件扫描 分级规则引擎。整个处理流水线可以拆成几个模块扫描层接收文件路径或目录按扩展名过滤出可识别语言的文件同时在Git场景下自动获取diff信息只审查变更部分。解析层调用tree-sitter解析源码为语法树这一步是精准检查的基础。相比正则语法树能区分出“代码里出现字符串eval”和“实际调用了eval函数”的本质区别。检查层由规则引擎驱动每条规则是一个独立插件接收语法树节点和上下文输出问题列表。报告层汇总问题按严重级别和文件维度聚合输出Markdown或JSONCI模式下直接驱动退出码。增量审查是个很关键的决策。早期版本每次都是全量检查在大仓库上不仅慢而且会把历史存量问题全部翻出来噪音极大。后来改成基于git diff --name-only --diff-filterAM只取新增和修改的文件顺手也解决了“存量问题淹没新增问题”的难题——新代码引入的新问题会被单拎出来重点标红。这里有个设计细节值得展开说。规则引擎内部把检查项分成三个轮次执行格式与残留类第一轮检查调试残留、TODO标记、超出配置长度的行等。这类检查不依赖跨文件信息单节点遍历即可速度最快。代码质量类第二轮检查空值未处理、危险函数调用、过度复杂的条件表达式等。需要局部上下文分析开销中等。业务语义类第三轮这是需要跨文件或跨函数分析的检查比如某个函数是否缺少对应的事务包裹、某个API调用链上是否有缺失的参数校验。这类规则执行最慢默认只针对diff触达的文件开启。三轮的设计让工具的响应时间始终可控。对于一次中等规模的PR约20个文件变更全流程执行时间基本保持在3-5秒完全可以在开发者本地和CI里无感运行。3. 快速上手从零到全量审查报告说再多都不如直接跑一遍。我建议先在一个小规模项目上试感受一下工具的自然输出再决定怎么给团队定制规则。3.1 环境准备与安装工具用Python实现Python版本要求3.10及以上内部大量使用dataclass和match语法。安装就一条命令pip install open-code-review装完先看下版本确认安装成功open-code-review --version如果项目里有多个Python版本建议用pipx装避免污染全局环境pipx install open-code-review3.2 初始化配置文件在项目根目录执行open-code-review init这个命令会在当前目录下生成一个.ocr.toml配置文件我选TOML而不是YAML是因为Python端TOML标准库原生支持少一层依赖解析也更严格。生成出来的默认配置长这样[general] # 支持的语言默认从文件扩展名自动识别 languages [python, typescript] # 检查报告输出目录 output_dir ocr-reports [thresholds] # 问题总数超过该值CI退出码返回1 max_issues_total 30 # 严重级别为error的问题数超过该值CI退出码返回1 max_errors 5 [extensions] python [.py] typescript [.ts, .tsx] [ignore] # 支持glob模式匹配 paths [third_party/**, generated/**, **/*.min.js, test/fixtures/**]init之后可以先不急着改配置跑一次全量检查看效果open-code-review scan . --format markdown --output ocr-report.md对于中小型仓库这个命令会在十几秒内完成一次全量扫描并把结果写到指定文件里。3.3 读取报告的三个层次刚接触这个工具的人往往会犯一个误区——试图把报告里的每一条问题都清零。实际上一个正常仓库跑完第一遍就是会有几十上百条各色提醒这很正常。我建议分三个层次去读报告Error级问题必须处理通常是空指针解引用、危险函数调用、明显的数据竞争隐患。这类问题解释成本低、修复收益高应该立刻纳入迭代计划。Warning级问题按需处理如未使用的变量、可简化的条件分支、过高的复杂度。扫描一遍挑出与本次需求直接相关的部分处理即可。Info级提示供参考如风格偏好、命名建议。这类问题多数是团队规范自动化的副产品看看就好不值得专门开单去修。在后续规则配置部分我会详细说明怎么根据实际情况把这些分级标准和团队预期对齐。4. 规则配置详解从内置规则到自定义检查规则引擎是open-code-review的灵魂。内置了大约40条规则按语言和关注维度做了分类。这里我把最常被使用的几条和背后的设计逻辑拆开讲一讲。4.1 高频内置规则的判定逻辑拿“空值未处理”这条规则举例。它在AST上做的是这么一件事找到每个函数调用点检查参数量级和类型是否与函数签名匹配再判断是否存在可空的函数返回值被直接解引用。用tree-sitter拿到解析树之后Python的foo.bar()调用会被解析成attribute节点然后追踪这个attribute的源头是不是一个可能返回None的函数调用如果是就会发出error级别提醒。再比如“调试残留”检查在Python里匹配breakpoint、pdb.set_trace、print等节点调用在TypeScript里匹配console.log、debugger语句。这条规则精确到AST节点级别所以字符串中的console.log不会被误判只有实际的方法调用才会被捕获。“危险函数”检查覆盖了eval、exec、subprocess带shellTrue等常见高风险调用并附加了参数溯源逻辑如果shellTrue是通过变量传入的会做一次简化的流分析追溯变量的赋值来源判断是否可控。这种场景下规则不会绝对执行而是降级为warning避免误伤合法用法。下面这个表列出的是我团队里最常用的几条规则和一些典型误报场景规则名称默认级别典型命中场景常见误报场景debug-residualerror遗留的debugger或breakpoint字符串或日志里的同类文本已通过AST规避null-safetyerror可能为None的返回值直接取属性被上层守卫条件保护但仍被静态命中的代码dangerous-evalerror直接调用eval/exec白名单框架内的动态代码加载需手动忽略complexity-gatewarning单个函数圈复杂度超过阈值自动生成的声明代码已在默认忽略列表hardcoded-secretwarning代码中出现疑似密钥的字符串测试用的假密钥可通过正则白名单排除duplicate-branchinfoswitch/if-elif结构中出现重复分支体某些意图性重复但仍需保留的分支4.2 自定义规则的接口设计内置规则覆盖了通用场景但真正让工具在团队里“生根”的是自定义规则。曾有一个服务端团队遇到一个高频问题某个内部RPC框架要求所有对外暴露的方法都必须包裹在事务切面里但新同学经常漏写。这种规则用内置能力没法覆盖但通过插件机制写起来非常干脆。自定义规则本质上是一个Python类继承自BaseRule实现check方法from ocr.api import BaseRule, RuleContext, Issue, Severity class TransactionRequiredRule(BaseRule): name transaction-required description 对外RPC方法必须使用事务包裹 severity Severity.ERROR target_languages [python] def check(self, ctx: RuleContext) - list[Issue]: issues [] tree ctx.syntax_tree # 遍历所有函数定义节点 for node in tree.query((function_definition name: (identifier) fn)): fn_name_node node.child_by_field_name(name) fn_name fn_name_node.text.decode(utf-8) # 只检查RPC暴露方法约定以handle_开头 if not fn_name.startswith(handle_): continue fn_source node.text.decode(utf-8) # 检查函数体是否包含事务装饰器 if transactional not in fn_source: issues.append(Issue( severityself.severity, messagefRPC方法 {fn_name} 缺少 transactional 事务装饰器, file_pathctx.file_path, linefn_name_node.start_point[0] 1, columnfn_name_node.start_point[1] 1, )) return issues写完之后把文件放到.ocr/rules/目录下重启扫描工具规则自动被加载。这个示例在实现上刻意用了字符串搜索验证装饰器存在实际项目中更优雅的做法是解析装饰器列表节点再判断但对于大多数场景字符串级别的判断已经足够精准且易于维护。5. 二次开发添加一条新规则的全过程很多团队拿到工具后第一反应是“我要加一条新规则”然后卡在不知道去哪写、怎么写、怎么验证。这里我用一个真实落地的例子完整演示一条新规则的诞生流程。5.1 需求背景我们团队最近在治理Python代码里直接使用logging模块的问题。集团要求所有日志必须通过统一的日志中间件输出这样才方便接入日志采集平台做结构化分析。但代码里时不时还会冒出logging.info(...)这种裸调用。人工review时很难盯住每个文件所以想把它做成自动化检查。5.2 规则代码实现基于上面的需求规则逻辑其实很直接找到所有名叫logging的import语句然后检查代码中出现的logging.xxx调用是否还被其他逻辑包装。由于我们团队约定所有日志都要走logger get_logger(__name__)这个工厂函数所以规则的核心是从AST中识别logging模块直接调用而不是通过logger变量调用。from ocr.api import BaseRule, RuleContext, Issue, Severity class UnifiedLoggerRule(BaseRule): 禁止直接使用logging模块统一通过日志中间件记录 name unified-logger description 日志输出必须使用统一logger禁止直接调用logging模块 severity Severity.WARNING target_languages [python] def check(self, ctx: RuleContext) - list[Issue]: issues [] tree ctx.syntax_tree # 1. 先确认文件里是否import了logging模块 import_nodes tree.query((import_statement) imp) has_logging_import False for node in import_nodes: if logging in node.text.decode(utf-8): has_logging_import True break if not has_logging_import: return [] # 2. 找出所有通过logging.xxx调用的位置 call_nodes tree.query((attribute) attr) for attr_node in call_nodes: attr_text attr_node.text.decode(utf-8) if not attr_text.startswith(logging.): continue line attr_node.start_point[0] 1 issues.append(Issue( severityself.severity, messagef[{attr_text}] 应替换为 logger get_logger(__name__) 后调用, file_pathctx.file_path, lineline, )) return issues这里面的查询语法是tree-sitter的S-expression。关于这个语法有个小窍门如果不知道目标语言某类节点长什么样可以在项目里加一句代码断点然后用Python直接打印解析树结构比对着文档猜要快得多。5.3 规则的本地验证规则写完千万别直接提交到全局规则库。先把规则文件放在一个临时目录里用一个最小复现项目验证行为是否正确open-code-review scan . --rules custom_rule.py --format json构造的测试样例要覆盖三种情况正常的logger调用、裸的logging调用、仅被import但未调用的场景。确认裸调用被准确标记、其余两种静默通过再把规则加入团队共享规则库。5.4 规则上线后的灰度控制这里分享一个实践经验新规则上线时别直接把它设为error级别并全量禁用旧代码很容易在CI里炸出一堆历史问题造成“狼来了”效应。推荐的做法是先在Info级别跑两周把仓库里所有历史命中数量、分布摸清楚再召开一次review对齐会确认哪些是历史债务需要分期清理、哪些是规则本身要放宽条件。等存量问题清理得差不多了再提到Warning或Error级别。这个灰度思路对几乎所有静态检查工具都适用。6. 接入CI的最佳实践GitHub Actions与误报控制工具在本地跑得再欢如果没法在CI里把关价值就打了对折。目前团队使用GitHub Actions直接在workflow里拉取工具然后执行扫描即可无需额外部署服务。6.1 最小化CI配置在仓库的.github/workflows/code-review.yml中添加如下配置name: code-review on: pull_request: types: [opened, synchronize] jobs: ocr-scan: runs-on: ubuntu-latest permissions: contents: read pull-requests: write steps: - uses: actions/checkoutv4 with: fetch-depth: 0 - uses: actions/setup-pythonv5 with: python-version: 3.11 - name: Install open-code-review run: pip install open-code-review - name: Run OCR scan run: | open-code-review scan . \ --format markdown \ --output ocr-report.md \ --ci-mode - name: Upload report artifact uses: actions/upload-artifactv4 with: name: ocr-report path: ocr-report.md这个配置里有两个细节需要留意。第一fetch-depth: 0必须写上。增量审查模式依赖完整的git历史来计算diff默认的shallow clone拿不到目标分支和合并基点的差异会导致扫描退化为全量检查既慢又噪音大。第二--ci-mode这个参数会让工具在发现问题达到阈值时以非零退出码结束从而让CI流程失败。如果不想让一堆历史存量问题立刻阻塞开发流程需要先在配置文件的[thresholds]里把max_issues_total设大一点或者配合下一节的基线机制使用。6.2 用评论机器人代替“一言不合就挂红叉”把CI直接卡死是最省事的做法但对团队协作体验并不友好。如果PR一上来就被机器人标红开发者会产生条件反射式的忽略心理严重时连真正需要处理的问题也被带过。更温和的方案是用GitHub Actions的评论功能让工具以评论形式汇报扫描结果不强制阻塞合入。具体做法是去掉--ci-mode并把报告内容通过API写入PR评论。工具支持直接输出GitHub Markdown格式的评论摘要包含按文件聚合的问题清单和严重级别分布。团队运营一段时间之后可以根据问题被修复的比例来决定是否上调某几个规则的级别把这个“软提示”逐渐过渡为“硬约束”。这个渐进策略比一刀切的强阻塞在工程文化上更可持续。6.3 基线机制让历史问题是历史问题接CI的时候最大的阻力通常来自存量问题。仓库里可能有一万个历史warning如果全部暴露在CI结果里PR作者会发现屏幕上全是与自己无关的噪音工具很快就被无视了。解决方案是引入基线机制。工具提供baseline命令在接入CI之前先跑一遍全量扫描把当前所有问题快照成基线文件open-code-review baseline --output .ocr-baseline.json之后每次CI增量扫描时工具会自动加载基线文件只报告“新增”的问题。基线文件建议纳入版本控制并且以季度为单位滚动更新——每个季度由专人全量跑一次评估哪些存量问题已自然清退并及时把基线刷新。这个机制极大缓解了“历史债务”对工具落地的阻碍也让团队可以放心地把阈值收紧到更合理的范围。7. 踩坑笔记那些文档没有告诉我的事这个工具从零到团队内普及前后经历了大约一个半月踩的坑至少能写满一面A4纸。这里挑几个非常典型、值得后来者提前防备的说一说。7.1 tree-sitter节点类型并非所有语言都完备第一坑来自于“同一条规则在不同语言上命中率天差地别”。具体来说同一个“检查未使用变量”的规则在Python上命中率很精准但在TypeScript上经常出现误报——原因是TypeScript的AST中变量声明和绑定关系比Python复杂得多部分场景下需要依赖TypeScript的类型系统才能正确判定。而tree-sitter本身只做语法分析不做类型推导。这个问题的现实解法是不要指望一个通用引擎统一所有语言的规则精度。对于确定是类型相关的规则在TypeScript上还是应该借助专门的工具链比如eslint插件open-code-review的价值放在语言无关的框架级检查上更合适。工具在规则注册时允许显式声明支持的语言列表这不仅能防止误报也能让规则引擎的执行效率更好。7.2 增量模式下忽略列表的动态维护比想象中频繁增量审查降低了噪音但也带来了一个新问题同一个文件如果在某次PR中只改了一行注释但它整体被纳入了diff范围那么该文件里所有存量问题都会被当作增量问题重新播报一遍。这让“只报新问题”的机制大打折扣。我最后的处理方案是双重检查工具在生成增量报告时除了过滤基线文件之外还会对每个命中问题的代码行做一次比对——只有该问题所在的行确实出现在本次diff的变更行中才会被标记为不通过。如果一个函数因为旁边一行注释改动被拖动进来但问题行本身没变就不会被误报。这个逻辑极其重要。没有它CI里会充满“我明明没动这个函数它却报错”的甩锅现场。7.3 规则执行顺序会影响结果而且影响很大规则不是独立运行的。一个文件如果已经命中了“文件级禁用”的规则比如某个文件被标注为生成的代码那么该文件内的其他所有规则都应该跳过否则一条规则否决了整个文件其他规则还在发出“改进建议”整个报告读起来会非常荒诞。还有一类顺序依赖是一些规则会生成“中间标注”例如标记某个变量是用户输入的不可信数据后续的规则会消费这个标注做进一步分析。这种数据流依赖要求规则引擎内部必须按照注册顺序执行且规则之间传递的是完全可变的数据结构。我在前几个版本里犯过用SQL语句的思维设计规则引擎的错误——试图让规则之间无副作用、可并行执行后来发现业务语义规则根本绕不开上下文共享。如果团队要写自定义的语义规则建议在规则对象上暴露一个modify_context钩子允许规则在检查结束后向上下文中写入自定义标记后续规则从上下文中读取。这个设计模式比规则之间传递消息队列要轻量得多调试起来也直观。7.4 文件编码的隐形地雷扫到一个编码不规范的仓库时工具报了一堆“UTF-8解码失败”的错误。查下来有些人是GBK编码的linter配置文件有些是Windows平台的CRLF换行都被文件的默认open()逻辑挡在了门外。最终在工具层把文件读取统一改成以二进制读入、优先utf-8解码、失败则尝试gb18030回退并在报告里标注“该文件编码非UTF-8部分检查已跳过”。这个兜底方案看似不起眼但对工具在企业环境里的接受度影响很大——审查工具如果连文件都读不了就没有任何说服力。8. 实际效果与后续规划工具跑了一阵子之后我和团队review了一下数据和主观体验。先说客观数据。接入open-code-review之前一次中等规模PR的review周期中位数大约是26小时因为在等reviewer凑齐工具上线后格式类、残留类问题的提前拦截率达到了九成以上reviewer提的comment里一半以上从“这个debugger没删”“这里少了空值判断”变成了真正关于方案设计和代码结构的讨论。主观体验上的变化更大。几个核心模块的维护者反馈现在review的过程没有以前那么“耗电”了不需要同时记十几个规则去逐行对照工具已经把该提醒的都提醒完了人只需要做判断题而不是出题。团队内新同学融入代码库的速度也明显变快了——工具给出的报告里带着文件路径和行号新同学拿到一个错误可以直接跳到对应位置比早年靠师傅一对一review“带教”高效得多。后续的规划方向上我准备做三件事。第一把增量审查进一步下沉到IDE插件层面支持在保存文件时触发一轮局部分析把反馈从CI阶段提前到编写阶段。第二引入针对Python的轻量类型推理能力从tree-sitter语法树扩展到更深的符号分析重点提升Overload和泛型场景下的精度。第三把团队自定义规则里通用的、与业务无关的部分贡献回社区减少其他团队二次开发时重复造轮子的成本。如果说最后一个建议我特别想说代码审查工具的价值不在于机器人多么聪明而在于它是否真正融入了团队现有的工作流。无论选什么工具先想清楚团队最痛的是review流程里的哪个环节然后把自动化精准地插入那个环节。工具只是杠杆找准支点才是关键。
阅读完成 · 觉得有帮助?
咨询建站