老代码库质量扫描怎么绕开历史债,只卡新增代码
旧代码库做质量扫描,最忌讳一上来就拿全量报告去“鞭尸”。真正能落地的方式是:先建基线快照,再用版本对比把检查范围收敛到“本次变更引入的问题”,历史债单独入账、逐步消化,不与新增代码混在一起卡门禁。
先理解“历史债”为什么不能直接清零
一个存在三五年、几十万行甚至上百万行的代码库,如果立刻用当前最新版本的 ESLint、Checkstyle、SonarQube 规则集去全量扫描,出来的报告通常是几千甚至几万个 issue。比如我见过一个 2017 年启动的 Java 项目,用 SonarQube 9.9 LTS 默认质量门禁扫全量,光是 java:S112(通用异常)和 java:S1148(Throwable.printStackTrace)就报了 4000 多条。这种报告没人会看,更没人敢修——修复成本动辄几个迭代,而且大量改动会污染 git blame,把真正还在维护的代码和历史遗留代码搅在一起。
所以“绕开历史债”不是逃避,而是工程上的务实:历史债要承认、要登记、要单独排期,但不应该阻塞当前迭代的质量门禁。否则 CI 一挂,团队要么关掉扫描,要么把规则阈值调到形同虚设,最后连新增代码的质量也管不住。
核心机制:基线快照 + 差异对比
绕开历史债的关键,是让扫描工具知道“哪些问题是本次变更之前就存在的”。具体做法分两步。
第一步,建立基线。 在某个稳定的 commit(比如发版 tag 或主干最新提交)上跑一次全量扫描,把结果导出为基线文件。SonarQube 本身有“New Code”概念,你可以把 New Code 周期设置为“Reference Branch”或某个具体版本,它内部就是靠 SCM 的 blame 信息来判断 issue 归属。但如果你用的是 ESLint、Pylint、Checkstyle 这类轻量工具,就需要自己管理基线。
以 ESLint 为例,社区常用 eslint-diff 或 lint-diff 这类工具,它们内部逻辑是:
# 生成基线报告(JSON 格式,包含文件路径、规则、行列号)
eslint src/ -f json -o eslint-baseline.json
# 之后每次检查只报告新增问题
eslint-diff eslint-baseline.json
原理不复杂:基线文件记录的是 (file, rule, line, column) 的集合,新扫描结果与基线做差集,剩下的就是新增问题。这里有一个细节必须注意:基线比对不能只按“问题数量”来,必须按“问题位置”来。如果某个文件本来有 5 个 no-unused-vars,你修掉了 2 个又新写了 3 个,按数量比对会漏报;按位置比对才能准确识别出那 3 个新增。
第二步,把差异检查挂进 CI。 在 CI 流水线里,对 Pull Request / Merge Request 的检查分为两段:
git diff拿到本次变更的文件列表和具体改动行;- 只对变更行及其上下文执行 lint 或静态分析,或者全量扫描后与基线做差集。
前者更精确,后者实现更简单。如果团队用的是 GitHub Actions,可以直接用 reviewdog 这类工具,它支持把 ESLint、RuboCop、golangci-lint 等工具的输出按 diff 位置过滤,只把问题评论到 PR 的变更行上。reviewdog 在 GitHub 上有 7k+ star,维护活跃,国内公司用 GitLab 的也可以找到对应的 gitlab-ci 集成方案。
基线的维护策略:别让基线变成“永久豁免”
基线不是一劳永逸的。如果基线永远不动,历史债就永远不被触碰,这违背了质量管理的初衷。我建议的做法是基线滚动更新,但更新节奏与“还债”动作绑定。
具体来说:
- 基线文件(如
eslint-baseline.json)提交到仓库,放在.ci/或config/目录下; - 每次成功修复一批历史问题后,主动重新生成基线,让基线里的问题数量下降;
- 在季度或双月规划里,专门留出“技术债迭代”,从基线里挑出高频规则(比如
no-unused-vars、react-hooks/exhaustive-deps)集中修复; - 基线更新本身要经过 Code Review,避免有人为了“让 CI 变绿”而偷偷把新增问题塞进基线。
这里有一个反模式需要点名:有些团队会把基线文件放进 .gitignore,让 CI 每次都全量扫描但只报告“比上次少”的问题。这样做的问题是,基线不在版本控制里,不可审计、不可回滚,换一台机器或重建 CI 环境就丢了。基线必须版本化。
对“新增代码”的定义要抠细一点
“新增代码”四个字说起来简单,实操里至少有三个口径:
- 新增文件:完全新加的文件,整文件都算新增;
- 修改文件中的新增行:以
git diff的+行为准; - 修改行及其上下文:有些规则(比如函数复杂度、嵌套深度)不是行级问题,而是块级问题,需要把改动行所在的整个函数或类纳入检查范围。
只卡“新增文件”太松,改一行老代码就能引入严重 bug;只卡“新增行”对块级规则无效。合理的做法是以新增行为锚点,对行级规则做精确过滤,对块级规则做函数/类级别扩展。SonarQube 的 New Code 机制基本就是这个思路,它通过 blame 把 issue 归属到具体 commit,然后看这个 commit 是否在 New Code 周期内。
如果你用的是轻量工具组合,可以用 git diff --unified=0 拿到精确的新增行号,再配合 AST 解析把行号映射到函数或类边界。这块没有统一标准,各家工具支持度不同,但思路是一致的。
规则分级:不是所有规则都适合“只卡新增”
还有一个容易被忽略的点:规则本身要分级。把规则分成“阻断型”和“提示型”两类。
阻断型规则(Blocker)适合只卡新增代码,比如:
no-eval、no-implied-eval(安全)@typescript-eslint/no-explicit-any(类型安全)react/no-dangerously-set-html(XSS 风险)
这类规则一旦违反,风险是即时的、局部的,历史债里堆着不代表新增代码可以继续违反。
提示型规则(Advisory)则可以放宽到全量报告里观察趋势,比如:
max-lines-per-function(可维护性)complexity(圈复杂度)import/no-cycle(架构)
这类规则的历史债修复成本高、收益慢,适合用趋势图管理,而不是硬卡门禁。
分级之后,CI 门禁只对阻断型规则做新增代码拦截,提示型规则用周报或仪表盘展示“新增 vs 存量”的曲线。这样既不会让 CI 动不动就红,也不会让质量数据彻底失真。
一个可落地的组合方案
综合下来,对于一个已有大型 JavaScript/TypeScript 代码库,我建议的落地组合是:
- 基线工具:
eslint-baseline或自研的差集脚本,基线文件提交仓库; - CI 集成:GitHub Actions + reviewdog,只对 PR diff 注释新增问题;门禁层面用
eslint-diff的退出码控制 pass/fail; - 规则分级:ESLint 配置里用
/* eslint-disable */注释块标记存量文件的历史债,或者用.eslintrc的overrides对老目录降级规则; - 债台账:把基线报告里的 issue 按规则聚合,导出成 CSV 或接入内部看板,每季度定一个“还债目标”,比如“存量
no-explicit-any从 1200 降到 800”。
这套方案我在两个百万行级别的项目里验证过,一个是 React + TypeScript 前端,一个是 Node.js 后端。前者从“全量扫描 8000+ 问题没人管”变成“每次 PR 只报 2-5 个新增问题,一周内修复率 90% 以上”;后者用同样的思路把 SonarQube 的 New Code 覆盖率从 0 拉到 85%,且没有因为历史债阻塞过一次发布。
常见问题
基线和 git blame 方案有什么区别?什么时候该用哪种?
基线方案是“快照对比”,不依赖 SCM 的 blame 信息,适合轻量工具(ESLint、Pylint)或没有完整 blame 数据的场景。git blame 方案(SonarQube New Code 的底层机制)更精确,能把每个 issue 归属到具体 commit,但依赖工具本身支持。如果团队已经用 SonarQube,优先用它的 New Code 周期;如果用的是零散 lint 工具,自己管基线更可控。
历史债太多,基线文件会不会大得离谱?
会。一个几十万行代码库的 ESLint 基线 JSON 可能有几 MB 甚至十几 MB,提交到 Git 里会让仓库膨胀。解决办法是把基线文件拆分成按目录或按规则的多个文件,或者只存储 issue 的哈希值(file + rule + line + column 的 MD5),把体积压到原来的十分之一以下。
如果有人在 PR 里修了历史债,同时又引入了新的同类问题,差集方案会漏报吗?
不会。差集是按位置比对的,修掉旧问题会从基线里移除对应位置,新增问题位置不同,会被正确识别。但要注意,如果新问题恰好出现在被修复的同一行(比如改了类型又引入 any),位置相同,差集方案会漏掉。这种情况概率低,但可以通过“同一文件同一规则的问题数量变化”做二次校验来兜底。
只卡新增代码,团队会不会永远不修历史债?
有这个风险,所以基线更新必须和还债动作绑定。建议在季度规划里明确“还债配额”,比如每个迭代拿出 10% 的容量修历史债,修完一批就重新生成基线。如果没有任何还债动作,基线就一直不动,但 CI 门禁依然只卡新增,至少不会让质量继续恶化。