深入浅出 DeepSeek Harness 第八部:证明与守卫——怎样知道系统真的成立 第 54 章

Invariant 目录文档化:CI 怎样逼每个包自带守卫

快照测试只能证明录过的场景没坏。系统级的"永远成立"要靠每个包自己的 invariant companion——DSH 用 CI gate 强制:没有 src/invariant.ts 的包必须在 README 里写清"为什么不发 companion",否则 build 直接红。这一章讲这套守卫目录是怎么被工程化强制执行的。

本章以中文撰写

源码版本
dsh-v0.1.7-rc.2
验证日期
Commit
477b4f420553e8a52c2fbccc464d7561b239c443

第 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 章:快照测试证明录过的场景没坏;这一章讲没录到的路径靠什么兜底。

invariant 守卫体系:左侧四道门(checkManifest/checkBuild/checkSource/checkOmissionReason)扫描 packages//;中间 InvariantRegistry 服务按包名 register 占名、child fiber 隔离 install;违例抛 InvariantError 带包名;右侧 session companion 的 SessionTrace 记账本校验 seq/turn/step/pendingCalls 关系;底部 runtime-closure 与 docSyncLeafGates 在 build 期兜底。

四道门:一个包到底要满足什么

scripts/package-invariants.ts 是 CI gate 的核心。它对每个 workspace 包跑四道检查:

道门有 companion 的包无 companion 的包
checkManifestexports[”./invariant”] 必须指向 ./lib/types/invariant.d.ts,files 必须含 lib/invariant.jsexports 必须省略 ./invariant,files 必须省略 lib/invariant.js
checkBuildtsconfig 必须引用 runtime-diagnostics/invariants,tsdown 必须打包 lib/types/invariant.js同上省略项
checkSourcesrc/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