返回博客

全量封锁新 Lint 规则,反而让团队绕过 pre-commit hook

往一个已经有十几处历史违规的项目里引入一条全新的 harness 检查规则,是一件看起来正确、实则充满陷阱的事。我们在某后台管理系统里提炼了一个统一的权限查询工具函数,打算立刻用 pre-commit hook 封锁所有手写 SQL 权限拼接。结果不到一周,团队就开始到处加 --no-verify

现象

规则上线后的第三天,一个本来只改了一行 UI 的 PR 被 pre-commit 拦下来,因为触到了已有路由文件里的老写法。开发同学跑来问:"我又没动权限,凭什么卡我?"。随后几个 PR 开始出现 git commit --no-verify,后来甚至进了 CI 脚本。规则变成了装饰品。

还有另一种反向失效:项目早期没加任何 harness,直接上了工具函数。某次 PR 悄悄在一个新路由里重新手写了权限过滤逻辑,谁也没注意到,三个月后数据越权才被发现。

两条路都走不通。

根因

全量封锁的前提假设是:「现在就能一次性迁移所有消费点」。当历史违规超过 5 处,这个假设几乎必然不成立——每个历史路由改动风险不同,搭在一起难以 review,出问题也难以回滚。

强推的结果是:开发者发现合规代价太高,直接绕过工具。规则存活了,但被绕过了。这比没有规则更危险,因为团队产生了「规则已覆盖」的错误安全感。

坑:全量封锁 = 逼出 --no-verify

历史违规 5+ 时,把规则立刻设成全局 blocking,等同于把「绕过 pre-commit」变成团队的日常操作。规则名义上存在,实际上死了。

正解

按历史违规数量分三档:

| 违规数 | 策略 | |--------|------| | 0(新项目) | 合同代码 + 规则同 PR 上,立即 blocking | | 1-2(少量) | 合同代码 + 迁移全部消费点 + 规则,同 PR | | 5+(历史债) | 合同代码 + 1 个 pilot 迁移 + 带 allowlist 的规则,分 2-3 个 PR |

核心机制是显式 allowlist + 带过期日期的源文件标记

# check-row-scope.sh
LEGACY_ALLOWLIST=(
  "src/app/api/dashboards/stats/route.ts"
  "src/app/api/dashboards/widget-data/route.ts"
)
 
legacy_until() {
  grep -Eo '@r29-legacy[[:space:]]+until=[0-9]{4}-[0-9]{2}-[0-9]{2}' "$1" \
    | sed -E 's/.*until=//' | head -1
}
 
today="$(date +%F)"
for file in "${FILES[@]}"; do
  if is_legacy_allowed "$file"; then
    until_date="$(legacy_until "$file")"
    if [[ -z "$until_date" ]]; then
      echo "VIOLATION · $file 在 allowlist 但缺少 @rule-legacy until= 标记"
      violations=$((violations + 1))
    elif [[ "$until_date" < "$today" ]]; then
      echo "VIOLATION · $file legacy 标记已于 $until_date 过期"
      violations=$((violations + 1))
    fi
    continue   # 跳过常规禁用模式检查
  fi
  # ... 常规违规检测
done

被豁免的历史文件必须在文件头部写上带截止日期的标记:

/**
 * GET /api/dashboards/stats
 *
 * @r29-legacy until=2026-05-15
 * PR 6 必须将此路由迁移到 PermissionResolver.buildScopeQuery()。
 */

标记是源码的一部分,不是外部 YAML。日期一到,harness 立即报错;想延期必须 commit 一次修改,留下可查的 audit trail。团队无法静默地把豁免变成永久。

同 PR 还必须有一个 pilot 消费点——最小改动量的一个已有路由迁移到新合同,带真实集成测试验证。这证明合同在生产级代码路径里可用,然后才有资格让其他路由分批跟进。

规则文档里还要有 Migration path 章节,明确点名哪个 PR 会把 allowlist 清零:

## Migration path
- PR 5:创建 buildScopeQuery,迁移 /api/customers GET 作为 pilot,安装 R29 + allowlist。
- PR 6:迁移 dashboard 路由,shrink allowlist 至空。
- Final:删除 legacy allowlist,规则进入纯 blocking 状态。
正解:显式 allowlist + 源码内过期标记 + pilot

把历史债登记在 allowlist 里,每条债必须携带截止日期标记;合同代码同 PR 内跑通一个 pilot 消费点;规则文档明确清零 PR。新增代码不在 allowlist 内,立即被拦截。

一句话外卖

引入全局 lint 规则时,历史违规是「已知债务」,强制立刻还清反而逼出绕过行为;让债务「可见、有截止日期、新代码立即合规」比强制一次性还清更能让规则存活下去。