资讯动态

从AST到规则引擎:我的代码评审辅助工具实践

发布时间:2026/10/19 10:03:22 来源:尧图企业网站定制
1. 为什么我觉得代码评审工具是个被低估的领域先说一下我做这个项目的起因。这几年团队规模从几个人涨到十几个人代码量翻了好几倍但评审质量反而在下降。问题不是大家不愿意看代码而是Pull Request动辄几百行改动靠肉眼从头翻到尾注意力撑不住很多问题就这么漏过去了。更麻烦的是每次评审的标准还不统一——有人关注命名有人盯着性能有人只关心有没有bug结果同样的代码不同人review出来的结论完全不一样。我一开始想的很简单写个脚本把常见的风格问题、明显的坏味道、潜在的bug模式自动扫一遍给评审人打个辅助。但做着做着发现这件事的水比想象中深得多。open-code-review这个名字听起来像是一个通用的开源评审工具但真正落地的时候你会发现它核心要解决的其实是一个很朴素的问题怎么把人看代码这件事的经验变成一套可复用、可量化、可自动化的规则。这个项目我前后折腾了大概三个月推翻重来了两版最终跑通了完整的流程。这篇文章不打算写成一份干巴巴的README翻译而是想把我在设计、踩坑、实战里积累下来的东西讲清楚。适合谁看如果你是团队里负责推代码规范、想提升评审效率的工程师或者你正在纠结要不要自己写一个类似的评审辅助工具这篇应该能给你不少参考。2. 起手式我到底想解决什么问题动手之前我给自己定了一个原则不做一个大而全的AI评审机器人。市面上很多工具号称能自动review所有代码但实际上误报率高得吓人团队用两天就烦了。我要做的只是一个辅助层它不替代人而是帮人把最耗眼力的那部分活儿先干完。2.1 从日常评审的痛点反推需求我把团队过去三个月的评审记录翻了一遍统计了一下大家在评论里到底在说什么。统计结果挺有意思的问题主要集中在这么几类风格一致性问题有人用单引号有人用双引号有人缩进两个空格有人用四个这类占了差不多三成。明显的逻辑缺陷空指针没判、数组越界没防、异步回调里改了共享变量这类大概两成半。可读性问题函数太长、命名误导、魔法数字直接裸奔这类两成左右。性能隐患循环里查数据库、不必要的深拷贝、锁粒度太大这类不到一成。剩下的才是真正的业务逻辑问题并发安全、事务边界、异常处理策略这些才是必须靠人来判断的。看完这些数据我反而松了口气。前四类问题加起来超过七成而且它们有一个共同点有明确规则可以程序化检测。真正需要人类智慧去判断的只有最后一小部分。所以我给自己定了个目标把这七成能用规则覆盖的问题自动化让评审人把精力集中在剩下三成真正有价值的事情上。2.2 工具边界哪些该管哪些不该管这个想清楚很重要因为它决定了项目的规模和复杂度。我列了一个明确的范围表该管不该管代码风格统一缩进、引号、命名约定业务逻辑是否正确常见bug模式空指针、越界、资源未关闭方案设计是否合理可读性指标函数长度、圈复杂度架构是否符合未来规划性能反模式循环内IO、N1查询产品需求理解是否到位测试覆盖提示新增代码未覆盖代码与需求的一致性把边界划清楚之后项目一下子变得可控了。**不越界是这种工具能活下去的前提。**你一旦开始试图理解业务逻辑就等于踏进了自然语言处理和程序分析的深水区那不是一个小项目能抗住的。3. 技术选型为什么没有直接选现成的Linter很多人的第一反应肯定是这种需求不是已经有ESLint、Pylint、RuboCop了吗自己造轮子干嘛这个质疑很合理我一开始也是这么想的但深入调研之后发现现成工具解决不了我的核心诉求。3.1 现成Linter的四个短板我调研了ESLint、Pylint、PMD、SonarQube这一票工具发现它们对于团队评审辅助这个场景普遍存在这么几个问题**规则与团队经验脱节。**现成工具内置的规则是大而全的通用规则集但我们团队真正踩过的坑、沉淀下来的经验它们完全没有体现。比如我们曾经因为一个错误地使用了lambda表达式导致延迟初始化失效的bug这个教训在ESLint里根本不存在。**误报率没有针对团队代码库调优。**通用规则集的误报率在真实项目里通常高到让人崩溃。比如禁止使用eval这条规则在有些工具里默认开启但我们有一个第三方库内部合法使用了eval于是每次评审都报错久而久之大家对这个工具就麻木了。**缺少评审语境的融合。**我们团队评审代码时有个习惯会打标签比如性能隐患、可读性差、逻辑缺陷、安全问题、建议优化。现成工具的输出没有这些分类它只告诉你这里有问题但不告诉你这个问题属于什么维度评审人还得自己翻译一遍。**无法充分覆盖跨文件依赖。**很多现成的Linter是单文件分析但真正的工程问题往往是跨文件的——A文件里定义了一个可变全局变量B文件里改它C文件里读它这种问题单文件工具完全无能为力。这四个短板本质上指向同一个结论我需要一个能承载团队经验的分析框架而不是一套固化的规则。3.2 最终架构规则引擎 静态分析 注解驱动想清楚要什么之后我的架构设计就很自然了。整个工具分为三层每层职责单一open-code-review ├── 规则定义层Rule Definitions │ ├── 内置规则集风格、反模式、bug模式 │ └── 团队自定义规则集YAML配置 ├── 分析执行层Analysis Engine │ ├── 文件级分析器 │ ├── 跨文件分析器 │ └── 变更context分析器基于git diff └── 输出层Report Generator ├── 规则命中详情 ├── 评审标签分类 └── 严重程度分级第一版我用的是Node.js因为团队的代码栈是JavaScript/TypeScript为主用JS写规则扩展成本最低团队成员想自己加规则也容易上手。核心依赖只用了三个babel/parser负责把代码转成ASTesquery负责在AST上做选择器匹配git命令行封装负责拉取diff信息。这套架构的关键点在于规则定义和引擎执行完全解耦。引擎本身不关心你定义了什么规则它只负责遍历AST、应用规则、收集结果。规则本身是一个纯函数输入AST节点和上下文输出问题描述和严重级别。这样设计的好处是团队今天想加一条禁止在循环体里调用JSON.parse只需要写一个几行的规则文件不需要动引擎任何代码。4. 核心实现拆解AST分析、规则引擎与diff融合这块是项目的技术核心我拆成三个部分来讲每个部分都有具体的实现思路和踩坑记录。4.1 AST分析让机器看懂代码结构所有静态分析工具的地基都是AST抽象语法树。简单说AST就是把源代码解析成一颗树每个节点代表一个语法结构。比如下面这行代码const result items.map(item item.price * item.count);解析成AST之后它会变成一棵树顶层是VariableDeclaration里面套了VariableDeclarator、CallExpression、ArrowFunctionExpression、BinaryExpression等等。我们写规则的时候本质上就是在这棵树上做模式匹配。用babel/parser解析代码极其简单import { parse } from babel/parser; const ast parse(sourceCode, { sourceType: module, plugins: [jsx, typescript], });拿到AST之后我用esquery做选择器匹配这个东西和CSS选择器非常像。比如我想查所有调用console.log的语句规则就是import esquery from esquery; const matches esquery(ast, CallExpression[callee.object.nameconsole][callee.property.namelog]);selectors查出来的是一组AST节点接下来只要遍历它们提取行号列号、代码片段、上下文信息组装成一条评审意见就可以了。这个阶段我踩的最大的坑是TypeScript类型注释的解析。团队代码里大量使用了TS类型babel/parser默认只解析纯JavaScript要支持TS必须启用typescript插件。但这还不够如果代码里用了enum、namespace这类TS特有语法还得配合babel/plugin-transform-typescript做转换。最开始没配全导致部分文件解析直接抛异常整个分析中断。后来我在入口加了一层降级逻辑babel解析失败的文件退回用espree解析再失败就直接跳过并记录警告。这样一来工具的健壮性好了很多。4.2 规则引擎动态加载、上下文传递、结果分级规则引擎是整个工具的心脏。我设计了一套极其轻量的规则协议每条规则就是一个对象export default { id: no-console-in-loop, description: 避免在循环结构中调用console方法防止循环内高频I/O, severity: warning, // error | warning | info tags: [performance, screen-output], selector: ForStatement, WhileStatement, DoWhileStatement, match(context) { const loopNode context.node; const consoleCalls esquery(loopNode, CallExpression[callee.object.nameconsole]); return consoleCalls.map(call ({ line: call.loc.start.line, column: call.loc.start.column, snippet: context.sourceCode.slice(call.start, call.end), message: 循环体内出现console调用会显著增加I/O开销请移到循环外部。, })); }, };引擎执行时做的事情很简单遍历所有rules对每个rule找到它的selector匹配的AST节点然后调用match(context)拿结果。这里context里除了AST节点还会传入一个重要的东西——变更上下文。变更上下文是diff系统带来的。简单说一条规则命中的代码如果它刚好在本次PR的变更范围内它的严重程度要升级如果它是旧代码只是被PR牵扯进来的严重程度要降级。这个设计直接解决了工具噪音问题评审只关心你这次改了什么而不是把整个历史存量问题都翻出来。这也是open-code-review和普通Linter最大的区别之一。结果分级方面我定义了三档error必须修复比如空指针、资源未关闭、性能反模式。warning建议修复比如函数太长、命名不够清晰。info仅供讨论比如风格偏好、可优化但不强求。这三档对于评审场景刚刚好。如果档位再多评审人反而不知道哪些该优先看。4.3 diff融合只评本次变更忽略存量问题diff融合是评审辅助工具区别于代码检查工具的分水岭。Linter是全局扫描它不管你这次改了什么只要代码里有问题就报。但评审的本质是增量审你只关心这个PR引入了什么问题。实现思路是这样的用git diff HEAD~1拿到整个PR的变更列表解析出每个文件的变更行区间。然后分析器每产出一条问题就去查一下它所在的代码行是否落在变更区间里。如果在标记为changed如果不在标记为legacy。import { execSync } from child_process; function getChangedLines(baseRef HEAD~1) { const diffOutput execSync(git diff ${baseRef} --unified0, { encoding: utf8 }); const result {}; const filePattern /^\\\ b\/(.)$/; const hunkPattern /^ -\d,\d \(\d),(\d) /; let currentFile null; for (const line of diffOutput.split(\n)) { const fileMatch line.match(filePattern); if (fileMatch) { currentFile fileMatch[1]; result[currentFile] result[currentFile] || []; continue; } const hunkMatch line.match(hunkPattern); if (hunkMatch currentFile) { const start parseInt(hunkMatch[1], 10); const count parseInt(hunkMatch[2], 10); for (let i start; i start count; i) { result[currentFile].push(i); } } } return result; }这套逻辑跑通之后效果立竿见影。以前跑一次Linter可能报出来50个问题但更新完diff过滤之后只报出这次PR真正引入的七八个问题。评审人看这七八个问题的精力是完全够的而且每一个都有明确的修复指向。工具不是把一个问题变成一堆问题而是把一堆噪音变成几个重点。5. 实战中的三次意外事故误报、漏报与规则冲突项目跑通之后我在真实的团队代码库上做了两轮测试效果总体符合预期但过程中也踩了三个典型的坑。这几个坑很有代表性任何做这类工具的人都大概率会碰到。5.1 误报事故装饰器导致的惊魂5分钟第一轮测试的时候工具跑完报告出来团队一个成员看了一眼说这玩意儿是不是疯了怎么把React组件的state更新都标成性能问题了我赶紧去查。报出问题的规则是no-unnecessary-spread它检测的是{...object}这样展开操作符冗余的情况。在很多场景下{...object}是故意用来做浅拷贝的并不是冗余。但我的规则实现得太粗暴了它只检查了展开操作符出现在对象字面量里没分析它是否被赋值给新变量、是否改变了引用、是否有其他语义。误会解释清楚之后就发现了这个问题的本质规则太语法化缺少语义化判断。所谓语义化判断就是不能只看节点长什么样还要看它在周围的代码里干什么用。我修了这条规则加了条件判断只有展开操作符被赋值给同一个变量名且变量从未被重新赋值时才算冗余。类似地我梳理了一遍所有内置规则按照语义敏感度重新分级。语义敏感度高的规则比如变量未使用、展开操作符冗余我都会尽量多收集上下文信息通过esquery的父子关系、兄弟关系来缩小误报范围。5.2 漏报事故跨文件的常量引用第二起事故更隐蔽。我们有条规则叫detect-magic-string检测代码里直接使用字符串字面量而没有定义常量的情况。它在单个文件里的检测非常准确——只要一个字符串字面量在表达式里出现且不是import路径就会报出来。但它漏了一个场景字符串虽然定义在常量文件里但常量文件里的值本身是裸字符串。比如// constants.js export const STATUS processing; // 字符串字面量 // order.js import { STATUS } from ./constants; if (order.status STATUS) { // ... }真正的魔法字符串是processing它藏在常量文件里如果只看order.js这条规则根本不会触发。直到有一次产品把状态从processing改成了in_progress结果漏改了三处线上出了事故复盘时才发现问题根源。这个教训让我意识到静态分析工具如果要深入工程实践跨文件分析是绕不开的坎。我后来给规则引擎加了一个全局模块解析的机制用相对路径和node_modules解析规则来定位模块的导出然后针对常量引用类规则做跨文件追踪。实现不算复杂但工程量比单文件分析多了不少。5.3 规则冲突事故两条规则互相打架第三个坑是新加规则时暴露出来的。团队希望加一条禁止在复杂表达式中使用三元运算符嵌套目的是提高可读性。但随后我发现这条规则和已有的prefer-ternary-over-if规则冲突了。prefer-ternary-over-if鼓励简单赋值场景用三元表达式禁止三元嵌套则限制复杂场景。两条规则同时命中一段代码时工具会同时输出两条建议其中一条说改成三元另一条说不要用三元评审人看到这种自相矛盾的输出第一反应就是工具不靠谱。这个问题在规则引擎层面没有完美的通用解法。我最终的方案是引入规则优先级和抑制机制每条规则声明自己的category引擎执行时如果两条规则的结果在同一个代码区间内相互冲突低优先级规则自动被抑制并在报告中留下被抑制的说明。这个方案不算优雅但它起码保证了输出的自洽性。任何做规则引擎的人都要尽早把规则冲突检测这个case考虑进去否则规则集一多起来工具输出就会像精神分裂一样。6. 让规则成为团队的评审记忆库工具跑到这个阶段我意识到一件更重要的事真正有价值的不是工具本身而是它能承载的团队经验。评审过程里每个人发现的坑、总结的经验如果能沉淀成规则那么这个工具的威力会越来越大。6.1 团队自定义规则的三种写法我把团队自定义规则的入口设计得非常轻一共三种写法**第一种最简单只在配置文件里声明一行。**适合处理那些虽然出现频繁但原因明确的问题比如禁止在项目里使用Date.now()因为线上环境时钟同步问题可以直接在配置里写rules: no-date-now: error**第二种通过esquery选择器写半规则。**适合处理能精确描述的语法模式比如禁止在useEffect依赖数组里使用内联对象写起来很快。**第三种完整规则文件适合包含复杂逻辑的场景。**比如禁止在未加锁的情况下修改共享的全局缓存对象这种规则需要分析多个语句之间的依赖关系就必须用完整的JS规则文件来写。6.2 从评审评论中挖规则的方法论最让我兴奋的部分是我发现了一个可持续增强规则库的流程把评审记录当作规则挖掘的原材料。具体做法是每次评审结束后我对评审产生的所有评论做一个简单分类这条评论是这个bug改一下就行还是这段代码的风格和团队不一致还是这个方案有更好的做法凡是属于语法风格、可读性、明显bug模式的评论我会整理出对应的固定写法转成规则。凡是属于设计方案、业务语义的评论我会记录下来但不转成规则超出工具边界。凡是针对特定库/框架用法的评论我会写进专门的框架规则集。用这个方法三个月下来我们的自定义规则从最初的12条涨到了46条。每一条规则背后都是一次真实的评审讨论都对应着团队踩过的一个坑。工具逐渐变成了团队评审经验的活字典。6.3 规则Review机制避免规则库烂尾规则数量涨上去之后一个新的问题出现了有些规则过时了。比如早期定的禁止使用某些特定API在库升级之后可能就不再适用。如果规则库不维护工具的输出会逐渐失真最后团队又回到不看工具报告的老路。我定了一个简单的规则Review周期每个月末扫一遍规则命中率。命中率为零的规则不一定是多余的但需要人工确认是不是已经过时命中率超高的规则也要看——如果一条规则命中率超过一定比例说明它可能被误用成了团队风格评分器这也不是好事。规则是帮助人做判断的不是用来给人打分的。7. 集成CI与评审机器人从辅助刷屏到评审助手工具能分析出结果只是第一步真正让它发挥价值的是怎么把结果送到评审人面前。7.1 在GitHub Action里跑起来我的目标是让工具在每次Pull Request创建时自动跑一遍然后把结果作为评论发到PR页面。这个用GitHub Action做非常简单核心就是三步checkout代码、运行工具、上传结果。我写了一个pipelinename: open-code-review on: pull_request: types: [opened, synchronize] jobs: review: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 with: fetch-depth: 0 # 必须拉全历史diff计算需要 - uses: actions/setup-nodev4 with: node-version: 20 - run: npm install - run: npx open-code-review --baseHEAD~1 --formatgithub - if: failure() uses: actions/upload-artifactv4 with: name: review-report path: ./review-report.json这里有个关键点fetch-depth: 0。如果不设置这个参数GitHub Actions的checkout只会拉取最新的一个commitgit diff HEAD~1会失败整个diff融合逻辑就失效了。这个坑我一开始就撞上了花了半个多小时才查明白。7.2 报告格式设计先结论、后证据、附修复建议工具分析的结果如果只是罗列一堆问题评审人看两行就想关掉。我花了不少心思设计报告的输出格式核心原则是先给结论严重级别和分类再给证据代码位置和片段最后给修复建议。一个典型的输出长这样[error] 第 45 行检测到未捕获的数组越界风险 代码items[index 1].name 建议先判断 index 1 是否小于 items.length再访问元素 标签logic-error [warning] 第 78 行函数 parseResponse 过于冗长42 行 建议考虑拆分为 parseHeader / parseBody 两个子函数 标签readability这种模式的好处是评审人扫一眼严重级别就能决定先看哪些点开证据就能快速定位代码修复建议则给了他一个明确的行动指引。工具输出不是判决书而是评审人的秘书——把关键信息拎出来摆整齐。7.3 与自动化机器人组合的实战效果我把工具接入团队的评审流程之后做了一个统计测评和结论性评论消耗的时间大概从平均每PR 25分钟降到了15分钟但更有价值的改变是注意力分配的变化。以前评审人需要花大量时间去找空指针、漏判、风格问题现在这些被工具接手了他们可以把全部精力放在讨论设计合理性和潜在业务风险上——这两件事才是代码评审真正的价值所在。当然我也遇到过抵触情绪。有人觉得工具报的问题太多了有人觉得工具都报了还要人干嘛。每次遇到这种情况我的回应都是一样的工具不是评判你代码好坏的标准它只是一个第二双眼睛。真正做决定的是你而且工具报出来的每一件事都有理由、有上下文、有修复建议你可以不同意但你不能说它没有逻辑。8. 我个人的三条经验关于可持续迭代这件事做到这一步我想聊一些超出技术本身的体会。这三条经验我觉得对任何一个做开源工具、团队成员协作工具的人都有用。**第一条边界感比功能多少更重要。**我见过太多工具死在功能膨胀上。一开始做评审辅助做着做着就想塞进自动修复、代码生成、开发者评分……每多一个功能系统的复杂度和误报率都会翻倍最终变成一个看起来很强、实际上没人敢用的怪物。我给自己定的原则很朴素如果一个需求需要理解业务语义才能做好那它就不应该放在这个工具里。**第二条误报率优先于召回率。**所有做检测类工具的人都会面临一个两难——规则定义松一点漏报多定义紧一点误报多。我的经验是与其追求抓到所有问题不如追求报出来的每个问题都站得住脚。因为误报多了团队会慢慢丧失对工具输出的信任而信任一旦没了哪怕你后面修得再准他们也不会再看了。这就是狼来了效应。**第三条规则要能溯源到一次真实的评审讨论。**我强烈建议任何加了自定义规则的人都把规则的来源记清楚——是哪一次评审、哪一段代码、谁提出来的、背后的原因是什么。这不是为了做文档而是为了将来规则过时或产生误报时你能快速判断这条规则当初是为了什么存在的从而决定是修还是删。没有溯源能力的规则库最终会变成一个无法维护的黑洞。open-code-review这个项目做到现在我对自动化评审这件事有了一个更清醒的认识。它没法替你做评审但如果你把团队积累的经验喂给它它能变成一个非常可靠、永远不知疲倦的初级评审人把你从最耗神的重复劳动里解放出来。也许你的团队也用得上这条路。下一次开着几十个评论的PR页面觉得疲惫的时候不妨想想哪些评论是一眼就能找到的重复问题然后试试把它们沉淀成规则。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑