跳到主要内容

内容阅读

文档即代码:dsh 用脚本生成图、目录和校验门禁

文档即代码:dsh 用脚本生成图、目录和校验门禁

dsh 把文档当成构建产物:12 个 gen-* 脚本从 TypeScript 源码生成配置目录、服务 API 参考、工具 schema、依赖图和 mermaid 图,39 个 verify-* 脚本在 CI 里断言这些产物和源码一致,改了代码没重新生成文档,门禁就挂。最细的一层是 type-equiv,文档里粘贴的类型声明逐符号和源码比对,JSDoc 一条不能少。这套体系的回报是文档在任何时刻都值得信任,代价是 123 个脚本入口的初始投入和持续维护。 结构上最值得抄走的一条规律:每个生成物都配一个同名的 freshness 校验,gen-config-catalog 对 verify-config-catalog,gen-tool-catalog 对 verify-tool-catalog。生成和校验成对出现,文档就无法静默过期。

漂移的文档比没有文档更糟

软件项目最常见的文档问题不是没有文档,是漂移。文档写的时候是对的,代码改了之后没人跟着改,文档就慢慢变成一份精心排版的谎言。读者不知道它过期了,照着配置,照着调 API,浪费一个小时才发现字段早改名了。

漂移的伤害比空白大,因为空白至少诚实。一个空README 告诉读者"这里没有信息,去看源码",一份漂移的 README 告诉读者一个错误答案。对开源项目这尤其致命:外部贡献者没有别的信息源,文档是他们和项目之间唯一的接口,接口说谎,生态就烂。

让人肉维护一致性在小型项目里勉强可行,在一个两百多个包的 monorepo 里必然失守。配置字段散落在几十个包的 Config 接口里,服务签名散落在五十多个 ctx 服务上,工具 schema、事件声明、模块依赖各是一个维度。任何一个维度的变更都要有人记得"哪些文档引用了它",这个记忆负担随包数量超线性增长,最终结果是开发者要么不写文档,要么写了不管。

dsh 的解法是把"文档和源码一致"从人的责任变成机器的责任。文档要么从源码生成,要么被源码校验,两者对不上就是 CI 失败,和类型错误同一级别的事故。这个思路叫 docs-as-code,关键不在名字,在把一致性问题转换成了工程问题:人负责写对源码和写好生成规则,机器负责无穷无尽的核对。

这笔账算的是时态。手写文档在写下那一刻是现在时,之后每一步都往过去时滑,而且没有任何机制标记它滑到了什么程度。生成加校验的文档永远是现在时,因为校验每次运行都重新问一遍"你还成立吗"。两者的差距不是质量差距,是时间意义上的差距:一个在腐烂,一个不会。

先盘家底:这套系统到底有多大

数字以仓库根 package.json 的 scripts 段为准,这是命令入口的权威清单。当前一共 123 个脚本入口,其中 gen-* 开头的 12 个,verify-* 开头的 39 个。

12 个生成脚本各有明确产出。gen-config-catalog 生成配置目录,gen-cordis-catalog 生成 Cordis 服务目录,gen-cordis-api 生成框架级 API 参考,gen-cordis-inspect-catalog 生成检查类服务目录,gen-tool-catalog 生成模型可见工具的 schema 目录,gen-client-catalog 生成浏览器端 slot 目录,gen-doc-graphs 生成文档里的 mermaid 图,gen-module-graph 生成模块依赖图,gen-persistence-catalog 生成持久化目录,gen-scoped-events 生成 scoped 事件参考,gen-third-party-notices 生成第三方许可证清单,gen-translation-brief 服务于翻译流程。

39 个校验脚本的管辖范围比生成脚本宽得多,因为校验的对象不只是生成物。后面单拆。

值得先看一眼的是 gen 和 verify 的命名对应:12 个生成脚本几乎每个都有一个同名 verify 兄弟,verify-config-catalog、verify-cordis-catalog、verify-cordis-api、verify-tool-catalog、verify-client-catalog、verify-doc-graphs、verify-module-graph、verify-persistence-catalog、verify-scoped-events、verify-third-party-notices。这不是巧合,是这套系统的结构基因。

生成和校验成对,文档才无法静默过期

单有生成脚本不够。生成脚本要人主动跑,忘了跑,文档就停在旧版本,和手写文档一样漂移,只是漂移得整齐一点。单有校验脚本也不够,它只能报错说"文档过期了",修复还得人肉重新粘贴,粘贴又可能出错。

两个合起来才闭环:gen 负责产出正确的新版本,verify 负责在每次 CI 运行时断言磁盘上的文档等于重新生成会得到的版本。你改了 Config 接口没跑 gen-config-catalog,verify-config-catalog 在 CI 里失败,失败信息告诉你跑哪个命令修复。修文档的动作从"记得改"变成"被门禁押着改"。

这个配对把文档的状态空间压缩到两个合法点:文档和源码一致,或者 CI 红了。不存在第三种"文档过期但没人知道"的状态,而第三种状态正是传统文档的默认态。

配对的另一半价值在 review。一个 PR 改了服务签名,reviewer 不需要核对文档里那几十处引用有没有漏,CI 已经核对了。人看逻辑对不对,机器看一致不一致,review 的注意力分配被这套分工重排了。

开发者实际经历的修复循环短得值得走一遍。改了一个 Config 接口,push,CI 在 verify-config-catalog 上红,失败信息指向过期的目录文档。本地跑 pnpm run gen-config-catalog,重新生成的目录出现在 diff 里,连同你的接口改动一起再审一遍,提交,绿。整个循环没有一步需要记"哪些文档受影响",因为答案永远是"跑那个 verify 告诉你的 gen"。当修复路径短到这个程度,门禁就从阻碍变成了导航。

这套配对还有一个容易被忽略的受益者:模型本身。gen-tool-catalog 产出的工具 schema 目录,人类读者拿它了解这个 harness 暴露了哪些工具,同时它也是模型可见工具面的一面镜子,文档和模型的词表来自同一份源码。在 agent harness 里文档的消费者是人和模型两个,生成式文档保证两个读者看到的是同一个事实。

生成脚本怎么工作

生成脚本的输入是 TypeScript 源码,手段是 AST 遍历或运行时反射,输出是 Markdown 或 JSON。拿 gen-config-catalog 说完整链路。

你在某个 LLM 包的 Config 接口里加了一个字段,比如一个流空闲超时的毫秒数。跑 pnpm run gen-config-catalog,脚本用 TypeScript parser 遍历所有可加载包的 config 声明,提取接口定义和 JSDoc 注释,写到 docs/config-catalog.md 对应包的条目下。JSDoc 原样保留,你写在字段上的说明就是读者在目录里看到的说明,一份注释两处生效。

这个脚本还做交叉校验:runtime Schemastery schema 里的每个 key,包括嵌套的,都必须能在声明的 config 类型上定位到。这防的是反向漂移:schema 接受一个字段但类型声明里没有,意味着代码行为和类型契约分叉了。生成器在这里兼职了一个类型层面的守卫。

产出文件顶部有机器可读的标记,内容是 Generated by gen-config-catalog,不要手编,跑某命令可重新生成。这个标记干两件事:告诉人类读者别手改这个文件,告诉 CI 和工具怎么刷新它。手编一个生成文件不会立刻爆炸,但下次生成时改动会被无条件覆盖,这个预期在文件第一行就立好了。

其他生成脚本同构。gen-cordis-catalog 把每个 ctx 服务的方法签名、事件声明、JSDoc 产出到各子系统文档的 Cordis API 区域,一个包改了方法签名,重新生成后文档自动更新。gen-tool-catalog 把每个工具的 name、description、参数 schema 汇成目录,模型可见的工具面在文档里有一个权威清单。gen-doc-graphs 生成文档里用的 mermaid 架构图和流程图,图和代码一起变更,不存在"图是三个月前的设计"这种状态。gen-third-party-notices 从依赖的 package.json 自动收集许可证信息,法务合规不再依赖人肉登记。

依赖方向的文档交给 gen-module-graph。包之间的依赖关系是架构约束的载体,谁依赖谁、方向对不对,直接决定重构能不能做。手画的依赖图在第二个包调整位置时就开始撒谎,生成的图永远和 workspace 声明同步,架构讨论从此站在同一个事实上。持久化目录和 scoped 事件参考同理,都是"事实在源码里、读者在文档侧"的维度,凡是这种结构的都适合生成。

type-equiv:把精细度推到单个类型声明

目录和图是粗粒度的同步,子系统文档里还有大量细粒度的类型声明粘贴,这是 type-equiv 管的层。

做法是文档里的声明用 ts type-equiv 围栏标记,并注册在 scripts/type-equiv.manifest.json 里。manifest 每个条目三个键:doc 指向文档路径,比如某子系统文档;symbol 是要提取的符号名,比如会话事件的联合类型;source 是源码路径,比如核心包的 types 文件。还有一个可选的 projection 键,取 public-api 时配合 ts public-api 围栏,给类做公开 API 投影:保留 public 字段、构造器、accessor、方法和 JSDoc,省略实现体和私有成员。

pnpm run verify-type-equiv 用 TypeScript parser 从 source 提取 symbol 的声明和 JSDoc,断言 doc 里围栏内容匹配。比较忽略空白和非 JSDoc 注释,但要求每条原始 JSDoc 注释都在。也就是说粘贴可以格式不同,语义必须逐字相同。

这意味着你改了某个事件类型的一个字段,文档里的粘贴必须同步更新,否则门禁失败。文档里的类型定义和源码是逐符号的 1:1 对应,对应关系本身登记在 manifest 里,可审计。

双语文档有个附加规则:中文版 .zh.md 里的配对块,只有和英文兄弟字节相同且顺序一致时才复用同一个 manifest 条目。字节相同才复用,等于说类型围栏不允许翻译,代码就是代码。这条规则把"翻译改动意外破坏类型对应"的路也堵上了。

用一个具体场景看这层校验的日常样子。会话事件是一个联合类型,新增一种事件变体是 harness 开发的常规操作。改完类型,子系统文档里粘贴的那个围栏立刻过期,verify-type-equiv 用 parser 从源码提取最新声明和全部 JSDoc,与围栏内容逐项比对,新变体在文档里缺失,比对不等,CI 红。你把新声明粘进围栏,注意把新变体的 JSDoc 注释也带上,因为比对要求每条原始注释都在,漏一条照样红。绿了之后这个变体在文档里的存在就有了和源码同级的保证,下一个读文档的人不会拿着旧的事件清单写消费逻辑。

配套的 doc-typecheck 管编译:普通 ts 围栏要能编译通过,type-equiv 和 public-api 两种围栏跳过编译,因为它们是声明片段不是独立程序。文档里的示例代码由此也是被测的,写错的示例在 CI 红掉而不是在读者手里红掉。

verify 的管辖范围比 Markdown 宽

39 个校验脚本里,跟生成物配对的大约占三分之一,剩下的管的是"文档性资产"的各个维度。这些资产不都长得像文档,但都承担文档的职责:告诉人或工具某件事的事实。

README 有必需章节的硬要求。verify-package-readme-limitations 检查每个包的 README 包含 Known Limitations 章节,verify-package-readme-model-experience 检查 Model Experience 章节。已知缺陷必须写出来,模型使用体验必须写出来,这两个维度的缺失是门禁失败而不只是风格建议。一个包声称能干什么由代码决定,它不擅长什么由 README 的这个章节决定,后者对选型用户的价值常常更高。

导出 API 必须有 JSDoc,由 verify-export-jsdoc 管。每个包必须有 invariant companion 文档,由 verify-package-invariants 管,把"这个包承诺什么不变"从口头约定变成文件存在性检查。

文档自身的卫生也有专门脚本。verify-md-links 和 verify-md-wrap 管内部链接有效和换行格式,verify-mermaid 管图能渲染,verify-doc-refs 管交叉引用,verify-doc-budgets 配合 manifest 管文档长度预算,过长的文档是信号,要么该拆要么该删,signal 是被量化的。

预算的执行方式值得一提。脚本目录里的 doc-budgets.manifest.json 把每份文档的长度上限登记成数据,verify 拿实际行数或字数去比。这比口头约定"文档别写太长"强在两点:上限是显式声明的数字,扩预算是一次显式的 manifest 变更,会出现在 review 里;超限是 CI 失败而不是埋在意见里,作者没有"先合了以后再删"的选项。文档膨胀在多数项目里是不可逆的熵增,在这里被一个 JSON 文件顶住了。

流程性资产同样在管辖内。verify-agent-note-format 和 verify-agent-note-classification 管设计笔记的格式和归类,verify-archived-agent-notes 管归档笔记,verify-translation-pairing 和 verify-translation-prompt 管翻译配对流程。甚至仓库结构本身也有人管:verify-package-paths、verify-vendored-links、verify-public-repository-links、verify-runtime-closure,各自盯着一类结构不变量。

这份清单的启示不在数量,在边界:docs-as-code 里的"文档"应按职责定义而不是按文件格式定义。凡是"陈述事实、会被读者依赖"的资产,都值得一个 verify;凡是没人核对的陈述,迟早漂移。

结构与发布维度还有一族校验,管的不是文档内容而是仓库形状。verify-package-paths 钉住包的路径布局,verify-cordis-config 钉住 Cordis 配置文件的合法性,verify-client-packages 和 verify-client-domain-graph 钉住客户端包的边界和依赖方向,verify-runtime-closure 钉住运行时闭包完整。发布侧的 verify-dsh-package-licenses 管每个包的许可证声明,verify-built-package-invariants 管构建后的包仍然满足不变量,verify-node-next-types 把构建产物的类型声明放进一个临时的 NodeNext 消费工程里编译,验证真的能被外部工程吃下去。这些校验守护的读者有的是人,有的是消费你包的构建工具,后者比前者更不宽容:人对过期的文档会抱怨,工具对过期的结构会直接失败。

文档站也是生成产物

文档不只是仓库里的 Markdown,还是一个被构建的站点。package.json 里挂着 docs:devdocs:builddocs:build:mpadocs:previewdocs:check 一族命令,另有 website:devwebsite:build 管官网。构建站的输入是文档源,站点本身因此成为派生产物,和 config-catalog 同属一个地位:可以重建,必须一致。

verify-doc-site-fragments 管站点片段的有效性,脚本目录里的 project-doc-site.ts 带着自己的 spec 实现站点工程化。派生 Markdown 也有专门的 paired-markdown-derivatives.ts 加 spec,钉住"从源文档派生的文件和源同步"这类关系。这个层次常见的问题是站点配置里引用了一个已经被改名的文档片段,构建时才炸;把片段检查放进 verify 族,问题在 PR 阶段就暴露。

把站点纳入 docs-as-code 的意义在于闭环完整:读者通过站点消费文档,站点引用仓库内的文档源,源由 gen 和 verify 管着。任何一环断开,比如站点手工复制了一份内容,漂移就从那一环重新开始生长。

生成器的生命周期

生成器不是写完就完的东西,它跟着源码的声明模式一起演化。源码里出现一种新的声明形态,比如一种新的配置组织方式或一个此前没有的导出模式,生成器不认识它,要么漏提取要么报错,这时要更新的是生成器本身,再带上它的 spec。

仓库里有一个现成的先例展示这类演化怎么做:migrate-packed-session-fixtures.ts。会话 fixture 的存储布局变更时,这个迁移脚本把旧布局批量改写成新布局,配套的 session-fixture-layout.ts 带着 spec 和一份布局快照。布局变更是破坏性的,但破坏被一个可运行的迁移吸收,历史数据不是负债而是一次脚本运行。

这个模式值得记下来:当生成物或校验物的格式本身要变,配一个迁移脚本,而不是让所有人在自己的分支上手改。格式变更的痛点从来不是新格式,是几十个存量文件怎么过去。

生成器退役也是生命周期的一部分。仓库里跑着 knip 检测未使用的导出和依赖,脚本目录自己的清单(scripts/AGENTS.md、repo-files.ts)也在管文件的去留。一个不再被任何 gen 或 verify 引用的工具脚本,和一段死代码同等待遇。

搬回自己的项目:从哪一对开始

这套系统不必整体照抄,规模不到时会先被维护成本压垮。按漂移的伤害排序,有一条经过验证的采纳顺序,这是我的归纳。

第一步挑漂移最疼的一个产物做一对 gen 加 verify。配置项文档几乎总是第一候选:字段增删频繁、读者直接照着配、错了立刻浪费用户时间。一个两百行的脚本扫描配置定义生成 Markdown,一个几十行的校验比对重新生成的结果和仓库里的版本,一天能写完,从合入起这个维度的漂移就死了。

第二步给所有生成文件加 do-not-edit 头。成本近乎为零,收益是终结"手改生成物然后被覆盖"的循环,这个循环每发生一次都在消耗贡献者对系统的信任。

第三步让文档里的示例代码可编译。doc-typecheck 的思路可以最小化复制:把文档里的代码块抽出来跑一遍 tsc 或对应工具的语法检查,示例代码从"看起来对"变成"编译过"。

预算和 type-equiv 放最后。文档长度预算在文档膨胀到拖累维护时才有正收益,过早引入只会制造形式主义。type-equiv 的前提是文档里真的需要粘贴类型声明,多数项目到不了这个密度,到了再上,manifest 的三键结构拿来就能用。

判断这套投入什么时候值得,有一个粗刻度:当你发现 reviewer 在 PR 里核对文档和代码是否一致,或者用户报"文档里说的字段不存在"这类 issue,账就已经算过来了。在那之前,一对 gen 加 verify 是全部的必要投资。

生成族里还有一支延伸到翻译流程。gen-translation-brief 为文档翻译生成工作底稿,verify-translation-prompt 和 verify-translation-pairing 管翻译提示词和配对关系,install-lefthook 在装钩子时顺带注册了一个 dsh-translation-pairing 的 git merge driver,配套 resolve-translation-pairing-conflicts 命令处理配对文件的合并冲突。思路一脉相承:双语文档的配对状态也是可以被机器核对的事实,不靠人记。对一个中英双语的仓库,翻译的腐烂速度不亚于文档本身,把它纳入 gen 和 verify 的管辖等于把第二语言的文档也锁进了同一个闭环。

doc-sync:一个命令跑完所有核对

日常入口被收拢成一个命令:pnpm run doc-sync。它聚合所有文档相关的 verify,改文档的 PR 本地跑一遍,全绿再推。要重新生成产物,跑对应的 gen 命令;要验证产物新鲜,跑 doc-sync。

生成的触发时刻不止 CI。Lefthook 的 pre-commit 钩子里有 THIRD_PARTY_NOTICES.md 的再生,依赖变了提交时许可证清单自动刷新,不等你想起。同一个钩子还做分块暂存检查、Oxlint 的有界重试、空白检查和 vendor 清单守卫;pre-push 钩子跑完整 typecheck。CI 门禁则在每次流水线运行时把全套 verify 跑一遍。也就是说生成物的新鲜度有三个把关时刻:提交时、push 时、CI 时,越早拦住修复成本越低。

校验按维度独立:type-equiv 管类型,config-catalog 管配置,cordis-catalog 管服务,tool-catalog 管工具。一个维度挂了不影响其他维度继续报告,一次修复可以精确知道还剩几个维度没过。

生成器自己也被测

这套系统有个自指的弱点:生成脚本也是代码,它错了,所有生成物一起错。dsh 对这个弱点的回应是把生成器当产品代码对待。

脚本目录里,gen-cordis-catalog 带 partition 和 record 两个 spec,gen-doc-graphs、gen-client-catalog、gen-third-party-notices 各有自己的 spec。生成逻辑不是想当然,是被测过的。一个已知例外是 gen-config-catalog,它是少数没有 spec 的生成脚本,这个空档本身也被当作已知状态记录着。

配套的基础设施同样是脚本:markdown.ts、jsdoc.ts、package-graph.ts 这些共享模块被多个生成器复用,各自带着测试。scripts 目录下的 spec 文件总数和生成脚本相当,仓库脚本和业务代码在测试纪律上是同一套标准。

这个细节决定 docs-as-code 的可信度。人们信任生成文档的前提是信任生成器,信任生成器的前提是它有测试、有 review、有门禁,和它生成的目标产物享受同等待遇。生成器成为特权代码的那一刻,整套系统就从"文档可信"退化回"文档取决于某个没人敢动的脚本"。

工具链的自证不止于 spec。脚本目录里能看到 oxlint-contract.spec.ts 和 lint-rule-fingerprint.spec.ts,前者钉住 lint 规则集本身的契约,后者给规则集做指纹防漂移;ci-workflow.spec.ts 直接测试 CI 工作流文件的结构。用测试盯住检查工具,是这套仓库的一贯姿势:管别人的脚本也要被管。

权衡

代价有三块。初始投入大,123 个脚本入口不是小数目,每个生成脚本要理解源码结构、提取正确信息、格式化成文档,每个校验脚本要定义一致性规则。维护成本持续存在:源码结构变了生成器要跟着变,一个新的声明模式可能要更新生成器才能正确提取。学习曲线真实:新贡献者要分辨哪些文档是生成的、哪些是手编的、怎么重新生成,onboarding 多了一课。

规模决定这套投入划不划算。十个包以下、文档十几篇的仓库,手写加人工 review 的漂移率低到感知不到,上生成器是负收益,维护生成器的工时超过救回的文档。分界线大约在"文档维度多到没人能记住哪个变更影响哪些文档"的地方,package 数量只是代理指标,真正的指标是变更和文档的关联复杂度。dsh 站在线的另一侧,两百多个包和五十多个服务把关联复杂度推到人工不可维护,脚本投入从奢侈变成必需。

回报对应也有三层。文档永远可信,读者看到的类型、配置、签名和源码一致,没有"这个可能过时了"的猜疑税。变更评审高效,一致性核对交给 CI,人专注逻辑。重构安全,改一个声明,所有引用它的文档自动报错,不用人肉搜。

还有一个不那么显形的回报:文档质量本身变得可治理。长度预算、必需章节、图可渲染,这些规则让"文档写得好不好"从主观印象变成客观门禁的一部分。对一个两百多个包、五十多个服务、几十个工具的 monorepo,没有这套系统,文档只有两条路,过时的废话或者没人维护的空壳。

结论

dsh 把文档当成构建产物和门禁对象。结构上两条主线:gen 和 verify 成对出现,让文档无法静默过期;verify 的管辖按职责而非格式划界,README 章节、JSDoc、设计笔记、翻译配对都在内。精细度的顶点是 type-equiv,逐符号、逐 JSDoc 的 1:1 对应。生成器自身被当作产品代码测试,这是整套系统可信的地基。写脚本的成本一次性付清,换来的是任何时刻都值得信任的文档,以及被机器接管的那部分 review 负担。规模不到的时候不必照抄全部,但"生成配校验"这一对结构,从第一个生成文档起就值得成立。

延伸阅读

上一篇:测试体系与性能压测:怎么测 dsh 这个 agent harness 下一篇:i18n 翻译配对与质量门禁:dsh 双语文档怎么不腐烂


GitHub 原文:45-docs-as-code-autogen-graphs-catalogs.md

评论

0
登录后可以参与评论和讨论。

EMPTY

还没有评论

欢迎留下第一条评论,帮助这篇内容更快形成讨论。