Invariant 目录文档化:CI 怎样逼每个包自带守卫
快照测试只能证明录过的场景没坏。系统级的"永远成立"要靠每个包自己的 invariant companion——DSH 用 CI gate 强制:没有 src/invariant.ts 的包必须在 README 里写清"为什么不发 companion",否则 build 直接红。这一章讲这套守卫目录是怎么被工程化强制执行的。
Written in Chinese
第 0 秒,你给某个包新增了一条事件类型,随手就提交了。CI 跑到一半突然红了:packages/foo/src/invariant.ts:12 — companion exports missing。你懵了——我只是加了个事件,为什么要写 invariant?
答案是:这个系统的正确性假设,分散在每一个包自己的守卫里。 你改了 session 包的事件流,session companion 里那些”seq 必须递增""tool/result 必须有先验 tool/call""turn 不能在开放 turn 外嵌套”的检查就必须覆盖到新事件——否则这条路径就是一个没有守卫的缺口。DSH 不接受”等出了 bug 再补测试”,它在 build 期就问你:你这个包,自己的守卫在哪?
这一章接第 53 章:快照测试证明录过的场景没坏;这一章讲没录到的路径靠什么兜底。
四道门:一个包到底要满足什么
scripts/package-invariants.ts 是 CI gate 的核心。它对每个 workspace 包跑四道检查:
| 道门 | 有 companion 的包 | 无 companion 的包 |
|---|---|---|
| checkManifest | exports[”./invariant”] 必须指向 ./lib/types/invariant.d.ts,files 必须含 lib/invariant.js | exports 必须省略 ./invariant,files 必须省略 lib/invariant.js |
| checkBuild | tsconfig 必须引用 runtime-diagnostics/invariants,tsdown 必须打包 lib/types/invariant.js | 同上省略项 |
| checkSource | src/invariant.ts 必须 register 自己的包名、有命名导出 name/inject/apply、不得 default-export、不得标 @generated | 跳过 |
| checkOmissionReason | 跳过 | README 必须含法定省略理由句式 |
这套检查的哲学是”对称”:发 companion 和不发 companion 都是一种表态,两种表态都要留痕。 不允许存在一个包——它既没有守卫,也没有说为什么没有守卫。
为什么省略理由要卡死句式? OMITTED_COMPANION_REASON 是一个正则,要求句子形如”No … companion is published because…”。你随便写一句”本包无 invariant”是过不了的。因为 README 里一句话的成本太低——工程上要的不是”有句话”,而是”这句话可被机器审计”。正则把人类语言收成了一个可校验的结构:省略必须显式归因,不许沉默。沉默意味着”没人想过这个包该不该有守卫”——而这正是 bug 的来源。
本节源码依据(5 处)
InvariantRegistry:运行时的守卫注册处
build 期只是把”该有的守卫”摆上货架。运行时由 InvariantRegistry 服务接管。它的核心行为:
register 即占名。 每个包的 companion 调用 register(packageName, installer) 声明”这个包的守卫归我管”。重名直接抛错——invariants: package "…" is already registered。包名在这里不是标签,是互斥锁。
install 跑在独立 child fiber。 注册成功后,installer 被送进一个子 fiber 执行——把这个包的检查挂到对应的事件钩子里。子 fiber 隔离的意义是:一个包的守卫安装失败,不会连带把整个 cordis 上下文拖垮。
违例必带包名。 检查失败抛 InvariantError,code 固定 'INVARIANT',消息格式钉死为 invariant violated by "${packageName}": ${message}。这不是一个匿名 throw new Error("broken")——它永远告诉你是哪个包的合同被违反了。
| 机制 | 语义 | 为什么重要 |
|---|---|---|
| register 占名 | 同包重复注册即抛错 | 防止两个包抢同一份守卫的责任 |
| child fiber install | 安装隔离 | 单包失败不炸全局 |
| InvariantError(code=INVARIANT) | 包可归因 | 日志里一眼定位责任包 |
| allowlist/blocklist | 正则过滤注册 | 调试时可临时关掉某包检查 |
本节源码依据(5 处)
session companion 长什么样:一本关系记账本
看一个真实的守卫——@deepseek-ai/dsh-session 的 companion。它的注释说”Package-owned relational invariants for the session event log”。它在每会话上挂一个 SessionTrace 记账本:
lastSeq 上一条事件的 seq 游标
openTurn 当前开放中的 turn 序号(null = 无)
openStep 当前开放中的 step 序号
nextTurn 下一个允许的 turn 号
nextStep 下一个允许的 step 号
pendingCalls 已发未收的 tool call id 集合
这六个字段就是 session 事件日志的”关系不变量”的全部。每 append 一条事件,记账本就对照检查:seq 是不是严格递增?tool/result 到来时 pendingCalls 里有没有对应的 call?turn 结束时 openTurn 是不是已经归零?不变量不是抽象原则,是这本账上每一笔借贷必须平。
companion 的契约也很薄:命名导出 name = 'session-invariant'、inject = ['invariants']——只依赖守卫服务本身,不反向耦合业务。这就是为什么它能被 CI 强制:一个包的守卫就是这么一个小文件,写它的成本低到”不写才需要理由”。
为什么 companion 要显式 inject [‘invariants’]? 因为守卫服务必须先于业务检查就位。这个依赖方向是单向的:业务包依赖守卫服务注册自己的检查,守卫服务不认识任何具体业务。方向反了,守卫就会变成被业务牵来牵去的摆设——所以 companion 的 inject 只朝一个方向指。
本节源码依据(4 处)
runtime-closure 与 doc gate:build 期的最后兜底
守卫目录之外,还有两道 build 期闸门。
verify-runtime-closure.ts 解决一个部署期才会暴露的问题:自动 peer 安装关掉之后,部署 manifest 只要漏了某个 preset 引用的插件、或漏了某个必需的 workspace peer,cordis 加载插件时才会炸——那太晚了。这个脚本在 build 期就把部署 manifest 与 packages/bundle/web-app/presets/*.patch.yml 全部 preset 对一遍:manifest 必须引用每个 preset 提到的插件、必须包含每个必需的 workspace peer。
docSyncLeafGates 是 run-gates.ts 里的文档同步 gate 群:doc-typecheck(文档类型检查)、docs-site-build(文档构建)、verify-doc-graphs(文档图)、verify-md-links(markdown 链接)、verify-type-equiv(类型等价)、verify-cordis-catalog(cordis 目录)。这一群 gate 保证文档与代码不漂移——正是本书自己要遵守的纪律。
| gate | 拦什么 |
|---|---|
| package-invariants | 该有的包级守卫缺失或没写省略理由 |
| runtime-closure | 部署 manifest 漏插件 / 漏必需 peer |
| docSyncLeafGates | 文档类型、链接、图、目录与代码漂移 |
为什么这么多 gate 而不是一个大检查? 因为每个 gate 对应一类”漂移”,且各自可以独立失败、独立定位。把所有检查揉成一个脚本,报错就没人看得懂了。分层小 gate 是工程上让错误可归因的另一重保险。
还有一层更实际的原因:这些 gate 跑在 CI 的不同阶段,有的快(verify-md-links、verify-type-equiv 标了 quick),有的慢(docs-site-build、doc-typecheck)。docSyncLeafGates 的注释说”Stable FIFO starts the longest leaves first”——长任务先起跑,短任务收尾,把 wall-clock 压到最短。可归因和跑得快,是同一套分层 gate 的两个收益。
本节源码依据(4 处)
集中 vs 分散:为什么守卫必须跟着包走
一个自然的反问:既然 invariant 这么重要,为什么不把所有检查集中到一个 check-all.ts 里?DSH 的选择是分散到每个包自己的 companion——这不是组织偏好,是正确性需要。
集中守卫的根本缺陷是知识断层:session 包的作者最懂”什么序列是合法的”,让他把这份知识写成一份外部脚本,等于让他对着一份他不再拥有、不再维护的代码做断言。代码改了,外部脚本往往第一个过期。分散守卫让守卫与被守卫的代码住在同一个包里——同一次提交、同一次 review、同一个 git blame。当你改 session 事件流时,必须同时打开的那个文件就在同目录下。
| 维度 | 集中守卫 | 分散 companion |
|---|---|---|
| 谁知道合同 | 外部脚本作者(靠文档猜) | 包作者(就在代码旁) |
| 代码漂移时 | 守卫静默过期,无人察觉 | 同包同提交,改代码必看到守卫 |
| 违例归因 | 报一个全局 check-all 红 | InvariantError 直接点名包 |
| 加新包 | 忘记注册就没守卫 | 不写 companion 即 CI 红 |
集中守卫能做对的前提,是有人持续把知识从包搬运到集中点。 工程上这条搬运链从来都不成立——所以分散不是为了优雅,是为了让守卫的”该存在”变成 build 期可验证的硬约束。这也解释了 package-invariants.ts 为什么宁可查 README 句式这种看似机械的东西:它的全部目的,就是消灭”这个包该不该有守卫”这个讨论——要么写出来,要么写清为什么不写,不允许第三种状态。
本节源码依据(2 处)
小结
- 守卫是包级合同,不是 debug assert:CI gate 强制每个 workspace 包要么发 invariant companion、要么 README 写法定省略理由。
- 四道门对称检查:发 companion 查 exports/files/tsconfig/tsdown 四件套;不发查 README 句式。
- 运行时可归因:register 占名、child fiber 隔离 install、InvariantError 必带包名与 INVARIANT code。
- session companion 是一本账:SessionTrace 六字段撑起 seq/turn/step/pendingCalls 全部关系检查。
- build 期兜底:runtime-closure 防 manifest 缺插件缺 peer,docSyncLeafGates 防文档与代码漂移。
下一章:守卫全部就位之后,系统怎么分层设防——第 55 章讲 Trust/Permission/Sandbox/Network 四层安全模型。
只读验证
repo="${DSH_SOURCE_DIR:?set DSH_SOURCE_DIR to the official fixed checkout}"
git -C "$repo" rev-parse HEAD # 应为 477b4f420553e8a52c2fbccc464d7561b239c443
grep -n "OMITTED_COMPANION_REASON" "$repo/scripts/package-invariants.ts"
grep -n "already registered" "$repo/packages/runtime-diagnostics/invariants/src/index.ts"
grep -rn "openTurn" "$repo/packages/core/session/src/invariant.ts" | head