
如何在百万行大仓里升级一条 CI 规则
在超过百万行的大仓里收紧一条 CI 规则,麻烦的不是规则本身,而是它撞上的存量。全量修复、直接卡 CI、先冻结再自治收敛——三种做法清理的是同一批历史报错,区别只在于谁来清、什么时候清、怎么保证清对了。
在一个代码量超过 100 万行的仓库里,如何修改一条 CI 规则?
这问题听起来像"改个配置文件"那么简单——找到 .eslintrc,把某条规则从 warn 改成 error,提交,收工。但在百万行量级的仓库里,同样一个动作牵出的却是一整套需要权衡的决策。下面拆开三种修法,以及各自要付出的代价。
1. 这本质上是个规模问题
在小仓库、小团队里,收紧一条规则约等于顺手全量修一遍——几十处报错,一个下午改完,谈不上什么"策略"。但规模一旦上来,同一个动作的后果被逐级放大:一条规则波及的可能是几百上千个文件、几十个日常在这些文件上工作的人。这个时候,"怎么改"会从一个操作问题,升级成一个需要权衡的维护决策——难点不在技术,而在规模。

本文讨论的就是后一种情形:仓库规模大到一定程度后,这条规则该怎么升级。
2. 加了规则,存量就得修,那怎么修?
这里的"CI 规则"泛指需要在 CI 阶段执行的各类质量检测,不止 lint——凡是卡在合入门口、对全仓生效的检查都算:lint、类型检查(tsc 的严格度)、测试覆盖率门禁、依赖与安全扫描、格式化、循环依赖检查、包体积门禁……这些检查的结构其实是一样的:规则一收紧,存量立刻大面积不达标。下面以 lint 为例,因为它最直观、最容易报错,但推理对上面每一类规则都成立。
假设你要新增或收紧这样一条规则:把它设成 error,或者把某个门禁的阈值调严。规则本身没问题、该加,但一提交,CI 瞬间报出几百上千处错误——因为规则是新的,而这些报错都是历史遗留的。
这批报错不会自己消失,总得有人把它们清掉,这里的核心问题是:谁来修、什么时候修、怎么保证修对了。顺着这几个问题往下想,大致有三种典型修法。
修法一:全量修复,当下一次改完。 一次性把所有报错改完,规则和修复放进同一个 PR 合入,这批报错由当下的一小群人一次性解决掉。它的好处是一次结清、账面干净,不留任何尾巴。代价则随规模上升——周期长,而且变动越大风险越高。它的验证高度依赖人工 review,可几百个文件的巨型 diff,review 根本兜不住,没人会真的逐行看完;一旦某处改错,就被"看起来改完了"这层表象盖了过去。报错量小时它确实是最省事的,但量一大就难以为继。
修法二:直接卡 CI,谁撞上谁改。 规则推上去设成 error,存量一处不改,谁在开发中撞上谁负责修。表面上这最省事——没有人需要集中修一大批代码。但它实际是把一次性的集中修复,拆成了无休止的零散打扰:此后每个撞上报错的人,都得停下手里的活去处理一批和自己无关的历史问题,注意力被打断,而那一大堆历史报错并没有真的变少,只是被无限期地拖着、谁碰上谁修一点。
举个具体例子:你去改一个几百行的老文件,代码不是你写的,你只顺手动了其中很小的一处——结果 CI 报错了,报的却是这个文件里一堆你从没碰过的历史错误。这就很尴尬:这些错误不是你引入的,你也不清楚当初为什么这么写、改了会不会出问题,但规则就卡在这,不修就合不进去。验证修得对不对,也一并压到了这个毫无准备的人头上。这类事一旦频繁发生,开发体验会明显变差。卡 CI 表面上把成本压到了最低,实际是把它无限期地摊派给了未来的每一个人。
修法三:先冻结,再让系统自己清。 这是一个比较理想的方式,所有历史报错先显式挂账,规则只对新代码生效;存量则交给一套自治循环,一点点收敛,直到清单归零。这批报错既不压在当下某一个人身上,也不打扰未来的所有人,而是交给一个不占用人类心智的系统,异步地、渐进地清理干净。

3. 基于 baseline 的渐进式自治清理
第三种修法分两步走,顺序不能反:先把存量冻结起来,再把冻结下来的清单交给自治循环慢慢收敛。
3.1 先冻结存量,防止劣化
冻结这一步先解决的不是"清理",而是"不再变多"。存量报错可以慢慢修,但有一件事必须立刻做到:不能再有新的同类报错混进来——否则边修边漏,清理永远追不上产生。冻结要的就是先划一条线,把问题圈在当前这批存量里,总量从此只减不增。
具体做法是一套通用的 baseline 模式:把当前所有历史报错登记成一份基线快照,规则对快照内的存量放行、对快照外的新增代码照常生效。这条线一旦划下,存量被原样封存、一处不改也不阻塞任何人,而任何新写的、或改动中触发的同类报错都会被规则当场拦下——问题就被收敛在了存量范围内,不再劣化。它适用于前面列的每一类 CI 规则,只是不同工具的落地形态不同。
以 lint 为例,ESLint 从 v9.24.0(2025 年 4 月)起提供了官方的 bulk suppressions,把这套做法直接内置了进来。假设你要收紧的是 @typescript-eslint/no-floating-promises(禁止没有 await、也没有 .catch() 兜底的"游离 Promise"——它是异步代码里丢异常、吞错误的常见来源),先在配置里把它设成 error,再运行:
# 先 --fix 修掉能自动修的,剩下无法自动修的再整体豁免
eslint --fix --suppress-all它会在项目根生成一份 eslint-suppressions.json,记录当前所有报错。这份文件的结构很简单——按"文件 → 规则 → 报错条数"两层嵌套,只记数量、不记具体行号:
{
"src/api/user.ts": {
"@typescript-eslint/no-floating-promises": { "count": 3 }
},
"src/legacy/order.ts": {
"@typescript-eslint/no-floating-promises": { "count": 1 },
"@typescript-eslint/no-explicit-any": { "count": 12 }
}
}只记数量这一点是理解整个机制的关键:某个文件某条规则记了 count: 3,ESLint 就放行这个文件里这条规则的前 3 处报错;一旦实际报错涨到 4 处,超出的那处照样被拦下来——前面说的"不能变多",就是靠这个数字卡住的:基线只认存量的数量,多出来一处就是新增,拦死。把这个文件提交进仓库,团队就共享同一条基线。如果只想冻结某一条规则,用 --suppress-rule:
eslint --fix --suppress-rule no-floating-promises等某处历史报错被修好,ESLint 会返回非零退出码并提示"有豁免项已不再需要",此时用 --prune-suppressions 把它从清单里摘掉,对应的 count 随之减少或整条移除。清单就这样单向收缩:新报错被规则实时挡在外面,旧报错每修一处就摘一条。在没有官方 suppressions 的工具链上,手写 eslint-disable 注释是退而求其次的替代方案,只是它散落在代码各处,终究不如一份集中清单好管理。
其它规则类各有对应机制:TypeScript 侧有把当前类型错误存成快照的工具,覆盖率门禁则可以把"当前覆盖率"设成下限、只许升不许降,更通用的做法是用 betterer 这类工具为任意检查建立快照。工具不同,但落地形态背后是同一套 baseline 思路。
冻结这一步做完,拿到的其实是两样东西。一是总量不再变多:规则已经生效,新增报错进不来,存量往后只会越修越少。二是那份 eslint-suppressions.json ——它把原本散落在几十万行代码里、看不见摸不着的历史报错,变成了一份逐项列出来的清单,每一项都标明了在哪个文件、是哪条规则、还剩几处。清理存量,从此有了一份明确的、可以逐条认领去改的账单。这份清单,正是下一步收敛的起点。

3.2 把收敛做成一条定时跑的自动化流程
冻结解决的是"不再变多",但存量还在那里,总得有人一点点清。好在这类清理有个特点:每处报错都是孤立的,改哪处、验证哪处互不牵连,通常也不需要理解整个系统——正因如此,它不必占用真人的时间,可以整个交给自动化。
具体怎么自动化?思路是把整个修复动作做成一条能自动跑的流水线:定时触发,让 AI 去修、去验证,人只管最后合入。它的骨架就是一轮完整的开发闭环:
- 搭一条能定时触发的流程。最省事的载体就是现成的 CI 系统——GitHub Actions 的 scheduled workflow、GitLab CI 的 pipeline schedule 都自带 cron 语法,配一行
schedule就能让它每天定点拉起这条流水线,不需要额外的常驻服务。 - 复现问题。让流程先跑一遍检查,拿到当前还没修的那批报错,从中挑出一小批(比如同一类的 5–10 处)作为这一轮的目标。
- 调 AI 修复。之所以敢把这一步交给 AI,是因为改得对不对有个死标准:原来报错的地方,改完不再报,就算修对了。标准这么明确,AI 不容易跑偏。把这一轮的目标报错连同"修到不再报"的要求丢给 Claude Code 这类编码 agent,让它逐处改。
- 验证。修完立刻跑一遍完整的质量门禁——lint、类型检查、构建、相关测试全绿才算数。没过就把失败日志回喂给 AI 再修一轮,这一步保证"改错了当场就被拦下"。
- 提交 PR。验证通过后,把这一轮的改动整理成一个聚焦、体量小、可独立 review 的 PR。
- 人工 review 合入。唯一保留给人的环节:看一眼这个小 diff,确认无误,合入。
前五步全部由流水线自动完成,发给编码 agent 的指令大致如下(以 lint 为例):
从当前 lint 报错中挑选 5-10 条同类的历史报错,逐条修复对应代码,
确保修复后 lint、类型检查、构建、相关测试全部通过。
提交一个聚焦、可独立 review 的小改动。这套流程真正改变的是成本结构。它定时触发、每天跑上几轮,每轮只动几处、都过一遍质量门禁,存量就这样一点点往下掉。复现、修复、验证这几个最耗人的环节全被 AI 包揽了,留给人的只剩最后 review 合入那一下——看的还是个几十行的小 diff,几分钟的事。没有人需要专门排一个 sprint 去清存量,它就在后台自己收敛,你甚至不太会注意到它的存在。

再往前想一层:你其实不必为收敛这一条规则单独搭一条流水线。上面这条闭环再抽象一层——把"选哪个任务、在哪个包上跑"交给一个中央调度器,把每一类修复(清死代码、消 any、收敛某规则存量……)都写成注册表里一条可调度的任务,再加一层审计去回收运行数据、动态调整各任务的权重——就成了一套能常驻后台、7×24 承接多类维护任务的自治框架,也就是 harness。收敛某条规则的存量,只是往它的任务注册表里加的一条而已。这套框架怎么从零搭起来,我在《Harness Engineering 实践》里给了完整的实现地图和一段可直接复制的初始化 Prompt,想落地的可以照着做。
4. 渐进式自治清理的好处
渐进式自治清理最直观的好处是渐进、自动化、不占心智,但这三点其实是一回事——清理存量本质上是一件可拆分、可验证、没有创造性、也没有截止压力的重复劳动。可拆分,才切得成小步;可验证又不需要创造,才敢整个丢给机器;不赶时间、也不用人拍板,才能从头到尾不占用谁。
除了这些,渐进还带来一个容易被忽略的收益:它把风险从一次爆炸,摊成了多次小改动。
前面方案一的"全量修复"要一次改掉几百上千处,风险是高度集中的。真出了回归,它就埋在几百个文件的 diff 里,review 根本盯不过来,事后想定位、想回滚都难。渐进收敛把同样的活拆成 N 次小改动,每次只动几处、单独验证、单独合入,风险也跟着分摊到每一步——哪一步出问题,影响都很小,定位快,回滚也干净。
全量修复最致命的就是"改得越多、风险越大",而渐进收敛恰好摁住了 diff 体积这一条。它的好处就不止于开发体验了——变更本身也比全量修复更安全。
5. 不适用场景
这套方案有它的适用边界,不是哪儿都该用。以我的经验,至少有三种情形不该往这条路上走。
规则本身可疑时,先质疑规则,而不是收敛存量。 如果几百处报错更像是规则定得太激进、或者压根不适合这个项目,那正确的动作是回滚或调整规则,而不是费力去精心清理一批本不该被拦下来的代码。
报错量很小时,全量修复更省。 只有几十处报错,直接改完最干净,没必要为此搭一套冻结加自治收敛的机制。
安全或正确性类的规则,可能等不起慢慢收敛。 如果一条规则拦的是真实的安全漏洞或线上事故隐患,存量就不是"迟早要清的旧账",而是"正在暴露的风险",该立刻清零,而不是挂进清单慢慢处理。
说到底还是第一节那个判断:自治收敛依赖一套已经跑起来的维护循环,从零搭这套机制本身就是成本,所以它是给大仓、大团队准备的方案。仓库和团队还没到那个规模,前两种简单做法可能就够了。
6. 小结
升级一条 CI 规则,难的从来不是改规则,而是规则一收紧就冒出来的那一大批历史报错。这些报错总得有人修——前面三种修法,区别只在于让谁修、什么时候修:或是当下一小群人一次改完,或是往后拖给每个撞上它的人,或是交给一套自动化系统在后台慢慢清。
我个人更倾向最后一种,但它有前提:那套自动化系统本身得搭、得维护。仓库不大、报错也就几十处的时候,直接全量改完反而更省事。说到底,这几种修法没有高下之分,只是仓库规模不同,该由谁来修这批报错的答案也就不同。
