先说结论:做 open-code-review 的初衷,不是想替代 CodeRabbit、Sweep 或者市面上一堆 AI Code Review 服务,而是受够了每次提 PR 都要等外部服务回调、受够了规则模板无法和团队内部规范打通。这个项目定位是"可以长期跑在自己 CI 里的开源审查框架",它把代码审查从一个黑盒服务变成了一个你能完全掌控的工程组件。我在正式动工之前,先花了两周做调研。当时市面上的方案大致能分成三类,每一类都有让人没法忍的短板:方案类型代表产品/形态优势硬伤SaaS 托管审查CodeRabbit、各类 AI Review Bot接入快、交互好看审查逻辑不透明,结果不可复现,团队规范难以自定义平台绑定插件GitHub Action、GitLab CI 里现成插件轻量、就在 PR 流程里换个平台等于换工具,规则写死,二次开发成本高传统静态分析SonarQube、ESLint、SpotBugs分析深度强面向整个项目,不面向 diff,存量问题会把增量噪音淹没对比完我意识到,自己缺的不是另一个"智能分析器",而是一个能夹在 diff 提取和静态分析之间的桥:输入是本次 PR 的代码改动,输出是针对于具体改动行的、可回复、可处理、可追溯的审查评论。基于这个判断,我给 open-code-review 定了三条设计原则:以 diff 为唯一输入;规则插件化注册;每次运行结果必须完全可复现。这套定位决定了后面所有技术选型。有人问我,为什么不用大模型直接扫代码?我在第 6 部分会专门讲 LLM 和确定性规则怎么配合,但这里先亮观点:工程化落地时,非确定性输出、延迟、上下文长度限制每一项都够喝一壶。open-code-review 先把确定性做扎实,再考虑让 AI 做加法,顺序不能反。如果你正打算在团队里搭建一套轻量、可控、不吃外部服务的代码审查能力,或者你只是好奇一个开源审查工具的内部结构,这篇文章会把从设计到落地、再到踩坑修正的完整过程拆给你看。1. 为什么我要做这个"重复造轮子"的代码审查工具"别人已经有那么多审查工具了,为什么还要自己写?"这是我被问得最多的问题。答案其实特别务实:现有工具解决的是"审查这个动作有没有发生",而我想解决的问题是"团队规范能不能随着代码一起演进"。绝大多数团队做 Code Review 时会遇到一个隐性问题:评审意见是离散的、不可检索的。今天张三在 PR 里说"这里不要拼 SQL",明天李四又在另一个 PR 里犯同样的错。每条意见只存在于当时那个对话上下文里,没有被结构化沉淀成团队的共同资产。传统静态分析工具能沉淀规则,但它们的规则面向项目整体质量,很难贴合"本次改动引入了什么风险"这个评审场景。1.1 三个真实使用场景,决定了工具边界我调研期间收集了团队里二十几个"最让人抓狂的评审意见",归纳下来集中在三个场景:场景一:高危操作拦截。比如 MyBatis 的${}拼接、事务方法内调用远程接口、密钥硬编码。这类问题一旦漏掉,上线可能就是事故。场景二:风格与规范一致性。比如新增接口没有返回 DTO、在 Controller 里写业务逻辑、测试方法没有断言。这些问题不致命,但会在长期维护里持续放大。场景三:常识性兜底。比如空指针风险、资源未关闭、魔法值散落。这类检查静态分析器也能做,但它们往往把几百条告警一次性糊到开发者脸上,开发者根本分不清哪些是本次改动引入的。这三个场景有一个共同点:它们都只关心"这次改动的增量",而不关心十几万行存量代码。所以 open-code-review 的边界非常清晰——只对 diff 负责。存量问题不是它的责任范围,那交给 SonarQube 体系更合适。1.2 可复现性为什么是硬性要求很多审查工具最让人头痛的问题不是查不出问题,而是同一个 PR 跑两遍,结果不一样。尤其是在大模型方案里,温度参数稍微一变,上次报的问题这次就没了。这在技术债追踪上几乎是灾难:你没法向团队解释为什么一个已经确认的高危问题,在复查时消失得无影无踪。open-code-review 把可复现性作为一等公民:同一个代码提交、同一个规则版本、同一个配置文件,执行两次的 JSON 输出必须完全一致。因此核心审查引擎完全基于确定性规则——正则、AST 节点匹配、路径模式,不碰概率模型。这一条看着简单,实际对架构设计的影响非常大,后面讲规则引擎时你会体会到。2. 从 PR 评论到本地 CLI:工作模式的设计取舍很多做审查工具的人有个惯性:一上来就做"机器人",登录平台、监听 webhook、在评论里 @ 人。我一开始也差点这么干,但做到一半发现这个交互模型太笨重了。每次审查都要跑一个常驻服务,要配置平台权限,要处理回调签名验证,还没跑到规则匹配阶段就已经折腾掉半条命。于是我把 open-code-review 的工作模式拆成了两层:底层是一个纯命令行工具,输入是一个 diff 文件或者两个 Git ref,输出是一份 JSON 格式的审查报告。上层是平台适配层,负责把 JSON 报告格式化成评论,回填到 GitHub/GitLab 的 PR 里。这个拆分带来的自由度,用久了才能体会到:本地可以先跑、CI 里可以跑、甚至可以在 pre-push hook 里跑。工具本身不再被平台绑架,平台只需要一个轻量的展示层。2.1 每次拉取 diff 直接触发,而不是全量扫描核心引擎的入口只有 diff,不处理整个文件。很多人会忽略这个设计的分量。普通静态分析器看的是整个函数,它关心的是"这个文件现在健不健康"。而 diff 模式只关心"你这次新加了什么、删了什么、改了什么"。同样一段拼接 SQL 的代码,综合来看确实不规范;但如果它是三个月前的老代码,这次只是被换个位置,那就不应该再占用本次评审的注意力。评审资源的稀缺性决定了我们必须把火力集中在增量上。技术实现上,我用 Git 的 diff-tree 拿到两个 ref 之间的变更文件列表,再逐个文件解析 unified diff 的 hunk,把"改动的行号区间"和"改动后的内容"分开保存。规则引擎拿到的是已经切片好的增量数据,不接触仓库里的其他源码。这样做还有个附加的好处:敏感信息检测变得极其快——你只需要在 diff 内容里做模式匹配,而不必把整个历史提交翻出来跑一遍。2.2 插件式规则集与 CI 解耦规则如何加载,是审查工具的生死线。不同团队对"什么是问题"的定义差异极大。做支付系统的团队会日夜盯着金额计算有没有用浮点;做内容社区的可能最关心用户输入有没有被正确转义。这些规则如果写死在核心库里,核心库很快就会变成一个无法维护的大杂烩。所以 open-code-review 把规则设计成独立插件包,每个插件在启动时向核心进程注册名字、匹配器、优先级、修复建议四样东西。核心进程只负责调度和执行,不关心规则内部逻辑。规则仓库和主程序版本完全解耦——主程序可以一年不升级,团队规则却可以随一个 PR 即时更新。这个设计在实际落地时省了很多事。我们团队前后端是两个仓库:后端仓库挂载 Java 规则集,前端仓库挂载 React 规则集。同一个二进制,两份配置,互不干扰,CI 里只有一个无状态的可执行文件,逻辑干净得像一张白纸。3. 核心实现:把"代码味道"翻译成机器可判断的规则规则引擎是这个项目技术含量最高的部分。我最早的一版实现基本靠关键词匹配,准确率惨不忍睹。后来想明白了一个道理:任何规则本质上都在回答三件事——在什么文件类型里?在什么语法结构下?什么样的文本模式会命中?把这三个问题结构化表达出来,规则就从"碰运气"变成了"有据可依"。3.1 规则表现层:YAML 模板 + 正则 + 小型 AST 遍历你在 open-code-review 的规则仓库里看到的大多数规则,长这样:- name: no-raw-sql-in-controller language: java target: added scope: src/main path_ignore: - "**/migration/**" - "**/test/**" pattern: type: ast node: MethodInvocation rule: methodName in ["createStatement", "executeQuery", "prepareStatement"] message: "不要在 Controller 层直接执行 SQL,请使用 Repository 封装。" priority: critical这个 YAML 结构里面有四个核心字段,每个字段我都做过权衡:language:限制规则作用的文件扩展名,减少无意义的匹配尝试。target:决定匹配"新增行"还是"改动附近的行"还是"整个 hunk"。默认只匹配新增行,因为只有新增代码才是本次变更引入的风险。pattern:根据规则复杂度,可以是一段正则、一组子串、或者一个函数式的 AST 遍历入口。priority:info、warning、critical 三个级别,输送到平台后,只有 critical 会阻塞合并。为什么保留函数式 AST 入口?因为有些规则光靠正则是表达不出来的。比如"禁止在事务方法里做远程 HTTP 调用",你需要知道某个方法有没有事务注解,方法体里有没有调用 httpClient。这种语义关系正则搞不定,必须在语法树层面做小范围遍历。所以我在 pattern 字段里支持指定一个函数名,函数由插件提供,接收一个轻量级的 AST 节点上下文,返回布尔值。这里不要误解我:我并没有为每种语言重新实现完整 AST,而是复用了 tree-sitter 生成的语法树,只保留需要的节点类型和行号范围。这样规则插件可以按语法节点去匹配,又不用背整棵树的复杂度。实测下来,一个中型项目完整跑一遍 diff 大概在几百毫秒到两秒之间,完全能接受。3.2 定位到具体代码行,再回填到 Review 会话规则命中之后,怎么把"问题"映射回"代码的具体位置",是个极其容易被低估的细节。如果你简单记住"文件第 42 行有问题",那当 diff 更新后,行号必然漂移。开发者在旧版本代码上看到的评论,到新版本已经指着一行完全无关的代码了。open-code-review 的做法是:核心库维护一张从"hunk ID + 新文件行号"到"diff 位置"的映射表。评论不挂在绝对行号上,而是挂在 diff 里那一块具体的位置上。平台之所以能把旧评论标记为过时,也是因为它感知到了这个漂移,我们实际上提前做了同一层映射。这个细节在不少开源工具里是被忽略的。你可以看到它们把 issue 打到 PR 页面时,行号已经偏离实际位置,评论区和代码上下文对不上,开发者只能在评论区里追问"你说的是哪一行"。开放团队的审查工具,这种体验是很影响信任感的。所以 open-code-review 把"文本命中所在 hunk"和"当前文件绝对行号"分开存储,上游展示层按需选择,从根上避开了这个问题。4. 在真实仓库里稳定跑起来的三个关键点设计完规则引擎,我以为最难的已经过去了,结果在自己的 CI 里试跑一个多月才意识到,跑起来容易,稳定跑起来是另一回事。下面这三个问题,如果不在架构层面处理掉,工具根本没法在生产环境长期运行。4.1 幂等性:不能因为重复跑就把评论刷屏第一个坑是评论重复。最初我采用"整个 PR 跑完,全量结果一次性回填评论区"的思路,结果 CI 重试机制直接给我上了一课:第一次任务跑到一半超时,重试启动,第二轮任务又跑完一遍。第一遍已经回填了 12 条评论,第二遍又回填 12 条,评论区直接刷屏。解决思路是把审查结果以 commit 为粒度做数据幂等。每条结果有一个唯一指纹,生成方式大概是这样:fingerprint = sha256( diff_head + rule.name + location.hunk_id + location.new_line + match.snippet )结果入库时,如果相同指纹已存在,就直接复用旧结果,不再产生新的评论实体。无论 CI 重试多少次,开发者看到的永远是同一批评论,每一条都带有稳定的 ID,方便追踪和处理。4.2 上下文窗口:超长 diff 如何切片第二个问题是极端 diff。我们团队真的有人一次性改过 3000 多行文件,这种量级如果按"整个文件做上下文"去喂规则,会带来两个后果:内存占用暴涨,以及正则回溯超时。最终我把 diff 统一切片成"功能块"。切片逻辑不复杂:先按 hunk 边界切分,单个 hunk 超过阈值(默认 500 行)时,再按连续空行或函数定义行切成子块。规则引擎逐块处理,块与块之间不做全局推理。这里没有高深算法,但它在真实数据上验证得很充分:**大多数误报不是规则本身错了,而是引擎在过大的上下文里"看到"了太多它本不应该看到的东西。**上下文越小,规则命中越精准,这条经验已经被这个项目验证过无数遍。4.3 并发调用与审查历史落库第三个问题是并发。多个 PR 同时触发审查时,如果每次审查都重新初始化整个规则插件集合,尤其是 AST 解析器的初始化,代价非常可观。后来我引入了一个进程内的插件池,插件实例不持有任何与单次 diff 相关的状态,可以安全地并发复用。插件池的预热放在 CI 任务启动阶段,预热完成后,并发任务直接复用解析器实例。结果落库我选的是 SQLite,表结构非常简单:review_runs:记录一次审查运行的基本信息。review_issues:记录一次发现结果,指纹、规则名、级别、位置。review_issue_context:记录命中位置附近的代码片段。这里有个低级但深刻的教训是:**一定要把规则版本号写入结果表。**否则你更新团队规则之后,历史结果和你现在的规则对不上,你根本说不清哪些问题是因为规则优化而消失的,哪些其实是本来就消失了。版本号写进去,再去回溯误报率才有据可查。5. 高误报率背后的根因分析与修正任何规则系统都会面对误报问题,公开批评它最狠的也往往是误报。但很多团队把误报归咎于"规则写得不好",在我看来只说对了一半。大多数误报的背后,是规则的语境缺失。5.1 一个把数据迁移误判为 SQL 注入的案例先讲一个让我记忆犹新的案例。有一个后端仓库在做存量数据清洗,几千行 SQL 直接写在迁移脚本里,里面有不少字符串拼接——但变量全部来自配置中心,运行时输出到日志里。open-code-review 的"SQL 注入检测"规则组直接报了上百条高危告警,整个 PR 被机器人标红,开发者慌了一下午,最后发现全是误报。这个案子的根子不在"字符串拼接 SQL"这个动作本身,而在于引擎缺少了一个关键的上下文维度:文件路径。迁移脚本、测试夹具、演示代码,这些场景下 SQL 拼接的风险模型完全不同。一刀切地拿同一个规则去约束它们,一定会被海量假阳性淹没。5.2 规则优先级和跳过机制的建立那次事件之后,我给规则系统补上了三个能力,误报率整体降了一个数量级:路径级跳过(path_ignore):比如**/migration/**、**/test/**、**/fixtures/**默认不参与高风险规则匹配,除非显式开启。规则级作用域(scope):规则除了匹配内容,还能声明"只在 src 主代码目录生效"。分级审查(priority):critical 作为阻塞检查项,warning 和 info 只提示,不阻断合并。这套机制的核心逻辑是:**路径、目录、文件类型是最稳定、最低成本的语境信号。**它比任何自然语言语义分析都可靠。规则不再只是"在代码里找可疑片段",而是"在特定的语境里找可疑片段"。语境一旦对了,很多看起来很难的规则都变得可实现。我还调整了信息输出结构,让误报对开发者更友好。每条结果都带上了命中片段和规则文件链接,开发者不需要点进一个黑盒才知道"为什么报我"。5.3 小成本让规则自学习:基于已批准变更的负反馈还有一个让我比较得意的设计:负反馈机制。大多数静态工具只有一个单向行为——命中了就报告。但在真实团队里,经常会出现"人工确认问题不大,直接 approve"的情景。为了不让同一条规则反复骚扰同一个开发者,open-code-review 增加了一个"人工确认"列表。当一个审查结果被开发者标记为"忽略"或"确认无风险"时,系统会把"文件路径 + 规则名"这个组合记入抑制表,默认抑制 30 天。同时,这个反馈会写入一个 hidden 目录,供规则维护者复盘。但这种抑制机制有风险:它本质上是用人的判断去覆盖机器规则。如果开发者为了过 CI 随手点"忽略",规则系统会慢慢形同虚设。因此抑制记录必须可审计、可回滚。每次开发者在 PR 里点"忽略"时,系统都会记录一条元数据:谁、什么时间、在什么代码上下文里忽略的。当我们需要回头审视规则本身的问题时,所有数据都能被追踪,谁也不用手动去翻聊天记录。6. 演进方向:从"静态规则"到"团队规范沉淀"跑了大半年,open-code-review 带给团队最大的变化,不是检查出多少 bug,而是把原本散落在聊天记录、代码评审口头意见和个人博客里的团队规范,慢慢沉淀成了一套机器可执行的规则库。这个变化比一次多抓几个 bug 有价值得多。举个具体的例子。我们团队以前约定"接口返回值不能直接包装成 Map,要定义 DTO",这个约定几乎每个新同事都会踩一遍,因为文档根本没人看。后来我把这条约定写成一条针对 Java 类型检查的规则:检查返回值类型是否为 Map 或 JSONObject,并在 message 里附带项目现成的 DTO 示例链接。新同事一提交代码,机器人就会在 PR 里指出问题,还附上现成范例。这种把口头约定变成规则的做法,比任何培训都有效,因为它出现在问题发生的那一刻。6.1 团队规范的"规则化"路径我建议你按下面这条路径去沉淀团队规范,不要一口气全铺开:第一阶段:收集 Code Review 时被反复指出的问题,写成规则草稿,先入 keywords 规则快速验证命中效果。第二阶段:命中量稳定后,把规则细化到具体目录、具体文件模式,补上路径级跳过和优先级分级。第三阶段:如果某些规则在真实历史提交里误报率偏高,回到规则仓库里调整 pattern 或加负反馈,而不是直接删除整条规则。第四阶段:定期把规则的命中数据拉出来看。你会发现有些规则过了一个月就几乎命中不到了——那不一定代表它废了,而是它已经内化成了团队默认的开发习惯,这本身就是件好事。这个流程的本质是:**把隐性的团队知识逐步转写成显性的机器规则,再把机器规则逐步转写成默认习惯。**当规则不再需要运行时,说明团队已经进步了,规则完成了它的历史使命。6.2 与 AI 大模型结合时的角色分配最后聊聊大家都关心的 AI。我目前正在尝试给 open-code-review 加一个可选的"LLM 建议器"模块,角色定位非常明确:给确定性规则做加法,而不是替代它。具体方案是两条路径并行。确定性规则引擎先做第一轮过滤,高置信问题直接上 PR 评论;对于规则漏掉但人类大概率会关心的部分,比如"这个异步方法没有超时控制",再由大模型基于 diff 上下文生成候选建议。候选建议不会直接出现在评论区,而是进一个旁路队列,由人工确认确认后合并。这样既保住了规则引擎的确定性和可追溯性,又吸收了 LLM 的语义理解能力。如果你也在把大模型引入代码审查,我给你提个醒:千万别让 LLM 直接写评论,否则同一个 PR 跑三遍,你会收获三套完全不同的意见,团队对审查系统的信任会瞬间清零。确定性引擎负责"守住底线",LLM 负责"拓展上限",各干各擅长的事,才是工程上最务实的组合。最后分享一个使用上的心得:一定要从 2-3 条自己团队最痛的问题开始跑,不要在刚开始就导入四五十条规则。规则一多,误报和噪音会把机器人的信誉拖垮,开发者最后会无视它的所有评论。先让它少说、说准,再让它慢慢变强,这个顺序比技术本身重要得多。
先说结论:做 open-code-review 的初衷,不是想替代 CodeRabbit、Sweep 或者市面上一堆 AI Code Review 服务,而是受够了每次提 PR 都要等外部服务回调、受够了规则模板无法和团队内部规范打通。这个项目定位是"可以长期跑在自己 CI 里的开源审查框架",它把代码审查从一个黑盒服务变成了一个你能完全掌控的工程组件。我在正式动工之前,先花了两周做调研。当时市面上的方案大致能分成三类,每一类都有让人没法忍的短板:方案类型代表产品/形态优势硬伤SaaS 托管审查CodeRabbit、各类 AI Review Bot接入快、交互好看审查逻辑不透明,结果不可复现,团队规范难以自定义平台绑定插件GitHub Action、GitLab CI 里现成插件轻量、就在 PR 流程里换个平台等于换工具,规则写死,二次开发成本高传统静态分析SonarQube、ESLint、SpotBugs分析深度强面向整个项目,不面向 diff,存量问题会把增量噪音淹没对比完我意识到,自己缺的不是另一个"智能分析器",而是一个能夹在 diff 提取和静态分析之间的桥:输入是本次 PR 的代码改动,输出是针对于具体改动行的、可回复、可处理、可追溯的审查评论。基于这个判断,我给 open-code-review 定了三条设计原则:以 diff 为唯一输入;规则插件化注册;每次运行结果必须完全可复现。这套定位决定了后面所有技术选型。有人问我,为什么不用大模型直接扫代码?我在第 6 部分会专门讲 LLM 和确定性规则怎么配合,但这里先亮观点:工程化落地时,非确定性输出、延迟、上下文长度限制每一项都够喝一壶。open-code-review 先把确定性做扎实,再考虑让 AI 做加法,顺序不能反。如果你正打算在团队里搭建一套轻量、可控、不吃外部服务的代码审查能力,或者你只是好奇一个开源审查工具的内部结构,这篇文章会把从设计到落地、再到踩坑修正的完整过程拆给你看。1. 为什么我要做这个"重复造轮子"的代码审查工具"别人已经有那么多审查工具了,为什么还要自己写?"这是我被问得最多的问题。答案其实特别务实:现有工具解决的是"审查这个动作有没有发生",而我想解决的问题是"团队规范能不能随着代码一起演进"。绝大多数团队做 Code Review 时会遇到一个隐性问题:评审意见是离散的、不可检索的。今天张三在 PR 里说"这里不要拼 SQL",明天李四又在另一个 PR 里犯同样的错。每条意见只存在于当时那个对话上下文里,没有被结构化沉淀成团队的共同资产。传统静态分析工具能沉淀规则,但它们的规则面向项目整体质量,很难贴合"本次改动引入了什么风险"这个评审场景。1.1 三个真实使用场景,决定了工具边界我调研期间收集了团队里二十几个"最让人抓狂的评审意见",归纳下来集中在三个场景:场景一:高危操作拦截。比如 MyBatis 的${}拼接、事务方法内调用远程接口、密钥硬编码。这类问题一旦漏掉,上线可能就是事故。场景二:风格与规范一致性。比如新增接口没有返回 DTO、在 Controller 里写业务逻辑、测试方法没有断言。这些问题不致命,但会在长期维护里持续放大。场景三:常识性兜底。比如空指针风险、资源未关闭、魔法值散落。这类检查静态分析器也能做,但它们往往把几百条告警一次性糊到开发者脸上,开发者根本分不清哪些是本次改动引入的。这三个场景有一个共同点:它们都只关心"这次改动的增量",而不关心十几万行存量代码。所以 open-code-review 的边界非常清晰——只对 diff 负责。存量问题不是它的责任范围,那交给 SonarQube 体系更合适。1.2 可复现性为什么是硬性要求很多审查工具最让人头痛的问题不是查不出问题,而是同一个 PR 跑两遍,结果不一样。尤其是在大模型方案里,温度参数稍微一变,上次报的问题这次就没了。这在技术债追踪上几乎是灾难:你没法向团队解释为什么一个已经确认的高危问题,在复查时消失得无影无踪。open-code-review 把可复现性作为一等公民:同一个代码提交、同一个规则版本、同一个配置文件,执行两次的 JSON 输出必须完全一致。因此核心审查引擎完全基于确定性规则——正则、AST 节点匹配、路径模式,不碰概率模型。这一条看着简单,实际对架构设计的影响非常大,后面讲规则引擎时你会体会到。2. 从 PR 评论到本地 CLI:工作模式的设计取舍很多做审查工具的人有个惯性:一上来就做"机器人",登录平台、监听 webhook、在评论里 @ 人。我一开始也差点这么干,但做到一半发现这个交互模型太笨重了。每次审查都要跑一个常驻服务,要配置平台权限,要处理回调签名验证,还没跑到规则匹配阶段就已经折腾掉半条命。于是我把 open-code-review 的工作模式拆成了两层:底层是一个纯命令行工具,输入是一个 diff 文件或者两个 Git ref,输出是一份 JSON 格式的审查报告。上层是平台适配层,负责把 JSON 报告格式化成评论,回填到 GitHub/GitLab 的 PR 里。这个拆分带来的自由度,用久了才能体会到:本地可以先跑、CI 里可以跑、甚至可以在 pre-push hook 里跑。工具本身不再被平台绑架,平台只需要一个轻量的展示层。2.1 每次拉取 diff 直接触发,而不是全量扫描核心引擎的入口只有 diff,不处理整个文件。很多人会忽略这个设计的分量。普通静态分析器看的是整个函数,它关心的是"这个文件现在健不健康"。而 diff 模式只关心"你这次新加了什么、删了什么、改了什么"。同样一段拼接 SQL 的代码,综合来看确实不规范;但如果它是三个月前的老代码,这次只是被换个位置,那就不应该再占用本次评审的注意力。评审资源的稀缺性决定了我们必须把火力集中在增量上。技术实现上,我用 Git 的 diff-tree 拿到两个 ref 之间的变更文件列表,再逐个文件解析 unified diff 的 hunk,把"改动的行号区间"和"改动后的内容"分开保存。规则引擎拿到的是已经切片好的增量数据,不接触仓库里的其他源码。这样做还有个附加的好处:敏感信息检测变得极其快——你只需要在 diff 内容里做模式匹配,而不必把整个历史提交翻出来跑一遍。2.2 插件式规则集与 CI 解耦规则如何加载,是审查工具的生死线。不同团队对"什么是问题"的定义差异极大。做支付系统的团队会日夜盯着金额计算有没有用浮点;做内容社区的可能最关心用户输入有没有被正确转义。这些规则如果写死在核心库里,核心库很快就会变成一个无法维护的大杂烩。所以 open-code-review 把规则设计成独立插件包,每个插件在启动时向核心进程注册名字、匹配器、优先级、修复建议四样东西。核心进程只负责调度和执行,不关心规则内部逻辑。规则仓库和主程序版本完全解耦——主程序可以一年不升级,团队规则却可以随一个 PR 即时更新。这个设计在实际落地时省了很多事。我们团队前后端是两个仓库:后端仓库挂载 Java 规则集,前端仓库挂载 React 规则集。同一个二进制,两份配置,互不干扰,CI 里只有一个无状态的可执行文件,逻辑干净得像一张白纸。3. 核心实现:把"代码味道"翻译成机器可判断的规则规则引擎是这个项目技术含量最高的部分。我最早的一版实现基本靠关键词匹配,准确率惨不忍睹。后来想明白了一个道理:任何规则本质上都在回答三件事——在什么文件类型里?在什么语法结构下?什么样的文本模式会命中?把这三个问题结构化表达出来,规则就从"碰运气"变成了"有据可依"。3.1 规则表现层:YAML 模板 + 正则 + 小型 AST 遍历你在 open-code-review 的规则仓库里看到的大多数规则,长这样:- name: no-raw-sql-in-controller language: java target: added scope: src/main path_ignore: - "**/migration/**" - "**/test/**" pattern: type: ast node: MethodInvocation rule: methodName in ["createStatement", "executeQuery", "prepareStatement"] message: "不要在 Controller 层直接执行 SQL,请使用 Repository 封装。" priority: critical这个 YAML 结构里面有四个核心字段,每个字段我都做过权衡:language:限制规则作用的文件扩展名,减少无意义的匹配尝试。target:决定匹配"新增行"还是"改动附近的行"还是"整个 hunk"。默认只匹配新增行,因为只有新增代码才是本次变更引入的风险。pattern:根据规则复杂度,可以是一段正则、一组子串、或者一个函数式的 AST 遍历入口。priority:info、warning、critical 三个级别,输送到平台后,只有 critical 会阻塞合并。为什么保留函数式 AST 入口?因为有些规则光靠正则是表达不出来的。比如"禁止在事务方法里做远程 HTTP 调用",你需要知道某个方法有没有事务注解,方法体里有没有调用 httpClient。这种语义关系正则搞不定,必须在语法树层面做小范围遍历。所以我在 pattern 字段里支持指定一个函数名,函数由插件提供,接收一个轻量级的 AST 节点上下文,返回布尔值。这里不要误解我:我并没有为每种语言重新实现完整 AST,而是复用了 tree-sitter 生成的语法树,只保留需要的节点类型和行号范围。这样规则插件可以按语法节点去匹配,又不用背整棵树的复杂度。实测下来,一个中型项目完整跑一遍 diff 大概在几百毫秒到两秒之间,完全能接受。3.2 定位到具体代码行,再回填到 Review 会话规则命中之后,怎么把"问题"映射回"代码的具体位置",是个极其容易被低估的细节。如果你简单记住"文件第 42 行有问题",那当 diff 更新后,行号必然漂移。开发者在旧版本代码上看到的评论,到新版本已经指着一行完全无关的代码了。open-code-review 的做法是:核心库维护一张从"hunk ID + 新文件行号"到"diff 位置"的映射表。评论不挂在绝对行号上,而是挂在 diff 里那一块具体的位置上。平台之所以能把旧评论标记为过时,也是因为它感知到了这个漂移,我们实际上提前做了同一层映射。这个细节在不少开源工具里是被忽略的。你可以看到它们把 issue 打到 PR 页面时,行号已经偏离实际位置,评论区和代码上下文对不上,开发者只能在评论区里追问"你说的是哪一行"。开放团队的审查工具,这种体验是很影响信任感的。所以 open-code-review 把"文本命中所在 hunk"和"当前文件绝对行号"分开存储,上游展示层按需选择,从根上避开了这个问题。4. 在真实仓库里稳定跑起来的三个关键点设计完规则引擎,我以为最难的已经过去了,结果在自己的 CI 里试跑一个多月才意识到,跑起来容易,稳定跑起来是另一回事。下面这三个问题,如果不在架构层面处理掉,工具根本没法在生产环境长期运行。4.1 幂等性:不能因为重复跑就把评论刷屏第一个坑是评论重复。最初我采用"整个 PR 跑完,全量结果一次性回填评论区"的思路,结果 CI 重试机制直接给我上了一课:第一次任务跑到一半超时,重试启动,第二轮任务又跑完一遍。第一遍已经回填了 12 条评论,第二遍又回填 12 条,评论区直接刷屏。解决思路是把审查结果以 commit 为粒度做数据幂等。每条结果有一个唯一指纹,生成方式大概是这样:fingerprint = sha256( diff_head + rule.name + location.hunk_id + location.new_line + match.snippet )结果入库时,如果相同指纹已存在,就直接复用旧结果,不再产生新的评论实体。无论 CI 重试多少次,开发者看到的永远是同一批评论,每一条都带有稳定的 ID,方便追踪和处理。4.2 上下文窗口:超长 diff 如何切片第二个问题是极端 diff。我们团队真的有人一次性改过 3000 多行文件,这种量级如果按"整个文件做上下文"去喂规则,会带来两个后果:内存占用暴涨,以及正则回溯超时。最终我把 diff 统一切片成"功能块"。切片逻辑不复杂:先按 hunk 边界切分,单个 hunk 超过阈值(默认 500 行)时,再按连续空行或函数定义行切成子块。规则引擎逐块处理,块与块之间不做全局推理。这里没有高深算法,但它在真实数据上验证得很充分:**大多数误报不是规则本身错了,而是引擎在过大的上下文里"看到"了太多它本不应该看到的东西。**上下文越小,规则命中越精准,这条经验已经被这个项目验证过无数遍。4.3 并发调用与审查历史落库第三个问题是并发。多个 PR 同时触发审查时,如果每次审查都重新初始化整个规则插件集合,尤其是 AST 解析器的初始化,代价非常可观。后来我引入了一个进程内的插件池,插件实例不持有任何与单次 diff 相关的状态,可以安全地并发复用。插件池的预热放在 CI 任务启动阶段,预热完成后,并发任务直接复用解析器实例。结果落库我选的是 SQLite,表结构非常简单:review_runs:记录一次审查运行的基本信息。review_issues:记录一次发现结果,指纹、规则名、级别、位置。review_issue_context:记录命中位置附近的代码片段。这里有个低级但深刻的教训是:**一定要把规则版本号写入结果表。**否则你更新团队规则之后,历史结果和你现在的规则对不上,你根本说不清哪些问题是因为规则优化而消失的,哪些其实是本来就消失了。版本号写进去,再去回溯误报率才有据可查。5. 高误报率背后的根因分析与修正任何规则系统都会面对误报问题,公开批评它最狠的也往往是误报。但很多团队把误报归咎于"规则写得不好",在我看来只说对了一半。大多数误报的背后,是规则的语境缺失。5.1 一个把数据迁移误判为 SQL 注入的案例先讲一个让我记忆犹新的案例。有一个后端仓库在做存量数据清洗,几千行 SQL 直接写在迁移脚本里,里面有不少字符串拼接——但变量全部来自配置中心,运行时输出到日志里。open-code-review 的"SQL 注入检测"规则组直接报了上百条高危告警,整个 PR 被机器人标红,开发者慌了一下午,最后发现全是误报。这个案子的根子不在"字符串拼接 SQL"这个动作本身,而在于引擎缺少了一个关键的上下文维度:文件路径。迁移脚本、测试夹具、演示代码,这些场景下 SQL 拼接的风险模型完全不同。一刀切地拿同一个规则去约束它们,一定会被海量假阳性淹没。5.2 规则优先级和跳过机制的建立那次事件之后,我给规则系统补上了三个能力,误报率整体降了一个数量级:路径级跳过(path_ignore):比如**/migration/**、**/test/**、**/fixtures/**默认不参与高风险规则匹配,除非显式开启。规则级作用域(scope):规则除了匹配内容,还能声明"只在 src 主代码目录生效"。分级审查(priority):critical 作为阻塞检查项,warning 和 info 只提示,不阻断合并。这套机制的核心逻辑是:**路径、目录、文件类型是最稳定、最低成本的语境信号。**它比任何自然语言语义分析都可靠。规则不再只是"在代码里找可疑片段",而是"在特定的语境里找可疑片段"。语境一旦对了,很多看起来很难的规则都变得可实现。我还调整了信息输出结构,让误报对开发者更友好。每条结果都带上了命中片段和规则文件链接,开发者不需要点进一个黑盒才知道"为什么报我"。5.3 小成本让规则自学习:基于已批准变更的负反馈还有一个让我比较得意的设计:负反馈机制。大多数静态工具只有一个单向行为——命中了就报告。但在真实团队里,经常会出现"人工确认问题不大,直接 approve"的情景。为了不让同一条规则反复骚扰同一个开发者,open-code-review 增加了一个"人工确认"列表。当一个审查结果被开发者标记为"忽略"或"确认无风险"时,系统会把"文件路径 + 规则名"这个组合记入抑制表,默认抑制 30 天。同时,这个反馈会写入一个 hidden 目录,供规则维护者复盘。但这种抑制机制有风险:它本质上是用人的判断去覆盖机器规则。如果开发者为了过 CI 随手点"忽略",规则系统会慢慢形同虚设。因此抑制记录必须可审计、可回滚。每次开发者在 PR 里点"忽略"时,系统都会记录一条元数据:谁、什么时间、在什么代码上下文里忽略的。当我们需要回头审视规则本身的问题时,所有数据都能被追踪,谁也不用手动去翻聊天记录。6. 演进方向:从"静态规则"到"团队规范沉淀"跑了大半年,open-code-review 带给团队最大的变化,不是检查出多少 bug,而是把原本散落在聊天记录、代码评审口头意见和个人博客里的团队规范,慢慢沉淀成了一套机器可执行的规则库。这个变化比一次多抓几个 bug 有价值得多。举个具体的例子。我们团队以前约定"接口返回值不能直接包装成 Map,要定义 DTO",这个约定几乎每个新同事都会踩一遍,因为文档根本没人看。后来我把这条约定写成一条针对 Java 类型检查的规则:检查返回值类型是否为 Map 或 JSONObject,并在 message 里附带项目现成的 DTO 示例链接。新同事一提交代码,机器人就会在 PR 里指出问题,还附上现成范例。这种把口头约定变成规则的做法,比任何培训都有效,因为它出现在问题发生的那一刻。6.1 团队规范的"规则化"路径我建议你按下面这条路径去沉淀团队规范,不要一口气全铺开:第一阶段:收集 Code Review 时被反复指出的问题,写成规则草稿,先入 keywords 规则快速验证命中效果。第二阶段:命中量稳定后,把规则细化到具体目录、具体文件模式,补上路径级跳过和优先级分级。第三阶段:如果某些规则在真实历史提交里误报率偏高,回到规则仓库里调整 pattern 或加负反馈,而不是直接删除整条规则。第四阶段:定期把规则的命中数据拉出来看。你会发现有些规则过了一个月就几乎命中不到了——那不一定代表它废了,而是它已经内化成了团队默认的开发习惯,这本身就是件好事。这个流程的本质是:**把隐性的团队知识逐步转写成显性的机器规则,再把机器规则逐步转写成默认习惯。**当规则不再需要运行时,说明团队已经进步了,规则完成了它的历史使命。6.2 与 AI 大模型结合时的角色分配最后聊聊大家都关心的 AI。我目前正在尝试给 open-code-review 加一个可选的"LLM 建议器"模块,角色定位非常明确:给确定性规则做加法,而不是替代它。具体方案是两条路径并行。确定性规则引擎先做第一轮过滤,高置信问题直接上 PR 评论;对于规则漏掉但人类大概率会关心的部分,比如"这个异步方法没有超时控制",再由大模型基于 diff 上下文生成候选建议。候选建议不会直接出现在评论区,而是进一个旁路队列,由人工确认确认后合并。这样既保住了规则引擎的确定性和可追溯性,又吸收了 LLM 的语义理解能力。如果你也在把大模型引入代码审查,我给你提个醒:千万别让 LLM 直接写评论,否则同一个 PR 跑三遍,你会收获三套完全不同的意见,团队对审查系统的信任会瞬间清零。确定性引擎负责"守住底线",LLM 负责"拓展上限",各干各擅长的事,才是工程上最务实的组合。最后分享一个使用上的心得:一定要从 2-3 条自己团队最痛的问题开始跑,不要在刚开始就导入四五十条规则。规则一多,误报和噪音会把机器人的信誉拖垮,开发者最后会无视它的所有评论。先让它少说、说准,再让它慢慢变强,这个顺序比技术本身重要得多。