资讯动态

轻量级代码审查工具 open-code-review:规则引擎设计与 CI 集成实践

发布时间:2026/10/19 9:19:14 来源:尧图企业网站定制
做开源项目这么些年我最大的感受是写代码永远比审代码轻松。PR一多起来靠维护者人力去盯效率低不说各种重复性问题还会反复出现——有人又在代码里留了 TODO有人把访问密钥直接写死在配置里还有人把调试日志忘在主干代码上。这类问题每次都要人工点出来不说又不行说了又显得啰嗦特别消耗心力。open-code-review 这个项目就是从我自己的这个痛点里长出来的。它的定位很直接一个轻量级的代码审查辅助工具通过静态规则检查在 Pull Request 阶段自动拦截一批低级问题把维护者从重复劳动里解放出来让他们把精力放在真正需要人来做的事情上——看设计、聊架构、抠业务逻辑。项目以 CLI 工具为核心同时提供了 GitHub Action 封装你往仓库里扔一个配置文件PR 一开就能自动跑审查意见直接回写到 PR 评论区。对开源维护者、技术团队负责人甚至一个人维护多个仓库的独立开发者都有直接用处。这篇文章不打算只讲项目本身做了什么功能我想把整个设计和实现思路完整拆开包括规则引擎怎么设计、配置格式为什么长这样、接入 CI 时有哪些坑、实际用起来怎么调优。想自己动手实现的照着思路走就能搭一个出来。1. 项目定位与核心设计思路1.1 代码审查的痛点在哪里先说场景。一个项目活跃起来之后PR 的来源基本分三类核心成员的重构改动、贡献者的新功能、还有大量修 bug 的小改动。前两类值得仔细 review第三类数量多但改动小里面却偏偏最容易混进“脏东西”。我统计过自己维护的几个仓库PR 里出现频率最高的问题有这么几类遗留的 TODO/FIXME 注释、硬编码的密钥或 token、调试用的 console.log/print 语句、被注释掉的旧代码块、明显不规范的缩进和命名。这些问题单个看都不算严重但不处理又不行等它们堆积到主干分支里后面再回头清理的成本要高得多。关键是这些问题完全不需要人来判断。它们有固定的特征用规则就能识别。既然能自动化为什么还要浪费维护者时间去逐条看open-code-review 想解决的就是这部分机械性的重复劳动。1.2 方案选型为什么不用现成的 Linter做这个项目之前我认真对比过现成方案。ESLint、RuboCop、golangci-lint 这些语言级 Linter 确实能覆盖一部分问题但它们有两个绕不开的局限。语言绑定太死。一个仓库多种语言是常态前端有 TypeScript后端有 Go脚本里有 Python难道要分别配三套 Linter、写三套 CI 步骤维护成本直接翻倍。更麻烦的是很多个性化工意是 Linter 管不了的。比如团队约定“TODO 注释后面必须带责任人”或者“禁止在业务代码里出现某些敏感词”这类场景 Linter 要么不支持要么得写复杂的自定义插件。所以我把方案定成做一个语言无关的轻量规则引擎核心逻辑只有四件事——读取配置、扫描文件、匹配规则、生成报告。不依赖具体的语言解析器只基于正则和文本特征做检查。这样天然跨语言一套规则配置可以同时管前端和后端仓库。当然代价是牺牲了部分检测精度AST 级的问题查不出来但对于前面说的那几类“低级问题”正则匹配已经足够。1.3 整体架构与一次完整的审查流程open-code-review 的结构不复杂拆开看就三层。最外层是入口分两种形态。一种是命令行工具本地装了跑open-code-review scan --config .open-code-review.yml就能扫指定目录另一种是 GitHub Action封装好之后的用法就是在 workflow 里加一个 step适合 CI 场景。中间层是核心引擎。它负责解析配置文件、遍历变更文件、逐条执行规则、聚合结果。这层是整个项目的心脏所有规则在这里被解释和执行。最底层是输出适配器。审查结果不能只有一种表现形式。在终端里跑要的是可读性强的表格输出在 CI 里跑要让结果直接显示在 PR 评论区。适配器模式可以解决这个切换。一次完整的审查流程是这样的提交 PR触发 workflowcheckout 代码后计算变更文件列表把列表交给引擎引擎对每个文件跑一遍所有启用规则收集命中记录最后把记录格式化为审查意见通过 GitHub API 以评论形式发回 PR或者以检查报告的形式展示在 Actions 页面上。2. 规则引擎的核心实现细节2.1 规则配置的 YAML 格式设计配置格式是整个项目体验的起点我花了不少心思在上面。第一版用的是 JSON后来果断换成了 YAML。原因很简单JSON 不适合写注释而规则配置恰恰是最需要注释说明“这条规则为什么存在”的地方。一个完整的配置长这样version: 1 rules: - name: no-todo description: 禁止在代码中遗留TODO/FIXME标记 enabled: true level: warning match: regex: TODO|FIXME|HACK target: - comment - string - name: no-hardcoded-secret description: 检查硬编码的密钥 enabled: true level: error match: regex: (password|passwd|secret|token)\\s*[:]\\s*[\][^\][\] target: - code - name: no-debug-log description: 禁止遗留调试输出 enabled: false level: warning match: regex: console\\.(log|debug|info)|fmt\\.Println target: - code每个字段都有明确用意。name是规则唯一标识报告里要按它归类description会直接展示在审查评论里写得好不好直接影响别人理不理解这条规则enabled用来开关不用删配置level区分严重程度error 会让 CI 失败warning 只提示不阻断match.regex是匹配模式target让用户指定规则作用在什么内容上。target字段是这套设计里比较有特点的地方。我把代码文件按内容特征分成了三种上下文注释、字符串字面量、纯代码。还是拿TODO来说在字符串里出现“TODO”这个词和出现在注释里性质完全不同——前者可能是业务文案的一部分后者才是真正需要处理的遗留标记。不做区分的话误报率会高得离谱。2.2 正则匹配能做到什么程度有朋友问我正则匹配会不会太简陋要不要上 AST 分析。我的回答是得分场景。AST 分析很强大能精确到“哪个变量在哪个作用域里被赋了值”但它有个致命的问题——每种语言都得单独实现一套解析器。你想做成 Go 的天然只懂 Go想支持十种语言就得引入十套解析器依赖项目瞬间变得非常重。open-code-review 从立项起就坚持“语言无关”AST 路线直接排除。正则匹配的短板也很明显它对上下文的理解有限。但如果你把问题定义得足够窄完全能拿到满意效果。比方说查硬编码密钥用一条针对password、token这类关键词的赋值语句正则效果已经很好。做到“差不多准确”不难真正的难点在控制误报。控制误报的办法不在正则本身而在匹配上下文。实现的时候我会先对每行代码做一次分类判断这一行是注释、字符串还是普通代码再决定是否对当前行执行规则。这样做的好处是规则作者不需要想破脑袋写花式正则去绕过字符串里的干扰项直接用target: [comment]就能把范围限定死。2.3 引擎执行流程与关键代码解析引擎的执行流程可以拆成五个阶段配置加载、文件发现、行分类、规则匹配、结果聚合。配置加载阶段读取 YAML 文件解析成结构体。type Rule struct { Name string yaml:name Description string yaml:description Enabled bool yaml:enabled Level string yaml:level Match struct { Regex string yaml:regex } yaml:match Target []string yaml:target } type Config struct { Version int yaml:version Rules []Rule yaml:rules }文件发现阶段负责收集待扫描的文件。为了性能默认只扫 Git 变更过的文件而不是整个仓库。这一步的实现思路是读git diff --name-only的输出。行分类是引擎里最有意思的部分。对于每一行我用一个轻量状态机判断它当前处于什么上下文遇到//或#进入注释态遇到引号进入字符串态遇到引号结束符退出。复杂度 O(n)比每次匹配规则都跑一遍正则要高效得多。分类完成后每条规则只对指定上下文的行执行正则这个设计直接砍掉了大量无效计算。规则匹配阶段就很简单了遍历规则遍历行做正则匹配。Go 的标准库regexp足够用如果要支持更复杂的 PCRE 语法可以考虑引入第三方库但实测下来 RE2 的语法覆盖了实际场景里 90% 的需求没必要额外增加依赖。最后的结果聚合阶段把命中记录按文件路径和规则名分组生成统一的数据结构type Finding struct { RuleName string json:rule_name Description string json:description Severity string json:severity File string json:file Line int json:line Content string json:content } type Report struct { Findings []Finding json:findings }这个结构是引擎和输出适配器之间的协议。后面想加新的输出方式比如导出 SARIF 格式给 GitHub Code Scanning 用只需要加一个适配器核心引擎完全不用动。3. 从零搭建项目的实操过程3.1 目录结构规划项目初始化的时候我按可扩展的方向设计了目录结构。好结构应该让新成员一看就大概知道哪里改了什么东西。open-code-review/ ├── cmd/ │ └── open-code-review/ │ └── main.go ├── internal/ │ ├── config/ │ │ └── config.go │ ├── engine/ │ │ ├── engine.go │ │ ├── classifier.go │ │ └── rule.go │ ├── scanner/ │ │ └── scanner.go │ └── report/ │ ├── report.go │ ├── text.go │ ├── json.go │ └── github.go ├── action.yml ├── go.mod └── template/ └── config.example.ymlcmd是程序入口internal下面按职责分包config管配置解析engine是规则引擎本体scanner负责文件遍历report放各类输出格式的实现。因为项目目标是做成公共工具重点是稳定和可测试所以把对外暴露的接口都集中在 report 包里方便加新输出。3.2 核心代码实现的关键点main 函数的逻辑非常直白——解析参数、读配置、跑引擎、输出报告。难的是把引擎这层写好。引擎我设计成组件化的方式核心是一个Engine结构体type Engine struct { rules []Rule } func NewEngine(cfg *Config) *Engine { engine : Engine{} for _, r : range cfg.Rules { if r.Enabled { re, err : regexp.Compile(r.Match.Regex) if err ! nil { continue } r.regex re engine.rules append(engine.rules, r) } } return engine } func (e *Engine) ScanFile(path string) ([]Finding, error) { content, err : os.ReadFile(path) if err ! nil { return nil, err } lines : strings.Split(string(content), \n) contexts : classifyLines(lines) var findings []Finding for _, rule : range e.rules { for i, line : range lines { if !rule.matchesContext(contexts[i]) { continue } if rule.regex.MatchString(line) { findings append(findings, Finding{ RuleName: rule.Name, Description: rule.Description, Severity: rule.Level, File: path, Line: i 1, Content: strings.TrimSpace(line), }) } } } return findings, nil }有几个工程细节值得说一下。一是正则编译失败的处理我选择跳过该规则并且输出一条警告到 stderr不能因为一条写错的规则让整个流程崩溃。二是规则顺序配置里先写的规则先执行后面的规则不会做交叉判断这个设计保持了规则的独立性每条规则管自己的事互不干扰。classifyLines是行分类器它的实现思路是用一个简单状态机来跟踪当前是否处于字符串或注释状态。这里偷了个懒没有做完整的引号转义判断遇到\这种转义序列时可能会误判。但实际使用中影响不大毕竟我们的目标只是去除一部分明显的干扰项并不是要做到 100% 精确。3.3 测试先造样本再建用例代码写完之后最重要的事情就是测试。我给引擎写测试用例的顺序是先造一批有代表性的样本文件再针对每个规则验证输出是否符合预期。样本文件得覆盖正常情况、边界情况和误报场景。比如针对 no-todo 规则我会准备三个文件一个在注释里写了 TODO 的一个在字符串里写了 TODO 的还有一个既在注释又写 TODO 又在字符串写 TODO 且代码里还有变量名叫todoList的。第三个文件就是为了测试分类器是否足够智能能在注释和字符串上命中却不会命中变量名。测试代码用 Go 标准库的testing包就能写func TestScanFile(t *testing.T) { cfg : Config{ Rules: []Rule{ { Name: no-todo, Enabled: true, Match: struct{ Regex string }{Regex: TODO|FIXME}, Target: []string{comment}, }, }, } engine : NewEngine(cfg) findings, err : engine.ScanFile(testdata/todo_sample.go) if err ! nil { t.Fatal(err) } if len(findings) ! 2 { t.Errorf(expected 2 findings, got %d, len(findings)) } }这类测试不用追求覆盖率有多高关键是规则行为的变化能被立刻感知。后面我调整分类器逻辑的时候一个go test ./...就能确认没有破坏原有行为。4. 接入工作流与团队实践指南4.1 打包成 GitHub Action 的完整步骤核心引擎跑通之后接入 GitHub Action 只是包装一层的事。GitHub Action 本质上就是一个 Docker 容器或者 JavaScript 脚本。Go 写的程序跨平台编译很方便所以我选了 Docker 方案。步骤很简单。先在项目根目录加一个action.ymlname: Open Code Review description: Automated code review with custom rules inputs: config: description: Path to the config file required: false default: .open-code-review.yml token: description: GitHub token for posting comments required: true default: ${{ github.token }} mode: description: Output mode: comment or report required: false default: comment runs: using: docker image: Dockerfile args: - ${{ inputs.config }} - ${{ inputs.mode }} - ${{ inputs.token }}然后注册工作流时用户只需要在自己的仓库里加一个.github/workflows/code-review.ymlname: Code Review on: pull_request: types: [opened, synchronize] permissions: contents: read pull-requests: write jobs: open-code-review: runs-on: ubuntu-latest steps: - uses: actions/checkoutv4 - uses: your-name/open-code-reviewv1 with: config: .open-code-review.yml mode: comment一个常见的坑是permissions配置。GitHub Token 默认有一个最小的权限模型如果你的 workflow 没有显式声明pull-requests: writeAction 就算发现了问题也没权限往 PR 上发评论。这个权限不写在 workflow 里就会变成静默失败——Action 正常跑完但什么都没有发生排查起来特别容易绕圈子。4.2 审查结果的两种输出模式评论模式mode: comment会把每条命中记录拼接成 Markdown 格式的评论发布到 PR 讨论里。我设计的评论格式比较紧凑开头放统计摘要紧跟着一个表格列出所有问题发现 3 个问题1 error, 2 warning | 规则 | 严重级别 | 文件 | 行号 | |------|---------|------|------| | no-hardcoded-secret | error | src/config.js | 15 | | no-todo | warning | src/utils.go | 42 | | no-debug-log | warning | src/main.py | 8 |report 模式则把结果以 JSON 格式输出到 stdout配合其他工具继续消费。这种方式适合团队已经有一套自动化流程比如要把结果存进数据库做趋势分析的场景。评论模式更适合小团队和开源项目所见即所得report 模式更适合有定制化需求的中大型团队。实际接入时我建议先用 report 模式观察一段时间确认规则不会产生大量误报后再切换到 comment 模式直接暴露给大家。4.3 团队规则沉淀与迭代规范工具能不能在一个团队里真正落地关键看规则配置是否贴合团队自己的代码规范。团队落地我推荐按三步走。第一步先用内置默认规则扫一遍主干代码记录所有命中结果——这个阶段的结果基本全是误报和无关紧要的风格问题目的是摸清家底。第二步根据第一轮结果调整配置把确实不关心的直接关掉把误报高的用target限制匹配范围保留真正想拦截的模式。第三步把配置提交进仓库开始在新 PR 上生效。规则迭代的时候我会建议团队为每条规则补一句“为什么存在”的理由。比如 no-hardcoded-secret 的 description 是“检查硬编码的密钥”但如果配上“使用环境变量管理密钥”这样的说明给审查评论接收者的体验会好很多。description 字段会原样出现在输出里它就是规则的文档。一个很实用的技巧是给description写上改进建议。在 GitHub 评论模式下开局一句“检测到硬编码密钥建议改用 CI 环境变量注入”比干巴巴的“检测到硬编码密钥”有用得多。5. 常见问题与排错实战5.1 误报太多怎么办误报是静态检查工具最大的敌人一个工具只要误报率太高团队会迅速对它失去信任最后只能弃用。open-code-review 的误报来源主要有三类正则写得不够精准、上下文分类判断错误、以及忽略了字符串内容里的自然语言。处理思路从前到后分三层。第一层检查target是否设置得够窄第二层优化正则本身尽量用边界限定符把匹配范围收紧第三层如果某条规则实在无法兼顾准确率就把它默认关掉只在特定分支或特定文件路径上启用。正则优化有几个实用技巧。用\b单词边界来防止“密码”这个词嵌在长单词里被误命中用[a-zA-Z0-9_]代替.*来匹配变量名字符串内容用字符类限定而不是通配。规则写得越具体误报就越少。我在项目里沉淀了一条针对 no-todo 的推荐配置只扫注释类型的目标忽略target: [comment]范围外的内容。这个配置在实际开源项目里跑误报率基本可以压到 2% 以下。5.2 扫描性能和延迟问题PR 越多扫描延迟对开发者体验的影响就越明显。一次审查要几分钟开发者的注意力早就转移到别的事情上了审查意见的价值就打了折扣。性能优化主要做了三件事。第一只扫描变更的文件这是 FTP 上最早实现的优化。方法是用git diff --name-only拿到变更列表然后只对这些文件执行扫描逻辑。这个优化直接把一个几千文件的仓库扫描时间从分钟级降到秒级。第二对文件内部的扫描过程做了并发化。Go 的 goroutine 处理这个场景很顺手每个文件一个 goroutine结果通过 channel 汇聚。经过测四核机器上大概有接近 3 倍的加速比。第三不加载大文件全文。扫描器只读取文件内容进行逐行处理然后立即丢弃避免内存占用问题。还有一个容易忽略的细节Action 的环境里没有.git目录时git diff会执行失败。这个场景发生在先解压源码包再运行的场景中。所以扫描器的实现里加了一个回退策略如果git diff拿不到变更列表就退化成扫描整个目录。虽然慢一些但至少不会直接报错。5.3 实际踩过的几个坑第一个坑是 YAML 正则转义问题。配置文件中写正则时\b、\d这类字符经常和 YAML 的转义规则冲突。最稳妥的做法是给整个正则值加单引号而不是双引号或者在 Go 代码里读取配置时做一次字符串规范化。我踩过这个坑的现场是本地跑得好好的规则一放进配置文件就报错排查了半天发现是 YAML 解析时把\吞掉了。第二个坑是 GitHub 评论频率控制。当一次扫描有几十条命中时如果直接逐条发评论不仅会触发 GitHub API 限流还会把 PR 讨论刷成一片噪音体验极差。我的解决方案是所有命中汇总成一条评论按文件优先级排列并且当命中数量超过 30 条时只展示前 30 条然后附带一条提示说明完整报告在 Action 日志里。第三个坑是 Action 缓存导致的重复评论。PR 更新后重新执行 Action又发现同样的问题于是又发一条评论评论区就重了。解决方法是先查找当前 PR 是否已有 open-code-review 评论如果有就更新那条评论而不是新发一条。这个逻辑可以通过 GitHub API 的list comments接口实现。6. 从 open-code-review 到审查文化的养成项目跑起来之后我个人的一个体会是工具能拦截懒散但真正改变团队习惯的是流程本身。open-code-review 把“低级问题不该出现在 PR 里”这件事变成了自动检查项开发者慢慢就会在提交之前自己先跑一遍本地扫描因为谁都不想被机器人点名。我自己最舒服的使用方式是本地开发完commit 之前先跑一次open-code-review scan --config .open-code-review.yml。这个过程和gofmt、golangci-lint一样成为提交前的一个习惯动作。规则是自己定的所以不会有“机器不近人情”的抵触感扫描结果也是先给自己看质量再不济也不会造成社交尴尬。这个项目后续的方向我已经在考虑几个扩展点支持更多输出格式比如 SARIF 标准格式方便接入 GitHub Code Scanning增加一个规则模板仓库收集各团队沉淀出来的高价值规则甚至可以在命中记录里附带简单的修复建议。但核心定位不会变——它始终是一个轻量的、语言无关的、可自定义的辅助工具帮维护者节省时间而不是取代人去思考。

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

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

免费获取报价 →
↑