Storybook docgen-harness 深度解析:用快照比较器与性能基准守住多框架 Docgen 的正确性边界
@storybook/docgen-harness 是 Storybook 仓库内部的一个私有测试工具包,位于 code/lib/docgen-harness。它围绕 "docgen beyond React"(把文档提取能力扩展到 React 之外的框架)这一工程目标工作:把各框架遗留 docgen 管线今天真实产出的 argTypes 与代码片段记录下来作为基线快照,并用一个"current or better"(不低于现状)的比较器约束未来的 OSA 引擎,同时配套一套 docgen 性能与内存基准。读完本文,你将理解这套基线录制-比较机制的每一条规则、如何为 Vue3/Angular 添加 fixture 与捕获 compodoc 输入,以及如何运行、解读它的性能门禁。
它是什么、不发布什么
根据 README 与 package.json:
- 这是一个私有测试 harness(
"private": true),记录遗留 docgen 管线当前产出的 argTypes 和生成代码片段为经过 review 的快照,并以此把即将落地的 OSA 引擎约束在"持平或更好"的水平; - 目录中的一切不会发布到 npm;
- 从源码结构看,它通过 src/index.ts 向外只暴露比较器的公共面:
compareArgTypes、expectCurrentOrBetter、parseArgTypesSnapshot、compareSnippet及Framework/Violation等类型,性能基准(src/perf/)是独立 CLI,不在导出 API 中。
运行方式与三类测试文件
从仓库根目录运行:
yarn test code/lib/docgen-harness # 跑全部比较与基准自测
yarn test code/lib/docgen-harness -u # 有意变更之后重录,然后 review diff
每个框架有三类测试文件:
*-baselines.test.ts:按 fixture 逐例记录 argTypes 与片段,并通过比较器自比每一个已提交的基线;*-legacy-gaps.test.ts:把已知遗留缺陷钉成test.fails红色标记——一旦 baseline-path.ts 从'legacy'翻转为'osa',它们就变成硬性要求。源码中当前值就是export const BASELINE_PATH: BaselinePath = 'legacy',注释明确了翻转即"硬化"所有红色标记;*-render.test.ts:冒烟挂载 fixture,保证组件真的能渲染。
Vue3 有第二个 recorder:vue3-component-meta-baselines.test.ts。因为 Vue 有两个生产 docgen 引擎,它针对同样的 fixture 驱动 opt-in 的 vue-component-meta 路径(vue3-vite 中的 docgen: 'vue-component-meta'),写出 cm- 前缀的快照。
目录布局
README 给出的结构(与仓库实际一致,src/perf/ 下三个子目录各自独立):
src/
├── index.ts # 比较器的公共出口
├── compare/
│ ├── argtypes.ts # 逐键 argTypes 规则
│ ├── snippets.ts # 片段规则 + 框架分发
│ ├── snippets-vue3.ts # Vue 匹配器
│ ├── snippets-angular.ts # Angular 匹配器
│ ├── parse-element.ts # 根元素与属性扫描
│ ├── parse-snapshot.ts # 已提交 argtypes*.snapshot 文本的解析器
│ ├── expect-current-or-better.ts
│ ├── is-snapshot-update-run.ts
│ └── types.ts
├── vue3/ # baselines / component-meta-baselines / legacy-gaps / render
├── angular/ # baselines / legacy-gaps / provider-seam / render / compodoc 解析对等
├── svelte/ # 计划中
├── web-components/ # 计划中
└── perf/ # 性能基准
├── PERF-METHODOLOGY.md # 测量契约
├── docgen-perf/ # 每引擎延迟与内存套件(含 engines/ 与 generators/)
├── docgen-memory/ # docgen-server 内存回归门禁
└── docgen-shared/ # 两个套件共享的采样、统计、预算与路径
"Current or better" 比较器
核心语义一句话:expectCurrentOrBetter 在候选结果丢失任何基线已记录的东西时失败,同时放行改进。
argTypes 规则
- 基线中的每一个键、description、default value 和 type 都必须存活;
- type 只允许两种变化:归一化深度相等,或明确的改进——catch-all 变结构化、字面量联合增加成员;
- 语料库约一半记录的是
other:遗留引擎把无法解析的自由文本停靠在那里(如TreeNode、Array([object Object])、{ theme: string; dense: boolean })。这类桩接受"补上实体结构"(空 enum/union/object 不算改进)或"解析为它已点名的标量/单个字面量"的候选;无关标量或字面量属于侧向变化,失败; - 只有三个"什么都没记录"的标记接受任意候选:
empty-enum、undefined、空字符串——这是今天 Angular 与 Vue 的记法,所以新增框架时要重新审视这个清单; - 规则无法识别的解析方式(比如遗留的
TSFunctionType变成functionsbType)宁可失败也不猜测,重录并 review diff; - 已记录的
table.type.summary必须存活(丢掉即违规),但strictTable之外其文本可自由变化; required、table.category、jsDocTags、control/action以及 description/default 的内容被有意不比较(仅strictTable下比较required)——因为这些会固化"被记录的谎言"(#28706)或引擎专属词汇。
compare/argtypes.ts 中的 compareArgTypes 实现了上述规则,产出带种类的违规列表:参数消失是 lost-arg,description 消失是 lost-description,默认值消失是 lost-default,type 消失是 lost-type,类型保真度下降或侧向变化是 type-fidelity。它还明确跳过 ES 私有 #member(遗留 Compodoc 会记录它们,现代提取器只在 propsTable: 'all' 下暴露,它们的丢失永不门禁)。
片段规则
- 以集合比较"被表示的绑定名",所以纯格式差异永不失败,但丢失绑定会失败;
- 指令写法归一化:Vue 的
:x/v-bind:x、@x/v-on:x、#x/v-slot:x及任何.modifier都读作同一个名字; - Angular 比较额外门禁根元素同一性:标签名必须匹配,且裸(无值)属性——被压坏的属性选择器标记——必须存活。
接受方式与录制门禁
- 没有 allowlist 文件:已提交的基线就是 allowlist。接受一次有意变更的方式是
-u重录并 review diff; - recorder 读取每个已提交文件,并在快照调用之前跑完所有门禁,因此
-u运行会拒绝排队一次回归的录制,直到代码修好前保持红色; - 已提交的
argtypes*.snapshot文件是 pretty-format 文本而非 JSON。parseArgTypesSnapshot读回后通过逐字节重序列化来自我验证,并拒绝任何携带"写入者二义条目边界形状"的解析字符串;对于未转义写入即与真实条目边界字节相同的字符串,解析期无法检测,由 recorder 的 parsed-vs-live 证明在常规运行、CI 运行以及-u运行(针对即将写出的确切字节)上守住; - 新增框架:扩展
Framework联合类型,在 snippets.ts 的 switch 处编译失败,直到新匹配器存在——用类型系统强制完成工作。
源码视角:expectCurrentOrBetter 的豁免与双向检查
expect-current-or-better.ts 展示了规则如何落地:
- argTypes 分支支持
legacyBaseline与strictTable两个选项,并通过declaredDefaultOmissions声明"已知是初始化器源码而非可显示值"的遗留默认值,逐条与lost-default违规匹配并检查过期(候选已恢复默认值时必须移除声明); - snippet 分支支持
declaredOmissions:候选预期省略、但基线表示了的参数(其源码引用了静态片段无法声明的绑定)。双向检查——列在这里但候选实际表示了的参数会失败,所以这份清单不会超出它记录的缺口而存活; - 失败时抛出单个包含全部违规的错误,一次失败展示整个缺口。
信任模型
比较器机器检查的是一个有意的子集:argTypes 的基线参数名、description 存在性、default 存在性、table.type.summary 存在性与类型保真度;snippet 的被表示绑定名、根元素同一性与 Angular 裸属性存活。其余一切(description/default/summary 文本、table.category、control/action、逐参数 jsDocTags、新增参数)只能靠 -u 时 review 的逐字节快照 diff,或沙箱门禁的 change findings 捕获。
两个标志把信任限定在基线真正值得信任的地方:legacyBaseline(仅用于基线是遗留 compodoc 录制的腿)豁免管线虚构的裸 false/NaN/null 默认值;strictTable(仅用于 ACM 自棘轮——其基线由同一引擎记录)额外门禁 table.type.summary 文本变化与 table.type.required 的 true→false 翻转。
README 同时明确承认盲区:整个项目的回归可以在日常 CI 层(沙箱基线门禁)才浮现;已知被接受的盲区包括引号/裸拼写碰撞的 enum 成员('"small"' 读作 small),以及 vitest 在写出时把 \r/\r\n 归一为 LF,导致带 CR 的提取永远无法录绿(常响,但从不沉默)。
Vue 的第二录制器:vue-component-meta
vue3-component-meta-baselines.test.ts 精确复现 vue3-vite vite 插件的 meta 处理——checker 选项、空 meta 跳过、嵌套 schema 修剪、exposed 去重、vue-docgen-api 的事件 description 回填——因此 cm- 快照展示的是 vue-component-meta 用户今天真实得到的东西。对应的生产实现见 code/frameworks/vue3-vite/src/plugins/vue-component-meta.ts。要点:
- 让这份拷贝与生产插件保持同步是手工的,没有任何东西检测漂移;
cm-前缀让每个 recorder 的过期片段守卫只作用于自己的文件;sourceFiles记录<sfc>,因为生产存的是绝对模块 id,而快照必须无路径;- 与遗留 recorder 一样,它自比每个已提交基线,因此 checker 或插件的降质变更会以具名违规失败,而不是落成不起眼的 diff;
- 插件与 checker 的变更在这里落地为 review 过的快照 diff:#35565(
schema: true)移动了 25 个cm-argtypes.snapshot文件中的 17 个,而所有cm-snippet-*逐字节不变;记录的状态取决于 lockfile 解析到的vue-component-meta版本(README 写作时是 3.3.9),所以依赖升级同样是 review 过的基线变更。
添加 fixture
每个 case 一个目录,recorder 自动发现。全新 fixture 的首次运行会失败一次(快照文件在套件结束时 flush),再跑一次并提交即可。原则:把薄弱的或错误的遗留输出原样录下,绝不"改进" fixture 让遗留结果看起来更好;快照必须确定——无时间戳、无绝对路径。
- vue3:一个 PascalCase SFC(文件名成为每个片段中的组件标签)加
input.stories.ts; - angular:一个 kebab-case
<case>.component.ts(类名必须与 compodoc 捕获完全一致)加input.stories.ts与捕获的compodoc-input.json;signal fixture 还提交从真实ngc输出捕获一次的、带ɵcmp输入/输出映射的aot-cmp.ts(JIT 下它们是空的)。Stories 从 src/angular/csf-types.ts 导入 CSF 类型;两个运行时测试文件被排除出 vue-tsc 程序,因为 angular-vite 客户端源码并非 strict-clean。
捕获 compodoc 输入(Angular)
捕获固定在 @compodoc/compodoc@2.0.0(package.json 中即此版本)。用其他版本重捕获是一次 review 过的基线变更——signal 解析跨版本漂移剧烈。Compodoc 扫描最近 package.json 下的一切并忽略 tsconfig include,所以必须从任何 Node 包之外的暂存目录捕获:
- 把组件及其支撑源码(绝不放
input.stories.ts)加 case 的tsconfig.json复制进一个空目录,如$(mktemp -d); - 在那里运行
npx -y @compodoc/compodoc@2.0.0 -p tsconfig.json -e json -d .; - 把产出的
documentation.json移回为compodoc-input.json; - 运行
cd code && yarn fmt:write。
没有任何东西检测 fixture 源码与其提交捕获之间的漂移,所以编辑组件必须与重捕获放在同一次变更里。
已知遗留缺口
README 把"遗留管线今天做不到什么"完整列出,这些清单正是未来 OSA 引擎的验收目标。
Vue3
- 接受的 delta:OSA 片段是静态的,所以 Controls 的实时更新不会重新渲染它们;
- 片段从不渲染事件处理器;函数参数被静默丢弃;
table.jsDocTags保持undefined;script-setup SFC 中组件级 docblock 不被捕获;- 字面量字符串联合永远不会变成
enumsbType,且值保留引号; - 数组与交叉类型属性的 type 记录的是字符串化的
convert()兜底(Array([object Object])); - 响应式 props 解构的默认值不可见,只有
withDefaults()会被提取; defineModel('name')命名模型不可见;片段渲染裸属性而非v-model:name;- 作用域插槽绑定的类型从不被提取,只有名字;
defineExpose成员完全不记录类型——只有名字和 description,而vue-component-meta能把同样成员解析为number和() => void;- 超过
Number.MAX_SAFE_INTEGER的 bigint 在片段中丢失精度; - 按设计薄的基线:
Pick组合的 props 记录{},递归类型只留名字桩,运行时数组 props 是type: undefined; defineProps<ReturnType<typeof useComposable>>()在遗留工具链中无法构建;语句块事件表达式会直接让parse()崩溃(#23851)。两者都不可能存在基线。
Angular
- 接受的 delta(无标记):片段只含绑定——没有 ng-content 子节点、
model()没有 banana-in-a-box、函数与undefined按原文插值; - 每个装饰器输入都记录
required: true;compodoc 从不发出optional(#28706); - 无字面量默认值的 number 输入记录虚构的
NaN默认值;数值表达式默认值也塌缩为NaN; - 非数值表达式默认值记录原始源码字符串(
Math.max(1, 3)); - JSDoc 标签从不结构化地进入 argTypes:
@deprecated消失(#9721),@see文本泄漏进 description,@default值保留引号与尾随换行; function、any与泛型类型字符串塌缩为{ name: 'other', value: 'empty-enum' };- 字面量联合、别名联合与 TS enum 在 compodoc 2.0.0 下都解析为 enum sbType——#33779 的塌缩在该版本不复现;
- 跨文件继承被完整解析(回归基线,不是缺口);
angularFilterNonInputControls关闭时,properties/methods/view child段面会以 argTypes 出现(含私有字段,#22007);开启后限制为 inputs;model()记录一个输入加一个合成的${name}Change输出;其背后的 compodoc 怪癖写在 code/lib/angular-compodoc/README.md;- 片段只用第一个逗号分隔的选择器;属性选择器被压坏为裸属性。
Issue 关联用例
fixture 复现未关闭的 issue,待 OSA 引擎落地时验证并关闭,每个在 *-legacy-gaps.test.ts 里有红色标记:
- vue3:#11774/#12331 →
cross-file-runtime-props/(导入的运行时 props 必须解析为真实 argTypes,遗留记录{});#12331/#22187 →cross-file-props-spread/;#12850/#23470 →prop-slot-name-collision/;#19394 →runtime-multi-constructor/(type: [String, Number]必须变成结构化联合);#20593 →runtime-proptype-cast/;#24270(部分)→define-slots-literal-bindings/;#26465(部分)→slots/;#26465(未复现)→define-slots-with-props/(3.3.9 下不出现,cm-argtypes.snapshot完整记录 description、默认值与 slot 文档,作回归基线无标记;issue 的次要 HMR 症状是 dev-server 行为,超出本 harness 范围);#29354 →cross-file-union-alias/;#30045 →type-intersection-whole/; - angular:#28706 →
decorator-io-basics/(TS 可选输入必须记录required: false);#9721 →jsdoc-tags/;#33779(未复现)→decorator-union-enum/(回归基线无标记);#29697(未复现)→signal-io/;#22007 →properties-methods-noise/(过滤标志的起源 case,也是两个标志状态有意义地不同的 fixture;ACM 引擎以propsTable: 'api'默认丢弃私有与#属性、方法及@internal成员,但保留protected成员与所有声明的输入/输出,因此acm-基线有意比遗留基线少行)。
性能基准:src/perf/
这是 "docgen beyond React" 问题的另一半:上面快照比较器回答正确性,src/perf/ 回答多快、占多少内存。它是一组 CLI 而非包 API 的一部分,src/perf/ 的一切都不从 src/index.ts 再导出。所有命令在 code/lib/docgen-harness 下运行(与 package.json 中 scripts 字段对应):
yarn bench:docgen-perf # 每引擎冷/热延迟与内存,完整 profile(约 1 分钟)
yarn bench:docgen-perf --quick # 冒烟 profile;其数字被标记为不可比较
yarn bench:docgen-perf-gate # 同一套件 + 预算断言——CI 门禁所跑的
yarn bench:docgen-memory # docgen-server 内存回归门禁
bench:docgen-perf 在共享沙箱目录下生成合成项目,每个引擎在独立子进程运行,并把结果 JSON 写到旁边。生成的树保留在磁盘上供打开查看,每个引擎/场景拥有自己的目录,生成器写入前清空——因此两次基准不能并行跑(一次会清掉另一次正在读的树)。bench:docgen-memory 断言重提取无泄漏,且程序回收修复仍能把紧堆运行从 OOM 翻转为存活。
单引擎、非默认引擎
yarn bench:docgen-perf --engine react-osa # 单引擎
yarn bench:docgen-perf --engine react-legacy --engine react-osa # 一次调用跑一个对照组
yarn bench:docgen-perf --json /tmp/results.json # 结果落点
默认运行是 react-legacy、react-osa、vue-docgen-api、vue-component-meta 与 compodoc。两个 id 在默认之外,只在指名时测量:react-legacy-rdt(react-docgen-typescript 解析器)与 vue-component-meta-next(版本对别名)。只有对照对在同一调用中都测到时 ratio 才出现——只指名一侧给你一行表数据但没有比较。Compodoc 的 CLI 无法解析时带消息跳过;其他所有引擎都从 workspace 读取,缺一个即失败而非跳过。
两种 React 形状
React 引擎把每个场景跑两遍,因为 Storybook 用两种代价相差很大的形状记录组件:
whole-index——对索引中所有组件的一次批处理,即 manifest 生成器所做的事;first-story——请求所要求的那一个组件,即 docgen server 所做的事。这是开发者等 Controls 填充前等待的数字。
README 给出的冷启动比值:在 index 上是 0.73,在 first story 上是 0.08——只读其中一个会对引擎成本形成误导。PERF-METHODOLOGY.md 进一步解释了 save 目标的刻意设计:first-story 下轮询会记录冷路径从未触及的组件,retained heap 的上涨无法与泄漏区分,所以反复重触同一个已记录组件以保持稳态可比。
比较同一引擎的两个版本
vue-component-meta-next 是本包 package.json 里的别名,固定到精确版本(当前为 npm:vue-component-meta@3.3.8,而当前侧是 vue-component-meta: ^3.3.9)。把别名指向要测的版本、yarn install,然后一次调用跑两边:
yarn bench:docgen-perf --engine vue-component-meta --engine vue-component-meta-next
要点:候选版本必须精确固定而不是区间——两个 caret 区间可能解析到同一次安装,那这次运行就是引擎自己和自己比。套件在每个 ratio 旁打印两个解析版本,两个相等时明确宣布这不是比较。
机制不绑定 Vue:一个引擎条目声明它测量哪次安装,子进程 import 该说明符而非硬编码包名,一个对就是仅该字段不同的两个条目。PERF-METHODOLOGY.md 给出了在另一个引擎上设置对照对的步骤及其未覆盖的那一个 case。
门禁与预算
两个门禁跑在 CircleCI 的日常层,由 PR 上的 ci:daily 标签按需触发——没有任何调度器,所以这不是夜间防护。
bench:docgen-perf-gate 以固定 profile 跑套件,断言 src/perf/docgen-shared/budgets.ts 中的预算,然后跑一个故意失败的引擎并要求其以非零退出,以此自证失败检测能力。它默认写入沙箱目录;CI 传 --out ./perf-results 以便把结果存为构建产物。
以下情况门禁拒绝报告绿色:--quick 运行、空预算表、被预算的引擎被跳过或失败——每一种都看似在保护实际什么都没断言。
预算是比值与绝对 MB,从不使用原始毫秒,因为共享 CI 执行器上的墙钟噪声太大,不适合门禁。只在 CI 上测得的数字面前修改预算,并在 PERF-METHODOLOGY.md 中记录来源。修改任何指标、预算或版本对之前,先读 PERF-METHODOLOGY.md——这些数字只有在它定义的条件下才有意义。
方法论层面,PERF-METHODOLOGY 明确了五个指标(冷提取、热提取、整项目扫描、峰值内存、泄漏检测,其中泄漏斜率拟合排除第一次 save)、以及可重复性规则:固定合成项目、每次测量全新进程、延迟取中位数、只比较同一机器上的相对比值,且对照对在重复间交替顺序(偶数 N 的奇偶性在 ratios.test.ts 中被断言,而不是留给下次改常量的人)。
基准还自带针对聚合、报告与生成器逻辑的单元测试,所以 yarn test code/lib/docgen-harness 会把它们与 fixture 比较一起跑起来。
不放在这里的东西
- 框架 provider 代码住在各自框架的包中;
- 性能基准不属于本包的导出 API;
- 没有任何东西发布到 npm。
理解 docgen-harness 的关键,是把它看作 Storybook "docgen beyond React" 工程的质量合同:正确性一侧用"基线即 allowlist"的棘轮保证新引擎不丢失任何旧引擎已记录的信息(连规则无法识别的变化都宁可失败),性能一侧用同机比值、版本对与自证失败的门禁保证数字可信。两类机制都刻意把"人 review diff"保留在关键路径上——这正是一个内部 harness 而非发布库的设计取舍。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00