前后端各跑各的 lint,最后在 pre-commit 和 CI 里用同一份规则文件把两套工具链对齐

前后端各维护一套 lint 规则,最后靠 pre-commit 和 CI 把两套工具链对齐——这件事我做了三年,踩过的坑比想象中多。先说结论:如果项目从一开始没有把规则源统一到一份“规范文件”上,后面无论怎么在 pre-commit 和 CI 里补救,都只是在打补丁;真正可行的方案是让 ESLint 和 PHPStan/RuboCop/Checkstyle 之类的后端工具链共享同一份规则定义,再各自生成原生的配置文件。

这里说的“同一份规则文件”不是指一个 JSON 同时被 ESLint 和 RuboCop 读——那不可能。而是指一份中立的、工具无关的规则描述(比如 rules.jsoncode-style.yaml),由它派生出前端的 .eslintrc.json 和后端的 .rubocop.yml 或者 phpstan.neon。规则文件里定义的是“命名规范”“缩进宽度”“禁止的 API”“复杂度上限”这类跨语言、跨工具都能表达的约束。工具链各自消费这份文件,生成自己认得的格式。

我在一个 Rails + React 项目里第一次这么做。后端用 RuboCop,前端用 ESLint + Prettier,中间还夹着一些 TypeScript 共享类型。最开始两边各写各的规则,结果出现了一个很典型的冲突:前端约定函数名用 camelCase,后端 Ruby 方法名用 snake_case,但 API 字段名到底用哪种?前端同学按 JS 习惯在 JSON 里用了 camelCase,后端同学在 serializer 里输出 snake_case,联调时天天吵。后来我们把“API 对外字段命名”这一条提到共享规则文件里,明确写 api_field_case: snake_case,前端在 ESLint 里用 camelcase 规则的反向配置检查请求体,后端在 RuboCop 里检查 serializer 的输出。两边工具链不同,但规则源头一致,冲突才消停。

具体到工程落地,我现在的做法是三层结构:

第一层:规则源文件。 放在仓库根目录,比如 config/lint/rules.json。内容大致是:

{
  "indent": 2,
  "max_line_length": 100,
  "naming": {
    "js": "camelCase",
    "ruby": "snake_case",
    "api_field": "snake_case"
  },
  "complexity": {
    "max_cyclomatic": 10,
    "max_function_lines": 40
  },
  "forbidden": [
    "console.log",
    "puts",
    "binding.pry",
    "debugger"
  ]
}

这个文件是唯一的手写规则源。任何想改规则的人,改这里,然后跑一个生成脚本。

第二层:生成器。 一个 Node 脚本或者 Ruby 脚本,读 rules.json,输出 .eslintrc.json.rubocop.ymlphpstan.neon 等。生成器的逻辑要写得非常直白,避免“智能转换”。比如 indent: 2 同时写到 ESLint 的 indent 规则和 RuboCop 的 Layout/IndentationWidth 里;forbidden 数组里的 console.log 映射到 ESLint 的 no-consoleputs 映射到 RuboCop 的 Style/StderrPutsStyle/StdoutPutsbinding.pry 映射到 RuboCop 的 Lint/Debugger。生成器里甚至可以维护一个简单的映射表,把规则源里的 key 对应到各工具的规则名。

第三层:pre-commit 和 CI 的消费。 这里有个关键点:pre-commit 和 CI 里跑的必须是生成后的配置,而不是各自手写的配置。否则规则文件改了,但某个人本地没跑生成器,提交上来的代码还是按旧规则检查。解决办法是在 pre-commit 钩子里先跑生成器,再跑各自的 lint:

# !/bin/bash
# .git/hooks/pre-commit
node scripts/generate-lint-configs.js

# 前端
cd frontend && npx eslint --no-error-on-unmatched-pattern src/

# 后端
cd ../backend && bundle exec rubocop

CI 里同样如此,甚至可以在 CI 里加一步“检查生成后的配置是否与规则源一致”——如果开发者在本地改了 rules.json 但没跑生成器,生成的 .eslintrc.json 就会和规则源不匹配,CI 直接失败。这一步我建议用 git diff --exit-code 来实现:

# .github/workflows/lint.yml
- name: Generate lint configs
  run: node scripts/generate-lint-configs.js
- name: Check config drift
  run: |
    git diff --exit-code .eslintrc.json .rubocop.yml

这一步的价值在于:规则文件的变更和生成配置的变更必须原子化。 谁改规则,谁就得把生成出来的配置一起提交。否则规则文件是新的,配置是旧的,pre-commit 和 CI 跑的还是旧规则,规则文件就成了摆设。

再说说两套工具链“对齐”这件事里最容易忽略的一个点:对齐的不是工具行为,而是规则意图。 ESLint 的 no-unused-vars 和 RuboCop 的 Lint/UnusedMethodArgument 在各自语言里的语义并不完全一样。你不可能让两个工具的输出逐条对应,但你可以让它们对同一类问题的态度一致。比如“不允许未使用的变量”这一条,两边都开;但“变量命名风格”这一条,JS 用 camelCase,Ruby 用 snake_case,这不是冲突,而是语言惯例的差异。规则文件里要区分“全局一致”和“语言特有”两类规则。全局一致的写进共享文件,语言特有的留在各自工具的配置里,但要在规则文件里注明“该规则仅适用于某语言”。

举个例子,复杂度限制。我在规则文件里写 max_cyclomatic: 10,前端 ESLint 用 complexity 规则,后端 RuboCop 用 Metrics/CyclomaticComplexity。两者的计算方式有细微差别(ESLint 的 complexity 计算的是函数内部的分支数,RuboCop 的 CyclomaticComplexity 计算的是方法的圈复杂度),但阈值统一成 10,至少保证了两边对“复杂度”的容忍度是一致的。如果后端某个方法复杂度 11,前端某个函数复杂度 13,两边都会报错,不会出现“后端卡得很严、前端放得很松”的情况。

还有一个落地细节:pre-commit 里不要跑全量 lint,只跑 staged files。 全量 lint 在大型项目里会慢到让人想关掉钩子。用 lint-staged 处理前端,后端用 rubocop --force-exclusion 配合 git diff --cached --name-only 只检查暂存的文件。CI 里则跑全量,作为最终兜底。pre-commit 负责快速反馈,CI 负责权威判定,两边用同一份生成后的配置,规则才不会漂移。

最后说一个反直觉的结论:不要试图让 pre-commit 和 CI 跑完全相同的命令。 pre-commit 的目标是“快速拦截明显问题”,CI 的目标是“完整验证”。如果 pre-commit 跑全量 lint,开发体验会崩;如果 CI 只跑 staged files,那等于没检查。两者的差异是合理的,只要它们消费的规则配置是同一份生成产物。我在一个项目里甚至让 pre-commit 只跑 ESLint 的 --fix 和 RuboCop 的 -a,自动修复格式问题,不做深度检查;CI 里跑完整的 eslintrubocop,不做自动修复。这样本地提交快,CI 检查严,规则源仍然只有一份。

常见问题

规则文件改了,但生成出来的 ESLint 配置和 RuboCop 配置冲突怎么办?

先确认冲突是“规则意图冲突”还是“工具映射错误”。如果是意图冲突,比如规则文件里写 max_line_length: 100,但 ESLint 的 max-len 和 RuboCop 的 Layout/LineLength 对“行长度”的计算方式不同(一个算上注释,一个不算),那就要在生成器里做显式处理,而不是在规则文件里写模糊的 max_line_length。可以拆成 js_max_line_lengthruby_max_line_length,或者统一成“不含注释的代码行长度”,然后在生成器里各自适配。

如果团队里有人不跑生成器,直接手改 .eslintrc.json 怎么办?

在 CI 里加配置漂移检查(git diff --exit-code),让手改的配置无法通过 CI。同时把生成脚本写进 package.jsonpostinstall 或者 prepare 钩子里,让每次安装依赖后自动生成。如果还不够,可以在 .eslintrc.json.rubocop.yml 的头部加注释“此文件由 scripts/generate-lint-configs.js 自动生成,请勿手改”,并在生成器里每次覆盖时保留这个注释。

前后端用的语言差异很大,比如前端 TypeScript 后端 Go,共享规则文件还有意义吗?

有,但要缩小共享范围。TypeScript 和 Go 的命名惯例、缩进、复杂度阈值仍然可以共享。比如 indentmax_line_lengthmax_cyclomaticforbidden(可以放通用的禁止项,如硬编码密钥、调试输出)这些跨语言有效的规则。语言特有的规则(比如 Go 的 gofmt、TypeScript 的 no-explicit-any)留在各自工具配置里,但要在规则文件里注明归属。共享文件的价值不在于覆盖所有规则,而在于让跨语言的底线一致。

pre-commit 钩子运行生成器会不会太慢?

生成器本身很快,读一个 JSON 再写几个 YAML/JSON 文件,通常几十毫秒。慢的是后面的 lint。如果生成器慢,说明它做了太多事,比如去请求远程规则库或者做复杂的 AST 分析。生成器应该保持“纯函数”特征:输入 rules.json,输出配置文件,不做网络请求、不做文件系统遍历。这样它在 pre-commit 里跑完全没有压力。