☰
从AST到自动修复:自研代码质量检查工具impeccable的实践
2026/10/11 10:40:58 网站建设 项目流程

1. 先说说我为什么非要造一个impeccable

impeccable这个代号,是我在去年搭起来的一套代码质量检查工具,英文原意是"无可挑剔、没有瑕疵"。起因其实很俗:我所在的团队每天花在代码评审上的时间越来越多,但吵的内容却越来越奇怪——空行多了一个、某变量名用了缩写、一段重构没有拆成小函数……这些问题的本质不是大家不认真,而是团队从来没有一个能把"规范"自动执行起来的东西。于是我花了三周时间写了impeccable,专门解决一件事:让"无可挑剔"不再是一句口号,而是一个可以被代码库自动校验和自动修复的机制。

工具刚做出来的时候,团队里有人觉得是多此一举,有人觉得是在挑战市面上的开源方案。但真正跑了一个月之后,评审时间缩短了接近一半,新人上手时也不会再因为"不懂规矩"被反复打回。这篇博客不打算写成宣传稿,我就把从设计、实现到接入工作流的完整过程拆开讲一遍,包括踩过的坑和让我后半夜爬起来修Bug的那个修复逻辑事故。如果你也在跟代码风格和评审效率较劲,这套思路应该能给你一些启发。

1.1 一次鸡毛蒜皮的Code Review事故

事情发生在一次普普通通的周五。A同学提交了一个功能分支,B同学负责评审。两个人在评论里来回纠缠了七八轮,核心争议是:一个缓存对象里的字段到底应该按字母序排列,还是按访问顺序排列。A说按访问顺序更贴近业务,B说按字母序方便查找。最后组长拍板"按字母序,因为这是团队规范",但翻遍团队文档,发现这条规范只存在于某次周会的会议纪要里。

这么一件小事,最终消耗了两个人加组长大约两个小时。这其实不是人的问题,而是"规范"这个抽象的东西没有落地工具。文档会过时,口头约定会被遗忘,唯一能长期稳定执行的就是代码。我当时的第一个想法是:如果有一套工具,能把"字段排序"这种规则变成机器检查项,并且能自动把不合适的排序改成合适的样子,那这场架根本不会发生。

那次事故之后,我在团队里做了一次小调查,发现大家的痛点其实高度集中:规则散落、评审标准因人而异、简单格式问题占用了大量讨论时间、新人需要反复试错才知道什么叫"符合团队风格"。这些痛点是通用的,也是impeccable最原始的出发点。

1.2 为什么现成的工具满足不了我们

当时我们已经在用一些常见的开源代码检查工具,它们确实能帮助团队少踩很多坑。但用了一段时间之后,我开始意识到它们的边界在哪里。首先是规则的可定制性不够。很多工具的核心规则集是社区定义的,面向的是全行业的通用规范,团队内部那条"缓存字段按字母序"的特定约定,很难通过简单配置表达出来。如果硬要扩展,就得跳到插件机制里写比较底层的代码,维护成本一开始就很高。

其次是"自动修复"的深度问题。大多数工具能自动修的,只是行尾分号、空格、引号这一类的表层格式问题。像"把import语句按字母序排列"这种涉及抽象语法树深层结构的操作,要么不支持,要么修出来的结果经常和团队习惯不一致。更别说语义级别的检查,比如"某个重构分支根本没有被调用"、"这段代码和上面的逻辑互相矛盾",这些都不是简单规则能覆盖的。

还有一点是性能和集成成本。整套检查在本地跑一遍要等很久,团队里几台配置稍低一点的机器跑一下就要两到三分钟,大家为了省时间就选择跳过检查,最后变成CI里的一次"红灯"而已。这不是工具不好,而是它为了通用性牺牲了针对性和可落地性。我当时的判断是:与其在通用工具上打补丁,不如按自己的实际流程做一个足够聚焦的方案,把"检查什么、怎么修、什么时候跑"这三件事完全握在自己手里。

1.3 impeccable要解决的三大问题

经过梳理,我给这个项目定了三个必须解决的终极问题,后面的所有设计都围这三个问题展开。

第一,把散落各处的规范变成可执行代码。不管是周会纪要、评审留言还是某人的口头习惯,都必须变成一条一条结构化的规则。每条规则要有固定的ID、描述、默认等级和对应的修复策略。这样团队在讨论"该不该这么做"的时候,讨论的是具体的一条规则ID,而不是模糊的感受。

第二,不只会报错,还要能安全地修。一个检查工具如果只是把问题标红,其实只是把"评审员"换成了"机器人",并不会减少修复的工作量。impeccable要能做到:对于低风险问题直接自动修复;对于高风险问题给出精准提示和修复建议,让开发者一键确认。这背后需要一套比较完善的"安全回退"机制,后面我会详细讲。

第三,规则必须可插拔。不同团队、不同项目,甚至前端和后端代码,都应该能够共享一部分通用规则,同时保留各自的定制能力。也就是说,impeccable本身只是个引擎,规则全部以插件形式存在,用户可以组合出适合自己的规则集。这样才能避免"用了工具之后反而被工具绑架"的尴尬。

2. 把"无可挑剔"翻译成机器能懂的规则

确定方向之后,最核心的问题是:怎么把人类语言里的"规范"翻译成程序能理解的逻辑。我一开始踩过一个误区,以为写几个正则表达式就能搞定所有检查,结果做出来的工具既慢又容易误判,面对多行嵌套代码几乎无能为力。后来我才意识到,要做真正可靠的检查,必须让程序理解代码的"结构",而不仅仅是"文本"。

2.1 规则的三个抽象层次

设计规则体系的时候,我把所有检查项分成三个层次。第一个层次是词法和语法层,主要看"代码长什么样",比如函数名是否用了驼峰式、字符串里是否出现了禁止的URL、文件末尾是否有换行。第二个层次是语义层,主要看"代码能不能在运行时有预期行为",比如变量是否被声明、循环里是否有无限递归风险、分支条件是不是恒为真。第三个层次是风格与架构层,主要看"代码组织结构是否符合团队约定",比如领域文件的划分、组件文件是否包含过多可复用模块。

这三个层次对应了不同的检查手段和修复难度。词法语法层靠解析后生成的抽象语法树就能搞定,语义层需要结合符号表和类型信息,风格与架构层最难,因为有时候同一个问题可以有很多种"正确"修法。我把这条认知直接放进了规则引擎的接口设计里,每条规则在声明时必须指定自己属于哪个层次,并且提供"修复建议"的可执行函数。

抽象层次检查内容典型规则示例自动修复难度
词法与语法层代码文本结构和AST形态禁止使用废弃的调用语法、函数参数数量不得超过3个低
语义层变量引用、作用域、常量与类型禁止声明后未使用的变量、禁止读取未初始化的状态中
风格与架构层命名方式、模块边界、依赖方向缓存对象的字段必须按字母序排序高

这个分层不只是为了好听,它直接影响了我后面修Bug的方式。例如,语法层的修复可以大胆反复执行,修坏了语法检查立刻能发现;语义层的修复一旦出错,可能不会报错但运行时逻辑会变,所以修复前必须做更严格的判定;风格层的修复则最容易改坏别人的代码,我把这类修复默认标记为"需要人工确认"。

2.2 为什么核心要选AST而不是正则

在很多人的印象里,代码检查工具就是一堆正则表达式。但正则只能看到文本的"表面",它不理解嵌套关系。举一个最简单的例子:检查"循环体内不允许调用某个带副作用的方法"。如果只用正则去搜索方法名,很容易把注释里的内容、字符串里的内容、其他同名函数里的内容都误伤。再比如检查JSX组件里是否缺少key属性,正则几乎无法分辨哪个标签是真正的组件实例。

AST的优势,是把源代码变成一棵结构化的树。每个节点都携带位置信息、父节点和兄弟节点的关系,规则只需在树上做一次遍历,就可以精确判断某个方法调用到底是"被循环包裹"还是"只是恰好出现在同一行的注释里"。更重要的是,AST能支持"重写"。修复的过程本质上就是修改这棵树的节点,再把新的树还原成源代码。如果不用AST,自动修复就无从谈起,因为你无法在文本层面安全地做"把第二个参数挪到第三个参数后面"这种操作。

当然,AST也有代价,最明显的是解析耗时。一个大型文件解析成完整AST可能要几百毫秒,这比正则慢得多。所以我在impeccable里做了非常激进的增量缓存:只要文件内容和依赖环境没变,就直接复用上次的解析结果。这样全量扫描速度也可以控制在几秒到几十秒之间,完全够用。

2.3 一个最小规则的完整示例

为了让思路更具体,我写一条非常简单的规则示例。假设团队约定:函数参数不能超过4个,超过的话需要合并成配置对象。impeccable里的规则就是一个带有meta和create两个字段的对象。meta里放规则描述、等级和修复策略,create里返回一个处理器集合,在不同AST节点触发对应逻辑。

// 示例规则:函数参数最多4个 module.exports = { meta: { id: "local/function-params-limit", description: "函数参数个数不应超过4个,超出的参数建议合并为配置对象", severity: "warn", fixStrategy: "suggestion" }, create(context) { return { FunctionDeclaration(node) { if (node.params.length > 4) { const diff = node.params.length - 4; context.report({ node, message: `参数数量超出限制,当前${node.params.length}个,最多允许4个(超出${diff}个)`, suggestions: [ { desc: "将超出部分合并为对象参数", // 这里省略具体的AST改写逻辑 } ] }); } } }; } };

你可能注意到它没有直接修,而是给了一个"suggestion"。我的设计原则是:凡是不能100%保证语义不变的修复,都只给建议,不给自动修改。上面这条规则在自动修复时很容易犯错,因为把参数合并为对象可能要同时修改函数体内所有引用,这在AST层面是一个较大的重构,风险太高。所以它只负责把问题找出来,再配合编辑器的自动化操作提示给开发者参考。

2.4 自动修复的"安全回退"设计

这是impeccable里我最满意的一块设计。自动修复最怕的不是修不到位,而是修坏了还没人发现。为了这个,我把所有修复策略分成了三个安全级别:绿的、黄的、红的。绿色代表纯格式修复,比如删除行尾空格、调整缩进,这类修复可以批量自动执行,即使执行一万次也不会改变运行结果。黄色代表需要依赖上下文分析,比如"变量声明式改成const",这需要先确认这个变量没有被重新赋值,一般可以在同一次AST遍历里完成验证。红色代表高风险重构,比如"调整import顺序"或"合并重复分支",这类修复会自动生成一个diff预览,并且默认不会在CI里自动应用,必须由开发者在本地确认。

颜色分级背后还有一个"安全回退"机制:每次自动修复之前,工具会先对源文件做一次语义快照。快照不只是一份哈希,而是记录文件里每个函数和模块的引用关系。修复完成之后,如果引用关系发生变化,工具会主动撤销这次修复,并输出警告。这个设计非常有用,因为即使我的引擎有Bug,也不会把错误扩散到整个代码库。

3. 跑通核心链路:从扫描到修复

有了规则和AST解析,接下来的事情就是把它串成一条完整流水线。这个过程远比想象中复杂。很多人觉得检查工具就是"读取文件、解析、跑规则、输出结果",真正动手之后你会发现,光是文件的编码、二进制文件的过滤、解析失败的降级处理,每个环节都能卡住你半天。

3.1 文件收集与解析器的容错

impeccable最开始只打算扫描源代码目录,但很快我遇到了一个尴尬的问题:直接把所有文件灌进解析器,有的文件编码是GBK,有的文件里还混着测试模板的代码,解析器一遇到语法错误就抛异常,整个流程直接中断。后来我加了一个文件级容错层:先根据后缀名和路径白名单过滤出需要检查的候选文件;再读取文件头部字节判断编码;最后在解析时捕获语法错误,把错误文件单独记录到"待人工处理"列表,绝不阻断其他文件的检查。

这个容错设计看起来很简单,但对真实代码库的稳定性贡献很大。以前遇到一个文件有语法错误,整个CI任务就会红,但那个文件可能是生成器自动生成的临时文件,根本不在我们应该审查的范围内。现在impeccable可以把这类文件跳过,同时生成一份报告提醒大家注意。我特别建议你要做类似工具时,永远不要假设输入文件都是"干净的",容错率决定了工具的人缘。

3.2 规则调度顺序的讲究

规则不是拿到AST上跑一遍就完事儿的。规则之间经常有依赖关系,比如"禁止未使用的变量"这条规则,应该先于"删掉未使用的import"这条规则执行,因为后者可能会改动import节点,而前者依赖的引用信息还没计算。为了避免这种互相干扰,我加了一个简单的规则调度器,按语义层规则 -> 词法层规则 -> 风格层规则的顺序执行。

这个顺序背后有一个直觉:语义层规则先建立全代码库的引用地图;词法层规则依赖引用地图来判断一段代码是否真的"没用";风格层规则在结构稳定的前提下做格式化调整更安全。实际执行下来,这个顺序能显著减少同一份代码被不同规则反复打回的情况。当然,规则依赖也可能比这个复杂,所以我在规则定义里额外暴露了requiresRules和conflictsWithRules两个字段,让调度器能自动处理显式依赖。

3.3 修复落盘与幂等性

自动修复并不是把改完的文件直接覆盖回去就结束了。我在设计的时候引入了"修复桶"的概念:所有修复操作先写到内存里的一个临时diff列表,再统一落实。这样做的原因有两个。第一是可以批量执行撤销,如果某一处语义快照校验失败,可以精准回滚那一条改动。第二是方便在落盘之前做一次"幂等性"校验——也就是说,把修复后的文件再跑一遍检查,如果仍然有同类问题,说明修复不彻底,应该标记为失败而不是继续覆盖。

幂等性校验听起来简单,做起来很容易忽略。我刚开始没有这一步,结果同一个文件被重复跑了多次检查,每一次都修一点,到了第三次才稳定。后来我把"修复后重检"做成强制流程,如果一轮修复之后问题数没有降到零,就说明存在规则冲突或者修复函数不收敛,工具会直接中止并输出冲突原因。这样做虽然稍微增加了运行时间,但换来了稳定可靠的行为。

3.4 性能优化:缓存和增量扫描

一个代码检查工具如果跑得太慢,最后一定会被团队放弃,这是我在项目初期就预感到的。为了让全量检查时间可接受,我做了两层优化。第一层是对"文件解析结果"做缓存。每个文件的AST和语义快照会以内容哈希为key存到临时目录里,只要文件内容不变、依赖解析器版本不变,就直接复用。第二层是增量扫描,impeccable会自动读取版本控制系统里的变更文件列表,支持只检查changed files以及被它们依赖的部分模块。

这两层优化叠加后效果非常明显。一个大约两万行代码的中等规模仓库,首次全量扫描大约需要8秒,之后如果只改了一个小文件,增量扫描能在0.3秒内完成。再加上我使用了进程池来并行解析多个文件,整体速度还能进一步提升。性能这个东西,不到大规模代码库不会体会到有多重要,但等团队开始每天跑一百多次检查时,你就知道这8秒和0.3秒的区别就是"大家愿不愿意用"的区别。

4. 接入版本控制钩子之后踩过的坑

工具的核心流程稳定以后,我开始把它接入到团队的工作流中。这一步远比我想象的惊险,因为工具要真正影响每个人的日常提交动作,任何一个小Bug都会被无限放大。我在这里踩了三个比较有代表性的坑,每一个都花了我不少时间才定位到根因。

4.1 与pre-commit钩子集成的正确姿势

最开始我想得很简单:在commit之前跑一次全量扫描,有问题就不让提交。这个方案上线第一天,团队就有人提交不了代码,原因是他的分支上有一个历史遗留的警告信息一直没有处理。全量扫描面对这种情况非常不友好,因为警告是旧代码留下的,跟他本次的改动无关,但钩子把账全算到了他头上。

后来我把策略改成"只检查本次变更涉及的文件"。没有用全量扫描,而是用版本控制系统提供的变更文件列表,去扫描实际被修改的部分。这样旧债不会被反复追讨,新引入的问题也能被及时拦截。更重要的是,我在钩子里区分了两种等级:error级问题直接阻止提交;warn级问题只打印提示,允许提交。这样既不放过硬伤,又不会让团队因为"格式强迫症"而暴走。

4.2 一次"误报危机"的完整排查链路

某天开始,团队里陆续有人反馈:impeccable把一段线性且没有任何问题的代码标成了"函数参数过多"。我起初以为是规则写得太激进,就把阈值调高,但奇怪的是,同样的代码在有的机器上完全正常,在另外两台机器上就会误报。

我花了三个小时排查这条链路:先对比了触发误报的环境和正常环境的区别,发现差异集中在解析器版本上;又检查了impeccable的依赖安装方式,发现其中一台机器使用了旧版解析器的缓存;最后定位到规则处理器在解析装饰器语法时,把对象字面量中的一个展开字段当成了函数参数的部分,导致数量计算错误。根本原因是新版解析器对"通配展开"的支持发生了变化,而我的规则没有考虑这个语法节点类型。修复方式是在规则里增加对展开字段的判断,并且在语义快照里加入解析器版本作为缓存因子。

这次排查给我上了一课:工具的依赖锁定和缓存失效策略,直接决定了它的可信度。从那以后,我把所有的解析器依赖都写入统一的锁文件,并且要求每次升级都跑一遍全量的回归用例,确保不会出现"某些机器新、某些机器旧"的分叉问题。

4.3 自动修复改坏逻辑的惨痛教训

这是我整个项目里印象最深的一次事故。当时我设计了一条"把import语句按字母序排序"的规则,并且非常自信地把它标记成了绿色安全级别。上线之后它确实能自动重新排序import,某次自动修复后,有个同事发现页面白屏了。

排查后才发现,被排序的import里有一个副作用调用,它的执行顺序原本是有意义的:必须先加载A模块,再加载B模块,而B模块在加载时会默认读取A模块挂载的全局属性。我的排序规则把所有import按字符串顺序排列,完全没考虑副作用依赖,直接改变了模块初始化顺序,最终导致了运行时报错。

这个事故让我把很多看起来"纯格式"的规则也重新归类为黄色甚至红色。只要一条规则的操作对象里包含可能具有执行顺序语义的语句,它就不能被无脑自动应用。我随后在规则里增加了一个sideEffectAware字段,如果为true,修复前会检查语句块里是否存在函数调用和顶层赋值,存在的话直接跳过自动修复。这个教训值回票价,虽然过程很痛苦。

5. 上线一个月后的实测数据与我的判断

项目稳定运行一个月后,我拉了一组团队内部的数据来做复盘。这个数据样本不算大,大概有八名开发人员、四个主要仓库,但趋势已经足够说明问题。我需要提醒的是,所有数据都来自我最开始定义的那三类规则,如果规则集差异很大,数字会有很大浮动,所以参考的是变化方向,而不是绝对值。

5.1 团队内部的一组对比数据

我用表格记录了几个关键指标:

指标接入前接入一个月后变化幅度
单次评审平均消耗时长约25分钟约13分钟降低48%
格式/风格类评论占比约60%不到15%大幅下降
提交前自动修复问题数0约每周320处从纯人工到自动
因低级错误导致的CI失败次数每周约9次每周约2次降低约78%
新人达到团队规范要求所需评审次数约4到5次约1到2次明显减少

最直观的感受是,评审时间被释放出来,大家终于可以把注意力放在"这个方案是否合理"而不是"这个空行是不是多了"。新人上手的时候也不用再看一堆文档去猜模板,直接在提交时看impeccable给出的具体提示和修复建议就够了。

5.2 impeccable做不到的事情

虽然这个工具给我带来了很多便利,但我必须站在从业者的角度说清楚它的局限。第一,它完全不理解业务意图。代码里"为什么这里要特殊处理某个边界条件"这类深层次的问题,靠规则是永远无法检查的,这一层只能靠人。第二,规则集一旦过于庞大,维护成本也会上升。每条规则都需要有人负责,定期根据团队变化和代码语言版本升级而调整。如果一个团队没有固定的维护者,规则库会慢慢腐化,最终变成"新工具里的老负担"。

第三,自动修复并不总是节省时间。对于复杂重构,修复建议往往需要开发者在编辑器里手动确认,这个过程如果做得不够顺滑,反而会打断开发状态。我的解决方案是把修复建议输出为可点击的diff,尽量减少人工操作,但对极其复杂的场景依然难免需要动手。所以,任何工具的核心目标都应该是降低重复劳动,而不是消灭思考。

5.3 自研工具值不值:我的判断标准

经常有人问我:"这种东西直接用开源工具不就行了吗?为什么非得自己造一个轮子?"我的回答很直接:如果你的痛点只是缺少某条规则,那就给社区工具写一个插件;如果你发现自己需要重新定义"检查流程"本身,那自研才是合理的。

在我这里,判断标准有三条。第一条,现有工具是否能在两个小时内完成你想要的核心改动?如果不能,自研的收益就可能高于成本。第二条,你是否需要一个特殊的"修复引擎"来配合团队内部的代码结构,而不是只在输出报告层面做文章?这是自研最有价值的地方。第三条,团队是否愿意为这个维护成本长期买单?如果只是一时兴起,那开源插件永远是更安全的选择。

impeccable对我个人来说是一次非常好的锻炼,它逼着我从解析器的底层细节一直考虑到开发者的日常流程,让我学会站在使用者的角度去设计一个"可靠且不打扰"的工具。如果你也有类似的计划,我建议你从一条最让你痛苦的规则开始,哪怕只做这一件事,把它做成自动化的,也已经值回投资。最后分享一个小技巧:写规则的时候,每条规则都强制写一段"为什么"字段,这样未来任何新人来维护规则库的时候,看到的不只是"怎么做",还有"为什么这么做",这会省掉大量不必要的讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询