13  Guardrails:结构的事实

测试回答的是”跑起来之后发生了什么”,这一层回答另一个问题: 这段代码放得对不对。

它做的事情是把仓库里那些反复出现的约定 —— 依赖方向、owner 边界、 某个 API 不该出现在哪里 —— 收敛成同一个入口的判定, 并且保证本地和 CI 跑的是同一条命令、同一份策略。

最后这一点重要到值得先说:只要两边能跑出不同结果, Agent 就会开始拿”本地是好的”当理由。一旦这个理由被接受一次, 这一层就废了 —— 它不再是一个判定,变成了一个可以协商的意见。

13.1 性价比最特别的一层

先看一组数。取完整的一天:结构检查跑了 284 个任务,失败 20 个 —— 7.0% 的失败率是所有类别里最高的,比界面测试的 4.7%、单元测试的 3.9% 都高。 但它的中位耗时只有 5.2 分钟,是界面测试的三分之一。

最常拦住人的那一层,恰好也是最便宜的一层。

这不是巧合。结构问题不需要把程序跑起来就能发现 —— 依赖方向、目录归属、 某个符号出现在了不该出现的地方,这些都是静态事实。它一旦漏进主干, 代价要等到很久以后才付:一条错误的依赖方向不会让任何测试变红, 它只会在半年后让某个模块无法被单独抽出来。

13.1.1 为什么高失败率是件好事

7.0% 这个数通常会被当成负面指标 —— “这一层太严了”“它老是挡人”。恰恰相反。

因为结构问题的特点是它们不产生即时反馈。一条错误的依赖方向、 一个放错位置的文件、一个内联的日志器 —— 没有任何东西会因此变红, 除非有一条规则专门看它。所以在没有这一层的仓库里, 这 7% 不是”没发生”,是”没被发现”。它们会持续累积, 直到某天有人试图抽出一个模块,或者试图理解为什么日志格式到处不一样。

一个高失败率的结构检查层,衡量的不是它有多严,是它之前漏掉了多少。 这个数应该随时间下降 —— 如果它一直保持在 7%,说明存在某种 持续产生结构问题的机制,通常是规则没有被前馈化(小节 15.8)。

13.2 策略与机制分离

整套东西被切成三段,每段只做一件事:

flowchart TB
    subgraph P["策略层(声明式,不含实现)"]
        direction LR
        C1["config/<br/>不可变策略"]
        C2["baseline/<br/>历史债务台账"]
        C3["arbiters/<br/>路径清单"]
        C4["rules.toml<br/>架构规则"]
    end
    BOOT["薄引导层<br/>丢弃环境里的版本覆盖 · 只加载封闭配置<br/>构建出运行器后把自己替换掉"]
    RUN["Rust 运行器(机制)<br/>仓库扫描 · 依赖图查询 · 语言适配<br/>lane 编排 · 结果渲染"]
    P --> BOOT --> RUN
    style P fill:#eef2fb
    style RUN fill:#e8f4e8

中间那层的两句注释值得单独说:丢弃环境里的版本覆盖、只加载封闭配置。 这两件事合起来保证了一件事 —— 判定的结果不取决于运行它的那台机器上 装了什么。

“构建出运行器后把自己替换掉”意味着引导层不会留在进程里, 它没有机会影响判定。引导层不参与判定,这是一条被实现方式保证的约束, 而不是一条纪律。

13.3 baseline 不是豁免清单

有一个概念一定要分清,因为混淆它的代价很高。

baseline 是一份被审阅过的历史债务台账,只允许单调收敛 —— 存量可以慢慢清掉,新代码不许往里加。真正的不可变策略在 config/

两者混为一谈的话,baseline 就会变成一个”加进去就不用管了”的垃圾桶。 这恰恰是 Agent 最容易选择的路径:面对一条报错, “把它加进豁免清单”永远比”改代码”便宜。

所以单调性本身是一条被检查的规则。一次运行的输出里会有 guardrails: baseline policy passed against c3c425291e7f 这样一行, 它比对的是这次的 baseline 相对某个基准版本有没有增长。 债务可以还,不可以借。

13.3.1 单调性是怎么被保证的

这句话落到实现上有一个容易做错的细节,而它值得单独看, 因为做错的版本看起来完全正常

台账里存的是每一条违规的指纹。天真的实现会把它们收进一个集合, 而实际的实现用的是多重集

pub struct FingerprintMultiset {
    counts: BTreeMap<String, usize>,
}

它记的不是”有哪些指纹”,是”每个指纹出现了几次”。

区别在哪?假设某个文件里有三处一模一样的违规 —— 同样的规则、 同样的模式、只是在不同的行。用集合的话,三处折叠成一个条目, 有人再加第四处,集合里还是那一个条目,单调性检查通过。 用多重集的话,计数从 3 变成 4,当场拦下。

也就是说,用集合的版本会让”添加与现有违规完全相同的新违规”变成免费的 —— 而这恰恰是最容易发生的一种债务增长,因为复制粘贴一段已经在台账里的代码, 是所有增长方式里最省事的那一种。

同一个文件里还有一句 if fingerprint.is_empty() { bail!(...) }: 空指纹被直接拒绝,因为一个空指纹会匹配所有东西, 它会让台账变成一张万能通行证。 这两个细节的共同点是:它们防的都不是”有人故意作弊”, 而是”一个看起来无害的实现选择,悄悄打开了一个口子”。

13.3.2 单调性检查也有三个状态

值得注意的是这个比较的返回值不是布尔,是一个三态枚举:

pub enum MonotonicComparison {
    Bootstrap,                        // 没有基线可比
    Passed,                           // 没有新增
    Growth { added: FingerprintMultiset },  // 新增了这些
}

Bootstrap 那一态是关键。它对应的情况是这一次没有可比的基线—— 第一次引入台账、或者拿不到比较基点。如果这里只有两态, 这种情况就必须被塞进”通过”或”失败”其中之一: 塞进通过,那么”拿不到基线”会静默放行任何增长; 塞进失败,那么第一次引入台账就没法进行。

这是 章节 11 那条三态纪律在台账层的形态, 只不过这里第三态的含义不是”我判不了”,是”这次没有可判的对象”。 两者的共同结构是一样的:一个只能说是与否的判定, 会把”这个问题在这里不适用”这种情况错误地归到某一边。

Growth 那一态携带了具体新增的那些指纹,不只是一个数字 —— 所以失败信息里能直接列出”你新增了这三条”, 而不是”台账变大了”。这又回到 小节 13.4.4 那条纪律: 误差的方向和大小,比误差的符号值钱得多。

13.3.3 台账的更新是事务性的

台账的写入路径下面有一个专门的事务子模块,分成会话、提交、快照三部分, 这意味着一次台账更新要么全部生效,要么完全不生效。

为什么需要这个?因为台账更新通常发生在一次大规模清理之后, 而如果写到一半失败(进程被杀、磁盘满、工具崩溃), 留下的是一个部分更新的台账 —— 一部分违规被清掉了,一部分还在。

部分更新的台账有一个很坏的性质:它是自洽的。 下一次运行不会报错, 它只会以那个错误的状态作为新基准 —— 债务凭空消失了一部分, 而没有任何人会知道。

13.4 规则长什么样

(二十三条规则的全量清单在 附录 A,按「值得先抄哪条」分了三梯队。)

看一条完整的定义。这条规则守的是”日志只能在唯一的一个文件里定义”:

[[rule]]
id   = "LOGGER-SINGLE-OWNER"
kind = "forbid_pattern"
enforce = true
scope = ["Modules/**/*.swift", "Foundation/**/*.swift"]
source_view = "code_only"
pattern = 'Logger\(label:'

allow_files = [
    "**/*Logger.swift",  "**/*Tests.swift",  "**/*Mock.swift",
    "**/TestHost/**",    "**/Preferences/Tools/**",
    "**/MicroKernel.swift",
]

sentinel_min = 50

incident  = "微内核服务:日志只在唯一的 Logger.swift 定义"
fix_hint  = "在该 target 的 Logger.swift 里定义 logger,其它文件引用它"

四个字段值得单独说,因为它们各自解决一类会让规则失效的问题。

13.4.1 source_view = code_only:规则不会被自己的文档绊倒

规则只看代码,不看注释和字符串字面量,所以上面那段讲解这条规则的注释文字, 本身不会把这条规则触发掉。

这类”规则被自己的文档绊倒”的情况在纯文本匹配的检查里非常常见, 而它的后果不是误报一次那么简单:一条会误报的规则会被绕过, 绕过之后它还继续消耗信任。

同类的坑在路径清单那边更狠 —— 有一份清单把某个文档目录故意排除 在扫描范围之外,因为禁止模式按整行匹配、并不区分代码和散文, 而那句用来解释”禁止某个参数”的说明文字,本身就会触发这条规则。

13.4.2 allow_files:每条豁免都写了理由

原始定义里,每一条豁免上面都跟着一行注释说明它为什么存在。 其中最说明问题的是 **/Preferences/Tools/** 那一条,它的理由是: 那是构建期的代码生成器,它「发射」出 Logger(label: 这个字符串, 那是字面量,不是真的 logger。

如果没有这行注释,半年后的人只会看到一条不明所以的白名单, 然后要么不敢动、要么随手删掉。两种都是坏结果:不敢动意味着 这条豁免永远留在那里,随手删掉意味着代码生成器明天开始报错。

这是一条可以直接抄走的纪律:豁免必须带理由, 而且理由要写它为什么不是违规,不是写”这里特殊”。

13.4.3 sentinel_min:检查器对自己的检查

如果某一天匹配数突然掉到阈值以下,更可能的解释是扫描逻辑坏了, 而不是代码一夜之间变干净了。所以这种情况会被当成故障报出来, 而不是当成通过。

这个机制在实现上比它在配置里看起来的更严格。看类型和它的构造方式:

pub(in crate::config) minimum_facts: NonZeroUsize,
// ...
minimum_files: required_nonzero(id, "sentinel_min", raw.sentinel_min)?,

两件事被类型系统锁死了。required_ 意味着哨兵是必填的 —— 你没法写一条不带哨兵的规则,转换阶段会拒绝它。NonZeroUsize 意味着 哨兵不能是零 —— 你没法通过把下限设成 0 来”关掉”这个自检。

一个可以被关掉的自检等于没有自检,而这里它在类型上就关不掉。

这条设计值得单独表扬,因为它回答了一个显然的质疑:“哨兵下限是个手写常量, 有人为了让规则过,把它调低不就行了?” 调低可以,移除和归零不行—— 这不是完全的防御,但最省事的那条绕过路径被堵死了。 (它仍然有局限,见 小节 18.5。)

13.4.4 incidentfix_hint:失败信息的质量

规则不是凭空立的:先出问题,再沉淀成规则。失败返回的时候 把”为什么有这条”和”怎么修”一起给出去,Agent 才有可能自己收敛, 而不是换个写法再撞一次。

用第四部的语言说:一个只返回”失败”的判定,只提供了误差的符号; 一个返回了 owner、证据和修复方向的判定,提供了误差的方向和大小。 只有后者能让搜索空间收缩。

13.5 不是所有规则都是模式匹配

上面那条是文本模式规则,简单直接。但这一层里最能说明水平的, 是那些不能用模式匹配表达的规则。

“禁止给 Service 加兜底 fallback”就是一条。天真的实现是”grep 一下 ??“, 而真实的实现是一个近两百行的分析器,有五层过滤 —— 而每一层都对应一次误报

第一层只对已知可选的 accessor 生效,集合来自服务图,不是猜的。 第二层剔除被局部变量遮蔽的名字。第三层做语句边界检查, 因为那个 ?? 可能其实在下一条语句里。第四层放行显式声明不可用的右值 (?? .unavailable 是合法的)。第五层是绑定跟踪 —— let x = accessor() 之后再 x ?? y,它会跟着值穿过一次局部绑定。

最后一层是分界线。它不是在匹配文本,它在追踪一个值的来源 —— 先找到”从一个已知可选的 accessor 取值并绑定到局部变量”, 再动态构造一个针对那个变量名的模式去找兜底。

这是 lint 和 checker 的区别:lint 匹配写法,checker 追踪语义。

这五层过滤的存在方式本身就是一条经验:你没法一次写对一条规则。 它的最终形态是被真实代码里的正确用法一次次逼出来的。

13.6 配置的两个类型层

还有一处设计值得学,它对付的是形状 D(配置声明了但没生效)。

配置被分成两个类型层:raw 是从 TOML 直接反序列化出来的, validated 是通过校验之后的。 validated 类型的字段全都是 pub(in crate::config) 可见性 —— 也就是说, 你没法在配置模块之外凭空构造一个”已校验”的配置。

它的效果是:一个函数只要拿到 validated 类型,就不需要再检查它合不合法。 合法性由类型携带,不由调用方的记性携带。

对照一下形状 D 里那次真实事故:某个配置因为 TOML 子表的解析问题被整段吞掉, 配额声明了却从未生效,而且没有任何东西报错。在这种分层下那次事故不会发生—— 转换阶段拿不到配额字段,就会直接失败,而不是产出一个”配额为空”的合法配置。

这是一条可以推广的原则:把”检查过了”这件事编码进类型, 而不是编码进流程。流程会被绕过,类型不会。

13.7 规则不是只有开和关

一条新规则如果一上来就拦人,仓库里的存量违规会让所有人当场停摆。 所以每条规则有两个档位:enforced 会让检查失败,report-only 只报数、不拦。

看一次真实输出的片段:

✓ [LOGGER-SINGLE-OWNER]        (enforced)    — clean (9046 scanned)
✗ [L10N-TABLE-LOCALITY]        (report-only) — 966 violation(s) (50 scanned)
✓ [PRODUCT-ISOLATION]          (report-only) — clean (22304 scanned)
✗ [FILE-HEALTH]                (enforced)    — 1 violation(s) (9151 scanned)
guardrails: architecture failed: enforced architecture rule failed

注意两行的对比:一条规则有 966 处违规,检查照样往下走; 另一条只有 1 处,直接把这条流水线拦掉了。 一条规则拦不拦人,跟它违规多少无关,只跟它是不是 enforced 有关。

这份输出还顺带把前面几节讲的东西摆在了台面上:每条规则后面的 scanned 数 就是哨兵的读数(这次扫了 9,046 处),而 baseline policy passed 那行 是历史债务台账的单调性检查

那 966 处 report-only 的违规,每一条都给全了:违规在哪、唯一的使用者是谁、 具体该怎么改。这条规则今天还不拦人,但它每跑一次都在把这 966 处的清单摆出来 —— 等存量收敛了,它就可以切成 enforced。

报数档还有一个没被用起来的身份:它是一个正在运行的对照组。 小节 15.4 会把这件事讲完 —— 那 966 处违规到底引发过几次故障,是一个数据全在手边、 却从来没有人去问的问题。

13.8 六条通道,以及为什么要分开

检查被切成六条独立的通道:架构(跨模块依赖方向与 owner 边界,基于构建图)、 路径不变量(只扫本次新增行)、Swift、Go、Rust、TypeScript (各自的 linter、类型检查与债务台账)。统一入口是一条命令, 可以跑全部、跑单条或跑指定的几条。

分成通道不是为了组织代码,是为了让”最小重跑集合”成为可能。 小节 14.3.3 讲过,路径清单的 checks 字段声明的正是 “命中我之后要重跑哪几条通道”—— 而如果检查是一整块, 那么任何一次失败之后都要全部重跑,而全部重跑的耗时, 就是 Agent 每次迭代的下界。

这是 章节 17 那条结论在这一层的具体应用: 通道的粒度决定了回路延迟的下界。

13.8.1 而它们共用同一套判定语义

六条通道背后是六种完全不同的工具,它们的输出格式、退出码约定、 失败模式全都不一样。运行器做的事情是把它们归一到同一个三态判定上

这个归一化是有代价的:每接一种新工具,都要判断它的哪些失败属于”内容违规”、 哪些属于”我判不了”,而这个判断没法自动做 —— 一个 linter 返回非零退出码,可能是发现了问题,也可能是配置文件读不出来。

但这个代价必须付。 不付的后果是 Agent 面对六种不同的失败语义, 而它无法可靠地区分”我该改代码”和”我该等环境修好”。

13.9 报数模式的一个隐藏成本

小节 13.7 讲了报数模式的必要性,这里补一个它的代价。

一条挂着 966 处违规的报数规则,每一次运行都会把这 966 处打印出来。 这意味着每一次 CI 输出都多了几百行,而输出里真正需要行动的部分 (那一条拦截的规则)被淹没了 —— 而 Agent 读的是这个输出。

这是形状 A 的一个变种,只不过发生在输出这一层: 信息量增加了,信噪比下降了,而下降是静默的。

正确的做法是给报数模式的输出分级 —— 摘要行常驻,详细清单按需展开。 这套系统的输出格式里已经有摘要行(每条规则一行,带扫描数和违规数), 但那 966 处的详细清单目前和摘要在同一个输出里。

这一条列在这里,是因为它示范了一件事:一个正确的机制, 在规模变大之后会长出自己的问题。报数模式是对的,而 966 这个数字 是它自己的成功造成的(规则覆盖了一个很大的存量)—— 然后这个成功 变成了新的成本。

13.10 证据来源的纪律

最后一条,短但要紧:语言事实的权威是编译器和 linter, IDE 或 Agent 的代码索引只能作参考,不接受它们当证据。

索引会滞后、会缺失、会因为配置不同而给出不同答案。用它来判定 “这里没有别的调用方”,迟早会错 —— 而这类错误的特点是 它看起来像一个事实,不像一个猜测。

13.11 规则本身也要被测

schema、glob、禁止模式、lane 选择、退出码、失败渲染都有契约测试。 更重要的是另一类测试:故意造出违规的变更,用来验证检查器自己没坏。

这和测试那章的变异验证是同一个思路,只不过对象换成了检查器 —— 一个从来不会报错的检查器,和一个不存在的检查器,是同一个东西。 章节 18 会给这个动作一个正式的名字。

13.12 检查器自己的判定覆盖

这一层检查所有的代码,那谁检查这一层?

答案分五块,而它是”判定覆盖到哪里,确定性就到哪里”这条原则 应用在自己身上的一个完整案例:契约测试守 schema、glob 匹配、 退出码与失败渲染的行为;故意造违规守每条规则确实会在违规时报错; 哨兵下限守每条规则的扫描面没有异常缩小;文件大小硬顶守 检查器自己的代码不超过两百行每文件;而 CI 的四道门里 有一道专门跑策略机制自身的契约。

五层,而它们的成本是真实的 —— 检查器的代码量里 有相当一部分是它自己的测试。

13.12.1 但仍然有一处没被覆盖

规则集的整体健康度没有被检查。

单条规则的正确性有覆盖(契约测试加故意造违规),单条规则的存活有覆盖(哨兵)。 “这套规则集作为一个整体,是不是还在收敛”没有 —— 小节 15.13 那张清单里的七项,一项都没有被自动化:规则总数的变化、 零违规的报数规则、扫描数的趋势、台账的总量趋势、绕过率。

其中五项的数据全都是现成的(每次运行的输出里就有),缺的只是把它们存成时间序列。 这是我对这套系统提出的第五条建议,而它和前四条形状一样: 数据在手边,缺的是把它接进一条判定。

13.13 常见的三种反对意见

这一层最常被质疑,而三种质疑各有各的道理,逐条回答。

13.13.1 “这不就是 lint 吗,我们已经有了”

区别有三处,而每一处都不是程度差异:失败类型从两态变成三态; 规则带一个必填且不能为零的自检;存量不靠”全局关掉这条规则”处理, 而是靠一份只能降不能升的台账加一个报数档位。

第三处差别最实际。普通 linter 面对存量违规只有两个选项, 而两个都是坏选项:全局关掉意味着新代码也不再受约束; 逐处加抑制注释意味着抑制注释会被复制粘贴地传播出去, 而且没有任何东西告诉你抑制的总数在涨还是在跌。

单调台账是第三个选项:存量被记账,新增被拦住,而且总量只能降不能升。 这不是把问题解决了,是把问题变成了一个有方向的量—— 而一个有方向的量可以被管理,一个散落在注释里的量不能。

13.13.2 “维护这些规则的成本会超过收益”

这个担心是对的,而且 小节 15.7 会说明它现在还没有被解决。 但有两个数据可以缩小这个担心的范围。

第一个是规则的总量增长很慢。那个规则文件从建立到现在只有 20 次提交, 二十三条规则。规则不是被批量加进去的,是一次一条跟着一次具体改动进来的。

第二个是这一层的检查成本是所有层里最低的:中位 5.2 分钟, 是界面测试的三分之一,而它的失败率是所有类别里最高的。 所以真正的成本不在”跑”,在”维护规则本身”—— 而那个成本的大小取决于规则的误报率,误报率又取决于规则边界 有没有被真实数据校准过(小节 14.4)。

结论:抄来的规则成本高,长出来的规则成本低。 这一章从头到尾没有给出一份”推荐规则清单”,也是这个缘故。

13.13.3 “Agent 会想办法绕过去”

会 —— 只要规则误报。这不是一个需要被防住的行为,它是一个信号: 一条被频繁绕过的规则,说明它的边界画错了。所以正确的应对不是加固规则, 是测量绕过率(小节 15.6.1),而这个数据现在没有被采集。

值得补充的是,绕过在这套系统里其实不容易,不是因为防得严, 而是因为本地和 CI 跑的是同一条命令、同一份策略 —— “本地是好的”这个最常见的绕过理由被结构性地堵死了。

13.14 从一条规则看这一层的完整成本

把一条规则从提出到退休的全部成本列出来,因为这一层最常见的失败 是低估了后半段。

阶段 成本 谁付
想出这条规则 零(它来自一次事故) ———
实现检查 半天到几天 一次性
调参到误报可接受 通常比实现更久 一次性,但容易被低估
报数模式期间清存量 取决于存量大小 一次性
切成拦截 ———
误报时被打断 持续 每个人,每次
重构时跟着改范围 持续 偶发但必然
判断它该不该退休 持续 通常没人付

第三行和倒数第一行是这张表的重点。调参之所以被低估, 是因为它在提案阶段完全不可见 —— 你提出规则时想的是”禁止 X”, 而实际实现时会发现有六种合法的 X。小节 13.5 里那五层过滤, 每一层都是这个阶段的产物。

最后一行之所以通常没人付,是因为它没有触发事件。 没有任何时刻会有人问”这条规则还该存在吗”,除非有人专门去问。 这两行合起来解释了一个现象:规则集的实际维护成本, 大部分不在”跑”,也不在”实现”,而在这两处不可见的地方。

13.15 结构检查为什么应该是最先建的那一层

三层判定里,如果只能先建一层,应该是这一层,理由有三个。

它最便宜。 中位 5.2 分钟,不需要起环境、不需要跑程序。 这意味着它可以在每一次改动上跑,而不是只在合并前跑 —— 而”能在每次改动上跑”决定了它是前馈还是反馈(小节 5.7)。

它的失败最容易被修。 一个结构违规的修法通常是明确的 (“把这个挪到那儿”),而一个行为失败的修法需要理解。 这个区别对 Agent 尤其重要,它直接决定了自主修复率。

它拦下的问题,代价最延迟。 这条最反直觉。一个行为 bug 的代价是即时的 —— 功能坏了,很快会被发现;而一个结构问题的代价是延迟的: 一条错误的依赖方向不会让任何测试变红,它会在半年后让某个模块 无法被单独抽出来,或者让一次重构的成本翻三倍。 延迟的代价是最容易被低估的那种。

“最常拦住人的那一层恰好也是最便宜的一层”因此不是巧合 (小节 13.1):结构问题多,是因为它们不产生即时反馈, 所以在没有检查的情况下会持续累积。

13.16 这一层的输出格式为什么重要

一次运行的输出长这样:

guardrails: verdict applies to e9a7c9d220a6 plus 13 uncommitted paths
==> guardrail: architecture
✓ [LOGGER-SINGLE-OWNER]        (enforced)    — clean (9046 scanned)
✗ [FILE-HEALTH]                (enforced)    — 1 violation(s) (9151 scanned)
guardrails: baseline policy passed against c3c425291e7f
guardrails: architecture failed: enforced architecture rule failed

六行里有四条不同的信息,各自服务不同的用途:第一行的版本号说明 这个判定绑在哪个快照上;每条规则的 scanned 数是哨兵的读数; 档位标注决定这条会不会拦人;台账那行是单调性检查的结果。

第一行值得单独说。“这个判定适用于版本 X 加上 13 个未提交的路径” 明确了这个结论的有效范围:代码一变,旧结论就不再适用。 它防的是”上一轮跑绿了,这一轮沿用一下”—— 在 Agent 快速迭代的节奏下,这种复用几乎必然出错。

它的实现依赖一个前提:比较基线必须是确定的(小节 17.11)。 基线一旦浮动,“这次改了什么”就没有稳定答案,那么这个版本号也就没有意义了。

13.16.1 三个字段的信息密度对比

FILE-HEALTH 那一行做例子,看这套格式的信息密度。 ✗ [FILE-HEALTH] (enforced) — 1 violation(s) (9151 scanned) 一行里有五个事实: 哪条规则、它拦不拦人、失败了、违规几处、扫了多少。

一个典型的 linter 输出是 error: file too long (312 lines, max 200) 加一个位置。 它有两个事实:什么错了、在哪。缺的三个是:这条规则叫什么 (所以没法查它为什么存在)、它是不是强制的(所以不知道该不该现在修)、 扫描面有多大(所以不知道这个检查有没有正常工作)。

这三个缺失里第三个最要命 —— 一个只报告违规、不报告扫描面的检查器,在它自己坏掉的时候会输出一片干净。

13.17 建这一层的顺序

如果从零开始建,按这个顺序,每一步都能独立产生价值。

第一步是一条规则加报数模式,并且打印扫描面。不要拦人。 这一步的产出不是”合规”,是一个数字:你的存量有多大。 第二步是退出码三分,要在有第二条规则之前就做, 因为它改变的是所有规则的语义,越晚改动越大。

第三步是台账加单调性,在存量大到不可能一次清完时引入, 注意用多重集而不是集合(小节 13.3.1)。 第四步是哨兵下限,触发条件是规则超过五条 —— 到那时你已经不可能靠肉眼注意到某条规则突然不报了。 第五步是语义规则,触发条件是你发现自己在给一条文本规则加第三个例外。

五步之间没有跳过的余地,每一步都在为下一步提供前提。 特别是第一步:没有存量的数字,你无法判断第三步什么时候该做。

13.18 规则的三种粒度

同一个意图可以被写成三种粒度的规则,而它们的性质完全不同。

13.18.1 粒度一:禁止一个具体的写法

禁止 Logger(label: 出现在生产代码里。

实现简单,误报可控,失败信息精确。代价是它只挡这一种写法, 换个构造方式就绕过去了。

13.18.2 粒度二:禁止一类行为

禁止在非 owner 文件里构造日志器。

覆盖面大。代价是需要知道”什么是构造日志器”, 而这通常需要语义分析 —— 也就是 小节 13.5 里那种五层过滤的实现。

13.18.3 粒度三:让这类行为不可能

日志器的构造函数只对 owner 文件可见。

不需要规则,编译器就是执行者。代价是需要语言支持,而且需要一次重构。

13.18.4 该选哪个

默认选粒度一。 理由不是它最好,而是它最便宜, 并且它提供了走向粒度三所需要的数据:跑一段时间之后, 你会知道有多少处、分布在哪、有哪些合法的例外。这些数据是设计粒度三所必需的。

完整的路径是:粒度一(便宜,覆盖不全)→ 跑一段,收集数据 → 粒度二(贵,覆盖全)或者直接跳到粒度三(最贵,但问题从此消失)。 跳过粒度一直接做粒度三,是在没有数据的情况下做设计, 而那通常会得到一个考虑不周的抽象 —— 和目录层级”必须被第二个实例挣得” 是同一条道理(小节 6.2)。

13.19 什么样的规则不该存在

四种,各有各的问题。

无法说出失败形态的不该存在,它是品味不是规则(小节 7.5.1)。 误报率高到需要频繁加豁免的不该存在,它的边界画错了 —— 加第三个豁免时就该重新设计。和另一条规则意图重叠的不该存在, 两条重叠的规则说明背后那条真正的原则没被找出来。

第四种最难判断:只在一个地方适用的规则不该占用全局规则集的位置。 如果一条”通用规则”实际上只在一个模块里有意义,它应该是那个模块的局部约定, 因为全局规则集的每一条都在被所有人阅读和维护。 难判断的原因是规则往往从一个具体的地方开始, 而”它会不会在别处也适用”要跑一段才知道 —— 所以合理的做法是先加,然后在健康检查时问一句 “它到目前为止只在一个地方命中过吗”(小节 15.13)。

13.20 这一层和另外两层的成本对比

用同一天的实测数据对比三层:

中位耗时 失败率 每次判定的信息量
结构检查 5.2 分钟 7.0% 高(规则名 + 位置 + 修法)
单元与集成 7.8 分钟 3.9% 中(断言失败的位置)
界面与端到端 14.9 分钟 4.7% (一个失败的步骤)

三列一起看,这一层在所有维度上都占优。“每次判定的信息量”那一列 很少被讨论,但它很实际:一次结构检查失败告诉你哪条规则、哪个文件哪一行、 为什么有这条、怎么修;一次端到端测试失败告诉你在第几步挂了。

信息量的差别直接转化成迭代轮数的差别小节 17.6)。 所以这一层不只是”更早发现问题”,它还是”发现的问题更好修”—— 而后半句在有 Agent 的场景里比前半句更值钱。

13.21 从这一层能读出的组织信息

最后一个观察,它超出了技术范围:一个团队的结构检查规则集, 是这个团队争论过什么的化石记录。

那二十次提交,每一次都对应一次真实的分歧被固化成了机制。 反过来读,规则集的形状能透露不少事情:规则集很小, 说明要么没撞过墙、要么撞了没沉淀;规则集很大但没人能说出诞生原因, 说明是抄来的;规则集里有一批零违规的报数规则, 说明有人加了但没人负责收尾。

规则集在缩小是最健康的信号,也是最罕见的—— 它意味着有人在做把规则变成结构的工作(小节 15.8), 也就是把一条需要持续付出维护成本的规则, 换成一次性的、由编译器执行的约束。

13.22 ⚙️ 小规模怎么做

这一层的最小可用版本不需要构建系统,也不需要 Rust:一个脚本、 一份 YAML 规则表、一条 CI 任务,就够了。规则表起步只要三个字段: 匹配什么、为什么有这条、怎么修。第四个字段(哨兵下限) 等规则超过五条再加 —— 在那之前,你自己就是那个哨兵, 你会注意到检查突然不报了。

一条具体的建议:第一条规则,选你团队在评审里重复说得最多的那句话。 如果你想不起来是哪一句,说明现在还不该做这一层 —— 这一层守的是已经付出过代价的约定,而不是你希望团队遵守的理想。

13.23 检查器该多严

最后回答一个必然会被问的问题。判据不是”严不严”,是”误报率”。

一条零误报的规则无论多严都不会造成问题,因为它拦下的每一次都是真的; 而一条有误报的规则无论多松都在消耗信任 —— 每一次误报都在教人 “这个检查的结论不一定对”,而这个教训一旦学会就很难忘掉 (小节 5.8)。

所以正确的问题不是”我们该拦多少”,而是”我们的每一条规则,误报率是多少”。 这个数在这套系统里没有被测量,它是这一层最大的一处盲区 —— 而且它盲的正是”这一层的健康度”这个最重要的量。

13.24 这一层最容易被建错的地方

按遇到的顺序,列三个。

13.24.1 第一个:把它当成代码风格工具

症状是规则集里大部分是缩进、命名格式、导入顺序。 问题在于这些东西格式化工具已经解决了,而且是零人工的 —— 把它们放进这一层,等于用一个贵的机制解决一个便宜的问题, 同时稀释了这一层真正该管的东西。

这一层该管的是结构:依赖方向、owner 边界、 某个 API 不该出现在哪里 —— 那些格式化工具管不了的。 判据很简单:如果一条规则能被自动修复,它就该属于格式化工具,不属于这里。

13.24.2 第二个:规则没有分档

症状是所有规则一上来就拦人,后果是所有人当场停摆, 然后规则被关掉,然后”关掉规则”成了一个先例 —— 而先例比那条规则本身贵得多。

13.24.3 第三个:本地和 CI 跑的不是一回事

症状是有人说”本地是好的”。这个症状一旦出现,这一层的权威就开始漏气, 而权威一旦漏气,它拦下的每一次都会被质疑。

修法是让两边跑同一条命令、同一份配置(小节 13.2)。 这通常意味着把检查从 CI 配置里挪进一个可以本地执行的脚本, 而 CI 只是调用它 —— 一次半天的改动,换掉一整类将来的争论。

三个错误的共同点是它们都不会立刻造成明显的后果, 而它们会在几个月内让这一层失去作用。

13.25 规则数量的参照

给一个粗略的参照,帮读者判断自己的规模:

仓库规模 合理的架构规则数
单产品,十万行以内 3–8 条
多模块,百万行 10–20 条
这套系统(310 万行,26 产品) 23 条

注意增长是次线性的。代码量翻了三十倍,规则只翻了三倍多, 因为规则守的是”结构的种类”,不是”代码的数量”—— 一个仓库里有多少种结构性的约定,和它有多少行代码关系不大。

这个观察给出一个诊断:如果你的规则数随代码量线性增长, 说明其中有一批规则是产品域特定的(“这个模块不许调那个 API”), 而那些应该是局部约定,不该占用全局规则集的位置。

13.26 这一层和格式化工具的分界

小节 13.24.1 说别把这一层当代码风格工具, 但那条只给了症状,没给分界线。给一条能用的。

分界线是”能不能被自动修复”。 一个问题如果存在一个 唯一的、确定的正确形态,那么它属于格式化工具 —— 缩进、引号、导入顺序、尾随空格,这些都有唯一解, 所以正确的机制是自动改掉它,而不是报告它

这一层管的东西没有唯一解。“这个日志器该定义在哪个文件里” 有多个可接受的答案(那个 target 的 Logger.swift, 或者把这个文件挪进别的 target); “这个依赖方向不对”的修法可能是移动代码、 可能是引入一个抽象、也可能是这条依赖本来就该存在而规则该改。 没有唯一解意味着必须由人(或 Agent)来决定, 而这正是”报告”这个动作存在的理由。

这条分界线还有一个副作用值得利用:凡是能被自动修复的, 就不该出现在这一层的输出里。因为它们每出现一次, 都在稀释那些真正需要判断的条目 —— 一份混着二十条缩进问题和一条依赖方向问题的输出, 读它的人(和 Agent)会先处理那二十条, 因为它们更容易。

所以一个健康的结构检查层,它的输出应该是短的。 如果你的这一层每次报几十条, 先检查其中有多少是能被自动修掉的 —— 把那些挪走之后剩下的那几条,才是这一层真正的对象。

13.27 压成三句话

一、最常拦住人的那一层,恰好也是最便宜的一层。而这不是巧合 (小节 13.1.1):结构问题不产生即时反馈, 所以在没有这一层的仓库里,那 7% 不是”没发生”,是”没被发现”。

二、baseline 是单调收敛的债务台账,不是豁免清单。而这个区分 在实现上是一个多重集加一个事务(小节 13.3.1)—— 两个看起来很技术的细节,守的是”债务只能还不能借”这条唯一重要的性质。

三、规则的边界必须拿真实数据调,而哨兵不能被关掉。 前半句决定它会不会被绕过,后半句决定它坏了你会不会知道。

13.28 这一层的一个隐藏收益

最后说一个通常不被提起的收益:这一层的规则集, 是一份关于”这个仓库在乎什么”的可执行文档。

一个新来的人(或者一个新的 Agent)想知道这个团队的架构原则是什么, 最可靠的信息源不是架构文档,是那份规则表。因为文档可能过时、 可能没人读、可能和实际做法不一致,而规则每天都在跑。

这意味着规则表有一个文档永远没有的性质:它不可能和现实脱节。 如果它脱节了,它会开始误报,然后被修 —— 而这个反馈回路是自动的, 不需要任何人记得去维护它。所以维护好这份表,某种程度上 也是在维护一份永远准确的架构说明,而这个收益不体现在任何拦截统计里。