做开源项目这么些年,我最大的感受是:写代码永远比审代码轻松。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,触发 workflow,checkout 代码后计算变更文件列表,把列表交给引擎,引擎对每个文件跑一遍所有启用规则,收集命中记录,最后把记录格式化为审查意见,通过 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.yml:
name: "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.yml:
name: 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/checkout@v4 - uses: your-name/open-code-review@v1 with: config: .open-code-review.yml mode: comment一个常见的坑是permissions配置。GitHub Token 默认有一个最小的权限模型,如果你的 workflow 没有显式声明pull-requests: write,Action 就算发现了问题也没权限往 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;增加一个规则模板仓库,收集各团队沉淀出来的高价值规则;甚至可以在命中记录里附带简单的修复建议。但核心定位不会变——它始终是一个轻量的、语言无关的、可自定义的辅助工具,帮维护者节省时间,而不是取代人去思考。