一条不能失败的规则只是一个建议
我们的仓库带着一份架构指南:分层边界、每个状态只能有一个 owner、服务先定义接口再实现、每一层都必须挣得自己的存在。这是一份好指南。它同时也是散文,而散文没法让构建失败。
失效方式不是有人不同意它。而是代码安静地从它漂走——模块多放深了一层、本该唯一的值出现了第二个写者、logger 被每个文件各自构造而不是放在指定 owner 里。这些都是小的、看起来都很有道理的改动。每一个都会被一位脑子里已经装着整条规则的评审抓住,被一位没有的评审放过——在一个同时还有另外六件事的 diff 里。
后来工具改变了这道算术。这个仓库里很大一部分代码现在是由自动编辑器而不是由人手敲出来的。这不会让规则变得不成立;它会让「规则存在」和「规则被遵守」之间的落差大得多、积累得快得多。编辑器不会在一个 diff 的第四十个文件开始疲倦。它不读贡献指南。而且它没法在 code review 里被告知「嘿,这个是真的违规」——因为没有 code review,只有一个要么成功要么不成功的工具调用。
于是问题不再是「我们怎么提醒大家」,而是**「怎么把一条规则变成工具能让之失败的东西?」**
给每条不变式一份清单
答案是每条不变式一个目录,里面放一份清单,由一个 runner 载入并强制。我们把每一条叫做 arbiter——裁决单条规则、并且能被要求解释自己的东西。今天一共有三十三条。每一条大致长这样:
schema_version = 1
id = "analytics-autocapture-interaction-granularity"
risk = "high"
paths = [
"Foundation/iOS/.../AnalyticsReporterService/.../Autocapture/**/*.swift",
]
invariant = "One $autocapture event represents one user intent, never one UIKit target-action dispatch. ..."
真正承重的是五个字段,各自回答一个不同的问题:
paths—— 这条规则管辖哪些文件。作用域在这里不是细节。一条管得太宽的规则不再是规则,而是噪音——因为第一次误报就教会所有人忽略这个机制。invariant—— 规则本身,一段散文,人可以跟它辩论。它必须自身就成立且完整,因为这正是机器查不了、只能靠人的第一件事。read—— 动这块之前要先读哪些文件。上下文也是契约的一部分。一条只有读完三个文件才能遵守的不变式,就该自己说出来,而不是假设编辑器已经知道。forbid—— 哪些子串出现在新增行里就算违反。checks—— 改完之后要跑哪些语言相关的闸门。
上面那段 invariant 值得完整读一遍,因为整个系统就是希望它们被写成这个样子的。它没说「注意别过度捕获」。它说清了一个事件意味着什么,然后陈述事故:按 dispatch 上报,正是它让一次滑块拖动发出了某产品全部遥测的五分之四,并在预算压力下把真实业务事件挤出了上传队列。一条带着数字的规则,是可以被不同意它的人辩护的。一条只说「小心点」的规则不能。
写给那一行,不是写给那个 diff
这个机制之所以对自动编辑器有效,是因为 forbid 的 glob 匹配的是新增行——大小写不敏感,在写的那一刻,而不是在一个 diff 上、一次评审上、或一次事后审计上。
那是唯一一个机器还来得及被拦住的时刻。一个跑在 diff 上的 linter 已经让改动存在了;评审看到它时已经太晚、也太贵。一条在你正在敲的那行上开火的规则,可以在没人知道自己遵守了它的情况下被遵守。
它也把负担反转了。在基于评审的强制下,知道规则的人正是必须记住它的人,而必须记住它的人往往恰是上下文最少的人——在六文件改动里扫一眼的那个评审。在基于行的强制下,规则跟着路径走。碰一个在某条 arbiter 管辖下的文件,arbiter 就一起过来。
这套机制的另一半是提前问同一个问题:给定一组路径,哪些不变式在赌桌上、需要哪些检查?编辑器可以在写任何东西之前问这个问题,然后先把 read 列表加载掉。你没法强制一条你从没展示过的规则,也没法一次展示三十三条。
那个既是缺点也是优点的麻烦
匹配器的两个性质看起来像 bug,其实不是。
注释和字符串也算。 glob 匹配整行,所以一条提到被禁 API 的注释本身就是命中。后果是:关于这些函数的散文必须绕着说——我们自己规则文件里那条注释写的是「poller APIs」而不是点名。写起来有点别扭。
它同时恰恰是你想要的性质。一个能写出一段令人信服注释的编辑器,被赐予了任何只匹配代码的规则,就等于被赐予了一条就地绕过它的路。堵上那扇门的代价是几句别扭的措辞,换来的是一条没法在代码里辩论掉的规则。
影响面是量出来的。 最初的草稿用的是宽 glob,因为宽 glob 好写也好想象。它们是错的。分析代码里的进度监视器有一些辅助方法,名字里含有被禁的那个子串,而它们和被禁的那个调用毫无关系,于是裸的子串匹配把完全无辜的文件也标记了。修法是把调用形状而不是那个词钉住,然后把结果对着那些树核对:
对着三棵树下每一个 Go 文件实测:合起来只选中那九份已知拷贝,别的一个都不选。
那句话就写在规则里,并且是系统里每一条 glob 都被要求达到的标准。一条会在不该触发的文件上触发的规则,不是一条更严的规则;它是一个附带红色构建的 bug,而它的第一笔代价由那个在无关改动上撞见它、并由此断定整个机制坏了的人支付。
另一半:检查形状,而不是检查子串
不是每条不变式都能表达成一个被禁子串,假装能,就会产出看起来被强制、其实没有的规则。
对这些,机制是 check 而不是 glob。全仓结构性规则住在另一张表里,并且每一条都被要求指明它在防的那次事故:
[[rule]]
id = "LOGGER-SINGLE-OWNER"
kind = "forbid_pattern"
source_view = "code_only"
incident = "…the rule this exists to prevent is named here, not described in the abstract"
要求每条规则都带上 incident,在做着一件安静而沉重的工作。一条没人能说出它防了哪次失败的规则,是一条在它变得碍事时没人会替它辩护的规则——而它一定会变得碍事,因为会在某人正做到一半的重构上触发的规则总是会。于是事故被留在规则上,意味着想削弱这条规则的人必须在同一个文件里、在这个 diff 会显示出来的地方,删掉它存在的理由。
实现是一个约七万行、跨数百个文件的 Rust workspace,通过一条命令进入,启动时丢弃所有环境里的构建配置、只加载自己那份——好让某个开发者的个人 Bazel 设置无法改变一条守卫的判定。这是一件小事,却是想明白过的:一道结果取决于某人 ~/.bazelrc 的策略闸不是策略闸,是抽奖。
它在哪里停下
把边界诚实说出来,是让其余部分可信的一部分,而这一条咬到过我们。发布签名那条规则标着 critical,读起来像一个密钥扫描器。它不是。它是对改动形状的检查——你有没有删掉这次构建需要的那个 profile——而它本可以报绿,同时让六个签名私钥躺在仓库里,因为 mutation 检查和凭据扫描器是完全不同的两件仪器。我们是偶然发现它们的,而修它们是另一篇文章的主题。
这条教训越过了密钥本身。一个带着令人安心的严重级别标签的机制格外容易被过度信任,恰恰是因为它在别的维度上一直在挣信任。如果你的闸门写着「critical」,值得问一句:它实际看得见哪一类问题。
小结
如果一条规则只能被读到,它迟早会被漂离。升级路径不是把那段话写得更好;而是给规则一份清单——它管辖的路径、它的一句话、它要先读的上下文、它禁止的形状——好让工具能对它采取行动。
把强制指向正在写的那一行,而不是正在评审的那个 diff。那是机器唯一还来得及被停下的时刻,而且它把负担从上下文最少的人身上,挪到了被触碰的那条路径上。
假定写代码的那一方会试图靠写字来合规。匹配注释和字符串会让你多费一点措辞,并堵上最明显的那条旁路。
并且量出影响面。每条 glob 在上线前都要对着它管辖的文件核对一遍,并把结果写进规则里。一条会在无辜文件上触发的守卫,赔掉的不只是一次误报——它赔掉的是这个机制本身,永久地,因为没有人会去覆盖一条他已经学会绕开的规则。