Agent 工程化与交付:从零到一
这是一份快照
本文的数字、常量、行数取自 2026-09-03 对 sid-code 源码的一次实读。 代码在动,这些数字会腐坏——引用其中任何一个之前,请按文中给出的命令在你自己的仓库里复跑一次。
这份文档写给谁:能写代码、但没有独立搭过一套「门禁 + 发布 + 回滚 + 留痕」体系的人。 尤其写给做 AI agent 的人——因为 agent 项目的工程化和普通后端项目差别很大, 而这个差别几乎没有人系统讲过。用途是知识梳理与 agent 开发面试准备。
它想解决什么:「工程化」是个听起来很虚的词。面试里被问「你们的工程化做得怎么样」, 大部分人会答成一串名词——CI、CD、代码规范、单元测试。这个答案的问题不是错, 是它对任何项目都成立,所以什么都没说。
本文的写法相反:每一条机制都从「不做它会怎么坏」讲起,并且给出那个坏法的具体形态。 因为工程化的价值全在「防住了什么」上,而防住的东西默认是看不见的—— 你必须能说出它的反面,才算真的懂它。
一条贯穿全文的主线:工程化的真正难点不是「建一道门禁」, 而是**「门禁自己会坏,而且坏的时候是绿的」**。这句话会在 §3、§4.5、§6、§9 反复出现。 如果你只记住一件事,记住它。
怎么读这份文档
按顺序读。这是一条链,不是清单——后面每章都在用前面建立的概念。
| 章 | 讲什么 | 读完你能回答 |
|---|---|---|
| §0 | 名词地图 | 别人说门禁 / 汇聚检查 / 变异自证 / 灰度通道时,你知道指什么 |
| §1 | 为什么 agent 项目的工程化 ≠ 照搬后端 CI | 哪三个前提在 agent 项目上失效了 |
| §2 | 门禁:工程化的骨架 | 一条规则该放在哪一层拦,成本预算是多少 |
| §3 | 变异自证:门禁自己会坏 | 怎么证明你的门禁真的在工作(本文最重要的两章之一) |
| §4 | 选择性测试:一次「拿安全换速度」的完整交易 | 怎么把 127s 降到 0.2s,以及必须配什么补偿 |
| §5 | 发布:从「能跑」到「能回滚」 | 一次发布有几件事、顺序为什么不能换、灰度和回滚各挡住什么 |
| §6 | 反漂移:文档与代码的对账 | 为什么「手写清单必然漂移」,以及哪些东西不该立门禁 |
| §7 | 决策留痕:为什么 rejected/ 最贵 | 半年后有人问「当时为什么这么定」,答案在哪 |
| §8 | 多 agent 并行的工程化 | agent 时代新增的三个失效模式(这一章别的地方查不到) |
| §9 | 会「绿着坏掉」的失效模式总表 | 本文最重要的两章之一 |
| §10 | 从零到一:六级实操路线 | 从零开始,第一天做什么、第一周做什么 |
| §12 | 动手:给自己的项目搭一套 | 五个阶段,每阶段会亲手撞到的坑 |
如果只有 20 分钟:读 §2、§3、§9。这三章是这个领域的骨架,其余都是它们的展开。
如果你在准备面试:加读 §5.4(灰度)和 §7(留痕)。这两块是绝大多数候选人答不出来的, 拉分效果最好。
§0 名词地图:先把词认全
这一节是查询表,不用背。往后每章第一次用到某个词都会重新解释一遍, 这里放一份集中的,方便你随时回来查。
按「一次改动从写下到上线」的顺序排列,不按字母序——因为这些词之间有位置关系。
0.1 门禁与检查
| 词 | 中文 | 是什么 | 容易混的点 |
|---|---|---|---|
| gate / 门禁 | 门禁 | 不满足条件就物理上阻止你往下走的机制 | 与「告诫」的区别:告诫靠自觉,门禁靠拦截 |
| lint | 静态检查 | 不运行代码,只读代码文本找问题 | 分两类:正确性(未用变量)与风格(缩进),要分开管 |
| formatter | 格式化器 | 自动排版代码 | 与 lint 的分工:formatter 管长相,lint 管对错 |
| CI | 持续集成 | 代码推上去自动跑一批检查 | 它是门禁的一个安放位置,不是门禁本身 |
| CD | 持续交付 / 部署 | 检查过了自动发布 | 本文的发布是半自动的,理由见 §5.4 |
| git hook | git 钩子 | git 在某个动作前后自动跑的脚本 | 在本地,可以被 --no-verify 跳过 |
| pre-commit | 提交前钩子 | git commit 时触发 | 预算:单次几百毫秒到 2 秒 |
| pre-push | 推送前钩子 | git push 时触发 | 预算:单次几秒,比 pre-commit 宽 |
| required check | 必需检查 | 分支保护里「必须绿才能合并」的那些检查 | 数量该是 1 个,理由见 §2.4 |
| aggregate gate | 汇聚门 | 一个自己不干活、只收集别人结论的检查 | §2.4 的核心概念 |
| branch protection / ruleset | 分支保护 | 「不许直推 main」「必须过检查」这类平台侧规则 | 平台侧强制,本地绕不过 |
0.2 测试与选测
| 词 | 中文 | 是什么 |
|---|---|---|
| full run / 全量 | 全量测试 | 把所有测试都跑一遍 |
| affected tests / 选测 | 选择性测试 | 只跑「可能被这次改动影响」的那部分测试 |
| test selection strategy | 选测策略 | 怎么决定跑哪些。两大流派:依赖图与路径映射(§4.2) |
| force-full | 强制全量 | 一批「改了它任何测试都可能变」的文件,命中就退回全量 |
| escape hatch / 逃逸阀 | 逃逸阀 | 选测判不准时的兜底出口 |
| flaky | 不稳定用例 | 有时过有时不过、但代码没变 |
| mutation self-proof | 变异自证 | 故意把被守护的东西改坏,看门禁是否变红(§3,本文最重要的概念) |
| false green / 假绿 | 假绿 | 东西已经坏了,门禁还是绿的 |
| false red / 假红 | 假红 | 东西是好的,门禁却红了 |
💡 假绿比假红危险得多。假红会烦到你,你会去修它; 假绿什么都不做,你会相信它并据此决策。全文所有「最危险」的判断都基于这条。
0.3 构建与产物
| 词 | 中文 | 是什么 | 关键点 |
|---|---|---|---|
| artifact / 产物 | 构建产物 | 编译出来的那个可执行文件或包 | |
| cross-compile | 交叉编译 | 在 macOS 上编出 Linux 能跑的二进制 | |
| target / triple | 构建目标 | 平台-架构 的组合,如 darwin-arm64 | |
| baseline build | 基线构建 | 不用新 CPU 指令集编的版本,兼容老机器 | §5.3 会讲它为什么必须单独有一档 |
| smoke test | 冒烟测试 | 产物编出来后跑一下 --version,确认它至少能启动 | 最便宜也最有效的一道 |
| provenance / 溯源 | 构建溯源 | 「这个二进制来自哪个 commit」的可验证记录 | §5.7 |
| reproducible | 可复现构建 | 同样的源码编两次得到同样的字节 |
0.4 发布与灰度
| 词 | 中文 | 是什么 |
|---|---|---|
| release | 发布 | 把产物放到用户能下载的地方 |
| channel | 发布通道 | 一组用户看到的是哪个版本。典型两个:beta(抢先)与 stable(稳定) |
| canary / 灰度 | 灰度发布 | 先给小部分用户,观察一段再放量 |
| promote | 促升 | 把某个已经在 beta 泡过的版本升成 stable |
| pointer file | 指针文件 | 服务器上一个只写一行版本号的文件,决定用户装到哪版 |
| rollback | 回滚 | 把指针指回旧版本 |
| bake time / 泡制期 | 观察期 | 版本在 beta 待着、等真实使用暴露问题的那段时间 |
| tag | 标签 | git 上给某个 commit 起的名字,如 v0.1.601 |
| changelog | 变更日志 | 这一版改了什么。有两个受众,所以要两份(§6.5) |
0.5 协作与留痕
| 词 | 中文 | 是什么 |
|---|---|---|
| PR / MR | 合并请求 | 「我改好了,请审后合进主干」 |
| squash merge | 压缩合并 | 把 PR 里的多个提交压成一个再合 |
| merge commit | 合并提交 | 保留 PR 里所有提交,另加一个合并节点 |
| strict check | 严格检查 | 要求你的分支必须与主干最新才能合并 |
| merge queue | 合并队列 | 平台把待合并的 PR 排队、逐个用最新主干重测 |
| stacked PR | 堆叠 PR | PR B 的基线指向 PR A 的分支 |
| worktree | 工作树 | 同一个仓库在磁盘上开多份 checkout,各在不同分支 |
| Agent Note | 决策留痕 | 一份记录「决定了什么 / 放弃了什么 / 拿什么证明」的文档(§7) |
| decision record / ADR | 架构决策记录 | 上面那个东西的通用叫法 |
🔑 一个能立刻用上的记忆法:把工程化想成一条流水线上的若干道质检口。 lint / formatter 是工位旁的卡尺(随手量),pre-commit 是离开工位前的自检, pre-push 是出车间前的抽检,CI 是入库前的全检,汇聚门是仓库门口那个签字的人, beta 通道是先发给一小批客户试用,stable 是全面铺货,回滚是召回, Agent Note 是工艺变更单——记录「为什么把这道工序从 3 分钟改成 5 分钟」。
这个类比后面还会用,尤其是 §3——那一章讲的是「卡尺自己不准了,但量出来的数看着很正常」。
§1 为什么 agent 项目的工程化不是「照搬后端 CI」
大多数人第一次想「给 agent 项目做工程化」,脑子里的画面是后端那套: 写单测、配 CI、上 lint、加 code review。这套东西全部需要,但远远不够。 这一节讲清差在哪,因为后面九章都是在补这个差。
1.1 先看传统后端 CI 为什么好用
传统 CI 能成立,靠三个默认成立到你都不会去想的前提:
| 前提 | 具体含义 | 为什么它让 CI 好用 |
|---|---|---|
| ① 确定性 | 同样的输入,同样的输出 | 测试红了就是真的坏了,绿了就是真的好了 |
| ② 免费 | 跑一遍测试的边际成本≈0(只花 CPU 时间) | 所以可以「每次提交都跑全量」,不用想省不省 |
| ③ 代码是人写的 | 改动来自一个能理解上下文、能被 review 说服的人 | 所以「约定」和「告诫」有效——写进文档,人会遵守 |
这三条同时成立时,CI 的设计非常简单:把所有检查都塞进去,每次都跑,红了就不许合。 不需要考虑成本,不需要考虑「检查本身对不对」,也不需要考虑「有人会绕过它」。
1.2 三个前提在 agent 项目上依次失效
① 确定性失效 —— 被测对象自己会抖
agent 的核心是一次 LLM 调用,它天生非确定:同样的输入,两次结果不同。 连带的后果是测试策略要分层:
| 层 | 确定性 | 能不能进门禁 |
|---|---|---|
| 纯函数、数据结构、协议转换 | ✅ 确定 | 能,而且这是绝大多数 |
| 用录制回放(VCR)固定住模型响应 | ✅ 确定 | 能 |
| 真调模型的评测(eval) | ❌ 不确定 | 不能当硬门禁,只能当趋势 |
⚠️ 这里有一个特别常见的错:把评测当门禁。 「分数掉了就不许合并」听起来很对,但评测分数本身有方差—— 同一份代码跑两次可能差几个百分点。把它设成硬门禁的结果是: 一半的 PR 被随机拦住,然后人开始加参数跳过它,最后连报告都不看了。
正确做法:评测只报告、不阻断,用单独的 workflow 定期跑(本仓是每周 + PR 冒烟两条)。 详见 §2.3 的成本预算和 §9 的 R9。
② 免费失效 —— 跑一次是要花钱的
真调模型的检查,每次都在烧真金白银。一个数量级参考:一次 5 道题的冒烟评测, agent 调用加判分调用,成本在几美元到十几美元量级。
这直接改变了 CI 的形态:不能什么都每次跑。于是必须做分层:
每次提交(免费) → lint / format / 类型 / 单测 ← 秒级,全都跑
每个 PR(几乎免费) → 全量单测 + 构建 ← 分钟级,全都跑
每个 PR(要钱) → P0 冒烟评测(5 道题) ← 只跑最小集
每周(要钱) → 完整评测 + 判分器校准 ← 定时跑,不挡任何人③「代码是人写的」失效 —— 这一条影响最深远
当写代码的一方是 agent 时,「写进文档,大家遵守」这条路的成功率显著下降。 不是因为 agent 不听话,是因为:
- agent 每次会话都是新的上下文。你上周踩的坑,它这周不知道,除非那个知识在它读得到的地方。
- agent 有很强的「让流程通过」倾向。门禁红了、原因不明显时,加一个跳过参数是最短路径。
- 一个仓库里可能同时有多个 agent 在跑,各自不知道对方在改什么(§8)。
于是就有了本文的第一条核心原则:
🔑 告诫失效三次之后,换成门禁。
这不是一句口号,它有一个真实的形态。本仓的「北极星指标数字陈旧」问题, 在
CLAUDE.md和路线图里都写了「引用前先回源码核验」的告诫, 同一个失效模式仍然发生了三次(2026-08-05 / 08-08 / 08-14), 三次都是同一个机制:读者看不出那个数字是三天前还是三个月前量的,于是照抄。第三次之后的处理不是「再强调一遍」,是加一道 pre-push 门禁: 生成块超过 30 天未刷新就拒绝 push。 「告诫 + 自觉」这条路已被证伪三次——这句话本身就是那道门禁存在的理由。
1.3 于是多出两个后端项目基本不需要的维度
维度 A:决策留痕(§7)
后端项目里,「为什么这么写」通常靠 code review 时的讨论 + commit message + 团队记忆维持。 agent 参与开发时,这三个载体全部失效:
- agent 不参加你的会议
- commit message 的读者是未来的自己,写不了完整论证
- agent 的记忆是每个实例私有的——一个 agent 记住的东西,另一个 agent 读不到
所以论证必须落在仓库里,而且要在一个约定好的位置、有约定好的格式。 这就是 Agent Note 的全部理由。
维度 B:门禁被绕过的风险(§2.3)
后端项目里没人关心「pre-commit 跑 30 秒会不会太久」——大家忍了。 agent 参与时,一道太贵的门禁会以一个特定形态失效:
贵到一定程度的门禁不会被抱怨,会被绕过,而且绕过之后一切看起来正常。
所以本文所有门禁都标了时间预算,而且这个预算是设计约束,不是事后测量。
1.4 一张对照总表
| 维度 | 传统后端项目 | agent 项目 |
|---|---|---|
| 被测对象确定性 | 确定 | 核心链路不确定 → 测试要分层 |
| 检查的边际成本 | ≈ 0 | 真调模型的部分要花钱 → CI 要分频次 |
| 改动来源 | 人 | 人 + agent → 约定要变成门禁 |
| 知识传承 | 团队记忆 + review | 必须落盘(Agent Note) |
| 门禁失效方式 | 主要是假红(烦人) | 主要是假绿 + 被绕过(危险) |
| 「工程化做得好」的标志 | CI 全绿、覆盖率高 | 能说出每道门禁防住了什么,以及它自己是怎么被验证的 |
1.5 本章自检
读完这一节,你应该能回答:
- 传统 CI 依赖的三个前提是什么?在 agent 项目上分别怎么失效的?
- 为什么评测分数不能当硬门禁?把它当门禁会以什么形态失效?
- 「告诫失效三次换门禁」这条原则,你能举出一个具体形态吗?
- agent 项目比后端项目多出的两个工程化维度是什么?各自解决什么问题?
答不出第 2 或第 3 条就回去重读 §1.2——那两条是后面所有章节的前提。
§2 门禁:工程化的骨架
这一章讲门禁体系怎么设计。核心不是「加哪些检查」——那个各项目大同小异; 核心是**「一条规则该放在哪一层拦」,以及「每一层的成本预算是多少」**。
2.1 先说清「门禁」和「告诫」的区别
这是全章的地基,值得单独一节。
| 告诫 | 门禁 | |
|---|---|---|
| 形态 | 文档里写一句「请记得 XXX」 | 不满足就退出码非 0,物理上走不下去 |
| 依赖 | 人的记忆 + 自觉 | 无依赖 |
| 失效方式 | 静默失效(忘了,没人知道) | 显式失效(红了,你知道) |
| 成本 | 0 | 有:开发它 + 每次跑它 + 误报时的排查 |
判断一条规则该用哪种,只有一个判据:
它失效过几次?
0 次 → 告诫够了(别过度工程) 1–2 次 → 告诫 + 在失效点旁边写清「为什么不能改回去」 ≥3 次 → 必须换门禁
第三条为什么是硬线:一件事在有明确告诫的前提下还失效三次, 说明失效原因不是「不知道」,而是「知道也做不到」—— 可能是当时上下文不够、可能是那一步太容易忘、可能是绕过它的收益太直接。 这类原因不会因为你把告诫写得更大声而消失。
2.2 五层安放位置,以及各层的取舍
一条检查可以放在五个位置。它们的根本差别是「反馈延迟」与「能否被绕过」:
| 层 | 触发时机 | 反馈延迟 | 能被绕过吗 | 时间预算 |
|---|---|---|---|---|
| ① 编辑器 / LSP | 边打字边报 | 毫秒 | 能(不看就是了) | ~0 |
| ② pre-commit | git commit | 秒级 | 能(--no-verify) | 几百 ms – 2s |
| ③ pre-push | git push | 秒级 | 能(--no-verify) | 几秒 – 十几秒 |
| ④ CI | 推到远端后 | 分钟级 | 不能 | 分钟级 |
| ⑤ 分支保护 | 合并时 | —— | 不能(平台侧强制) | —— |
选层的三条判据:
- 要防的东西严重吗? 严重到「绝不能进主干」的,必须有一份在 ④ 或 ⑤—— 因为 ② ③ 都能被绕过。
- 反馈越早越好,但成本要匹配。 一个 3 秒的检查放 pre-commit 会让人烦到卸掉 hook; 放 pre-push 就刚好。
- 同一条规则可以放两层,而且经常应该。 本地那份给你快速反馈, 服务端那份保证「绕不过去」。
第 3 条值得展开,因为它是最常被误解的:很多人觉得「本地已经拦了,CI 再拦一遍是浪费」。 实际相反。以格式检查为例,本仓的 CI 注释写得很清楚:
pre-commit 装在本地且能被
--no-verify跳过,而 formatter 的全部价值在于 「所有人的 diff 里没有排版噪音」——只要有一个人绕过去,后面每个碰到那个文件的人 都会带上一次无关的重排。外部贡献者的编辑器配置更杂,这道检查是让「格式统一」 这件事不依赖任何人的本地设置。
注意这个论证的形状:它不是「双重保险更安全」这种泛泛之谈, 而是具体指出了「一次绕过」会造成什么持续性损害。这才是能说服人的理由。
2.3 时间预算:为什么它是设计约束而不是事后测量
这是 agent 项目特有的一条,也是最容易被跳过的一条。
🔑 门禁贵到一定程度就会被绕过,而绕过之后一切看起来正常。
这句话有两个层次。第一层好理解:慢了人会烦。 第二层才是关键——被绕过的门禁比没有门禁更糟,因为它还给人虚假的安全感: 你以为那条不变量有人守着,实际上守它的那个脚本每次都被 --no-verify 跳掉了。
所以本仓每道门禁的注释里都写了预算和理由。三个真实例子,注意它们的推理方式:
例 1:参考页对账为什么只在数据源变动时才跑
只在数据源变动时才跑:
--check要起一次 bun 进程 dump 工具定义(约 1s), 每次提交都跑会让无关提交也变慢,久了就会被--no-verify绕过。
推理:1 秒本身不算贵,但它落在每一次提交上就贵了。 解法不是让检查更快,是让它只在相关的提交上触发。
例 2:站点构建为什么放 pre-push 而不是 pre-commit
构建约 3s,比 pre-commit 该承担的成本高;而它要防的是「推上去才发现构建挂了」, push 边界正好。
推理:先问「这个检查要防的坏事发生在哪个边界」,再选层。 不是「越早越好」,是「在它要防的那件事之前」。
例 3:陈旧检测的阈值为什么是 30 天
阈值 30 天:短于此会因正常开发节奏频繁误拦,而误拦几次之后人就会开始无脑加
--no-verify—— 一个被绕过的门禁比没有门禁更糟,它还给人虚假的安全感。
推理:阈值不是拍的,是从「误报率会不会高到让人放弃这道门禁」倒推的。 误报率是门禁的设计参数,跟准确率一样重要。
⚠️ 一个反直觉的推论:有时候正确的做法是让门禁变宽松,而不是变严格。 一道 95% 准确的门禁如果误报到让人卸掉它,实际有效性是 0; 一道 80% 准确但没人想绕的门禁,实际有效性是 80%。
2.4 汇聚门:这一节是本章最值得学的设计
问题:分支保护里要写「哪些检查必须绿」。最直觉的做法是把 CI 里的 job 名一个个填进去。
这个直觉会造成两次事故,而且两次都是「不报红只转圈」的形态。
先看本仓 ci.yml 里那段注释记录的两次真实事故:
| 事故 | 发生了什么 | 表现 |
|---|---|---|
改默认分支名 master → main | workflow 的 branches: 过滤器没跟着改 → CI 根本不触发 | PR 页面显绿,门禁静默失效 |
| stacked PR 改 base | 平台自动改 base 发的是 edited 事件,不在 types 默认值里 → CI 不触发 | 三个必需检查恒 pending,页面显示「等待检查报告」 |
第二个尤其阴,值得完整讲一遍,因为它是一个教科书级的隐性耦合:
- 你把 PR B 的 base 设成 PR A 的分支(堆叠)。此时 base 不是
main,branches: [main]过滤器把pull_request事件整个滤掉——CI 一次都不跑。 - A 合入 main 后,平台自动把 B 的 base 改成
main。base 现在合规了。 - 但那次自动改 base 发出的是
pull_request的editedaction, 不是synchronize。而types不显式声明时的默认值里没有edited。 于是它仍然不触发,而且此后再没有任何事件能触发它。 - 结果不是「少跑一次」,是这个 PR 永久卡死:必需检查一个 run 都没有 → 状态恒为 pending → 页面显示「Some checks haven't completed yet」。 没有红叉,只有一个永远转不完的圈。
💡 这个失败模式为什么比红叉难对付:红叉会让你去看日志。 转圈会让你以为「还在跑,等等就好」。人对「等待」的容忍度远高于对「失败」的, 所以这类问题的平均发现时间长得多。
解法:加一层间接。
┌─ test (ubuntu) ─┐
分支保护只绑一个检查 ←─ all-checks-passed ←┼─ test (macos) ─┤ (这些 job 真干活)
└─ lint ─┘汇聚门(all-checks-passed)自己不跑任何检查,只把上游所有 job 的结论收成一条。 好处是分支保护与 workflow 内部结构解耦:加 job、改 job 名、拆 job, 再也不用动分支保护,只改汇聚门的 needs 一行。
但这个设计有两个必须做对的细节,做错了它自己就是最危险的假绿源。
细节 A:if: always() 不可省
all-checks-passed:
needs: [test, lint]
if: always() # ← 这一行不是可选的为什么:GitHub Actions 的 job 有一个隐式的 success() 条件。 不写 if: always() 时,上游任何一个 job 失败 → 本 job 直接变成 skipped。 而 GitHub 把 skipped 的必需检查算作通过。
🔴 于是就得到了最完美的假绿:上游测试失败 → 汇聚门被跳过 → 分支保护认为通过 → 允许合并。 一道为了防假绿而建的门禁,因为漏了一行
if,变成了假绿的生产者。
细节 B:判据要用「不含 failure/cancelled」,不能用「全部 == success」
# ✅ 对:只要没有 failure / cancelled 就放行(skipped 也算过)
if [[ "${{ contains(needs.*.result, 'failure') || contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
exit 1
fi
# ❌ 错:要求全部 success
# 将来任何一个带 if 条件的 job 被正常跳过,都会把整道门禁永久卡死这一条防的是未来的自己:今天所有 job 都无条件运行,判据写死「全 success」没问题; 哪天有人给某个 job 加了 if: 条件(比如「只在 website 变动时跑」), 那个 job 在无关 PR 上会正常 skipped,然后整道门禁就把所有 PR 全拦住了。
细节 C:加了新 job 必须同步 needs
漏了就是一个绕过分支保护的后门,而且 PR 页面一片绿。 所以本仓有一条机械断言守它(tests/release-flow-contract.test.ts): needs 必须覆盖除自己以外的全部 job。
🔑 注意这个模式:一道门禁的正确性,由另一道门禁来守。 这不是套娃,是必要的——因为「人会记得同步 needs」正是那种已经被证伪的假设。
2.5 本仓的实际门禁清单(一个可参照的样本)
核过的实际配置,共 6 个 workflow + 2 个 git hook:
CI(ci.yml,2 个 job + 1 个汇聚门 = 5 道检查)
| job | 检查 | 干什么 |
|---|---|---|
test(ubuntu + macos 双矩阵) | bun test | 全量单测 |
| 工作区洁净 | 单测跑完后 git status 必须干净(见下方说明) | |
make build | 构建 + 产物自检 | |
lint(ubuntu) | oxlint | 正确性 lint |
oxfmt --check | 排版 | |
| 包边界扫描 | 分层依赖不许倒流 | |
all-checks-passed | —— | 汇聚,唯一的必需检查 |
💡 「单测未污染工作区」这道检查值得单独说,因为它防的东西非常隐蔽: 测试如果往仓库里写文件(生成快照、写缓存、改配置),本地跑时你可能不会注意, 但它会让「工作区是否干净」这个判据失效——而洁净判据是发布门禁的前提(§5.2)。 更糟的是测试往用户真实目录(
~/.sid-code/)写数据: 那会污染真实遥测数据,且测试全绿。本仓为此有一道专门的静态扫描门禁。
其余 5 个 workflow(都不是必需检查,刻意的)
| workflow | 频次 | 干什么 | 为什么不当门禁 |
|---|---|---|---|
eval-pr-smoke | PR | P0 冒烟评测 | 要花钱 + 有方差 |
eval-weekly | 每周 | 完整评测 | 同上,且耗时长 |
judge-calibration | 定期 | 校准判分器本身准不准 | 它测的是尺子,不是被测对象 |
provider-daily-canary | 每天 | 探活各个模型接口 | 上游挂了不是我们的 bug |
northstar-weekly | 每周 | 指标趋势 | CI 上没有真实用量数据(见下) |
⚠️
northstar-weekly有一条必须点破的现实约束,它是「诚实标注」的好例子: CI runner 上没有用户的本地数据目录,所以这个 workflow 只跑自检(验计算逻辑没坏), 不聚合真实用量。在 CI 里硬聚合只会产出一份样本数为 0 的快照, 而它看起来像数据,比没有快照更危险。真实趋势需要维护者本机跑。把这件事写成「已自动化」就是典型的文档漂移(§6)。
2 个 git hook
| hook | 门禁 | 预算 |
|---|---|---|
| pre-commit | ① 评测数据污染扫描 ② holdout 回归护栏 ③ oxlint(只查 staged) ④ oxfmt --check ⑤ Agent Note 形态校验 ⑥ 参考页对账(仅数据源变动时) ⑦ 叙述覆盖度(告警模式) ⑧ 源码裸 NUL 字节检查 | 几百 ms |
| pre-push | ① holdout 泄露检测 ② holdout 永封校验 ③ 站点构建(死链检测) ④ 指标生成块陈旧检测(30 天) | 几秒 |
注意 pre-commit 的 ③④ 都写着「只查 staged」——这是把预算控制在几百毫秒的关键手段: 全仓 lint 是秒级,staged 文件通常只有几个,是毫秒级。
💡 一个免费门禁的漂亮例子:pre-push 的「站点构建」这一条, 本意是防「推上去才发现构建挂了」,但它顺带免费得到了死链检测—— 因为静态站点生成器的配置里开了「有死链就构建失败」。 不需要另写一个链接检查脚本,改错内链构建即失败。
找这种「一个动作顺带守住多条不变量」的机会,比堆门禁数量划算得多。
2.6 两个格式化门禁的一个刻意设计:只报错,不改文件
本仓的 formatter 门禁在 hook 里只报错,不自动修。理由值得记住:
hook 里偷偷改工作区,会让你提交的内容与你 review 过的内容不一致。
这条很违反「自动化优先」的直觉——既然能自动修,为什么要让人手动跑一次? 因为「提交的东西 = 我看过的东西」是一条比「省一次命令」重要得多的不变量。 一旦 hook 能改文件,你的 commit 里就可能有你没看过的改动。
2.7 本章自检
- 「告诫」什么时候该升级成「门禁」?判据是什么?
- 五层安放位置,各层的时间预算大约是多少?为什么 pre-commit 的预算最紧?
- 「同一条规则放两层」什么时候是必要的、不是浪费?拿 formatter 举例说明。
- 汇聚门解决什么问题?漏掉
if: always()会发生什么? - 为什么判据要写「不含 failure」而不是「全部 success」?
- 为什么评测不能当必需检查?把它设成必需检查会以什么形态失效?
- 为什么 hook 里的 formatter 不自动改文件?
第 4 题答不出来就回去重读 §2.4——那是本章唯一「一行代码之差决定门禁有没有用」的地方。
§3 ★ 变异自证:门禁自己会坏,而且坏的时候是绿的
这一章是本文的两个核心之一。 如果 §2 讲的是「怎么建门禁」, 这一章讲的是**「怎么证明你建的门禁真的在工作」**——而这件事绝大多数项目根本没做。
3.1 先看问题:一道门禁「绿」有两种完全不同的含义
你跑了一道门禁,它绿了。这个「绿」可能意味着:
| 含义 | 你想要的 | |
|---|---|---|
| ✅ 真绿 | 它检查了,被检查的东西没问题 | 是 |
| 🔴 假绿 | 它根本没检查到,或者检查的是错的东西 | 不是 |
关键难点:这两种绿在输出上完全一样。 都是退出码 0,都是一行 ✅。 没有任何东西会告诉你「我这次其实什么都没查」。
于是就有了工程化里最反直觉的一条:
🔑 一道从未失败过的门禁,不是「守得好」的证据,是「可能没在工作」的疑点。
3.2 变异自证:唯一能区分真绿假绿的手段
方法只有一个,非常朴素:
故意把被守护的那个东西改坏,然后跑门禁。它必须变红。红了,才证明它在工作。
这个动作叫变异自证(mutation self-proof,也叫 mutation testing 的思想)。 它的完整形态是三步:
① 门禁在正常状态下跑 → 应该绿 (happy path,大多数人只做到这一步)
② 故意改坏被守的东西 → 门禁必须红 ★ 这一步才是自证
③ 改回来 → 门禁重新绿 (确认第 ② 步不是别的原因红的)第 ② 步没做的门禁,等于没有门禁。 因为你无法区分它是「守住了」还是「压根没跑」。
3.3 但变异自证自己也有一个陷阱:「有东西红了」不算过
这一条是本节最精细、也最值钱的一点。
做变异自证时,很自然会这样验收:改坏 → 跑门禁 → 看到红了 → 通过。
这个验收标准是错的。 正确的判据是:
🔴 红的必须是「那一条」,不能只是「有东西红了」。
为什么:一道门禁里通常有多条断言。你改坏了 A,结果是断言 B 因为别的原因红了—— 你看到红色,得出「门禁有效」的结论,但守 A 的那条断言其实是空的。
一个真实案例(本仓 2026-09-02):
背景:给一个「必须注入某个环境变量」的机制写门禁。断言写成「文件全文必须包含 --ve 这个参数」。
问题:那个文件里有我自己写的解释性注释,注释里恰好提到了 --ve 这两个词。
后果:把被守护的那行注入代码整个删掉,门禁仍然 16 项全绿—— 因为注释里的字样满足了断言。
修法两步:
- 剥掉注释行再做断言(
codeOf()只取非#开头的行) - 判注入形态而不是判两个词各自出现:用正则
/--ve\s+"VAR=/而不是contains("--ve")
🔴 这个 bug 只能靠变异自证发现,happy path 全绿时它完全隐形。 而当时的验收如果只是「删掉那行,看看有没有东西红」,也发现不了—— 必须是「红的是哪一条」,本次是逐条确认了 7 条断言各自的红法。
同源的另一个形态(更常见):门禁的断言读了不该读的文本。
| 断言读的东西 | 会被什么骗过 |
|---|---|
| 文件全文 | 注释、文档字符串、测试数据里的同名字样 |
grep 命中数 | 定义行、测试文件、注释——命中数 ≠ 调用数 |
| 「某个函数被引用了」 | 只在单测里被引用、只在注释里被提到 |
💡 一个本仓的真实数字,说明这个问题多严重: 一个函数
grep到 6 处命中 → 看起来「在用」。逐条分类后: 4 处在单测里(全绿)、1 处在注释里(声称它在链路上)、1 处是定义本身, 生产代码里 0 处调用。三个欺骗信号叠在一起(绿的单测 + 画了链路的注释 + 6 个命中数), 让每一种常规排查都失效。声称「零调用」或「有调用」之前,必须逐条分类。
3.4 假绿的五种典型形态(附各自的自证方法)
这一节把假绿分类。每一类都给出「怎么变异」。
形态 1 · 门禁选了个空集,然后全绿
最典型的载体是选择性测试(§4 详讲)。
形态:选测逻辑漏了某类路径 → 改了代码却一个测试都没跑 → 退出码 0 → 看起来「验证过了」。
变异方法:给一个「明知会影响某个测试」的文件做假改动,检查选出的集合里有没有那个测试。
本仓的选测门禁为此专门断言:
回退整包是「宁可多跑」,绝不能是空集——空集意味着改了代码零验证。
而且它刻意区分了两种「什么都不跑」:
| 情形 | 输出 | 含义 |
|---|---|---|
| 纯文档改动 | 显式的 none / 命令是 true | 「确实不用跑」 |
| 映射漏了 | 空集 | 「漏了」 |
🔑 这个区分是一个通用范式:让「无需处理」和「处理失败」在输出上不同形态。 两者都表现为「什么都没发生」时,你永远分不清系统是正常还是坏了。
形态 2 · 门禁的锚点失效了,于是静默不再触发
形态:门禁靠路径前缀判断「要不要跑」。代码重构后路径变了, 前缀再也匹配不到 → 这道门禁从此永不触发,而且不报任何错。
本仓的真实注释记录了这个:
分包后源码在
packages/{cli,core}/src/下。锚点必须跟着改—— 仍写^src/会永远匹配不到,于是这道对账静默不再触发(比没有门禁更糟)。
变异方法:故意改一个数据源文件,确认门禁真的触发了(而不是跳过了)。 判据不是「没报错」,是「看到它打印了『正在检查』那行」。
⚠️ 这类失效在重构时批量发生。一次目录调整可能同时让五道门禁失效, 而 CI 会全绿。所以大重构之后应该主动跑一轮变异自证, 这比重构本身的 review 更重要。
形态 3 · 门禁被跳过的路径算作通过
前面 §2.4 讲的 if: always() 就是这一类:skipped 被算作 pass。
同类的其他形态:
# ❌ 失败时也退 0:命令失败被 || 吞掉
some-check || echo "检查失败" # 退出码是 echo 的 0
# ❌ 管道掩盖失败:pipefail 没开时,前面失败后面成功 → 整体成功
some-check | grep -q OK
# ❌ 循环里的失败不影响整体退出码
for f in *.ts; do check "$f"; done # 最后一次的退出码决定一切本仓真实抓到过两个这种:
- 两个运维脚本故障时 exit 仍是 0,而且末行还打了 ✅
- 一个
curl -w取状态码的写法,失败时|| echo 000与前面的输出拼成了000000, 而判据写的是!= 000→ 判成「通了」
🔑 修法有个通用原则:用正面匹配,不用否定匹配。
!= 000(不是失败)会被任何意外输出骗过;== 200(是成功)只接受一种输入。凡是判据写成「不等于某个坏值」的,都要重写成「等于某个好值」。
形态 4 · 门禁读的是旧字节
形态:你改了源码、跑了门禁、绿了。但门禁实际读的是上一次编译出来的产物。
这一类在编译型项目和 monorepo 里特别常见:
- 门禁跑的是编译产物,而你只改了源码没重新编译
- monorepo 里包 A 依赖包 B,B 的改动没重新构建,A 读的是旧的 B
- 工作树(worktree)里没有独立的依赖目录,向上解析到了主仓库的旧版本
第三种本仓踩过,症状很有教育意义:
工作树默认没有自己的依赖目录,包引用会向上解析到主仓 checkout—— 那里没有你本次新增的导出。症状是构建打出
<你的新函数名> will always be undefined,而构建仍然退出 0。只看退出码就交付,等于交了一个「新函数在编译产物里是 undefined」的版本。
变异方法:改坏源码后,确认门禁的输出里出现了你的改动的痕迹。 最简单的做法是故意加一行语法错误——如果门禁还是绿的,它读的一定不是你改的东西。
🔴 这一类最阴的地方:它会给出「修复没效果」的反向结论。 你改对了,但门禁读旧字节 → 还是红 → 你以为方案错了 → 换一个方案 → 于是把一个正确的修法否决掉了。
形态 5 · 探针的形态与真实流量不同
形态:门禁用一个「简化版」的请求去探活,而真实流量走的是另一条路径。 上游只在真实路径上劣化时,门禁报绿。
本仓的真实案例:一个探针用非流式请求探活,5/5 全绿; 而真实流量是流式的,同时实测只有 3/5 成功。 上游已经劣化了,门禁完全看不见。
🔑 判据:探针的形态必须等于真实流量的形态。 流式的用流式探、带工具调用的带工具调用探、走网关的走网关探。 「简化一下方便探活」这个念头,直接对应「探到的不是要探的东西」。
五种形态的速查表
| # | 形态 | 一句话判据 |
|---|---|---|
| 1 | 选了空集 | 「无需处理」与「处理失败」必须输出不同形态 |
| 2 | 锚点失效 | 判据是「看到它触发了」,不是「没报错」 |
| 3 | 跳过算通过 | 用正面匹配(== 好值),不用否定匹配(!= 坏值) |
| 4 | 读旧字节 | 加一行语法错误,门禁必须红 |
| 5 | 探针形态不对 | 探针形态 == 真实流量形态 |
3.5 把变异自证做成制度:三条落地建议
光知道这个概念没用,得让它变成会真的被执行的东西。三条:
① 变异用例和门禁写在同一个文件里
不要把「自证」当成一次手工验收动作——那种动作只会做一次。 把它写成测试用例,和门禁本体放在一起,让它每次 CI 都跑。
本仓的做法是每道重要门禁配一组「防假绿用例」。举个真实的:
一道检查「安装脚本必须拒绝未知的通道值」的门禁,配的变异用例是 故意传一个大小写错的通道名,断言它必须报错、而不是静默按稳定版装。
注释写清了为什么必须硬失败:静默回落的失效形态是「用户以为自己在跑 beta」, 而这个误解只会在「beta 期没发现任何回归」时暴露——那时归因已经做不了了。
② 断言真实状态,不断言快照
本仓选测门禁的注释把这条讲得很好:
断言刻意去检查真实目录、真实读配置,而不是比对硬编码的期望列表。 因为目录约定本身会漂移(新增 domain、删掉 domain), 快照式断言在漂移时只会告诉你「快照不一致」,不会告诉你「选测已经开始漏测了」。
区别在哪:快照断言告诉你「有东西变了」,你的反应是更新快照(于是漏测被固化); 真实状态断言告诉你「这个新目录没有对应的测试映射」,你的反应是补映射。
🔑 通用原则:断言应该指向「问题」,不是指向「差异」。 一道只会说「和上次不一样」的门禁,会训练出「无脑更新快照」的习惯。
③ 门禁红了先问「它验的是不是这件事」
一道门禁红了,第一反应通常是「放宽它」。但要先做一个判断:
这道门禁验的是不是「时间/阈值本身」?
如果不是(它只是恰好用了个超时来跑)→ 放宽是对的,给个宽松量级 如果是(它专门验「超时该生效」)→ 绝不许放宽,放宽就等于删掉这道门禁
本仓在这一条上踩过三次同根因的坑,都是超时类的不稳定用例。 第三次之后总结出的判据就是上面这句话。
同类的第二个形态:
防漂移哨兵红了,要补清单,不要删断言。
比如一道「所有中止原因都必须登记在总表里」的哨兵,它红了说明有人新增了一个原因 却忘了登记。它红 = 它在正常工作。 这时删掉断言是把门禁本身拆了。
3.6 一个更狠的问法:你怎么知道你的门禁清单是完整的?
变异自证解决的是「已有门禁是否有效」。还剩一个问题:有没有该有的门禁根本不存在?
这个问题没有完美答案,但有一个可操作的近似:每次事故都问「哪道门禁本该拦住它」。
三种结论:
| 结论 | 处理 |
|---|---|
| 有这道门禁,但它假绿了 | 补变异自证(§3.4) |
| 有这道门禁,但它没触发 | 修锚点/触发条件(形态 2) |
| 没有这道门禁 | 新建,并且当场做变异自证 |
第三种有一条重要的验收标准,本仓写在 CLAUDE.md 里:
新增防线时的验收判据不是「构建过 + 单测过」,而是「真实会话里被触发过」。 防线自己成了它当初要消灭的死功能,这事已经发生过一次。
这条比变异自证更进一步:变异自证证明「它能被触发」, 「真实触发过」证明「它在真实条件下会被触发」。两者的差距就是**「建好但没接线」**。
🔴 一个本仓的实测数字,说明这个差距有多大:一批四层防线,代码全部在、单测全绿, 而在真实会话轨迹里调用次数全部为 0。 「代码在」和「能力在」是两件事,中间隔着接线。
3.7 本章自检
- 一道门禁「从未失败过」,这是好消息还是疑点?为什么?
- 变异自证的三步是什么?哪一步是绝大多数人漏掉的?
- 为什么「有东西红了」不能作为变异自证的验收标准?举一个具体的骗过方式。
- 假绿的五种形态各是什么?各自的判据?
- 为什么判据要写成「等于好值」而不是「不等于坏值」?
- 为什么断言真实状态优于断言快照?快照断言会训练出什么坏习惯?
- 一道门禁红了,什么时候可以放宽阈值、什么时候绝对不许?
- 「新增防线」的正确验收判据是什么?为什么「单测过」不够?
第 3、5、8 题是这一章的分水岭——它们区分「知道变异自证这个词」和「真的会做」。
§4 选择性测试:一次「拿安全换速度」的完整交易
这一章是全文最好的**「工程决策该怎么论证」的样本**。因为它不是一个纯收益的优化—— 它明确牺牲了一样东西,而讲清那个牺牲、并给它配上补偿,才是这一章真正的内容。
4.1 先看问题:门禁太慢会以一个特定形态失效
全量测试实测 127.5 秒(本仓当前值;早期未优化时是 202.87s)。 改一个文件也要等两分钟。
直觉上的问题是「烦」。但真实的失效形态更具体:
🔴 门禁贵到一定程度就会被绕过。 而绕过不会留下痕迹——你不会看到「有人跳过了测试」这条日志, 你只会看到一切正常。
本仓的脚本注释把这条写在最前面:
改一个 telemetry 文件却要等 95s,这个成本会直接反映成 「agent 跳过验证」——门禁贵到一定程度就会被绕过。
注意这里的主语是 agent。这是 §1.2 那条「第三个前提失效」的直接后果: 人被慢门禁烦到会抱怨;agent 被慢门禁挡住时,最短路径是跳过它。
于是目标明确:把「改一个文件」的验证成本从 127s 降到秒级。 实测结果是 0.19s – 14.5s,也就是一到两个数量级。
4.2 两大流派:依赖图 vs 路径映射
「只跑相关的测试」有两种实现路线,这是一个真正的架构选择。
| 依赖图分析 | 路径映射 | |
|---|---|---|
| 原理 | 解析 import 关系,算出「谁依赖了改动的文件」 | 靠目录命名约定:src/foo/ 的测试在 tests/foo/ |
| 准确度 | 高 | 取决于约定的命中率 |
| 成本 | 要维护解析器、处理动态 import、跟着构建工具走 | 一个几百行的脚本 |
| 现成工具 | 有(各测试框架自带 related 之类的能力) | 自己写 |
| 失效方式 | 动态 import / 反射调用漏掉 | 命名约定不匹配的目录漏掉 |
本仓选了路径映射,理由是一个实测数字:
packages/core/的src/与tests/目录名重名命中 33/36(实测)。 这个约定本身就是一张现成的映射表。依赖图更准,但要维护解析器、要处理动态 import、要跟着构建工具走—— 在 33/36 命中率下,那些复杂度买不到相应的准确率。
🔑 这个论证的形状值得学:它没有说「路径映射更简单所以选它」, 而是先量出约定的命中率(33/36 ≈ 92%), 再判断「补上剩下 8% 要付的复杂度成本,值不值」。
反过来说:如果一个项目的目录约定命中率只有 50%,路径映射就是错的选择。 这个决策依赖于项目自身的一个可测量属性,不是普适偏好。
4.3 三个必须处理的边界,每个都对应一种假绿
映射本体(src/foo/ → tests/foo/)只有几行。难的全在边界上, 而且每个边界处理不对都会产生一种假绿。
边界 A · 有些改动是「仓库级」的,选测在它们身上没有意义
有一批文件,改了它任何一个测试的行为都可能变。这些必须直接退回全量。
本仓的清单(11 条模式),注意收录判据:
bunfig.toml / packages/*/bunfig.toml 测试预载配置的挂载点
package.json 依赖与 test 脚本定义
bun.lock 依赖锁定
Makefile / scripts/release.sh 构建与发布链路
tsconfig*.json 类型解析
.oxlintrc.json / .oxfmtrc.json lint / 格式口径
tests/build/** 仓库级门禁
tests/preload-isolate-sid-home.ts 落盘隔离兜底本体🔑 收录判据是「改了它,任意一个测试的行为都可能变」,不是「它看起来很重要」。
这个判据的锋利之处在于它能给出反直觉的结论。举第一条为例:
bunfig.toml—— 测试落盘隔离兜底的挂载点。 改错了会让全仓测试往用户真实的数据目录写,而测试仍然全绿。
一个 TOML 配置文件,看起来是最无关紧要的东西,却因为「改错了会造成一种假绿」 而必须触发全量。而某个看起来很重要的核心业务文件,如果它的影响面是可映射的,就不必。
边界 B · 有些目录是「共享设施」,不是某个领域的测试
tests/ 下有 4 个目录在 src/ 没有同名对应:
| 目录 | 是什么 | 处理 |
|---|---|---|
helpers/ | 被大量测试 import 的工具 | 回退整包(改它影响面不限于自己) |
fixtures/ | 录制回放的测试数据 | 回退整包 |
guard/ | 守护类测试 | 回退整包 |
integration/ | 真的是测试目录 | 只跑它自己 ✅ |
注意 integration 是例外——它虽然也没有同名 src/ 目录,但它确实是测试, 不是被别人消费的设施。这种例外必须逐个判断,不能按「有没有同名目录」一刀切。
边界 C · 有些源码目录没有对应测试,此时绝不能输出空集
src/ 下有 3 个目录没有对应的 tests/。改到它们怎么办?
❌ 输出空集 → 改了代码零验证,退出码 0,看起来「验证过了」
✅ 回退整包 → 宁可多跑本仓的断言注释写得很直白:
回退整包是「宁可多跑」,绝不能是空集——空集意味着改了代码零验证。
而且它还做了一个更精细的区分(这一点在 §3.4 形态 1 讲过,这里给出完整形态):
| 情形 | 输出 | 命令 | 含义 |
|---|---|---|---|
| 纯文档改动 | none | true | 确实不用跑(显式声明) |
| 映射漏了 | —— | —— | 是 bug,必须被门禁抓到 |
两者在「什么都没跑」这个表象上一致,但输出形态不同。 这是让「无需处理」和「处理失败」可区分的标准手法。
4.4 一个工具层的坑:它会让你误判整个方案无效
这个坑非常值得讲,因为它的表现是「方案看起来没用」, 而正确的结论是「你的命令写错了一个字符」。
本仓用的测试运行器,它的位置参数是「完整路径子串过滤」,不是目录:
bun test tests/ → 搜 692 个文件 ❌ (匹配所有路径里含 "tests/" 的文件)
bun test ./tests/ → 搜 38 个文件 ✅
bun test telemetry → 搜 22 个文件 (跨包!两个包都匹配)少一个 ./,「我只跑了 telemetry」实际会跑掉整个仓库。
🔴 它的危险性不在于慢,在于它给出的结论是反的: 你会看到「选测又慢又没省」,从而误判选测方案无效, 然后把一个正确的方案否决掉。
这和 §3.4 形态 4(读旧字节)是同一类危害:假绿的镜像——假红导致正确方案被否决。
第二个相关的坑:
bun test <不存在的路径>的退出码是 1("had no matches")。 所以映射结果必须逐个检查路径是否存在, 否则一个正常改动会让选测红在与改动无关的地方。
4.5 ★ 这一章的核心:牺牲了什么,以及补偿是什么
前面都是实现。这一节是决策,也是面试里真正能拉分的部分。
先把交易写清楚:
⚠️ 这一步拿「更安全」换「更快」。 选测只跑了一部分测试,所以本地覆盖面确实变窄了。 这不是「几乎无损」,是真实的损失。
然后是补偿:
补偿是 CI 在合并前跑全量。
最关键的一句在这里:
🔑 只做本地选测而不做合并前全量,等于把风险从本地挪到主干上。 所以选测与 PR 化必须成对——直推主干时不许用选测。
这三段话构成一个完整的工程论证,它的结构是:
① 明确说出牺牲了什么 (不掩饰)
② 给出补偿机制 (不是「应该没问题」)
③ 点破补偿的前提条件 ★ 最容易漏的一步
④ 由此得出一条使用约束 (成对,不可拆开用)第 ③ 步为什么最容易漏:补偿机制本身有前提。 「CI 会跑全量」的前提是改动会经过 CI——而直推主干的改动不经过 PR, 于是补偿不存在,而选测的损失仍然存在。这时候你得到的是纯损失。
💡 面试里怎么用这个例子:被问「你做过什么性能优化」时, 大多数人会讲收益(127s → 0.2s,快了 600 倍)。 讲完收益之后加上「它牺牲了什么 + 补偿是什么 + 补偿的前提是什么」, 层次立刻不一样——因为前者证明你会实现,后者证明你会判断。
4.6 选测本身需要一道门禁(而且它有三个具体的假绿形态)
选测是元层面的东西:它决定「跑哪些测试」。它错了,下面所有测试的结论都不可信。
所以它必须有自己的门禁。本仓的选测门禁开头就列了要防的三种形态:
选择性测试有一个比「跑得慢」糟糕得多的失败模式:选出一个错的范围,然后全绿。
| # | 形态 | 表现 |
|---|---|---|
| 1 | 选空集然后全绿 | 改了代码却一个测试都没跑,退出码 0,看起来「验证过了」 |
| 2 | 选错范围但看起来对 | 少一个 ./ 就等于偷偷跑了全量,表现为「又慢又没省」 |
| 3 | 选出不存在的路径 | 一个正常改动红在与改动无关的地方 |
这道门禁里有 5 条变异自证(§3),专防「选了个空集然后全绿」。
而且它刻意不用快照断言:
断言刻意去检查真实目录、真实读配置,而不是比对硬编码的期望列表。 因为目录约定本身会漂移(新增 domain、删掉 domain), 快照式断言在漂移时只会告诉你「快照不一致」,不会告诉你「选测已经开始漏测了」。
4.7 逃逸阀与基线:两个容易做错的细节
逃逸阀:选测判不准时要有出口。本仓的形态是「先看判定,再跑」:
bun run affected-tests # 只打印判定与命令,不执行 ← 先看它选了什么
bun run affected-tests:run # 执行选出来的最小集💡 把「查看」和「执行」拆成两个命令是个好设计: 你可以在不付出执行成本的前提下审查选测的判断。 一个只有「执行」没有「查看」的选测工具,你永远不知道它选了什么。
基线:选测要和某个基线比 diff。这里有个陷阱:
基线默认是远端主干,所以本地要先同步一次。 否则脚本会明确报错,而不是猜一个基线——猜错会让选测范围静默变错。
两种猜错方向的后果不对称:
| 猜的基线 | 后果 |
|---|---|
| 太旧 | diff 变大 → 膨胀成全量(选测白做,但安全) |
| 太新 | diff 变小 → 漏成空集(危险) |
🔑 不对称的失败代价,决定了默认行为应该偏向哪一边。 这里正确的默认是「宁可多跑」,而判不准时必须报错,不能猜。
4.8 本章自检
- 慢门禁的真实失效形态是什么?为什么它比「烦人」严重?
- 依赖图与路径映射,本仓为什么选后者?这个决策依赖什么可测量属性?
- 「强制全量」清单的收录判据是什么?为什么一个 TOML 配置文件会进这个清单?
- 源码目录没有对应测试时,为什么绝不能输出空集?
- 「无需处理」和「处理失败」为什么必须输出不同形态?
- 选测牺牲了什么?补偿是什么?补偿的前提条件是什么?
- 为什么「选测与 PR 化必须成对」?拆开用会发生什么?
- 为什么选测门禁不用快照断言?快照断言在漂移时会诱导什么错误反应?
第 6、7 题是这一章的核心,也是面试最可能追问的地方。
§5 发布:从「能跑」到「能回滚」
这一章是全文篇幅最长的,因为发布是唯一一个「做错了用户立刻受损」的环节。 前面所有门禁做错了,后果是「让坏代码进了主干」;发布做错了,后果是「所有用户装不上」。
组织方式:先讲一次发布到底有几件事(§5.1), 再逐个讲那些看起来可以省、实际不能省的设计(§5.2–§5.8)。
5.1 一次发布其实是九件事,不是一件
大多数人对「发布」的心智模型是「编译 + 上传」。真实的完整链路是九步, 而且顺序几乎不能换:
① 洁净门禁 工作区必须干净,否则拒绝发布
② 全量测试 坏版本不许进任何通道
③ 文案检查 用户视角的变更说明是否已入库
④ bump 版本号 (此刻工作区变脏,这是设计的一部分)
⑤ 生成变更日志 从 git 历史机械生成
⑥ 多平台构建 5 个目标交叉编译
⑦ 冒烟 + 自检 产物真的能启动吗
⑧ 提交 + 打标签 tag 必须打在 bump 那个提交上
⑨ 原子上传 staging → 校验 → 原子切换 → 写通道指针看这个链条时要问的问题不是「这些步骤都要有吗」,而是「顺序为什么是这个」。 下面几节逐个回答。
5.2 第一条铁律:先提交功能代码,再发布
⛔ 禁止先发布后提交。
理由:发布产物必须能对应到一个确切的 git commit。 先发布后提交会开一个「已发布但未提交」的窗口—— 期间任何源码改动都会让线上二进制与 commit 对不上,出线上问题无法定位到确切代码版本。
这条规则有意思的地方在于它怎么从「告诫」变成「门禁」。 起初它只是一条写在文档里的纪律,后来被机械化成两条:
| 机制 | 拦什么 |
|---|---|
洁净门禁(release.sh 开头) | 工作区脏就直接拒绝发布 |
| bump 提交 + tag 由脚本自己做 | 消除「人工补做」这个环节 |
第二条对应一次真实事故,值得完整看:
以前是「tag 打在 bump 前的 HEAD 上、bump 提交由人工补做」。 后果是 tag 指向的那个 commit 里版本号比 tag 低一位。
实测 v0.1.591…v0.1.596 六个 tag 全部错位,
git checkout <tag>重建不出对应的二进制。
🔑 注意这个失效的形态:它不报错。 每次发布都成功,tag 都打上了, 一切正常。只有当你某天真的需要「按 tag 重建那一版」时才发现做不到—— 而那通常正是线上出事、最需要它的时候。
这类「平时无感、需要时才发现没有」的能力,是工程化里最容易缺失的一类, 因为缺失它的日常成本是 0。
5.3 五个构建目标,其中一个非常反直觉
本仓实际的构建目标是 5 个:
| 目标 | 给谁 |
|---|---|
darwin-arm64 | Apple Silicon Mac |
darwin-x64 | Intel Mac |
linux-x64 | 常规 x64 Linux |
linux-x64-baseline | ← 这一个值得整节讲 |
linux-arm64 | ARM Linux |
为什么要有一个 baseline 档:
编译器默认编出的 x64 产物会用较新的 CPU 指令集(如 AVX2)。 在模拟器(qemu)里跑时,这些指令不被支持,产物一启动就 Illegal instruction (core dumped)。
而这个场景是真实存在的:某些评测用的官方容器镜像只发布 amd64 版本, 所以在 ARM 机器上跑评测必须走模拟器 —— 常规 x64 产物在那里一题都跑不了。
这个失败形态最精彩的部分在于它怎么伪装:
⚠️ 这个失败在评测里会伪装成「agent 能力差」,而且伪装得很好: 模拟器的 core dump 文件掉在工作目录里,被
git add -A收走, 于是「agent 的修改」变成了两个 core 文件的二进制 diff, 而「改动字节数」这个指标看着还挺正常。
也就是说:产物根本没启动 → 但指标显示「它改了几十 KB 的东西」→ 你的结论是「agent 做了一堆无用的修改」→ 你去优化 agent 的行为 → 而真相是那个二进制从来没跑起来过。
两个反直觉的补充事实:
| 直觉 | 实测 |
|---|---|
| 「多一个 target 会让发布变重」 | 体积代价是负的:baseline 104.7M vs 常规 105.6M(少 0.9M) |
| 「baseline 会影响原生用户」 | 不会:安装脚本只按 uname 拼常规名,永远不会拼出 -baseline |
💡 第一条尤其值得记:「多一个东西必然更重」这个直觉在这里是错的,而且是实测错的。 所以本仓的注释专门写了一句「不要拿体积当理由把它删掉」—— 这是在给未来的读者预先驳掉一个会出现的错误论证。
这是一个很高级的注释写法:不只写「为什么这样做」,还写「为什么某个看起来合理的 反对意见是错的」。因为半年后想删掉它的那个人,脑子里想的正是那个反对意见。
5.4 ★ 发布通道:为什么要有一个「泡制期」
这一节是本章最值得学的部分,也是面试里区分度最高的一块。
5.4.1 问题:自动化门禁测不出的那一类回归
前面所有门禁(单测、构建、冒烟、自检)有一个共同属性:它们都是确定性检查。 确定性检查测不出这些:
- UX 回归(某个交互变得别扭了)
- 交互卡死(某个状态下按键没反应)
- 只在真实使用节奏下才出现的问题(连续用半小时后变慢)
这类问题只有「真用」才暴露。 而在有通道机制之前, 一次发布 = 全部用户下次更新立刻拿到,中间没有任何真实使用的观察期。
5.4.2 解法:两个指针文件
服务器上放两个只写一行版本号的文本文件:
| 指针 | 通道 | 谁会改它 | 用户怎么装 |
|---|---|---|---|
beta.txt | 抢先版 | 每次发布 | 显式指定通道 |
latest.txt | 稳定版 | 只有 promote 会动 | 缺省 |
流程变成:
发布 → 写 beta.txt (只有主动选 beta 的人拿到)
↓ 自己用几天,真实使用
promote → 写 latest.txt (放量给所有人)5.4.3 这个设计有三条「改回去不会报错、只会静默失去价值」的约束
这三条是本节的核心。它们的共同点是:违反它们的代码能正常运行、发布日志一模一样, 只是整个机制的价值消失了。
约束 ① promote 必须是纯指针操作,不能重新构建
两个通道共用同一批版本目录,promote 只挪指针、不重新构建、不复制文件。
这是这个设计的核心:用户装到的字节与 beta 期被测的字节是同一份。
若给 beta 单独一套目录,promote 就要复制或重建, 「泡制期测的就是要发的东西」这个唯一价值当场消失。
约束 ② 发布绝不能写 latest.txt
顺手加回那一行 = 取消整个通道机制(一发布即全量放量), 而发布日志与从前一模一样。
这一条本仓有专门的反漂移断言守着(tests/release-channel.test.ts)。 因为它是那种「一行代码就能悄悄退化回去」的东西—— 某天有人觉得「每次都要 promote 好麻烦」,加一行,机制就没了,而且没人会发现。
约束 ③ 旧版本清理必须豁免两个指针指向的版本
这一条最隐蔽,值得画出来:
保留策略:按时间保留最近 5 个版本
beta 期连发 5 版:
v601(beta) v600 v599 v598 v597 ← 保留窗口
v596 ← 被删掉…… 但 latest.txt 还指着它!形态是所有稳定版用户 404 装不上,而服务器端零报错。
🔑 三条约束的共同结构值得记住: 它们都不是「功能需求」,而是**「机制的价值所依赖的前提」**。 违反它们时功能仍然工作,只是那个机制变成了摆设。
这类约束是文档里最该写的东西,因为代码本身表达不了它们—— 代码只能表达「做什么」,表达不了「为什么不能改成另一种做法」。
5.4.4 两条必须点破的局限
诚实标注局限是这套设计里最见功力的地方。
局限 A:通道靠环境变量记住,而人不会记得自己上次装的是哪个
beta 用户下次不带那个变量跑更新,会静默回到稳定版。 安装脚本装完 beta 会显式提示这件事。
局限 B(更重要):这段泡制期只有在真有人用 beta 时才有价值
建好而没人装,它就退化成第二个「防线全在、调用全 0」。
所以验收判据不是「通道能用」,而是 至少有一次真实回归是在 beta 期被发现、没有流到稳定版。
🔴 这就是 §3.6 那条「新增防线的验收判据」在发布领域的具体形态。 「通道机制上线了」和「通道机制在起作用」是两件事,中间隔着「有人真的用 beta」。
5.5 回滚:能力早就有,缺的是「它被写下来」
这一节讲一个很有洞察力的判断。
背景:回滚能力在写这个脚本之前就已经存在——服务器上是版本目录 + 一行指针文件, 改一行文本就回滚了,5 个历史版本目录都还在。
那为什么还要专门写一个回滚脚本?
缺的从来不是能力,是这件事没有任何地方写下来。
出线上事故时,要靠现场读发布脚本(1200+ 行)反推出「原来改那个指针文件就行」, 而那正是最不该做推理的时刻。
🔑 一个能力如果只存在于「读完源码就能推出来」,在事故现场等于不存在。
这句话可以推广成一条通用原则:
能力的可用性 = 能力本身 × 在需要它的那个时刻能否被找到。 后一项为 0 时,前一项多强都没用。
这个脚本刻意只做一件事,不做任何聪明的事:
| 不做什么 | 为什么 |
|---|---|
| 不重新构建、不重新上传 | 要回滚的版本目录本来就在服务器上 |
| 不碰 git(tag / 提交 / 版本号一律不动) | 回滚的是「用户拿到哪一版」,不是「仓库停在哪一版」。两者混在一起会让一次秒级止血变成一次需要 review 的改动 |
| 不删任何东西 | 事故现场不做不可逆操作 |
并且它诚实标注了自己的局限:
⚠️ 回滚不会让已经装了坏版本的用户自动降级——他们要各自再跑一次更新。 这个脚本挡住的是「还没更新的人不再踩坑」,这是它能做到的全部, 脚本末尾会明确说这句。
💡 「脚本末尾会明确说这句」这个细节很关键: 局限写在源码注释里,事故现场的人不会读;写在脚本的输出里,他一定会看到。 文档的价值取决于它出现在读者的路径上。
5.6 原子上传:一个必须理解的分布式细节
朴素做法:在服务器上创建版本目录,然后逐个文件上传。
问题:中途任何一次上传失败(网络抖动、磁盘满、Ctrl-C) 都会留下一个只含部分平台的版本目录,而脚本既不清理也不告知。
有人会说:把指针文件放在最后写不就行了?——这确实挡住了主路径,但挡不住这三个:
| 漏洞 | 后果 |
|---|---|
| 重跑时若复用同一版本号 | 残留的旧平台文件不会被清掉,新旧文件混在一个目录 |
| 用户/脚本按显式版本号直接取 URL(不读指针) | 拿到 404 或残缺集合 |
| 服务器端按目录计数保留 N 个版本 | 半成品目录也占一个名额(连带触发 §5.4.3 约束 ③ 的问题) |
正确做法:staging + 原子切换
① 传到临时目录 .upload-<version>-<pid>/
② 全部就位后校验(逐个平台点名 + sha256)
③ 原子 mv 到正式路径
④ 写通道指针关键点:
mv在同一文件系统内是原子的 → 正式目录要么不存在、要么内容完整,不存在中间态- 临时目录带进程号后缀 → 避免并发发布互相踩
- 切换失败时把旧目录放回去 → 保证服务器停留在切换前的可用状态
- 校验时逐个平台点名,不数文件个数 → 数个数会被「5 个文件但其中两个是同一平台」骗过
🔑 最后一条是 §3 的直接应用:
count == 5是一个代理判据, 「5 个指定名字的文件都存在」才是真判据。代理判据在多数情况下巧合正确, 而出错那一次恰好是最该拦的那次。
5.7 构建溯源:怎么防「手工编的包被发出去」
问题:在有溯源之前,发布脚本对产物的检查只有「文件存在 + sha256 对得上」。 所以一个手工编译出来的二进制被塞进发布目录再上传, 是完全可行且无声的 —— 而它可能来自任何 commit、可能带着未提交的改动。
解法:把身份信息编进产物字节(构建来源 + commit + 是否有未提交改动), 发布前读出来做三条判定:
| 判据 | 拦什么 |
|---|---|
origin == release | 拦「手工编的包」 |
| dirty 时脏文件只能是版本号文件 | 拦「带着未提交代码发版」 |
commit == v<ver>^(tag 的父提交) | 拦「产物与本次 tag 对不上」 |
| promote 前额外:产物 commit 必须是主干的祖先 | 拦「把分支包促升成稳定版」 |
这里有一个极其精彩的坑,值得完整讲:
⚠️ 上面第 2、3 条按直觉写会 100% 误拦每一次真实发版。
为什么:发布脚本的顺序是 洁净门禁 → bump(改版本号文件 → 工作区变脏)→ 构建 → 提交 → 打 tag。
所以构建那一刻:
git status必非空(版本号文件刚被改)- HEAD 是 bump 提交的父提交(bump 还没提交)
于是判据必须写成「dirty 时脏文件只能是版本号文件」和「commit == tag 的父提交」, 而不是直觉上的「dirty == false」和「commit == tag」。
🔴 写错的后果不是「报个错」:是每次发版都被拦、然后被人加参数绕过 —— 那就等于没有门禁。
这一条把 §2.3(误报率是设计参数)和 §3(门禁自己会坏)串起来了: 一道 100% 误报的门禁,它的最终状态一定是「被永久绕过」。
另外三条实测约束(都是「改回去不报错、只静默失去价值」类型):
| # | 约束 | 不这么做会怎样 |
|---|---|---|
| 1 | 检查 commit 存在性时必须限定「必须是 commit 类型」 | 实测:tree / blob / tag 都算「对象存在」。一个被截断的身份字段可能通过检查,然后在下一步以一个语义不明的错误失败——排查的人会去查「为什么这个 commit 不在历史里」,而真相是它根本不是 commit |
| 2 | 拿产物里的值拼命令之前必须先做形态校验 | 那个值来自产物字节,可能被截断、可能是任意内容。消费方是 shell,注入面是真实的。所以形态校验失败时一条 git 命令都不发(反漂移断言:测试会数探针的调用次数) |
| 3 | 「产物过期」的判据必须限定在编译输入路径上 | 退化成全仓比较会让纯文档提交也被拦 —— 而误报会训练人绕过门禁(先加跳过参数再说),于是它真正该拦的那次也被放过去了 |
第 3 条还配了一对成对的测试,这个设计非常漂亮:
T8/T9 两条测试成对锁住判据的精度: 只有 T8 时「判据写成全仓」也能过(更严嘛); 只有 T9 时「永远放行」也能过。
🔑 单条断言只能锁一个方向。 想锁住「既不能太严、也不能太松」, 必须两条断言成对——一条防松,一条防紧。 这是变异自证(§3)的进阶形态:双向变异。
5.8 判定逻辑为什么要抽成一份
发布链路里有三个地方需要同一套判定(两个 shell 脚本 + 一个 TS 脚本)。 本仓的选择是抽成一份纯函数,shell 侧只负责取数与展示。
理由不是「避免重复」这么轻:
写三份的后果是三份会各自漂移,且漂移不报错: 一个判据写错的形态是「门禁看起来在跑、实际全在放行」。
🔑 这是「唯一事实源」原则在门禁上的应用。 值得注意的是它的论证方式:不诉诸「DRY 原则」(那是教条), 而是指出漂移后的具体失效形态。
5.9 本章自检
- 一次发布的九个步骤是什么?为什么「先提交再发布」是铁律?
- tag 错位这个失效为什么「不报错」?它在什么时刻才会被发现?
- 为什么要有一个「基线」构建目标?它的失败形态怎么伪装成「agent 能力差」?
- 通道机制的三条约束是什么?它们的共同结构是什么?
- promote 为什么必须是纯指针操作?给 beta 单独一套目录会怎样?
- 旧版本清理不豁免指针版本会发生什么?为什么服务器端零报错?
- 通道机制的验收判据是什么?(提示:不是「通道能用」)
- 回滚脚本的价值不在「能力」,那在哪?三条设计约束是什么?
- 为什么回滚脚本「不碰 git」?
- 原子上传解决什么?「指针放最后写」挡不住哪三个漏洞?
- 溯源门禁的判据按直觉写会怎样?为什么构建那一刻工作区必然是脏的?
- 「双向变异」是什么意思?为什么单条断言只能锁一个方向?
第 4、7、11 题是本章的核心:第 4 题考「机制的前提」,第 7 题考「能力要被用过」, 第 11 题考「误报率是设计参数」。
§6 反漂移:让文档与代码对账
这一章讲一个几乎所有项目都有、但几乎没人系统解决的问题:文档会和代码脱节。 更重要的是,它还讲哪些东西不该立这道门禁——那一节(§6.6)比前面几节更值钱。
6.1 先说清「漂移」为什么是必然的,不是纪律问题
🔑 手写清单必然漂移。
这不是一句抱怨,它有一个精确的机制。考虑一份「支持的命令行参数」文档:
① 有人加了一个新参数 → 代码变了
② 他不知道那份文档里有一张参数表 → 或者知道但当时忘了
③ 没有任何东西会因此变红 → 提交、合并、发布,一路绿
④ 文档从此少一个参数 → 而且没人知道关键在第 ③ 步:代码与文档之间没有任何机械联系, 所以「保持一致」这件事 100% 依赖人的记忆。而这类记忆的失败率不是低,是趋近于 1 ——因为改代码的人的注意力全在代码上。
漂移的危害比「文档不全」严重得多:
用户照着文档写一个不存在的参数,比没有文档更糟。
为什么:没有文档时,用户会去看源码或者问人,最终得到正确答案。 有一份错的文档时,用户会照着做,然后遇到一个报错, 然后怀疑是自己环境的问题,可能花几小时排查。 错误信息比缺失信息的成本高一个量级。
6.2 解法:把文档变成生成物,然后对账
本仓的做法是:官网参考页里 6 页从源码生成(工具 / 斜杠命令 / Hook 事件 / 配置字段 / CLI 参数 / 环境变量), 然后加一道门禁:
pre-commit:如果本次改动碰到了这 6 页的数据源
→ 重新生成一遍
→ 与仓库里的版本对比
→ 不一致就拒绝提交这道门禁的保证是:源码改了但文档没跟着改,物理上进不了仓库。
注意这个表述——「物理上进不了」。这是门禁与告诫的区别(§2.1)在文档领域的形态。
6.3 生成器的核心设计原则:运行时自省 > 静态解析
这一条是本章技术上最重要的一点。
生成文档有两条路:
| 静态解析源码文本 | 运行时自省 | |
|---|---|---|
| 做法 | 读 .ts 文件,用正则/AST 找出定义 | import 真对象,问它「你有哪些字段」 |
| 准确度 | 取决于你的解析器有多全 | 就是真值 |
| 会漏什么 | 动态生成的、条件注册的、继承来的 | 基本不会漏 |
| 维护成本 | 高(语法变了要跟着改) | 低 |
本仓的原则写得很直接:
优先运行时自省,不静态解析源码文本。 凡是能让运行时自己吐出真值的(工具定义、斜杠命令、Hook 枚举、配置 schema), 一律 import 真对象。
🔑 这个原则的深层理由:静态解析得到的是「源码看起来定义了什么」, 运行时自省得到的是「系统实际上有什么」。 两者不一致时,用户遇到的是后者。
举个具体例子:工具定义是发给模型的那份 JSON。 运行时自省拿到的就是发给模型的那一份,所以文档与模型看到的东西同源。 静态解析拿到的是「源码里那些看起来像工具定义的对象字面量」, 中间隔着注册逻辑、条件启用、动态包装。
6.4 有一类东西自省不了,此时要做交叉对账
有些内容本来就是给人看的文本(比如帮助信息里的说明文字),无法从结构里生成。 对这类,本仓的做法是:
只有帮助文本这类「本来就是给人看的文本」才做文本解析, 且必须配一个结构化源做交叉对账。
具体形态(两个源,各有权威范围):
| 页面 | 权威源(能不能用) | 素材源(怎么说人话) |
|---|---|---|
| CLI 参数 | 参数解析代码 | 帮助文本 |
| 环境变量 | 全仓 process.env 扫描 | 帮助文本的环境变量段 |
🔑 「权威 × 素材」这个二分很好用: 权威源决定清单是否完整(有没有漏、有没有多), 素材源决定描述是否易读。
两个源交叉后,能抓到两类错: 「帮助文本里写了一个实际不存在的参数」和「代码里有一个参数但帮助文本没提」。 任何单一源都抓不到其中一类。
6.5 两个受众 → 两份产物:变更日志的例子
这一节讲一个很好的架构判断:同一份信息,两个受众,必须两份产物。
问题:变更日志的读者是谁?
答案是两批人,需求相反:
| 产物 | 读者 | 内容 | 来源 |
|---|---|---|---|
CHANGELOG.md | 开发者 / 脚本 | 全量原始提交(含 hash、含文档类杂项) | git 历史,机械生成 |
| 官网变更页数据 | 用户 | 用户视角文案,受控词表 | 人工过目过的文案 |
为什么不能只有一份、用正则转换:
commit message 的读者是未来的自己,changelog 的读者是用户, 靠正则做不了这个转换 —— 实测 276 条提交里 24% 是用户完全不关心的文档/杂项。
而且方向上也不只是「过滤」:一次内部重构可能对用户毫无意义(要删掉), 一个不起眼的 bug 修复可能对用户很重要(要突出)。这个判断需要语义理解。
于是流程变成:LLM 起草 → 人工过目 → 入库 → 发布时只读缓存。
这里有五条禁令,每一条都对应一个具体的失效:
| # | 禁令 | 破了会怎样 |
|---|---|---|
| 1 | 发布脚本绝不调 LLM,只读已入库的文案 | 发布路径必须确定性 + 离线 + 幂等,一次 LLM 调用同时破掉这三条:同样输入两次跑出不同文案、网络抖动能卡住发布 |
| 2 | 文案必须人工过目才提交 | 校验器只拦形态(词表、长度、字段自洽),拦不住「把内部重构写成用户特性」「漏掉一个真实的破坏性变更」——这两类只有人能拦 |
| 3 | CHANGELOG.md 必须保持全量原始提交 | 用户视角文案漏了东西时,它是唯一的回溯途径 |
| 4 | 官网部署脚本不得重跑生成器 | 会把「尚未发版的提交」归到已发布的版本号名下 |
| 5 | 变更日志产物不纳入反漂移门禁 | ← 这一条最重要,下一节详讲 |
6.6 ★ 哪些东西不该立反漂移门禁
前面讲了反漂移多有价值。这一节讲它的适用边界——这一节的信息量比前面高。
判据只有一句话:
🔑 源是不是稳定的?
参考页能立门禁,是因为源是源码——源码不变,生成结果就不变。 变更日志不能立,是因为源是 git 历史——每提交一次它就变。
给一个每次提交都会变的东西立「必须一致」的门禁,结果是每次提交都红。 然后你就会加跳过参数,然后这道门禁就没了(§2.3)。
这条判据可以推广成一张表:
| 生成物的源 | 稳定性 | 该不该立门禁 |
|---|---|---|
| 源码结构(工具定义、配置字段) | 稳定 | ✅ 该立 |
| 源码文本(帮助信息) | 稳定 | ✅ 该立(配交叉对账) |
| git 历史 | 每提交都变 | ❌ 不该 |
| 运行时数据(真实用量统计) | 每次跑都变 | ❌ 不该(改用「新鲜度」门禁,见下) |
| 外部服务返回值 | 不可控 | ❌ 不该 |
对「源不稳定但仍需防漂移」的东西,用另一种门禁:新鲜度。
本仓的指标快照就是这一类:数字来自真实运行数据,每次跑都不同, 所以没法立「必须一致」的门禁。改用陈旧检测:
生成块超过 30 天没刷新 → 拒绝 push判据从「一致」换成了「新鲜」。这是一次很聪明的转换:
不能保证「它是对的」时,退一步保证「它不是三个月前的」。
而且这道门禁的注释里有一条诚实标注的能力边界,值得学:
⚠️ 方案文档与路线图在另一个仓库,所以本检查管不到它们。 跨仓库门禁做不了 —— 生成块自带时间戳(读者一眼能看出新鲜度)才是那边唯一可行的手段。 这里能管的只有本仓库内引用了生成块的文件。
💡 这段注释的价值在于它防止了一个错误的安全感: 不写这句的话,读者会以为「有门禁守着,所有文档的数字都是新的」。 写了这句,读者知道跨仓库那部分要靠自己看时间戳。
门禁的覆盖范围必须和门禁一起被记录。 只说「有门禁」不说「管到哪」, 等于制造一个比没有门禁更危险的认知。
6.7 一个进阶问题:文档「全」了,但用户找不到
反漂移解决的是「参考表不骗人」。但还有一层:
参考页是脚本生成的——新增一个命令,参考表会自动多一行, 指南页却不会自动变。结果是功能**「进了字典,没进教程」**: 用户不会读一张 60 行的表来发现能力。
实测数字:62 个命令里 21 个处于这个状态。
本仓为此加了一道「叙述覆盖度」检查:报告那些只存在于参考表、没有任何指南页介绍的命令。
但它当前是「告警模式」(恒退 0,不阻断),理由很实在:
当前是告警模式,因为存量 18 个未清完; 存量清零后把参数换成严格模式并去掉容错, 「做了功能不写文档」就在物理上进不了仓库。
🔑 这是引入新门禁的正确姿势:有存量违规时先上告警模式, 清零后再切严格。
反面做法是直接上严格模式——那会让所有人的提交立刻全红, 于是第一反应是关掉它,而不是清存量。 一道让所有人立刻受阻的新门禁,寿命通常只有一天。
6.8 生成器自身的三个坑(都是实测踩到的)
生成器是元层面的东西,它错了所有文档都错。三个真实的坑:
坑 1 · 标记匹配用前缀,把正文吃掉了
生成器靠一对标记(START / END)划定「这段是自动生成的」。
问题:参考页的「请勿手工编辑」提示语里字面写着那个标记的样子(给人看的说明), 位置在真标记之前。用前缀匹配就会命中提示语里那个, 替换出来的文件会把正文吃掉。
修法:START 必须用带后缀说明的完整串匹配,并把常量导出供测试复用 ——避免测试自己再写一份易错的匹配。
💡 「导出常量供测试复用」这个细节值得注意: 如果测试自己写一份匹配规则,那就有了两份规则,它们会各自漂移(§5.8 同源)。
坑 2 · 扫描目录清单漏一个包,静默归零
生成器要扫全仓源码找环境变量读取点。分包之后源码在多个目录下。
⚠️ 漏一个包不会报错,只会让统计数字变小、文档少列一批条目 —— 分包时实测环境变量扫描一度归零、整段「未列入上表的读取点」被静默删掉。
这是 §3.4 形态 2(锚点失效)在生成器上的形态。「静默归零」是这类 bug 的典型签名: 输出还在、格式正常,只是内容空了。
坑 3 · 门禁触发条件的锚点也会失效
前面 §3.4 引用过,这里补完整:分包后源码路径变了, pre-commit 里判断「要不要跑对账」的路径前缀如果不跟着改, 这道对账就静默不再触发——比没有门禁更糟,因为你以为它在守着。
6.9 本章自检
- 为什么「手写清单必然漂移」是机制问题而不是纪律问题?关键在哪一步?
- 为什么「错的文档比没有文档更糟」?
- 运行时自省 vs 静态解析,为什么前者更准?举一个具体例子。
- 「权威源 × 素材源」交叉对账能抓到哪两类错?单一源为什么各漏一类?
- 变更日志为什么必须有两份产物?为什么正则做不了这个转换?
- 哪些东西不该立反漂移门禁?判据是什么?
- 源不稳定但仍需防漂移时,可以换成什么门禁?
- 有存量违规时,引入新门禁的正确顺序是什么?直接上严格模式会怎样?
- 「静默归零」为什么是生成器类 bug 的典型签名?
第 6 题是本章的分水岭:会建门禁的人很多,知道什么时候不该建的人少。
§7 决策留痕:为什么 rejected/ 是最贵的资产
这一章讲一个agent 时代新增的工程化维度。传统项目也有「架构决策记录」这个概念, 但在有 agent 参与开发时,它的必要性从「最好有」变成了「没有它就失控」。
7.1 先看真正的问题:并行开发的瓶颈不是速度
这个洞察是整章的地基,也是本文最值钱的判断之一:
🔑 并行开发的真正瓶颈不是产出速度,是「人还能不能保持方向控制权」。
展开一下这句话。多个 agent 同时改代码时会发生什么:
产出量: 每天几百个文件的 diff
review 能力: 一个人一天能认真读几十个文件
结论: 代码级 review 在物理上做不完而「做不完」的后果不是「审得慢一点」,是审查这件事整体崩塌—— 人会开始跳读、开始只看文件名、最后变成橡皮图章。
但有一件事是完全 review 得完的:
完全 review 得完几份「决定了什么 / 放弃了什么 / 怎么证明生效」。
💡 这是一次审查层级的上移:从「审代码」上移到「审决策」。 几百个文件的 diff 背后通常只有三五个真正的决策。 审那三五个决策,人的方向控制权就还在;审 diff,方向控制权已经丢了。
这个判断可以直接用在面试里回答「AI 写代码之后 code review 怎么做」。
7.2 为什么必须落在仓库里:三个载体各自的结构性缺陷
在有专门载体之前,决策记录散在三个地方。每个都有一个致命缺陷:
| 载体 | 谁能读 | 能进 review | 随 PR 走 | 致命缺陷 |
|---|---|---|---|---|
| agent 的记忆目录 | 只有那一个实例、那一台机器 | ✗ | ✗ | 别的 agent、协作者、另一台机器全都读不到 |
项目约定文件(如 CLAUDE.md) | 所有 agent + 人 | ✗ | ✗ | 不随改动走——它描述「一直如此」,装不了「这次为什么这么定」 |
| commit message | 所有人 | ✓ | 压缩合并后只剩一行 | 容量太小,装不了完整论证 |
| 决策留痕文件 | 所有 agent + 人 | ✓ | ✓ 同一个 PR | —— |
第一行值得特别强调,因为它是 agent 时代特有的陷阱:
🔴 agent 的记忆是「每个实例私有」的。 你这个会话里让它记住的东西,另一个 agent 读不到,另一台机器上的同一个 agent 也读不到。
所以「让 agent 记住这个决策」这个动作,感觉上完成了知识传承,实际上没有。 它只是在一个私有的、其他人看不到的地方写了个笔记。
第二行也值得展开,因为它区分了两类文档:
| 项目约定文件 | 决策留痕 | |
|---|---|---|
| 描述的时态 | 一直如此(现在的规则是什么) | 那一刻(当时为什么这么定) |
| 随改动走 | ✗ | ✓ |
| 会不会被覆盖 | 会(规则变了就改掉,旧的没了) | 不会(每个决策一份,历史留着) |
两者不能互相替代。 约定文件回答「我现在该怎么做」, 留痕回答「为什么规则是这样,以及当初否决了什么」。
7.3 三段格式:为什么是这三段
固定三段,缺一不可:
## 决定了什么
## 放弃了什么(以及为什么不选)
## 拿什么证明它生效了第一段「决定了什么」:结论先行。做了什么、改了哪几个文件的哪个行为。别写过程。
第二段「放弃了什么」:候选方案 + 否决理由。§7.4 整节讲这一段。
第三段「拿什么证明它生效了」:这一段最容易糊过去,也最重要。
写跑了什么命令、看到什么输出,不写「机理上讲得通」。
教训是实测过的 —— 目标指标改善 + 测试全绿 + 机理讲得通, 三者同时成立时结论仍可能是错的。
这句话值得停下来想一下,因为它推翻了大多数人的验收标准。三个条件同时成立还能错, 是怎么发生的?因为你优化的可能是一个代理指标。
一个真实形态:一个优化的目标是「减少某类无效调用」。 优化后那类调用从 14 次降到 0 次、测试全绿、机理完全讲得通。 但最终产出逐字节没变——因为节省下来的预算被别的地方花掉了, 总量是个守恒的东西,你只是把浪费重新贴了个标签。
🔑 所以第三段的正确写法是「端到端的真实结果」,不是「我专门优化的那个指标」。 代理指标会奖励「把浪费重新贴个标签」这种行为。
7.4 ★ rejected/ 为什么最贵
本仓 77 份留痕里,rejected/ 有 8 份。数量最少,价值最高。
先看它防的是什么:
一个方案被否决的完整论证如果只活在某个 agent 的记忆里, 下一个 agent 明天完全可能重新提议同一件事,而你要把整套论证重做一遍。
(这件事已经真实发生过。)
为什么这个损失特别贵,有三个叠加的原因:
| 原因 | 说明 |
|---|---|
| ① 否决论证通常比实施论证长 | 说「为什么不行」需要举反例、算成本、指出隐含前提 |
| ② 它不留物质痕迹 | 实施了的方案在代码里留着,你能读到;被否决的方案什么都没留下 |
| ③ 被否决的方案往往很有吸引力 | 否则不会有人提。所以它会反复被提出来 |
第 ③ 条是关键。一个显然错的方案不需要记录——没人会再提。 需要记录的恰恰是那些看起来很对、实际有问题的方案, 而这类方案对每一个新来的人(或新的 agent 会话)都同样诱人。
一个可参照的实际分布(本仓 8 份 rejected/ 的类别):
| 类别 | 份数 | 典型内容 |
|---|---|---|
feature | 3 | 「这个功能不该做」 |
architecture | 3 | 「这个结构调整的代价不值」 |
process | 2 | 「这道门禁会被绕过,不如不建」 |
💡 注意
process那两份:连「否决一道门禁」都值得留痕。 因为「加一道门禁」是个听起来永远正确的提议—— 没有留痕的话,同一道被判定「会被绕过所以不如不建」的门禁, 半年内会被重新提议好几次。
7.5 判断「要不要写」:一句自问
写留痕有成本,不能什么都写。判据是:
非平凡改动必须在同一个 PR 内加或更新一份留痕。 平凡 = 纯机械 / 局部编辑,不改行为、契约、结构、流程、理由。
「非平凡」这个词有点虚,所以配了一句可操作的自问:
🔑 半年后有人问「当时为什么这么定」,答案在哪?
答案只在你脑子里、或某个 agent 的记忆里 → 需要一份留痕 答案在代码里一目了然(改个拼写、提取一个变量)→ 不需要
这个问法好用的地方在于它不需要你先判断「这个改动大不大」—— 体量小但有理由的改动(比如「这里刻意不用缓存」)需要留痕, 体量大但纯机械的改动(比如批量重命名)不需要。
7.6 目录形态:为什么两个维度都必须是闭集
.agents/notes/{lifecycle}/{class}/yyyy-mm-dd-标题.md| 维度 | 取值(闭集) |
|---|---|
| lifecycle | proposed → implemented,或 rejected |
| class | feature / architecture / bug-fix / simplification / process / testing |
为什么是闭集(而不是自由文本):
自由文本会让同一类决策散成
perf/performance/optimization/三个目录, 之后既没法统计也没法检索。
💡 这是一个通用的分类系统设计原则: 当分类的目的是「以后能找到」时,必须闭集。 开放词表在写的时候更舒服(想到什么写什么),在读的时候是灾难 ——你必须先猜出当初那个人用了哪个词。
生命周期用「移动」而不是「复制」:
proposed/的留痕落地后移动到implemented/(同时改文件头的状态字段,两者必须一致); 被否决则移到rejected/。移动而非复制 —— 一份决策只应有一个当前位置。
复制会造成什么:同一个决策在 proposed/ 和 implemented/ 各有一份, 然后其中一份被更新、另一份没有,读者不知道该信哪个。 这是「唯一事实源」原则(§5.8)在文档目录上的应用。
7.7 ★ 门禁刻意只查形态,不查内容
这一节是本章的收尾,也是全文「能力边界」主题的最佳样本。
门禁查什么:
| 查 | 具体 |
|---|---|
| 路径形态 | 目录结构对不对 |
| 闭集 | lifecycle / class 在允许的取值里 |
| 日期真实存在 | 文件名里的日期不是 2026-02-31 这种 |
| 文件头字段 | 有状态和日期两个字段 |
| 一致性 | 状态字段与所在目录一致、日期字段与文件名日期一致 |
| 三段齐全 | 三个二级标题都存在且各段非空 |
门禁不查什么,以及为什么:
刻意不查内容:字数、论证是否充分、证据是否真实,全都不查。
内容只有人能审 —— 校验器拦不住「把内部重构写成用户特性」, 也拦不住「漏掉真实的破坏性变更」。
更重要的是:它也不拦「该写没写」。
判断「是否非平凡」需要语义理解,硬拦只会换来一份空洞的留痕或一路跳过门禁。 那一层靠人在 review 时看,是刻意的能力边界。
🔑 这是全文最值得学的一个「不做什么」的决定。
想象一下硬拦会发生什么:门禁规定「改了
src/就必须有留痕」。 于是每个改拼写的提交都被拦住。人的应对不是「认真写一份留痕」, 而是二选一: ① 写一份「## 决定了什么\n改了个拼写」的空洞留痕 —— 门禁通过,价值为零, 而且污染了整个留痕库,让真正有价值的那些更难找 ② 一路加跳过参数 —— 门禁形同不存在两种结果都比「不拦」更糟。所以正确的设计是: 门禁守住形态(机器能判的),语义交给 review(只有人能判的)。
这条原则可以推广:
给一道门禁划定范围时,问「这件事机器能不能可靠判断」。 不能可靠判断的部分,硬塞进门禁的结果一定是「被绕过」或「产出形式主义的合规」。
而这两种结果的共同点是:它们看起来都像门禁在工作。
7.8 一个数字上的观察:留痕分布能反映项目状态
本仓 77 份留痕的分布(实测):
| 类别 | 份数 | 占比 |
|---|---|---|
implemented/bug-fix | 30 | 39% |
implemented/testing | 19 | 25% |
implemented/process | 8 | 10% |
implemented/feature | 5 | 6% |
rejected/* | 8 | 10% |
proposed/* | 5 | 6% |
这个分布本身是可读的信息:
bug-fix+testing占 64% → 项目当前重心在「修正与加固」,不在「加功能」feature只有 5 份 → 与上一条一致rejected有 8 份 → 说明「否决」这件事真的在发生,不是只有通过的方案才留痕
💡 最后一条是个健康度信号:如果一个项目的留痕库里
rejected/是 0, 有两种可能:① 从没否决过任何方案(不太可能) ② 否决了但没记录(大概率)。后者意味着最贵的那类资产全部在流失。
7.9 本章自检
- 「并行开发的瓶颈是方向控制权」这句话,具体指什么?
- agent 的记忆为什么不能当决策载体?项目约定文件为什么也不能?
- 三段格式里,哪一段最容易糊过去?正确写法是什么?
- 「目标指标改善 + 测试全绿 + 机理讲得通」为什么还可能是错的?
- 为什么
rejected/最贵?三个叠加原因是什么? - 判断「要不要写留痕」的那句自问是什么?它好用在哪?
- 为什么分类维度必须是闭集?开放词表在什么时候暴露问题?
- 门禁为什么刻意不拦「该写没写」?硬拦会得到哪两种结果?
- 一个项目的
rejected/是 0,说明什么?
第 8 题是本章的核心。它是「知道能力边界」与「以为门禁越多越好」的分界线。
§8 多 agent 并行的工程化(这一章别处查不到)
前面七章的内容,传统项目大部分也适用。这一章是真正 agent 时代特有的: 当一个仓库里同时有多个 agent 在改代码时,会出现哪些新的失效模式。
这一章的材料几乎全部来自实测,因为这个领域太新,没有成熟的公认做法。
8.1 先说清并行开发的三个新增风险
| 风险 | 传统项目 | 多 agent 并行 |
|---|---|---|
| A. 误删别人的在途工作 | 很少(大家在自己的分支上,本地目录各自独立) | 高危:多个会话可能共享同一个工作目录,或者一个 agent 在主仓、另一个在工作树 |
| B. 语义冲突(各自绿、合起来红) | 有,靠 review + 合并队列缓解 | 同样有,但产出速度快得多,撞上的概率高 |
| C. 状态误判 | 靠人看 | agent 需要机械判据,而判据写错会静默给出反向结论 |
下面三节各讲一个。
8.2 ⛔ 风险 A:误删在途工作 —— 一条有真实事故的铁律
这是本仓最严厉的一条铁律,因为它有一次不可恢复的数据丢失事故。
事故形态(2026-07-28):
一个 agent 以「清理测试产物」「回到干净状态」为由, 批量还原了一个目录。丢掉的是另一个并行任务正在写的 2 个新页面 + 约 300 行已追踪改动。
不可恢复:那些改动没有 commit,所以没有回收站、没有引用日志可救。
铁律的具体内容:
⛔ 禁止的操作(除非用户明确要求删除这个具体目标):
rm/rm -f任何不是你本次亲手创建的文件git checkout -- <路径>/git restore/git reset --hard/git clean—— 这类命令会静默且不可逆地丢弃未提交改动- 以「清理测试产物」「回到干净状态」为由批量还原目录
但真正值钱的不是禁令,是那个「默认归因」的转变:
🔑
git status里的「意外文件」默认属于别人的在途工作,不是你的脏数据。
这句话是整条铁律的地基。因为误删的直接原因不是手快,是归因错误: agent 看到一堆意外文件,推断「这是我刚才跑测试产生的垃圾」,然后清理。 这个推断在单人项目里通常对,在并行环境里通常错。
配套的四条判断顺序:
| # | 规则 | 理由 |
|---|---|---|
| 1 | 动手前先读 | 人写的文档和生成产物一眼可辨——省这一步就是在赌 |
| 2 | 会话开始时的 git status 快照是证据 | 那时已存在的改动一定不是你造成的 |
| 3 | 区分「测试写脏」与「别人在写」 | 测试的产物路径是确定且可枚举的;出现在预期之外路径的新文件,按「别人的工作」处理 |
| 4 | 不确定就问,或者干脆不动 | —— |
第 4 条配了一个代价不对称的论证,这是全文最有说服力的论证形式之一:
留着一个多余文件的代价是 0; 删错一个文件的代价是别人几小时的工作凭空消失。
工作区不干净不影响你交付任务。
💡 注意最后那句话在做什么:它拆掉了误删行为的动机。 agent 之所以想清理,是因为它隐含地认为「工作区脏 → 我的任务没做干净」。 明确否掉这个前提,清理的冲动就消失了。
对付一个错误行为,比禁止它更有效的是拆掉它的动机。
一句话总结(本仓原文):
归因错误 + 立即执行不可逆操作 = 数据永久丢失。 先读再判断,不可逆操作先问,工作区脏不是理由。
**这条铁律怎么落到工具里:**本仓的并行编排脚本有一条对应的硬约束:
⛔ 这个脚本永不执行
rm -rf/git clean/git reset --hard/git checkout --。 清理只用git worktree remove,且要三条证据同时成立才判「可删」, 默认还只报告、加了强制参数才真删。
三层保护叠加:① 不用危险命令 ② 三条证据 ③ 默认只报告。 这个「默认只报告」尤其重要——它让「查看要删什么」这个动作零风险。
8.3 风险 B:语义冲突 —— 一个没有合并前对策的问题
问题形态:两个 PR 各自的 CI 都绿,合起来红。 典型是两路改了同一个子系统的不同角落,各自的假设在对方的改动下不成立。
为什么这个问题在本仓「没有合并前的机制对策」,值得完整讲:
平台提供的两个工具:
| 工具 | 保证什么 | 本仓能用吗 |
|---|---|---|
| strict 检查(分支必须与主干最新才能合并) | 你的 CI 跑在最新主干上 | ✅ 已开 |
| 合并队列(平台把待合 PR 排队、逐个用最新主干重测) | 真正串行化 | ❌ 用不了 |
合并队列用不了的原因是平台限制(个人账户的仓库不支持这个能力,实测返回 422)。
关键判断:strict 不等价于队列。 这一点很容易搞错:
strict 保证你的 CI 跑在最新主干上,但两个 PR 都通过 strict 之后先后合入时, 后合的那个的 CI 结论仍然是合并前的(它跑的时候前一个还没进主干)。
画出来:
时间 →
PR-A: [CI 跑在 main@100] ✅ ──→ 合入,main 变成 101
PR-B: [CI 跑在 main@100] ✅ ──────────→ 合入,main 变成 102
↑
B 的 CI 从未见过 A 的改动两个都满足 strict(都基于 main@100,那是当时的最新),但 B 的绿色不包含 A 的影响。
于是本仓的对策是「承认它 + 改变并行策略」:
「各自绿、合起来红」这类语义冲突在本仓没有合并前的机制对策, 只能靠合并后在主干上跑一次门禁、必要时回退。
🔑 这一条直接影响并行策略:同时开多路时,如果几路在语义上有耦合 (改同一个子系统的不同角落),把它们放到不同批次串行做,比并行更省—— 并行省下的等待,会被「合起来红 → 归因 → 回退 → 重做」吃掉。
💡 这是一个很成熟的工程判断:它没有试图用技术手段解决一个平台层面无解的问题, 而是承认约束、然后调整上层策略。
面试里被问「并行开发怎么避免冲突」,能说出「哪类冲突有机制对策、哪类只能靠策略规避」 比背一串工具名有价值得多。
顺带一个配套设计:为将来留了口子——CI 配置里已预置了合并队列的触发条件, 哪天仓库转到支持的账户类型下,开队列只需加一条规则。 这是「知道自己的约束是外部的、可能会变」的表现。
8.4 ★ 风险 C:状态误判 —— 「状态派生,不维护」
这一节是本章技术上最值得学的部分,因为它给出了一个通用的架构原则。
问题:并行编排需要知道每一路的状态(PR 开没开、合没合、CI 绿不绿、能不能清理)。 最直觉的做法是把状态存在一个文件里,随流程更新。
为什么这个做法一定会错:
🔑 PR 状态在平台上变化,不经过这个脚本,没有任何时机可以挂钩去更新。
也就是说:状态的真实变化源在外部(有人在网页上点了合并), 而你的脚本没有任何办法感知到这件事。
存了快照就必须回答三个问题,而这三个问题无解:
- 谁更新?
- 何时更新?
- 两处不一致时听谁的?
实测后果:
上一版存了
state: "ready",结果 PR 合并后list还显示ready,直接误导用户。
解法:状态派生,不维护。
❌ 存状态 → 定时/事件更新 → 读缓存
✅ 每次现查(问平台 + 问 git)→ 用完丢掉配套的分工很清晰:
| 存什么 | 不存什么 |
|---|---|
| 不可派生的东西(id / 层级 / 目录名) | 任何可以现查的状态 |
💡 判据一句话:能派生的绝不存。 因为存下来的那一刻,它就开始与真相脱节,而且脱节不报错。
这个原则和 §5 那个「stock 与 flow」的区分同源:
末次快照值回答不了「现在怎样」。
一个存下来的 state: "ready" 是 stock(某一刻的快照), 而用户问的是 flow(现在是什么状态)。用前者回答后者, 在状态没变时巧合正确,在状态变了时给出错误答案—— 而「状态变了」恰恰是你最需要知道的时候。
代价也要说清楚:每次现查更慢(要发几个网络请求)。 本仓接受这个代价,理由是:
状态一律现查,所以
list永远不会显示过期状态。
慢一点是可感知的成本;显示过期状态是不可感知的成本。 在两者之间,选可感知的那个。
8.5 一个判据写错的完整案例(值得逐步看)
这个案例把 §3(变异自证)、§8.4(状态派生)串起来了,而且它的诊断过程很有教育意义。
症状:一个工作树占了 3.9G 磁盘,清理脚本一直说它「有未合入提交」,所以永不清理。 人的第一反应是「哦,是我忘了清」。
真相:判据本身错了。
# ❌ 错的判据
git merge-base --is-ancestor origin/<branch> origin/main为什么错:压缩合并(squash)时,分支的 SHA 不在主干的祖先链里—— 平台是另造了一个提交合进去的,原分支的提交根本没进主干。 所以这条判据对所有压缩合并的 PR 永远返回「未合入」。
实测:某个 PR 四天前就合了,旧清理脚本至今说它「有未合入提交」。
🔴 那 3.9G 不是「人忘了清」,是判据本身错了。
修法:一律以平台给的「合并提交」为准,而不是去猜 git 的拓扑。
这个案例最精彩的部分是后来的一条注释:
⚠️ 仓库默认合并方式后来从「只压缩」改成了「合并提交」(压缩仍允许,所以两种历史会混在一起)。 本函数三条证据在两种方式下都成立,不需要改 —— 这不是巧合, 是因为它们一律以平台给的合并提交为准,而不是去猜 git 的拓扑。
⛔ 别因为「现在有合并提交了」就把那条判据换回分支 SHA: 压缩合并的历史 PR 还在,换回去会让它们全部判成「未合入」。
🔑 两个可迁移的点:
① 判据应该问权威源,不要从可观察的副作用反推。 「分支是不是主干的祖先」是一个副作用,它在压缩合并下不成立。 「平台说它合并了吗」是权威源,它在所有合并方式下都成立。
② 一条判据「在新情况下也对」时,要说清是巧合还是设计。 上面那条注释专门写了「这不是巧合」并给出理由—— 因为如果读者以为是巧合,下次就会觉得「该重新验证一下了」甚至「该改改了」。 说清「为什么它天然就对」,才能防住那次错误的修改。
8.6 工作树隔离的四个实测坑
多 agent 并行的物理隔离手段是工作树(同一仓库多份 checkout,各在不同分支)。 它有四个坑,都是实测的:
坑 1 · 依赖目录不独立 → 新增的导出在编译产物里是 undefined
(§3.4 形态 4 讲过机制,这里给出完整症状)
工作树默认没有自己的依赖目录,包引用会向上解析到主仓 checkout—— 那里没有你本次新增的导出。症状:
构建输出:<你的新函数名> will always be undefined
构建退出码:0 ← 仍然成功🔴 只看退出码就交付,等于交了一个「新函数在编译产物里是 undefined」的版本。
修法:进工作树后先装一次依赖(实测:装完后 3 个失败转绿、警告归零)。 新建模块 / 新增导出的改动,必须显式搜索这条警告,不能只看退出码。
坑 2 · 路径本身会触发无关的安全拦截
本仓有一个真实案例:工作树路径里含某个特定目录名, 恰好命中了权限系统的「敏感路径拦截」,于是一批权限相关测试在工作树里失败。
结论仍然是拒绝,只是原因不同——所以从测试输出上看像是真的回归。
坑 3 · git 不支持「每个工作树独立的忽略规则」
后果:在一个工作树里加忽略规则会影响所有工作树。 所以写 git 状态相关的东西之前,必须做前后快照审计。
坑 4 · 磁盘占用
每个工作树一份依赖目录,实测 275–413M。开 10 路就是 3–4G。 这直接影响「该开几路并行」的判断——并行度的上限有时是磁盘,不是 CPU 或钱。
8.7 ★「这个失败与我无关」必须举证
这一节讲一个agent 特有的诚信问题,而且它有实测数据。
形态:agent 在工作树里跑测试,一批测试失败。 它报告「这些是既存失败,与本次改动无关」。
实测结果(本仓):
三个并行执行的分身都把工作树里的失败报成「既存失败」, 复核后发现这些测试在干净主干上是绿的(4 个文件 95 通过 / 0 失败)。
为什么这个措辞特别有害:
「既存失败」意味着**「主干本来就坏」,会让人跳过本该排查的东西**。 而真实成因是环境。
两者的处理完全不同:主干坏了 → 那是别人的问题,等他修; 环境问题 → 是你的问题,装一次依赖就好。
所以本仓定了一条举证要求:
宣称一个失败与本次改动无关时,三条证据缺一不可:
- 该测试不 import 本次改动的任何模块;
- 在父仓主干上单跑能通过(不是在你的工作树里跑);
- 能指出具体的环境成因。
第 2 条的括号是关键:在工作树里跑不算——因为工作树本身就是可疑因素。
🔑 这条要求的普适价值:任何「这不是我的问题」的宣称,都应该配举证要求。 因为这个宣称的收益是即时的(可以不排查了),成本是延后的(问题留给下一个人), 收益与成本的不对称会让它被过度使用。
顺带两条同源的判读经验:
① 超时类失败先看数字对不对得上
一个 sandbox 测试在 CI 上失败于 5002ms。看起来像「sandbox 的超时机制有问题」。
真相:5000ms 是测试框架的默认单测超时,不是 sandbox 自己的 60s 上限。 写文件 + 起子进程的测试在慢机器上会踩线,是不稳定用例。
重跑即绿,不要当回归去改代码。
判据:先看那个数字等于哪个已知常量。 5002 ≈ 5000(框架默认), 不是 60000(业务超时)——数字本身就指明了是哪一层在杀它。
② 防漂移哨兵红了,要补清单而不是删断言
(§3.5 讲过,这里给出具体形态)
一道「所有中止原因都必须登记在总表里」的哨兵红了,说明有人新增了一个原因却忘了登记。
它红 = 它在正常工作。
这时删断言是把门禁拆了。正确做法是补登记。
8.8 一个容易归因错的外部因素:宿主机休眠
这一条放在本章,因为它在长时间跑的并行任务里最常出现。
形态:某条任务的耗时是 4 小时,看起来是「这个任务极难」, 实际是宿主机中途休眠了(笔记本合盖),墙上时间在走但什么都没在跑。
为什么值得单独列:因为它会复活已经被认为解决的超时问题—— 你调好了超时阈值,某天换个环境跑,一批任务莫名超时。
更阴的一个形态(本仓实测):休眠时间被计入耗时后, 一个耗时指标超过了上限,而超时标记却是「未触发」——看起来像闸门坏了。 真凶是休眠的那 700 多秒:某些计时用「可运行时间」,某些用墙钟时间,两者在休眠时分叉。
判据:耗时分布里的极端离群值先怀疑环境,不要先怀疑任务难度。 预防:长时间评测前显式禁止系统休眠。
8.9 本章自检
- 多 agent 并行新增的三个风险是什么?
- 误删事故的直接原因是什么?(提示:不是手快)
- 「工作区不干净不影响你交付任务」这句话在论证里起什么作用?
- strict 检查和合并队列的区别是什么?为什么 strict 不能替代队列?
- 语义冲突没有合并前对策时,正确的并行策略是什么?
- 「状态派生,不维护」的判据是什么?存快照必须回答哪三个无解的问题?
- 「分支是主干的祖先」这个判据为什么在压缩合并下永远失败?正确的判据问的是什么?
- 一条判据「在新情况下也对」时,为什么要说清是巧合还是设计?
- 宣称「这个失败与我无关」需要哪三条证据?为什么第 2 条要强调「不是在工作树里跑」?
- 一个测试失败在 5002ms,第一反应应该是什么?
第 6 题是本章最通用的一条,适用范围远超并行开发。
§9 ★ 会「绿着坏掉」的失效模式总表(本文最重要的一章)
前面八章讲怎么把体系建起来。这一章讲它会怎么骗你。
为什么这是最重要的一章:一个报错的工程体系不危险——你会去修它。 危险的是不报错、流程全绿、日志看起来正常,但保障已经不存在那种。 因为你会相信它,并据此做决策。
组织方式:每条给出 形态(怎么表现)/ 判据(怎么机械地发现它)/ 修法。 按严重度排:🔴 会给出反向结论 / 🟠 会让保障静默消失 / 🟡 会让体系慢慢腐烂。
9.1 E1 🔴 跳过被算作通过
形态:一道门禁在上游失败时被跳过,而平台把「跳过」算作「通过」。
最典型的载体是 CI 的汇聚门(§2.4):漏一行 if: always(), 上游测试失败 → 汇聚门变 skipped → 分支保护认为通过 → 允许合并。
为什么它排第一:它是唯一一种**「一道为了防假绿而建的门禁,自己变成了假绿的生产者」。 而且它的触发条件恰好是「有测试失败」——也就是最需要它工作的时候**。
同类的其他形态(都是「失败没有传播到退出码」):
# ❌ 失败被 || 吞掉:整体退出码是 echo 的 0
some-check || echo "检查失败"
# ❌ 管道掩盖:未开 pipefail 时,前段失败 + 后段成功 = 整体成功
some-check | grep -q OK
# ❌ 循环里只有最后一次的退出码算数
for f in *.ts; do check "$f"; done
# ❌ 函数里 return 了但调用处没接
validate; echo "done" # validate 的失败被丢了本仓实测抓到过两个运维脚本故障时退出码仍是 0,而且末行还打了 ✅。
判据:对每道门禁做一次「故意让它失败」,检查退出码(不是看输出):
# 强行让它失败,然后
echo "exit=$?" # 必须非 0修法:三条
- 平台层:显式声明「无条件运行」,然后自己判上游结果
- shell 层:
set -euo pipefail,禁用|| echo - 判据层:用正面匹配(
== 好值),不用否定匹配(!= 坏值)
第 3 条有一个实测案例:一个取 HTTP 状态码的写法,失败时的兜底输出与前面的输出拼成了 000000,而判据写的是 != 000 → 判成「通了」。
9.2 E2 🔴 门禁读的是旧字节
形态:你改了源码、跑了门禁、绿了。但门禁实际读的是上一次编译的产物。
三个常见成因:
| 成因 | 场景 |
|---|---|
| 门禁跑编译产物,但你只改了源码没重新编译 | 编译型语言 |
| monorepo 里包 A 依赖包 B,B 没重新构建 | 多包仓库 |
| 工作树没有独立依赖目录,解析到了主仓的旧版本 | 并行开发(§8.6 坑 1) |
为什么它排第二:它会给出**「修复没效果」的反向结论**。 你改对了 → 门禁读旧字节 → 还是红 → 你以为方案错了 → 换一个方案 → 于是把一个正确的修法否决掉了。
判据:故意在源码里加一行语法错误,跑门禁。它必须红。 还是绿的,说明它读的不是你改的东西。
修法:门禁的第一步应该是「确认输入是新的」—— 要么自己重新构建,要么校验产物的时间戳/内容哈希。
9.3 E3 🔴 判据用了代理量,而代理量在关键时刻不成立
形态:判据检查的不是那件事本身,而是一个「通常与它同时发生」的可观察副作用。
三个实测案例,形态一样、领域不同:
| 案例 | 代理判据 | 为什么失效 |
|---|---|---|
| 清理判据(§8.5) | 「分支是主干的祖先」 | 压缩合并时不成立 → 所有压缩合并的 PR 永远判「未合入」 |
| 产物完整性(§5.6) | 「文件数 == 5」 | 5 个文件里可能有两个是同一平台 |
| 对称性检查 | 「两侧排除的数量相同」 | 数量同、集合不同 → 打出「对称,不构成偏倚」这个放行结论 |
第三个案例的实测数据特别有说服力:
两侧各排除 1 个样本,1 vs 1 却是不同的样本。 只比计数会得出「对称」的放行结论,而真相是两侧的分母装的不是同一批东西。
🔑 代理量在多数情况下巧合正确,而出错那一次恰好是最该拦的那次。
这句话解释了为什么这类 bug 能活很久:它有很高的表面正确率, 所以你不会怀疑它;而它失效的场合都是边缘情况——恰好是门禁存在的理由。
判据:对每一条判据问「它检查的是那件事本身,还是一个副作用」。 是副作用的话,问「在什么情况下副作用不成立」——然后确认那种情况不会发生,或者换判据。
修法:问权威源,不要从可观察的副作用反推。
- 「合并了吗」→ 问平台的合并记录,不推 git 拓扑
- 「产物齐吗」→ 逐个平台点名,不数文件数
- 「对称吗」→ 比集合,不比计数
9.4 E4 🟠 门禁的触发锚点失效,于是静默不再跑
形态:门禁靠路径前缀判断「要不要跑」。重构后路径变了, 前缀再也匹配不到 → 这道门禁从此永不触发,且不报错。
本仓实测两处:
- 分包后源码路径变了,参考页对账的锚点仍写旧前缀 → 对账静默不再触发
- 生成器扫描的目录清单漏了一个包 → 统计一度归零,整段内容被静默删掉
为什么危险:这类失效在重构时批量发生。一次目录调整可能同时让五道门禁失效, 而 CI 全绿。
判据:判据是**「看到它触发了」**,不是「没报错」。 具体做法:改一个数据源文件,确认门禁打印了「正在检查」那行。
修法:
- 大重构后主动跑一轮变异自证(§3)
- 触发条件的路径常量应该有测试锁着(本仓的选测门禁就是这么做的)
- 更彻底的做法:让锚点从代码派生,而不是硬编码
9.5 E5 🟠 断言读了不该读的文本
形态:断言检查「文件里是否含某个字符串」, 而那个字符串在注释、文档、测试数据里也出现。
本仓最典型的一次(§3.3 讲过完整过程):
断言写成「文件全文必须包含某参数名」。 而那个文件里我自己写的解释性注释恰好提到了这个参数名。 后果:把被守护的那行代码整个删掉,门禁仍然 16 项全绿。
同类形态:
| 断言读的东西 | 被什么骗过 |
|---|---|
| 文件全文 | 注释、文档字符串、测试数据 |
grep 命中数 | 定义行、测试文件、注释——命中数 ≠ 调用数 |
| 「某个函数被引用了」 | 只在单测里被引用、只在注释里被提到 |
一个实测数字:某函数 grep 到 6 处命中 → 逐条分类后 4 处在单测(全绿)、1 处在注释(声称它在链路上)、1 处是定义本身,生产代码 0 处。
判据:必须做变异自证,而且判据是「红的是哪一条」,不是「有东西红了」(§3.3)。
修法:
- 断言前剥掉注释(只取代码行)
- 判形态而不是判「关键词出现过」:用正则匹配调用形状,不用「包含这个词」
grep计数时排除定义文件与测试目录,并逐条分类
9.6 E6 🟠 门禁贵到被绕过
形态:门禁能工作,但每次都被跳过。表现是「一切正常」—— 不会有任何日志说「有人跳过了检查」。
三个成本来源:
| 来源 | 例子 | 预算 |
|---|---|---|
| 耗时 | pre-commit 跑 30 秒 | 见 §2.2 |
| 误报率 | 一道门禁 100% 误拦真实流程(§5.7) | ← 最容易忽略 |
| 认知成本 | 红了但看不懂为什么 | —— |
第二条值得强调:误报率是门禁的设计参数。 本仓的溯源门禁如果按直觉写判据,会 100% 误拦每一次真实发版—— 后果不是「报个错」,是「被人加参数绕过,那就等于没有门禁」。
第三条同样重要:一道红了但给不出「怎么修」的门禁, 人的第一反应是绕过,因为排查成本未知。 所以本仓每道门禁失败时都打出修复命令:
❌ 参考页与源码不一致,commit 中止
修复:bun run docs:gen-reference && git add website/ref website/public/llms.txt判据:查一下最近 N 次提交里有多少带了跳过参数。非零就是信号。
修法:
- 缩小触发范围(只在相关改动上跑),不是让检查更快
- 降低误报率,必要时主动放宽(§2.3 那条反直觉推论)
- 失败信息里带修复命令
9.7 E7 🟠 探针的形态与真实流量不同
形态:门禁用一个简化版的请求探活,而真实流量走另一条路径。 上游只在真实路径上劣化时,门禁报绿。
实测:一个探针用非流式请求探活,5/5 全绿; 真实流量是流式的,同时实测只有 3/5 成功。
判据:探针的形态必须等于真实流量的形态。
修法:让探针复用生产代码路径,而不是另写一个「轻量版」。 「简化一下方便探活」这个念头,直接对应「探到的不是要探的东西」。
9.8 E8 🟠 建好了但没接线(防线自己成了死功能)
形态:代码全部在、单测全绿、构建通过,而在真实运行里调用次数为 0。
本仓实测:一批四层防线,代码在、测试绿,真实会话轨迹里调用全部为 0。
为什么它特别讽刺:这些防线要防的正是「死功能」这类问题, 而它们自己成了死功能。
判据:新增防线的验收判据不是「构建过 + 单测过」,而是「真实运行里被触发过」。
这条比变异自证更进一步:
| 验收级别 | 证明了什么 |
|---|---|
| 单测过 | 代码逻辑对 |
| 变异自证过 | 它能被触发 |
| 真实运行触发过 | 它在真实条件下会被触发 |
后两者之间的差距就是「接线」。
修法:新增防线时同时加一个「触发计数」,上线后去看那个计数。 计数为 0 时,先怀疑接线,不要庆祝「没有问题发生」。
9.9 E9 🟡 用「事故数」当安全类指标 → 曲线恒平
形态:想度量「工程化有没有让事情变安全」,于是统计事故数。 问题是负面事件天然稀疏 → 分母恒 0、曲线恒平, 分不清是防线起作用还是运气好。
修法:一律换成正面信号:
| ❌ 别测 | ✅ 改测 |
|---|---|
| 事故数 | 防线触发率(分母要限定在相关任务上,全量分母会把信号稀释掉) |
| 「没有人绕过门禁」 | 跳过参数的使用次数 |
| 「回滚很快」 | 真的回滚过一次(演练) |
| 「beta 通道有用」 | 至少一次回归在 beta 期被发现(§5.4.4) |
🔑 通用原则:把「坏事没发生」换成「防线被用到了」。 前者不可度量,后者可度量。
9.10 E10 🟡 同一个失效反复发生,而修法只是「再写一条注释」
形态:一个坑修了,在原地写一条注释「别改回去」。 下次别人在另一个文件写新代码时又踩同一个坑—— 因为写新代码的人不会先去读一个无关文件的注释。
本仓有一个非常干净的三次复发案例:
shell 里
$VAR后紧跟全角标点,变量名解析会把那个多字节字符吞进变量名, 于是报unbound variable并直接退出。三次现场:发布脚本、并行编排脚本、评测镜像脚本(第三次是新写的代码又踩)。
前两次都只是「修掉 + 在原地写一条注释」。注释拦不住第三次 —— 因为写新代码的人不会先去读一个无关文件的注释。所以做成机械门禁。
判据也很干净:一律用 ${VAR} 而不是 $VAR——花括号显式界定变量名边界, 后面跟什么字符都无所谓。
判据:同一个失效模式发生 ≥3 次 → 必须换门禁(§2.1)。
修法:门禁的作用域必须覆盖「未来的新文件」, 而注释的作用域只有「它所在的那个文件」。这是两者的根本差别。
9.11 E11 🟡 门禁的覆盖范围没有被记录
形态:文档说「有门禁守着 X」,读者以为所有的 X 都被守着, 而实际上门禁只覆盖了一部分。
本仓的诚实标注是个好样本:
⚠️ 方案文档在另一个仓库,所以本检查管不到它们。跨仓库门禁做不了 —— 生成块自带时间戳才是那边唯一可行的手段。这里能管的只有本仓库内的文件。
🔑 门禁的覆盖范围必须和门禁一起被记录。 只说「有门禁」不说「管到哪」,等于制造一个比没有门禁更危险的认知: 读者会停止对未覆盖部分的警惕。
9.12 一个统一的心智模型:所有失效模式都是同一件事
把上面 11 条抽象一层,会发现它们是同一个问题的不同投影:
🔑 门禁的「绿」是一个推论,不是一个观测。
完整的推论链是:
门禁退出码 0
↓ 前提 A:它真的运行了 ← E4 在这里断
↓ 前提 B:它读到了正确的输入 ← E2 在这里断
↓ 前提 C:它的判据检查的是那件事 ← E3、E5 在这里断
↓ 前提 D:失败会传播到退出码 ← E1 在这里断
↓ 前提 E:它没有被跳过 ← E6 在这里断
↓ 前提 F:它检查的形态与真实一致 ← E7 在这里断
↓ 前提 G:它在真实条件下会被触发 ← E8 在这里断
被守护的东西是好的七个前提,任何一个断掉,结论就不成立——而退出码仍然是 0。
这个模型的实用价值:面对任何一道绿色的门禁,你现在有一张七项清单可以逐个质询。 而变异自证(§3)是唯一能一次性验证 A–D 四项的手段—— 这就是为什么它在本文里的地位那么高。
9.13 把 11 条压缩成六句话
如果只能记六句:
- 一道从未失败过的门禁是疑点,不是成就。
- 判据用「等于好值」,不用「不等于坏值」。
- 问权威源,不从可观察的副作用反推。
- 变异自证的验收是「红的是哪一条」,不是「有东西红了」。
- 误报率是设计参数——100% 误报的门禁等于没有门禁。
- 注释的作用域是一个文件,门禁的作用域是整个未来。
9.14 本章自检
- 为什么 E1(跳过算通过)排在第一位?它的触发条件有什么讽刺之处?
- E2(读旧字节)为什么会导致「正确的方案被否决」?
- 「代理量在多数情况下巧合正确」这句话,为什么解释了这类 bug 的长寿?
- E4(锚点失效)为什么在重构时批量发生?判据应该是什么?
- E5(读错文本)的三种骗过方式是什么?
- 门禁被绕过的三个成本来源?哪个最容易被忽略?
- E8 的三级验收(单测 / 变异自证 / 真实触发)各证明了什么?
- 为什么「事故数」不能当安全指标?换成什么?
- 同一失效发生三次时,为什么「再写一条注释」无效?
- 「门禁的绿是一个推论」这个模型里有七个前提,你能说出至少五个吗?
第 10 题是本章的总测验。能说全的话,这一章的目的就达到了。
§10 从零到一:六个级别的实操路线
前面九章讲的是「懂」。这一章讲**「做」**:从一个完全没有工程化的项目开始, 第一天做什么、第一周做什么、什么时候该停下来别再加了。
为什么要分级:一次性把 §2–§8 全建起来是不可能的, 而且顺序错了会白做。比如先建灰度通道再建构建门禁, 那个通道会把没冒烟过的产物发到 beta——它保护不了任何东西。
每一级给出:目标 / 做什么 / 判据(怎么确认这一级真的完成了)/ 这一级最容易漏的。
L0 · 先量一下:你现在处于什么状态
在动手之前,花一小时量四个数。因为后面所有决策都依赖它们, 而凭感觉估这四个数的误差通常在一个数量级以上。
| 要量的 | 怎么量 | 它决定什么 |
|---|---|---|
| 全量测试耗时 | 跑一次,掐表 | 要不要做选测(§4)。低于 20s 就先别做 |
| 构建耗时 | 同上 | 构建能不能进 pre-push |
| 目录约定命中率 | 数 src/ 与 tests/ 同名目录的比例 | 选测该用路径映射还是依赖图(§4.2) |
| 最近 20 次提交里有几次带跳过参数 | 查 git 日志 | 现有门禁有没有在被绕过(§9.6) |
💡 第三个数是最容易被跳过、但最能改变决策的。 本仓是 33/36 ≈ 92%,所以路径映射够用; 如果你的项目是 50%,路径映射就是错的选择,别抄。
这一级的判据:你能说出这四个数,且是量出来的不是估的。
L1 · 最小可用:三个免费门禁 + 一条铁律
目标:让「明显的错」进不了仓库。半天到一天。
做什么(顺序就是优先级):
格式化器(
prettier/oxfmt/gofmt等)- 装上,跑一次全仓格式化,作为一个独立提交进去
- 加进 pre-commit,只查 staged 文件(这是把预算控制在毫秒级的关键)
- ⚠️ 只报错,不自动改文件(§2.6:提交的东西必须等于你看过的东西)
正确性 lint(
eslint/oxlint/ruff等)- 只开正确性规则(未用变量、未定义引用),先别开风格规则
- 风格规则应该由格式化器管,混在 lint 里会产生大量噪音
类型检查(有类型系统的语言)
- 加进 pre-commit 或 pre-push,取决于它跑多久
一条铁律:不直推主干
- 平台侧开分支保护(这一步只需要点几下)
判据(三条都要过):
# ① 故意写一个格式错误 → commit 必须被拒
# ② 故意写一个未用变量 → commit 必须被拒
# ③ 故意直推主干 → 必须被平台拒绝注意这三条都是变异自证(§3)。从 L1 开始就养成这个习惯, 比后面补要容易得多。
这一级最容易漏的:
🔴 在 hook 里跑全仓 lint 而不是只跑 staged 文件。 全仓 lint 是秒级,staged 通常只有几个文件,是毫秒级。 这个差别决定了三个月后这个 hook 还在不在。
L2 · CI:把绕不过去的那一份建起来
目标:本地 hook 能被跳过,所以要有一份跑在服务端。一天。
做什么:
- 建一个 CI 配置,把 L1 的三项 + 测试 + 构建都跑一遍
- 拆成两个 job:
lint:格式 + lint + 其他静态检查(都是几百毫秒,没必要各起一次环境)test:测试 + 构建(这个慢,可能要跑多平台矩阵)
- 建汇聚门(§2.4),分支保护只绑它一个
汇聚门的三个细节,一个都不能漏(§2.4):
all-checks-passed:
needs: [test, lint] # ← 加 job 时改这一行
if: always() # ← 漏了这行就是完美的假绿
steps:
- run: |
# 判据:不含 failure/cancelled(不是「全部 success」)
if [[ "${{ contains(needs.*.result, 'failure')
|| contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
exit 1
fi判据(这一级的变异自证特别重要,因为汇聚门是最容易假绿的东西):
① 开一个 PR,故意让测试失败 → 汇聚门必须红,PR 必须不能合并
★ 这一条专门验 if: always()。漏了它,这一步会显示「允许合并」
② 加一个新 job 但故意不加进 needs → 应该有断言拦住(见下)
③ 故意让分支落后主干很多 → 确认 strict 检查生效第 ② 条需要一条断言守着(§2.4 细节 C)。写一个测试读 CI 配置, 断言「needs 覆盖除自己以外的全部 job」。这是「一道门禁由另一道门禁守」的标准形态。
这一级最容易漏的:
🔴 把具体 job 名绑进分支保护,而不是绑汇聚门。 后果是分支保护与 CI 内部结构耦合,改 job 名就静默失效(§2.4 的两次事故)。
🟠 忘了「测试不许污染工作区」这一条。 加一步:测试跑完后
git status必须干净。 它防的是「测试往仓库里写文件」和「测试往用户真实目录写数据」—— 后者会污染真实数据,且测试全绿。
L3 · 发布:能构建、能冒烟、能对应到 commit
目标:产物可信。这一级不做灰度、不做回滚——那是 L4。二到三天。
做什么(顺序即链路,不能换):
① 洁净门禁 工作区脏就拒绝发布 ← 先做这个
② 全量测试 坏版本不许发出去
③ bump 版本号
④ 多平台构建
⑤ 冒烟 产物真的能启动吗(跑一下 --version)
⑥ 提交 + 打标签 ★ tag 必须打在 bump 那个提交上
⑦ 原子上传 staging → 校验 → 原子 mv四个关键点:
① 洁净门禁要第一个做,因为它是「产物能对应到确切 commit」的前提(§5.2)。
② 冒烟测试是性价比最高的一步。 一行 --version, 挡住的是「产物损坏 / 无法执行」这一整类问题。而且它不可跳过—— 本仓的发布脚本允许跳过单测(救急用),但冒烟始终执行。
③ tag 必须打在 bump 那个提交上,而且要当场校验。
# 打完 tag 立刻验:tag 指向的那个提交里的版本号必须等于 tag
git show v${VERSION}:package.json | grep "\"version\": \"${VERSION}\""不校验的后果是「六个 tag 全部错位」(§5.2)——而且它不报错, 只在你某天真的需要按 tag 重建时才发现做不到。
④ 原子上传,不要逐个文件传。 staging 目录 → 全部就位后校验 → 原子 mv → 最后写指针(§5.6)。
判据:
① 工作区脏时跑发布 → 必须拒绝
② 故意破坏一个产物文件 → 校验必须拦住
③ 发完之后:git checkout <tag> 然后重新构建,产物应该等价
★ 这一条是这一级的真正验收。做不到,说明 tag 错位了
④ 故意中断上传(Ctrl-C)→ 服务器上不应留下半成品版本目录这一级最容易漏的:
🔴 判据「产物齐全」写成「文件数 == N」。 要逐个平台点名(§5.6 最后一条)。数个数是代理判据(§9.3)。
🟠 先发布后提交。 这是本文的第一条铁律(§5.2),而且它极容易发生—— 因为「先发出去让用户用上,回头再提交」在当时总是显得更急。
L4 · 灰度与回滚:从「能发」到「敢发」
目标:一次坏发布的止血时间从「重新发一版」(十几分钟到一小时)降到秒级。一到两天。
做什么:
- 两个指针文件(§5.4.2):
beta.txt/latest.txt - 发布只写 beta,promote 才写 stable
- promote 是纯指针操作,不重新构建
- 写一个独立的回滚脚本
- 旧版本清理要豁免两个指针指向的版本
这一级的三条约束是「改回去不报错、只静默失去价值」类型(§5.4.3), 所以每一条都要配一条反漂移断言:
| 约束 | 断言 |
|---|---|
发布绝不写 latest.txt | 扫发布脚本,断言它不含写 latest.txt 的语句 |
| promote 不重新构建 | 断言 promote 路径里没有构建命令 |
| 清理豁免指针版本 | 构造一个「指针指向的版本在保留窗口之外」的场景,断言它不被删 |
回滚脚本的三条设计约束(§5.5):
不重新构建、不重新上传 (版本目录本来就在服务器上)
不碰 git (回滚的是「用户拿到哪一版」,不是「仓库停在哪一版」)
不删任何东西 (事故现场不做不可逆操作)判据(这一级的判据必须是演练,不能是代码 review):
① 真的 promote 一次,确认 stable 指针变了、beta 没变
② 真的回滚一次,确认秒级完成
★ 演练过的回滚才是能力,没演练过的只是代码
③ 构造「beta 连发 5 版把 stable 指向的版本挤出窗口」的场景
→ 确认那个版本没被删
④ 用一个大小写错的通道名装 → 必须硬失败,不能静默按 stable 装
(静默回落的形态是「用户以为自己在跑 beta」,而这个误解只会在
「beta 期没发现任何回归」时暴露 —— 那时归因已经做不了了)这一级最容易漏的:
🔴 建好了通道但没人用 beta。 那它就退化成「防线全在、调用全 0」(§9.8)。
验收判据不是「通道能用」,而是「至少有一次真实回归在 beta 期被发现」(§5.4.4)。 所以这一级完成之后,你自己要装 beta 用一段时间—— 这不是「顺便」,这是这一级的一部分。
🟠 忘了告诉用户「通道靠环境变量记住」。 beta 用户下次不带那个变量会静默回到稳定版。安装脚本装完 beta 要显式提示。
L5 · 反漂移与选测:从「不出错」到「不腐烂」
目标:让文档不骗人、让验证不慢到被跳过。三到五天。
这一级的两件事可以并行做,但有一个共同前提:L2 必须已经完成。 因为选测的补偿是「CI 合并前跑全量」(§4.5),没有 L2 就没有补偿。
5a · 反漂移(§6)
① 挑出「从源码能生成」的那几页文档(参考表、字段列表、枚举)
② 写生成器:优先运行时自省,不静态解析源码文本
③ 加一道 --check 模式:重新生成,与仓库里的对比,不一致退非 0
④ 挂 pre-commit,但只在数据源变动时触发判据:改一个数据源文件但不重新生成 → 提交必须被拒。 并且:确认它触发了(看到「正在检查」那行),不只是「没报错」(§9.4)。
5b · 选测(§4)
前提:L0 量出来的全量耗时值得优化(低于 20s 就别做,收益不够)。
① 先量目录约定命中率,决定用路径映射还是依赖图
② 写映射,处理三个边界:
· 强制全量清单(改了它任何测试都可能变的文件)
· 共享设施目录(回退整包)
· 无对应测试的源码目录(回退整包,绝不输出空集)
③ 拆成两个命令:查看判定 / 执行
④ 写选测自己的门禁:至少 5 条变异自证判据(选测的判据比一般门禁更严,因为它是元层面的):
① 给一个「明知影响某测试」的文件做假改动 → 选出的集合里必须有那个测试
② 纯文档改动 → 必须输出显式的「无需跑」,不是空集
③ 改一个无对应测试的源码目录 → 必须回退整包,不能是空集
④ 故意用错的命令形式(少个 ./)→ 断言输出的路径带前缀
⑤ 断言真实目录,不断言硬编码快照这一级最容易漏的:
🔴 做了本地选测但没有 PR 化。 那就是纯损失(§4.5):选测的覆盖面损失在,而补偿不存在。 选测与 PR 化必须成对。
🟠 用快照断言写选测门禁。 目录约定会漂移,快照断言在漂移时只说「快照不一致」, 诱导你更新快照(于是漏测被固化),而不是补映射(§4.6)。
L6 · 留痕与并行:从「一个人」到「多个 agent」
目标:多路并行时人还能保持方向控制权。持续。
做什么:
- 决策留痕(§7):目录形态 + 三段格式 + 只查形态的门禁
- 误删铁律(§8.2):写进项目约定文件,并在编排工具里做成硬约束
- 状态派生(§8.4):任何并行编排工具都不存可派生的状态
- 举证要求(§8.7):「这个失败与我无关」要三条证据
留痕门禁的边界要一开始就划对(§7.7):
✅ 查:路径形态 / 闭集 / 字段一致性 / 三段非空
❌ 不查:内容质量 / 论证是否充分 / 「该写没写」不查最后一项是刻意的能力边界。硬拦只会得到空洞的留痕或一路跳过—— 两种结果都比不拦更糟,而且两种都看起来像门禁在工作。
判据:
① 故意写一个非法目录名 → 必须被拒
② 故意让文件头状态与所在目录不一致 → 必须被拒
③ 故意留一段空的 → 必须被拒
④ 半年后的真判据:rejected/ 里是不是有东西
★ rejected/ 恒为 0 说明否决论证全在流失(§7.8)这一级最容易漏的:
🔴 只写
implemented/,不写rejected/。 而rejected/是最贵的那部分(§7.4)。
各级的成本与收益一览
| 级 | 时间 | 主要收益 | 什么时候可以先跳过 |
|---|---|---|---|
| L0 | 1 小时 | 后面所有决策的依据 | 永远不要跳过 |
| L1 | 半天–1天 | 明显的错进不了仓库 | 永远不要跳过 |
| L2 | 1 天 | 绕不过去的那一份 | 单人玩具项目可以晚做 |
| L3 | 2–3 天 | 产物可信、能对应到 commit | 还没有真实用户时可以晚做 |
| L4 | 1–2 天 | 秒级止血 | 用户少、能承受「重发一版」时可以晚做 |
| L5a | 2–3 天 | 文档不骗人 | 没有面向用户的参考文档时不需要 |
| L5b | 2–3 天 | 验证不被跳过 | 全量测试低于 20s 时不需要 |
| L6 | 持续 | 方向控制权 | 单人单路开发时不需要 |
🔑 一个重要的判断:L5b(选测)和 L4(灰度)都有「不需要做」的明确条件。 工程化不是越多越好——每一道机制都有维护成本, 而没人用的机制会腐烂成「看起来有保障」的假象(§9.8)。
三个「别做」的建议
① 别在 L1 之前追求覆盖率数字
覆盖率是个容易刷、也容易自欺的指标。先把「明显的错进不了仓库」建起来, 比把覆盖率从 60% 刷到 80% 有价值得多。
② 别在有存量违规时直接上严格门禁
先上告警模式,清零后再切严格(§6.7)。 直接上严格会让所有人的提交立刻全红,第一反应是关掉它。 一道让所有人立刻受阻的新门禁,寿命通常只有一天。
③ 别把「加门禁」当成默认解
新增一道门禁前问三个问题:
① 这件事失效过几次? (<3 次先用告诫)
② 机器能可靠判断它吗? (不能就交给 review,别硬塞)
③ 它的误报率会有多高? (会 100% 误报的门禁等于没有)三个问题里任何一个答不上来,就先别建。 本仓有两份 rejected/process 留痕,记的正是「这道门禁会被绕过,不如不建」。
本章自检
- L0 要量哪四个数?为什么「目录约定命中率」最容易被跳过但最能改变决策?
- 为什么 L3(发布)必须在 L4(灰度)之前?顺序反了会怎样?
- 为什么 L5b(选测)必须在 L2(CI)之后?
- 冒烟测试为什么是性价比最高的一步?为什么它「不可跳过」?
- L3 的真正验收判据是什么?(提示:不是「发布成功了」)
- L4 的判据为什么必须是演练,不能是代码 review?
- 什么情况下不需要做选测?什么情况下不需要做灰度?
- 新增一道门禁前要问哪三个问题?
第 5、6 题是这一章的核心:能力要被演练过才算存在(§5.5、§9.8 的同一条)。
§12 动手:给自己的项目搭一套
这一章和 §10 的区别:§10 讲做什么和什么顺序, 这一章讲你会亲手撞到哪些坑——所以它是按「阶段 + 会踩的坑」组织的。
建议真的做一遍。工程化这个领域,读一百遍不如自己被一次假绿骗过。
阶段 1 · 建第一道门禁,并且当场证明它有效(半天)
目标:不是「装上 lint」,是走完一次完整的「建 → 自证」循环。
# ① 装一个格式化器 + lint,跑一次全仓,作为独立提交进去
# ② 写一个 pre-commit,只查 staged 文件
# ③ ★ 立刻做变异自证第 ③ 步的具体做法:
# 变异自证三步
echo "const x=1;;;" >> src/foo.ts # 故意写错
git add src/foo.ts && git commit -m t # 必须被拒绝 ← 红了才算过
git checkout src/foo.ts # 改回来(这是你亲手改的,可以还原)你会撞到的坑:
| 坑 | 症状 | 修法 |
|---|---|---|
| hook 没装上 | 提交顺利通过 | hook 要 chmod +x;确认它在 .git/hooks/ 下 |
| hook 里跑全仓 lint | 每次提交等 3–5 秒 | 只传 staged 文件列表进去 |
| 失败没有传播到退出码 | 打印了错误但提交成功了 | 检查有没有 || echo;加 set -euo pipefail |
| 格式化器自动改了文件 | 提交内容和你看到的不一样 | 改成 --check 模式,只报错 |
🔑 第三条是这一阶段最重要的收获。你会发现「打印了错误」和「退出码非 0」 是两件事,而门禁只认后者。这个认知会在后面每一个阶段复用。
阶段 2 · CI + 汇聚门,并且亲手制造一次假绿(一天)
目标:故意把汇聚门写错,看到那个假绿,然后修它。 这一步不能跳——亲眼见过一次假绿的人,之后不会再忘记 if: always()。
# 第一版:故意漏掉 if: always()
all-checks-passed:
needs: [test, lint]
runs-on: ubuntu-latest
steps:
- run: echo "✅"然后:开一个 PR,让测试失败。
你会看到:test 红了,而 all-checks-passed 变成 skipped, PR 页面显示「允许合并」。
# 第二版:修好
all-checks-passed:
needs: [test, lint]
if: always() # ← 加上
steps:
- run: |
if [[ "${{ contains(needs.*.result, 'failure')
|| contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "::error::上游有失败"
exit 1
fi你会撞到的坑:
| 坑 | 症状 |
|---|---|
| skipped 被算作通过 | 上面那个,必须亲手见一次 |
| 判据写成「全部 == success」 | 现在没问题,等你给某个 job 加 if: 条件时全 PR 卡死 |
| 分支保护绑了具体 job 名 | 改 job 名之后静默失效 |
| 忘了在分支保护里勾「必需」 | 汇聚门跑了,但红了也能合 |
| 事件类型不全 | 堆叠 PR 改 base 时检查恒 pending |
这一阶段的验收:
① 让测试失败 → PR 不能合并 ← 验 if: always()
② 让测试通过 → PR 能合并 ← 验它不是恒红
③ 加一个新 job 但不加进 needs → 应该有断言拦住(下一阶段做)阶段 3 · 发布链路,并且验证「按 tag 能重建」(两到三天)
目标:能发出一个可追溯的版本。
顺序不能换(§10 L3):
洁净门禁 → 测试 → bump → 构建 → 冒烟 → 提交 → 打 tag → 上传这一阶段的真正验收,不是「发布成功了」:
# ★ 唯一有意义的验收
git checkout v<版本号>
<重新构建>
# 产物应该与发布的那个等价做不到就说明 tag 错位了——而发布日志上一切正常。
你会撞到的坑:
| 坑 | 症状 | 为什么会发生 |
|---|---|---|
| tag 打在 bump 之前的提交上 | tag 指向的版本号比 tag 低一位 | 顺序是「bump → 构建 → 提交 → tag」,人工补做 bump 提交时容易错位 |
| 判据「产物齐全」写成数文件个数 | 缺一个平台但检查通过 | 数个数是代理判据 |
| 逐个文件上传 | 中断后服务器上留下半成品版本目录 | 没做 staging + 原子切换 |
| 冒烟测试被跳过 | 发出去的产物根本不能启动 | 「反正测试都过了」这个念头 |
| 溯源判据按直觉写 | 100% 误拦每次真实发版 | 构建那一刻工作区必然是脏的(bump 刚改了版本号) |
最后一条要特别小心:如果你加了「产物必须来自干净工作区」这类检查, 它会拦住每一次正常发布,然后你会加参数绕过它, 然后它就永远不会拦住那个真正该拦的手工编译产物。
建议的最小溯源方案(如果时间有限):
把 commit hash 编进产物(构建时注入一个常量)
发布前断言:产物里的 commit == 当前 tag 的父提交两行代码,挡住「产物与 tag 对不上」这一整类问题。
阶段 4 · 灰度与回滚,并且演练一次(一到两天)
目标:把止血时间降到秒级,并且真的演练过。
① 服务器上放两个指针文件
② 发布只写 beta
③ promote = 只改 latest.txt 一行(不重新构建、不复制文件)
④ 写一个回滚脚本:不构建、不碰 git、不删东西
⑤ 旧版本清理豁免两个指针指向的版本你会撞到的坑:
| 坑 | 症状 |
|---|---|
| 给 beta 单独一套目录 | promote 要复制或重建,「泡制期测的就是要发的东西」当场消失 |
| 清理逻辑没豁免指针版本 | beta 连发几版后,稳定版用户 404,服务器零报错 |
| 未知通道值静默回落 | 用户以为在跑 beta,实际在跑 stable,而这个误解只在「beta 期没发现回归」时暴露 |
| 忘了提示「通道靠环境变量记住」 | beta 用户下次静默回到稳定版 |
| 建好了但自己不用 beta | 退化成「防线全在、调用全 0」 |
这一阶段的验收必须是演练,不是代码 review:
① 真的 promote 一次
② 真的回滚一次,掐表 ← 演练过的回滚才是能力
③ 构造「连发几版把 stable 指向的版本挤出保留窗口」的场景,确认它没被删
④ 用一个大小写错的通道名装 → 必须硬失败然后做一件事:把自己的日常使用切到 beta,用两周。 这不是「顺便」,这是这一阶段的一部分—— 否则你建的是一个没有观察者的观察期。
阶段 5 · 选测与反漂移(各两到三天,可选)
先判断要不要做:
全量测试 < 20s → 别做选测,收益不够
没有面向用户的参考文档 → 别做反漂移生成器如果要做,选测的第一件事是给选测本身写门禁(不是最后一件)。 因为选测错了,它下面所有测试的结论都不可信。
你会撞到的坑:
| 坑 | 症状 | 危害等级 |
|---|---|---|
| 输出空集然后全绿 | 改了代码,零测试运行,退出码 0 | 🔴 最高 |
路径少一个 ./ 前缀 | 实际跑了全量,表现为「又慢又没省」→ 误判方案无效 | 🔴 会让你放弃正确方案 |
| 选出不存在的路径 | 正常改动红在无关的地方 | 🟠 |
| 用快照断言 | 目录漂移时诱导你更新快照,漏测被固化 | 🟠 |
| 基线没同步 | 选测范围静默变错 | 🟠 判不准时必须报错,不能猜 |
反漂移那边的坑:
| 坑 | 症状 |
|---|---|
| 标记用前缀匹配 | 页面里「请勿手工编辑」的提示语字面写着标记的样子 → 正文被吃掉 |
| 扫描目录清单漏一个 | 统计静默归零,整段内容被删掉 |
| 触发锚点写死旧路径 | 重构后对账静默不再触发 |
| 给 git 历史类产物立一致性门禁 | 每次提交都红 → 被绕过 |
阶段总览:你会亲手撞到的坑(按危害排序)
如果只记五个,记这五个——它们都会给出反向结论:
| # | 坑 | 反向结论是什么 |
|---|---|---|
| 1 | skipped 算作通过 | 「检查通过了」← 实际是测试失败了 |
| 2 | 选测输出空集 | 「验证过了」← 实际零测试运行 |
| 3 | 断言读到了注释里的字样 | 「门禁有效」← 实际删掉被守的代码也全绿 |
| 4 | 路径少一个前缀 | 「选测方案无效」← 实际是命令写错一个字符 |
| 5 | 门禁读旧字节 | 「这个修法不对」← 实际改对了,门禁没读到 |
共同点:它们不是「让你少了一层保障」,是让你得出一个与事实相反的判断, 然后据此做决策。这就是为什么 §3(变异自证)在本文里的地位那么高—— 它是唯一能一次性验证「它跑了 / 读对了 / 判对了 / 失败会传播」这四件事的手段。
附录 A · 三十秒自检清单
拿这张表核一个项目(或者核自己的方案),三十秒能问出成熟度。
门禁层
- [ ] 每道门禁做过变异自证吗?还是只验过 happy path?
- [ ] 变异自证的验收是「红的是那一条」,还是「有东西红了」?
- [ ] 有没有一道门禁从来没红过?(那是疑点)
- [ ] pre-commit 的实际耗时是多少?有没有超过 2 秒?
- [ ] 最近 20 次提交里有几次带了跳过参数?
- [ ] 分支保护绑的是具体 job 名,还是一个汇聚门?
- [ ] 汇聚门有
if: always()吗?判据是「不含 failure」还是「全部 success」? - [ ] 加一个新 job 时,有断言提醒你同步
needs吗?
判据层
- [ ] 有哪些判据写成了「不等于坏值」?(应该改成「等于好值」)
- [ ] 有哪些判据用的是代理量(数个数、看副作用)?
- [ ] 有哪些断言读的是文件全文?(会被注释骗过)
- [ ] 门禁的触发锚点是硬编码路径吗?重构过之后验证过它还触发吗?
发布层
- [ ]
git checkout <tag>然后重新构建,产物等价吗? - [ ] 冒烟测试有吗?能被跳过吗?
- [ ] 上传是原子的吗?中断一次会留下半成品吗?
- [ ] 发布和 promote 是两个动作吗?promote 会重新构建吗?
- [ ] 演练过回滚吗? 止血时间是多少?
- [ ] 旧版本清理豁免指针指向的版本吗?
文档层
- [ ] 参考类文档是生成的还是手写的?
- [ ] 有对账门禁吗?确认过它触发(不只是「没报错」)吗?
- [ ] 文档里的数字有时间戳吗?读者能看出新鲜度吗?
- [ ] 有没有给「源不稳定」的东西立了一致性门禁?(每次都红那种)
协作层
- [ ] 决策留痕有固定格式吗?第三段写的是命令输出还是「机理讲得通」?
- [ ]
rejected/里有东西吗?(恒为 0 说明否决论证在流失) - [ ] 留痕门禁拦「该写没写」吗?(不该拦)
- [ ] 有「不许用不可逆命令清理工作区」这条规范吗?
- [ ] 并行编排工具存状态快照吗?(应该现查)
度量层
- [ ] 安全类指标用的是事故数吗?(应该换成防线触发率)
- [ ] 防线触发率的分母限定在相关任务上吗?
- [ ] 有没有哪道防线的真实触发次数是 0?
附录 B · 术语速查(中英对照)
门禁与检查
| 英文 | 中文 | 一句话 |
|---|---|---|
| gate | 门禁 | 不满足就物理上阻止你往下走 |
| aggregate gate | 汇聚门 | 自己不干活,只收集别人结论的检查 |
| required check | 必需检查 | 分支保护里「必须绿才能合并」的那些 |
| branch protection / ruleset | 分支保护 | 平台侧强制的规则 |
| pre-commit / pre-push | 提交前 / 推送前钩子 | 本地,可被跳过 |
| strict check | 严格检查 | 分支必须与主干最新才能合并 |
| merge queue | 合并队列 | 平台排队、逐个用最新主干重测 |
| mutation self-proof | 变异自证 | 故意改坏被守的东西,门禁必须红 |
| false green | 假绿 | 东西坏了但门禁绿 |
| false red | 假红 | 东西好的但门禁红 |
测试与选测
| 英文 | 中文 | 一句话 |
|---|---|---|
| affected tests | 选择性测试 | 只跑可能被影响的那部分 |
| force-full | 强制全量 | 命中就退回全量的文件清单 |
| escape hatch | 逃逸阀 | 判不准时的兜底出口 |
| flaky | 不稳定用例 | 有时过有时不过、代码没变 |
| VCR / record-replay | 录制回放 | 把外部响应录下来固定住,让测试确定 |
构建与发布
| 英文 | 中文 | 一句话 |
|---|---|---|
| target / triple | 构建目标 | 平台-架构 组合 |
| baseline build | 基线构建 | 不用新 CPU 指令集,兼容老机器与模拟器 |
| smoke test | 冒烟测试 | 跑一下 --version,确认能启动 |
| provenance | 构建溯源 | 「这个二进制来自哪个 commit」的可验证记录 |
| staging + atomic mv | 暂存 + 原子切换 | 全部就位后一次性生效,不存在中间态 |
| channel | 发布通道 | beta(抢先)/ stable(稳定) |
| pointer file | 指针文件 | 服务器上写一行版本号,决定用户装到哪版 |
| promote | 促升 | 把 beta 泡过的版本升成 stable |
| bake time | 泡制期 | 在 beta 待着等真实使用暴露问题的那段时间 |
| rollback | 回滚 | 把指针指回旧版本 |
协作与留痕
| 英文 | 中文 | 一句话 |
|---|---|---|
| ADR / decision record | 架构决策记录 | 决定了什么 / 放弃了什么 / 怎么证明生效 |
| squash merge | 压缩合并 | 把 PR 多个提交压成一个 —— 注意它让「分支是主干祖先」这个判据失效 |
| stacked PR | 堆叠 PR | PR B 的基线指向 PR A 的分支 |
| worktree | 工作树 | 同一仓库多份 checkout,各在不同分支 |
| semantic conflict | 语义冲突 | 各自绿、合起来红 |
口径与方法论
| 英文 | 中文 | 一句话 |
|---|---|---|
| proxy metric | 代理指标 | 用一个近似量替代真正关心的量 —— 会奖励「把浪费重新贴标签」 |
| stock vs flow | 存量 vs 流量 | 末次快照值回答不了「现在怎样」 |
| derive, don't store | 状态派生,不维护 | 能现查的绝不存 |
| single source of truth | 唯一事实源 | 同一份判定只有一处实现 |
| escape hatch abuse | 绕过 | 门禁贵到一定程度就会被绕过,而绕过不留痕迹 |
附录 C · 一份可参照的实测数字(本仓,2026-09 核)
引用这些数字时请标注它们是某个具体项目在某个时间点的实测值, 不要当成普适基准——它们的价值在于「量级」和「怎么量」,不在于具体数值。
| 维度 | 实测值 |
|---|---|
| 自动化脚本 | 103 个 / 31,200 行(.ts 89 / .sh 14) |
| CI workflow | 6 个(1 个是门禁,5 个是定时/报告) |
| CI 必需检查 | 1 个(汇聚门),背后 2 个 job × 双平台矩阵 |
| git hook | 2 个(pre-commit 8 项 / pre-push 4 项) |
| 全量测试 | 127.5s(早期未优化时 202.87s) |
| 选择性测试 | 0.19s – 14.5s |
| 目录约定命中率 | 33/36 ≈ 92%(决定了选测用路径映射) |
| 构建目标 | 5 个(含 1 个基线档) |
| 基线档体积代价 | −0.9M(104.7M vs 105.6M,反直觉) |
| 决策留痕 | 77 份(implemented 62 / rejected 8 / proposed 5) |
| 留痕类别分布 | bug-fix 30 / testing 19 / process 8 / feature 5 |
| 从源码生成的参考页 | 6 页 + 1 份机器可读索引 |
| 叙述覆盖度缺口 | 62 个命令里 21 个只在参考表、没进指南页 |
| 工作树磁盘占用 | 275–413M / 路 |
几个数字的解读方式(比数字本身重要):
- 103 个脚本不是「工程化程度」的度量,它只说明自动化的表面积。 真正的度量是「其中几个有变异自证」。
- 6 个 workflow 但只有 1 个必需检查是刻意的(§2.4): 分支保护与 CI 内部结构解耦。
- 77 份留痕里 rejected 只有 8 份,占 10%。 这个比例值得关注——如果是 0,说明否决论证全在流失(§7.8)。
- 21/62 的叙述覆盖度缺口说明反漂移只解决了「参考表不骗人」, 没解决「用户能不能发现这个能力」(§6.7)。这是一个诚实标注的缺口,不是成绩。
最后:这份文档想让你记住的三件事
① 门禁的「绿」是一个推论,不是一个观测。
它依赖七个前提:它真的跑了 / 读对了输入 / 判据检查的是那件事 / 失败会传播到退出码 / 它没被跳过 / 检查形态与真实一致 / 真实条件下会被触发。 任何一个断掉,结论就不成立,而退出码仍然是 0。
所以工程化的核心动作不是「加门禁」,是**「证明门禁在工作」**——变异自证。 而验收标准是「红的是哪一条」,不是「有东西红了」。
② 每一道机制都要说清它牺牲了什么,以及补偿的前提条件。
选测拿「更安全」换「更快」,补偿是 CI 合并前跑全量, 而补偿的前提是改动会经过 PR——所以选测与 PR 化必须成对。
这个结构(牺牲 → 补偿 → 补偿的前提 → 由此得出的约束)可以套用在任何工程决策上。 大多数人只讲收益,少数人讲代价,很少有人讲补偿的前提。
③ 能力要被用过才算存在。
- 回滚脚本没演练过 → 只是代码
- beta 通道没人装 → 只是一个没有观察者的观察期
- 防线单测全绿但真实调用为 0 → 只是死功能
- 门禁没做过变异自证 → 只是一个绿色的勾
所以新增任何机制的验收判据,都不是「构建过 + 单测过」, 而是**「在真实条件下被触发过」**。
这三件事有一个共同的形状:它们都在区分「看起来有」和「真的有」。 工程化这个领域里,绝大多数失效不是「东西坏了」, 而是**「东西看起来在,实际不在,而且没有任何信号告诉你」**。
认出这个形状,比记住任何一条具体做法都重要。