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 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 策略与机制分离
整套东西被切成三段,每段只做一件事:
中间那层的两句注释值得单独说:丢弃环境里的版本覆盖、只加载封闭配置。 这两件事合起来保证了一件事 —— 判定的结果不取决于运行它的那台机器上 装了什么。
“构建出运行器后把自己替换掉”意味着引导层不会留在进程里, 它没有机会影响判定。引导层不参与判定,这是一条被实现方式保证的约束, 而不是一条纪律。
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 incident 与 fix_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)—— 两个看起来很技术的细节,守的是”债务只能还不能借”这条唯一重要的性质。
三、规则的边界必须拿真实数据调,而哨兵不能被关掉。 前半句决定它会不会被绕过,后半句决定它坏了你会不会知道。