资讯动态

从pre-commit到CI质量门禁:impeccable系统实战复盘

发布时间:2026/10/15 10:15:05 来源:尧图企业网站定制
先坦白一件事我写了十多年代码但自己交出去的东西从来不敢用 impeccable 这个词来形容直到去年我们团队把一套质量门禁系统真正跑起来并且项目代号就叫 impeccable我才发现无可挑剔其实是可以被拆解成一道道可执行检查的而不是一句挂在嘴边的空话。这件事的起因很简单我们的 code review 总是在重复讨论同一类低级问题——函数太长、分支太深、新增代码没有测试覆盖、依赖又多了几个漏洞评级。每次 review 的人都像复读机一样念规则写代码的人听完点头下一周照样犯。后来我意识到问题出在质量标准靠自觉这个假设上人和人的标准不一样状态也有好有坏光靠人肉盯永远会有盲区。于是我把质量要求拆成三道自动或半自动的关卡分别挂在提交前、CI 中和合并前让机器先把那些一眼就能判定不合格的东西筛掉把人的精力留给真正需要判断力的地方。如果你也在把代码质量从口头约定变成硬性门槛或者正在为如何让 CI 不只是摆设发愁这篇文章就是这套 impeccable 系统的完整复盘包括配置怎么落、指标怎么定、踩过的坑怎么绕全部基于真实项目实践。1. 为什么需要一个叫 impeccable 的项目质量基线失控的真实场景1.1 一次让我失眠的评审漏检先说一个真实事故。去年年初我们有个同事提交了一个看起来非常小的改动把一个工具函数从 80 行改成 120 行顺便调整了几个分支条件。Code review 过了因为改动看起来确实不大大家重点看了业务逻辑对不对没人去数函数长度。结果上线第二天这个函数在特定参数组合下直接栈溢出因为内部递归分支多了一层嵌套压测没覆盖到。事故复盘的时候我们翻出代码一看那个函数的圈复杂度已经到了 28。理论上这种复杂度应该早就被拦下来但团队没有一条规则能保证它被拦下来因为谁看谁觉得不对劲是靠不住的。我当时就在想如果有一个工具能在提交前就大声喊这个函数的复杂度超标了这件事根本不用靠人肉。1.2 质量不是完美而是当场无懈可击impeccable 这个项目名的寓意从来不是写出十全十美的代码。工程上的无懈可击是另一个意思在每一个关键时间点代码库都经得起几条客观标准的检验。我们给它定了三条核心设计原则在提交前拦截能在本地 30 秒内发现的问题绝不留到 CI 花 10 分钟跑完才告诉你。在合并前判定CI 负责执行完整、深度的检查并给出一个可解释的质量分作为合并的依据之一。例外必须可见不搞一票否决的暴政但任何跳过检查的行为都要留下记录方便事后复盘。这三条原则后来变成了系统的三个关卡pre-commit 快速体检、CI 深度质量门禁、合并前人工复核与质量报告配合。1.3 我们给它划定的边界在动手写配置之前我先明确了一个事实impeccable 不是一个新发明的大型平台也不是什么需要花几个月建设的基建。它更像是一组经过挑选的工具规则包外加一个我们自己写的质量评分脚本粘合在现有的 Git 工作流和 CI 平台上。我们当时的项目是 TypeScript 写的 Node.js 服务前端也用同一套仓库管理所以下面的示例基本以 JavaScript/TypeScript 生态为主。但你可以把它当作一套方法论看——换成 Python、Go 或者 Java思路完全一样只是具体的工具名变了。这套系统的组成很简单层级位置核心工具目标第一道门本地 pre-commithusky lint-staged30 秒内拦截低级问题第二道门CI 流水线ESLint、Vitest/Jest、依赖审计、质量评分脚本深度检查 量化评分第三道门合并前质量报告 人工 Code Review机器守底线人管品味2. 第一道门提交前的 30 秒体检pre-commit 钩子设计2.1 本地检查到底解决什么问题很多人觉得 pre-commit 钩子只是锦上添花CI 反正会查。但我的经验是它最大的价值是反馈速度和开发者体验。一次提交前的检查只需要 10 到 30 秒你脑子里还清楚地记得刚才写了什么改起来几乎零成本。如果这个问题留到 CI 跑完才报你可能早就切到下一个任务了回来看到一片红还得重新唤起上下文。另外本地钩子能挡住大量无效的 CI 提交。我们以前经常出现这种情况一天 20 次 push有一半是修 lint 错误CI 队列排得老长真正重要的检查结果被淹没在噪音里。上了 pre-commit 之后CI 的失败率肉眼可见地降下来。2.2 落到文件husky lint-staged 的完整配置具体实现我们用的是 husky 管理 Git 钩子lint-staged 只处理暂存区的文件。这个组合这么多年还是最稳的社区生态也成熟。在安装完依赖后核心配置是这样的{ lint-staged: { *.{ts,tsx,js,jsx}: [ eslint --fix, prettier --write ], *.{json,md,yaml,yml}: [ prettier --write ], *.ts: [ tsc --noEmit --skipLibCheck ] } }然后 .husky/pre-commit 文件里写npx lint-staged这里有一个非常关键的细节lint-staged 只检查暂存区的文件。为什么因为如果你对所有文件做全量检查一个改动会引发一整片文件的历史问题报错开发者会很绝望。只检查暂存文件意味着你只需要为你这次提交负责这是非常符合直觉的边界。另一个细节是 prettier 放在 eslint 后面跑。先让 eslint --fix 处理代码风格问题再用 prettier 统一格式化避免两者规则冲突。这需要你在 eslint 配置里关闭与 prettier 冲突的样式规则不然会陷入eslint 改完 prettier 又改回去的死循环。2.3 类型检查为什么不全放本地上面配置里有个tsc --noEmit注意我用的是.ts而不是*.ts因为 lint-staged 不支持只用--noEmit配合仅检查暂存文件的模式。实际上我们后来把这个从本地钩子里干掉了原因也很现实TypeScript 的--noEmit本质上是整仓库级别的类型检查文件一多就要 30 秒以上加上 lint-staged 的并发限制本地提交体验会变得非常难受。所以我们的取舍是本地钩子只跑 eslint 和 prettier利用 eslint 的类型感知规则比如typescript-eslint里需要 parserOptions.project 的那些规则做一定程度的类型相关检查。完整的tsc --noEmit全量检查放到 CI因为 CI 可以接受几分钟耗时而且有缓存。这个取舍看起来很平庸但它让开发者愿意保留钩子而不是气得把 husky 卸载掉。提示如果你的项目比较小几百个文件以内全量 tsc 能控制在 5 秒内你可以把tsc --noEmit留在本地钩子里。但对中大型项目别因为这个拖累提交体验CI 才是它的主场。2.4 被绕行的问题逃生门要留但要留下痕迹无论你怎么设计总有开发者会找到办法绕过钩子。git commit --no-verify是 Git 自带的官方后门任何一个 AB 测过权限的人都会用。我们的态度是不禁止绕行但让绕行付出可见的成本。我们用了一个简单粗暴的办法在 pre-commit 钩子的最后一个步骤里检查最近 20 条 commit 中有没有标记为--no-verify的提交如果有就在提交成功后的 notify 消息里提醒一句。这不是阻断而是让每个人都知道这个地方默认了不该被跳过。这个设计其实呼应了质量门禁的本质门不是用来关人的是用来提醒人的。真要绕过总归有办法关键是让绝大多数提交都默认走完整路径让例外可见、可复盘。3. 第二道门CI 中的深度质量门禁GitHub Actions 演示3.1 流水线拆成六个阶段pre-commit 只是第一层过滤。真正的深度检查发生在 CI 里我们当时用的是 GitHub Actions以下是一个典型的 workflow 结构阶段做什么失败阈值installnpm ci如果你用 pnpm 就 pnpm install --frozen-lockfile无linteslint --max-warnings 20warning 不超过 20typechecktsc --noEmit零错误testvitest run --coverage新增代码 diff coverage ≥ 80%build构建产物验证零失败quality-score计算综合质量分低于 60 分阻止合并每个阶段之间都用缓存连接起来尤其是 node_modules 和 eslint 的缓存不然每次跑全量 lint 太浪费时间。3.2 diff 覆盖率比总覆盖率更符合常识的指标覆盖率这个指标很多人一上来就设整体覆盖率必须达到 90%。这在老项目上基本是自杀式要求——存量代码本来就有一堆历史债务你不可能为了凑覆盖率去补几百个测试。而且总覆盖率有一个致命问题它太容易被老代码的存量优势骗了。你新增了 100 行代码一行测试没写总覆盖率可能只掉 0.1 个百分点完全无感。所以我们的核心指标是diff coverage也就是本次改动新增或修改的代码行的覆盖率。这个指标的意义很直接你可以不还旧债但你不能新增债务。实现上我们没有直接引 codecov而是自己写了一个轻量脚本。思路是在 CI 里先用git diff --name-only拿到本次提交涉及的文件。对测试报告lcov format按文件过滤找出本次改动文件的覆盖率信息。再用git diff -U0解析新增行号判断这些行有没有被测试执行覆盖到。这比想象中简单因为 Vitest 的 coverage 会输出每个文件的覆盖率数据而 lcov 里也记录了行号和命中次数。我们用 JavaScript 写了一个 120 行的解析脚本效果足够精确。设定阈值时我们的经验是新增行覆盖率 80% 起步仓库整体覆盖率 60% 起步。80% 不是拍脑袋而是因为我们看了历史数据后发现团队认真写测试的新功能覆盖率普遍在 85% 左右。如果设 90%很多增量代码会为了凑指标去写一堆断言假阳性测试如果设 70%又拦不住明显没测试的改动。80% 是一个努努力就能达到不努力肯定会暴露的点。3.3 质量评分是怎么算出来的五维模型覆盖率只是一个维度。我们后来发现光有覆盖率也不行因为代码可以很丑但测试都覆盖到了。所以我干脆写了一个质量评分脚本把五个维度的数据揉成一个 0-100 的分数。这个分数的公式score 100 session: 额外扣分项 - 复杂度超标的函数数量 × 3 - 代码重复率每超出阈值 1 个百分点 × 2 - 依赖漏洞数量高危及以上的 × 8中危 × 3 - 存在 TODO/FIXME 数量超过 5 个的部分 × 1 - 类型显式 any 的新增数量 × 2听起来有点拍脑袋但它的意义不是精密计量而是让团队对整体状态有一个快速共识。举个例子一个 PR 如果新增了一个圈复杂度 20 的函数即使覆盖率 100%分数也一定会被拉下来。我们用 eslint 的--format json输出拿到函数复杂度数据用jscpd或者eslint-plugin-sonarjs拿重复率用npm audit --json拿依赖漏洞数。把这些输出喂给脚本最终生成一个分数和一份报告。3.4 一个可用的评分脚本骨架下面这个脚本是简化版本但结构上跟我们实际在跑的很接近。它接收 eslint、coverage、audit 三个 JSON 文件输出一个quality-score.json。#!/bin/bash # 使用 jq 解析 JSON请确保 CI 环境安装了 jq SCORE100 # 1. 圈复杂度扣分假设 eslint 已用 sonarjs/cognitive-complexity 规则输出 COMPLEXITY_COUNT$(jq [.[] | .messages[] | select(.ruleId | contains(complexity))] | length eslint-report.json) SCORE$((SCORE - COMPLEXITY_COUNT * 3)) # 2. 依赖漏洞扣分 HIGH_VULN$(jq .metadata.vulnerabilities.high // 0 audit-report.json) MODERATE_VULN$(jq .metadata.vulnerabilities.moderate // 0 audit-report.json) SCORE$((SCORE - HIGH_VULN * 8 - MODERATE_VULN * 3)) # 3. 覆盖率扣分新增代码覆盖率低于 80% 的部分按比例扣分 DIFF_COVERAGE$(jq .diffCoverage // 0 coverage-summary.json) if (( DIFF_COVERAGE 80 )); then MISS$((80 - DIFF_COVERAGE)) SCORE$((SCORE - MISS / 5)) fi # 4. 强制下限 if (( SCORE 0 )); then SCORE0 fi echo {\score\: $SCORE, \complexityViolations\: $COMPLEXITY_COUNT, \highVulns\: $HIGH_VULN} quality-score.json这个脚本的精髓是每一分扣在哪里都能追溯到具体数据。分数不是橡皮图章它背后有报告可以点进去看。评分脚本的另一个好处是它天然形成一条质量回归线每次合并后把分数记录到一个 stats 文件里团队每周看一眼趋势比收集体感强多了。3.5 门禁反馈让机器在 PR 里直接说话分数算出来了但更重要的是让人看见。我们写了一个 GitHub Action 的第二步在 PR 上自动发一条评论内容包括质量分带环比变化本次新增的 lint error 和 warning 摘要覆盖率和 diff 覆盖率依赖漏洞告警这条评论不阻断合并但它是第三道门的重要输入评审者不用自己跑去翻报告机器已经把审查边界画出来了。4. 第三道门机器与人交接的规则Review 与质量例外4.1 机器守底线人管品味工程上有个很常见的误区希望自动化工具把 code review 干掉。我坚决不同意。自动化能做的只是有客观标准、可机械判定的事但代码里真正贵的东西——模块怎么切分、接口怎么设计、边界条件有没有想清楚、异常路径可不可恢复——都需要人来看。我们给人工 review 留的边界很具体机器管风格、复杂度、覆盖率、依赖漏洞、类型安全、明显错误模式。人管架构演进方向、模块职责、命名是否传达了意图、未来维护路径、性能与业务语义。这套系统没有让 review 变轻松多少说实话刚开始没有因为人的部分本来就没变。但后来我们发现一个微妙的变化Reviewer 不再需要在评论里贴这个函数太长了之类的废话因为机器已经提醒过了。人的评论反而更聚焦在这个模块为什么这么切和这个接口的语义不太对这一类真正需要对话的问题上。4.2 override 机制有规矩的绕行任何一个质量门禁如果百分之百刚性就会催生出两种畸形行为一是大量利用 --no-verify 之类的后门二是把规则当成敌人。所以我们专门设计了 override 机制。规则是这样的当 CI 质量门禁失败的前提下PR 作者可以在 PR 描述里写一行标记格式固定override:impeccable reason: 本次是紧急修复线上的数据损坏问题异常链路已有完整测试复杂度问题排队下个迭代重构。CI 里的判断逻辑会识别这行标记如果存在且 reason 非空分数低于 60 的阻止会被放行。但这个 override 动作会记录到我们的 weekly report 里每周五把本周谁用了多少次 override理由是什么发给全组。为什么这么设计因为我们很清楚设定阈值时没人能预知所有真实场景。紧急热修的时候你的第一优先级是恢复服务不是让一个函数变得特别好看。但我们不接受无记录的绕行你跳过门槛可以你要让所有人都知道你为什么跳过。上线三个月后我们发现一个有趣的规律大多数 override 的理由都很合理真正滥用的人不是没有但一般两周内就会被周报里的数据暴露出来。反而那些没有 override 机制、纯靠求人放行的团队低质量代码会悄悄漏过去因为人性倾向于走最省力的路。4.3 给人工 Review 的一张极简清单机器已经过滤掉了一大堆可机械判定的问题人工 review 清单反而可以做得非常短。我们贴在 Code Review 模板里的只有五条这个改动的边界是否清晰改动范围是否超出你的预期关键路径上的异常情况有没有被处理服务不可用、网络超时、数据不一致时会发生什么新增的依赖是否真的必要能不能用已有能力实现有没有更简单的方案复杂度的增加是否被清晰的注释或设计文档解释过对现有测试的影响是否被充分验证改动了哪些行为基准这张清单的执行时间基本能控制在 15 分钟内因为其他噪音已经被机器挡掉了。5. 真正上线之后的调参血泪史5.1 存量代码和增量代码一定要分开第一次把 eslint 全量规则打到整个仓库上结果你们应该猜得到全线飘红几百个文件报错看起来像是整个仓库都在抗议。当时我们差点就放弃这个方案了。后来我学到一个词渐进式严格。具体做法是用 eslint 的overrides字段把历史代码目录和新代码目录分开配置。新代码目录比如src/下的主要业务模块直接上 error 级别老代码目录先只开 warning并标记一个 deadline给团队两个月时间逐步清理。pre-commit 钩子默认只对新文件和新改动生效而 CI 的质量分则对所有目录有效但计算权重不同。这样做的心理效果比技术效果更重要开发者不会被一个月以来堆积的旧债淹没反而愿意在每次触碰老文件时顺手清理几处。两个月后老目录的 warning 数量降了 60% 以上我们才把老目录也升级成 error。5.2 三个拍脑袋阈值的教训这里我把具体踩过的三个坑写下来希望能给你省点时间。第一个是覆盖率阈值 90% 的失败。我们一开始很有理想把 diff coverage 设成 90%结果团队开始疯狂写为了覆盖而覆盖的测试测试里几乎没有断言全是调函数不校验返回值。这比没测试更糟因为代码看起来在进步实际在造假。后来降到 80%再配合强制要求至少一个有效断言情况才恢复正常。第二个是从 5% 起跳的重复率阈值。实际上把重复率卡得太死会逼着人引入过度抽象——几处相似的代码恨不得抽出一个通用基类结果耦合变重维护更麻烦。重复率是用来提醒你注意异常的不是用来追求极致 DRY 的。第三个是最开始把 TODO/FIXME 当成错误级别结果团队里出现了大量再也不回来填的假 TODO。后来我们反过来只记录不阻止每周在周会上过一遍新增的 TODO 数量。这个数字会提醒大家债还在但不用在这周还完。5.3 工具链本身的三个坑工具层面也有一些典型问题我挑了最值得说的三个husky 升级路径。我们最早用的 husky 6后来升到 8 再升到 9配置方式从package.json里的husky.hooks改成了.husky/目录再变成.husky/加core.hooksPath。如果你在旧项目里升级 husky记得npx husky init之后手动检查一下.husky/pre-commit有没有被正确挂上这个文件不会被 git 自动跟踪换机器时尤其容易丢。lint-staged 和 pnpm。我们用 pnpm 之后lint-staged 默认找命令的方式会有点问题因为npx在 pnpm 工程下解析比较慢还偶尔找不到本地依赖。建议在lint-staged的配置里直接用pnpm exec eslint或者用node_modules/.bin/eslint的路径避免环境变量问题。eslint 缓存失效。如果开 ESLint 缓存你会发现改了 ESLint 配置后缓存可能不会自动失效导致 CI 上出现我明明修了错误还是报错的诡异现象。CI 的 cache key 要把eslint.config.js的 hash 包含进去比如key: ${{ runner.os }}-eslint-${{ hashFiles(eslint.config.js) }}如果你还用.eslintcache最好在每次 CI 上别复用这个缓存本地用可以CI 用容易踩坑。5.4 用数据证明这套系统有效最后说点能让团队支持你持续做这件事的数据。我们跟踪了系统上线前三个月和上线后三个月的指标变化非常明显指标上线前上线后平均每次 PR 的评审轮次2.71.9合并后 7 天内引入回归的 PR 占比8.3%3.1%CI 失败率lint/type 阶段23%6%新增代码 diff coverage34%82%第一个月的阻力是真实的但数据出来之后最反对这套系统的那位同事也服气了。因为数据说明的并不是你们代码水平变高了而是低水平的错误不再依赖肉眼去发现。如果要我从这套 impeccable 系统里提炼一句心得我会说质量门禁的目的不是让流程看起来很严而是让好代码成为默认路径。默认路径的意思是不需要刻意努力、不需要靠某个人的坚持好习惯就已经嵌在工作流里了。最后一个小建议如果你也想做类似的系统别一上来就铺开五维评分和 override 机制那是我们迭代后的版本。先在 pre-commit 里跑通 lint-staged 这一项再在 CI 里加上全量 typecheck 和 diff coverage这两步带来的收益就远超投入了。剩下的等团队开始习惯了再加也不迟。质量建设不是一次性工程它是在一次次的刚刚好和稍微不够之间不断校准的长期活。

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

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

免费获取报价 →
↑