25 KiB
Agent Note: 本地门禁、钩子与测试提速
Status: implemented
English | 中文
问题
在基线(合并 #6013 时的 master)上,本地运行 Git 钩子、pnpm run test、test:docs 与 doc-sync(文档同步门禁)时,大部分 wall 时间花在结果并不依赖的工作上:
- 经打包器获得 Typert 声明。 pre-push 钩子运行的
typecheck先运行build:lib:host(Hosttsc -b tsconfig.host.json、Host tsdown 阶段与 Desktop bundle),再运行tsc -b tsconfig.client.json。lint、lint:fix、doc-typecheck与 CItypert-contracts门禁的前置步骤先运行 Host 包编译,再运行 Host tsdown 阶段(compile-referenced-projects.ts host-libraries)。类型检查只读取包声明与 Typert 声明,但无需重查时typecheck仍需 34.1/35.0 s,前置步骤需 52.2/29.6 s。 - 每次提交都生成第三方声明。 只要暂存文件是生成器的输入,pre-commit 钩子就重新生成
THIRD_PARTY_NOTICES.md。为找出浏览器侧的第三方 import,生成器对 75 个 Client 包试打包并运行一次 Vite 构建,因此每个包源码文件都是它的输入;暂存一个位于包src/子目录下的文件的提交,钩子合计 16.5/17.8 s,其中第三方声明任务占 12.4/13.4 s。 - 以宿主 CPU 数作为本地 worker 数。
os.availableParallelism()报告的是宿主 CPU 数(测量主机上为 256),而不是容器的 CPU 配额(64)。按此默认值,pnpm run test启动 255 个 Vitest fork,RSS 峰值 55.3 GiB,并偶发地多失败 12 个文件(14 次超时);同时doc-sync、test:docs、hygiene与check:all最多只并发 4 个门禁。 - 每个候选一次 Git 前缀查找。
verify-repository-references通过git cat-file --batch-check对约 26,000 个缩写十六进制候选逐个做 Git 前缀查找;这些查找占该门禁 166.8 s 中的 135.7 s。 - 重复解析 Markdown。 markdown links 对每个锚点目标再多解析两次,且探测目标是否存在时不缓存;translation pairing 做四次 glob,并在链接归一化后再次解析每个文档;包 README 的 model experience 与 limitations 检查各自把每个 README 解析三次;type equivalence 解析全部 1,835 个文档;Session format references 把每个配对解析四次。
- 重复读取目录。 markdown wrap、doc refs 与 package paths 背后的遍历器
uniqueRepoFiles对每个 pattern 与每层**各读取一次每个目录,并对每个匹配文件调用realpath。 - 串行启动 help 与重复的编译器探测。
gen-cli-help依次启动 12 个顶层源码模式的dsh … --help进程,每个进程大部分时间在等待 profile 启动的 I/O(49.9/53.9 s);gen-doc-graphs构建多个编译器 host 与 program,重复执行同样的ts.sys文件系统探测(48.7/44.7 s)。 - 受文件系统探测拖累的测试文件。 Typert generator 的
type-model.spec.ts的 61 个用例各自把 fixture(测试前置数据)复制到磁盘并运行一次完整分析,分析中的四个 TypeScript program 各自加载 DOM lib、@types/node与 zod v4,每次分析约 22,000 次 stat 调用。该文件耗时 316.3/329.1 s,是pnpm run test的长尾。
决策
以下八项决策缩短上述运行。每一项都给出其产物与诊断保持不变的依据,或它接受的有意差异。
| # | 决策 | 受影响的命令 | 实测 wall | 产物与诊断 | 有意的行为差异 |
|---|---|---|---|---|---|
| 1 | 类型检查不打包地生成 Typert 声明 | typecheck、lint、lint:fix、doc-typecheck、CI typert-contracts |
typecheck 34.1/35.0 → 21.9/20.0 s;前置步骤 52.2/29.6 → 12.5/11.3 s |
140 个 Typert 文件逐字节一致;检查的程序相同 | 不产出可运行 bundle,不写 client build record |
| 2 | pre-commit 钩子不重新生成第三方声明 | pre-commit |
16.5/17.8 → 4.3/4.5 s | 新鲜度由测试 lane 断言 | 第三方声明过期时在测试 lane 失败,而不是在提交时 |
| 3 | 本地默认 worker 预算 | run-gates 聚合、build、typecheck、pnpm run test、Oxlint、publint |
255 个 worker 时 test 为 243.7 s、55.3 GiB;test:docs 74.7 → 50.9 s |
检查不变 | 本地默认值不使用超过 12 或 48 的 CPU |
| 4 | 仓库引用候选对照一次对象列表解析 | verify-repository-references |
166.8 → 15.7 s | 27,050 个候选的判定 0 差异 | 无 |
| 5 | 文档门禁每个文档只解析一次 | markdown links、translation pairing、README model experience 与 limitations、type equivalence、Session format references | markdown links 53.4/51.6 → 25.9/25.1 s;type equivalence 23.0/22.5 → 6.0/5.5 s | 门禁输出逐字节一致 | 无 |
| 6 | 仓库文件遍历每个目录只列一次 | markdown wrap、doc refs、package paths | doc refs 8.8/7.0 → 3.2/3.4 s | 文件列表一致 | doc refs 不读取嵌套的 lib 目录 |
| 7 | CLI(命令行界面)help 读取并发运行;doc graphs 的探测做 memo | verify-cli-help、verify-doc-graphs |
49.9/53.9 → 6.9/7.2 s;48.7/44.7 → 32.7/34.2 s | 页面逐字节一致 | CLI help 峰值约用 3.6 GB 内存 |
| 8 | type-model.spec.ts 在内存工作区上运行 |
pnpm run test |
316.3/329.1 → 14.65/15.04 s | 61 个用例与断言不变;快照有一行不同 | 除 Emitter 用例外,桩替代 zod、@types/node 与 DOM lib |
测量条件
所有数字都是 wall 时间,取自同一个共享 Linux 容器:CPU 配额 64 核,宿主报告 256 个 CPU,一分钟 load average 为 20–150,因此样本带有噪声。工作树位于网络文件系统上,每次文件系统探测的代价都高于本地磁盘。a/b 表示两个样本,x → y 比较基线与本实现。
1. 类型检查不打包地生成 Typert 声明
-
目的: 让
typecheck以及lint、lint:fix、doc-typecheck与 CItypert-contracts门禁的前置步骤得到它们读取的声明,即tsc产出的包声明与 Typert 声明,而不运行 Host tsdown 阶段或 Desktop bundle。 -
改动:
typecheck是tsx scripts/build.ts --scope typecheck,它通过 run-gates 调度器运行下表中的步骤(scripts/build.ts)。该 scope 不构建 native addon、tsdown bundle、Desktop bundle 或 Web 产物,不写 client build record,可运行的lib/产物保持上一次build:artifacts的状态;启动或测试构建产物之前先运行build:artifacts。lint、lint:fix、doc-typecheck与typert-contracts的前置步骤是compile-referenced-projects.ts host-contracts,它在 host-src 之前运行 host-typert(compile-referenced-projects.ts)。build、build:artifacts、benchmark 构建与 vendor 演练都在编译前采用同一仅源码生成(顺序决策)。 -
共用生成:
scripts/generate-typert.ts对WORKSPACE_TYPERT_FACES(scripts/typert-faces.ts,即仓库生成的 face)调用WorkspaceTypertGenerator.generateContributors(),并用artifact-files.ts中插件同样使用的writeWorkspaceArtifacts()写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,依赖它的编译不会启动。host-typert 由 Node 加 tsx 的 ESM 钩子运行,而不经pnpm exec,因为后者的启动开销会拉长通往 Client 编译的路径。 -
以 build mode 运行聚合检查: host-check 与 client-check 在本侧 face 的包编译之后才开始,因此 build mode 发现所有 references 都是最新的,只检查聚合根程序,并在根程序的 tsbuildinfo 为最新时跳过它。
-
只读源码的分析: host-typert 能在 host-src 之前运行,是因为它的 Host program 不读取任何工作区构建产物。
tsconfig.base.json把@deepseek-ai/dsh-client-file-upload/types映射到源码;没有这条别名时,该说明符经包exports解析到lib/types/types.d.ts。有了它,Host program 加载 1,289 个文件,其中没有一个位于工作区包的lib/下。generate-typert.ts开启rejectBuildOutputs:face program 加载工作区包lib/目录下的文件,或无法解析对工作区包的 import(例如包尚未编译的工作区)时,build-output-guard.ts以TypertAnalysisError使分析失败,并指出每个这样的 import 说明符,以及把它映射到源码的paths条目。因此无论 host-src 是否已运行,缺失的别名都会使生成失败。公开 tsdown 插件保留独立的生成策略;根产物构建使用仅源码阶段。Typert generator README 记录了这些写入函数与该选项。 -
无损依据: 没有任何工作区 tsconfig 设置
allowJs,每个exports的types条件都指向lib/types/内或lib/typert.*,每个 tsdown 配置都设置dts: false,因此没有类型检查读取 tsdown 产物。140 个 Typert 文件(28 个包 × 5 个输出)在以下场景中逐字节一致:基线 tsdown 阶段、当前 tsdown 阶段、删除全部 Typert 输出后运行generate-typert.ts、隐藏全部 344 个lib/types目录后运行generate-typert.ts;临时给packages/goal/goal加一个 Remote 方法时,tsdown 阶段与generate-typert.ts写出的文件也相同。typecheck检查两个 face 的全部包 project 与两个聚合根程序,即build检查的程序。 -
性能: 无需重查时
typecheck为 34.1/35.0 → 21.9/20.0 s。两个聚合根程序都需要重查时为 83.5 → 76.0/72.1 s,其关键路径是约 66–70 s 的 Host 聚合检查。lint与doc-typecheck的前置步骤为 52.2/29.6 → 12.5/11.3 s。 -
取舍:
typecheck、lint、lint:fix、doc-typecheck与typert-contracts写出 Typert 声明但不写 bundle,因此在下一次build:artifacts之前,可运行的lib/JavaScript 可能落后于这些声明。 -
与构建的关系: 先生成元数据的顺序负责生成位置及其测得的代价。仅生成声明的命令仍省略打包。构建拆分 Note负责命令职责。
-
已知缺口: 增量
tsc -b不把 Client 源码经包exports访问到的lib/typert.remote-client.d.ts记为消费方 project 的输入;Remote 签名改变而 Client 源码未变时,增量检查不会重查消费方。这一缺口与由哪一种生成写出声明无关,由 Issue #6022 跟踪。
2. pre-commit 钩子不重新生成第三方声明
- 改动:
lefthook.yml中的pre-commit钩子没有第三方声明任务。gen-third-party-notices.spec.ts在测试 lane(包括必过的node 24 / coveragelane)中断言THIRD_PARTY_NOTICES.md已提交的字节,过期时指出pnpm run gen-third-party-notices。生成的第三方声明 Note 拥有这一新鲜度决策。 - 性能: 暂存一个位于包
src/子目录下的文件的提交,钩子耗时由 16.5/17.8 s 降为 4.3/4.5 s,其中第三方声明任务占 12.4/13.4 s。 - 取舍: 使第三方声明过期的提交会在推送后于测试 lane 失败,而不是在提交时被修正。
3. 本地默认 worker 预算
- 目的: 按共享工作站或容器确定默认并行度;在这类环境中 Node 报告的是宿主 CPU 数而不是 CPU 配额。
- 改动:
scripts/parallelism.ts拥有默认值。CI=true时它是 Node 报告的 CPU 数。否则,run-gates 调度器、build、typecheck、Oxlint 与 publint 的上限为 12(LOCAL_PARALLELISM_CAP),Vitest fork 与本地覆盖率预算的上限为 48(LOCAL_TEST_PARALLELISM_CAP)。doc-sync、test:docs、hygiene与check:all的每个门禁都构建完整的ts.Program,它们最多并发 8 个门禁,CI 中同样如此。显式的DSH_GATE_CONCURRENCY、DSH_OXLINT_THREADS、DSH_COVERAGE_MAX_WORKERS与 Vitest--maxWorkers优先;CI 中,除非 run-gates 的 CI 环境设置了这些变量,Vitest 与 Oxlint 使用各自的默认值。开发指南向贡献者说明这些默认值。
四种 fork 数下的 pnpm run test,运行的是第 8 项所替换的落盘版 type-model.spec.ts,32 个 worker 那次的 wall 由它决定:
| Vitest worker 数 | wall | RSS 峰值 | 额外的偶发失败 | 除最慢文件外其余文件完成于 |
|---|---|---|---|---|
| 255(报告的 CPU 数) | 243.7 s | 55.3 GiB | 12 个文件、14 次超时 | 约 135–170 s |
| 32 | 310.4 s | 15.7 GiB | 4 个文件、4 次超时 | 193 s |
| 24 | 337.2 s | 13.2 GiB | 5 个文件、5 次超时 | 233 s |
| 12 | 410.5 s | 8.4 GiB | 2 个文件、3 次超时 | 406 s |
- Oxlint: Oxlint 自身的默认值是 64 线程(读取 cgroup 配额);12 线程时其 wall 保持约 87–90 s,CPU 少约 15%;8 线程时慢约 4 s。
- 文档聚合: 并发 8 个门禁时
test:docs为 50.9 s,并发 4 个时为 74.7 s。不含第 4 项时测得的doc-sync在并发 4、8、12 个门禁时分别为 341.8、295.0、253.2 s,RSS 峰值分别为 10.0、13.5、16.3 GiB。 - 取舍: CPU 配额超过上限的机器,默认并行度也不超过 12 或 48,需要使用覆盖变量;配额低于上限的容器仍会超额订阅,因为 Node 报告的是宿主 CPU 数。并发 8 个门禁时
doc-sync的 RSS 峰值为 13.5 GiB,并发 4 个时为 10.0 GiB;并发 12 个门禁快 41.8 s,但峰值为 16.3 GiB。CI 的文档 workflow 运行doc-sync时同样最多并发 8 个门禁。
4. 仓库引用候选对照一次对象列表解析
- 改动:
verify-repository-references.ts用git cat-file --batch-all-objects --batch-check=%(objectname)一次列出全部对象名(约 120 万个对象,0.6 s),排序后在内存中对每个小写候选前缀做二分查找;随后一次--batch-check=%(objectname) %(objecttype)调用读取命中对象的类型。只有当候选前缀恰好匹配一个可用对象且该对象是 commit 时,它才算 commit 引用,这与 Git 自身前缀查找的规则相同;与 Git 的查找一样,该列表也包含不可达对象与 alternate 对象。 - 性能: 166.8 → 15.7 s。
- 无损依据: 在 27,050 个真实与合成候选上(包括歧义前缀、blob 与 tree 前缀以及随机字符串),Git 的逐候选前缀查找与内存查找的判定完全一致。
- 取舍: 120 万个对象的列表约为 49 MiB 的 Git 输出,读取时的输出上限为 1 GiB。
5. 文档门禁每个文档只解析一次
- 改动: 下表中的每个门禁对一个 Markdown 文档只解析一次,并把语法树传给本次运行中的所有使用方。
| 门禁 | 单次解析机制 | wall |
|---|---|---|
markdown links(verify-md-links.ts) |
一个按运行划分的缓存对每个文件只解析一次,同时取得其 destination 与锚点,并对每个解析后的目标只探测一次 | 53.4/51.6 → 25.9/25.1 s |
translation pairing(verify-translation-pairing.ts) |
一次 glob 覆盖所有范围 pattern;record 在下述守卫条件下复用原文语法树 | 51.0/47.1 → 30.3/29.3 s |
| package README model experience、package README limitations | 每项检查对一个 README 只解析一次,并把语法树传给 markdownProseLines() 与 markdownHeadingLines() |
各约 12 → 5.9 s |
type equivalence(verify-type-equiv.ts) |
文本预筛跳过不可能含源码等价围栏的文档;manifest(元数据清单)中的每个 TypeScript 源文件只解析一次 | 23.0/22.5 → 6.0/5.5 s |
| Session format references | persistence 渲染器把它解析出的语法树传给配对 record | 10.2 → 5.3 s |
- 配对复用守卫:
translation-pairing-record.ts中的normalizedTopLevelBlocks()仅在以下条件同时成立时,才为归一化后的文本复用原文语法树:每处链接归一化编辑都替换一个完整、无 title 且不在行首开始的 destination,原文等于该节点的url,且原文与替换文本都只含[A-Za-z0-9._/:#%-]。这些字符既不能结束 destination,也不能开始或结束其他语法结构,因此归一化后的文本解析出相同的节点,只是偏移量随之移动。其他任何编辑都回退为对归一化文本的完整解析。 - type equivalence 预筛:
type-equiv-prefilter.ts在文档含type-equiv或public-api,或者某个含```或~~~的行同时含&或\时,保留该文档进行解析。解析只会通过解码字符引用与反斜杠转义改变信息字符串,因此ts type-equiv与ts type\-equiv所在的文档都会被保留;type-equiv-prefilter.spec.ts覆盖这两种写法。 - 无损依据: 每个门禁在仓库上的输出逐字节一致,人为注入的错误也被同样报告。对配对而言,语料中全部 1,886 个文档的顶层块与全部 943 个配对的 record 都与完整重新解析的结果一致,随机 destination 的结果也一致。
6. 仓库文件遍历每个目录只列一次
- 改动:
scripts/repo-files.ts中的uniqueRepoFiles()在一次调用的所有 pattern 之间共享一个目录列表缓存,使每个目录最多读取一次,并且只对经符号链接到达的匹配调用realpath;只经真实目录到达的普通文件,规范路径为仓库 realpath 拼接其相对路径。它的可选谓词skipDirectory让遍历器不进入调用方排除其下全部路径的目录;verify-doc-refs.ts跳过第一段路径之后的每个lib目录,其下文件本来就全部被它排除。 - 性能: markdown wrap 25.8/25.0 → 21.3/21.2 s;doc refs 8.8/7.0 → 3.2/3.4 s;package paths 9.9/8.1 → 7.2/5.5 s。
- 无损依据: 三个门禁得到的文件列表逐项相同,损坏或循环的符号链接仍会经
realpath失败。 - 行为差异: doc refs 不列出被跳过的
lib目录,因此不可读的此类目录不会以 EACCES 使门禁失败。
7. CLI help 读取并发运行;doc graphs 的探测做 memo
- CLI help:
scripts/gen-cli-help.ts中的prefetchHelp()在默认 worker 预算内启动 12 个顶层dsh … --help读取(启动器、plugin,以及每个 profile 的简写与--profile形式),每个读取使用独立的临时DSH_HOME与DSH_AGENTS_HOME,并由按原顺序进行的遍历消费;嵌套读取共享同一上限。失败的读取在遍历请求该调用时才交给遍历;close()中止排队与运行中的读取,并等待每个已启动的子进程结束。该门禁为 49.9/53.9 → 6.9/7.2 s。 - doc graphs:
scripts/gen-doc-graphs.ts中的withMemoizedFileSystemProbes()在renderDocs运行期间对ts.sys的fileExists、directoryExists、realpath、readDirectory与getDirectories做 memo,使一次运行中的所有编译器 host 与配置解析共享同一个文件系统视图。运行期间工作区不得改变,写出页面之前恢复原方法。该门禁为 48.7/44.7 → 32.7/34.2 s。 - 无损依据: 两个生成器生成的页面逐字节一致。
- 取舍: 并发的 help 进程合计峰值约占 3.6 GB 内存。
8. type-model.spec.ts 在内存工作区上运行
- 改动:
type-model.spec.ts用type-model-workspace.ts中的memoryFileSystem()mocknode:fs与ts.syshost,使内存根目录下的 fixture 复制、用例编辑、写模式注解、tsconfig 展开与模块解析都不触及磁盘。每个工作区挂载最小的zod与@types/node声明桩;只有 Emitter 用例保留真实 zod。fixture 的tsconfig.base.json设置lib: ["ES2024"],不含 DOM lib;读取未修改 fixture 的用例共享一份深度冻结的分析结果。generator 源码没有任何测试专用路径。 - 性能: 单独运行该文件为 316.3/329.1 → 14.65/15.04 s。
- 行为覆盖保持不变的依据: 61 个用例名及其断言相同。快照只有一行不同,即外部 zod 符号路径,它指向桩(
<external>:node_modules/zod/index.d.ts#ZodType),而不是某个 pnpm store 版本。 - 取舍: 除 Emitter 用例外,该 spec 不针对真实文件系统、DOM lib 以及真实 zod 与
@types/node声明检验分析器。内存文件系统只实现分析器使用的node:fs调用(existsSync、readFileSync、realpathSync与writeFileSync);对内存路径的其他调用会落到真实文件系统,而该路径并不存在。
全仓对比
两棵树在同一时段测量,每个命令在 pnpm run build:artifacts 之后交替运行基线与本实现(pnpm run test 在两棵树上失败的都是依赖环境的文件,基线 42 个,本实现是其中的 28 个):
| 命令 | 基线 | 本实现 | 变化 | 对应项 |
|---|---|---|---|---|
pre-commit 钩子,暂存一个位于包 src/ 子目录下的文件 |
16.5/17.8 s | 4.3/4.5 s | −75% | 2 |
pnpm run typecheck(pre-push),无需重查 |
29.7/23.3 s | 14.2/16.1 s | −43% | 1、3 |
pnpm run lint |
110.0 s | 106.7 s | −3% | 1 |
pnpm run doc-typecheck |
45.2 s | 36.1 s | −20% | 1 |
pnpm run test:docs |
102.4/104.0 s | 26.8/29.3 s | −73% | 3–6 |
pnpm run doc-sync |
186.8 s | 70.3 s | −62% | 3–7 |
pnpm run test |
222.7 s(255 个 worker) | 148.1 s(48 个 worker) | −34% | 3、8 |
lint 的耗时主要是 Oxlint 本身,其前置步骤的下降见第 1 项。CPU 时间随 wall 一同下降:typecheck 的 user 时间 39.5 → 14.2 s,test:docs 212.6 → 126.3 s,doc-sync 628.2 → 503.2 s,test 3,417 → 3,014 s。
考虑过的替代方案
- 所有本地默认值一律以 12 为上限,包括 Vitest fork(第 3 项): 12 个 worker 时
pnpm run test为 410.5 s,255 个时为 243.7 s,慢 68%。 - 以 cgroup CPU 配额的一半作为本地预算(第 3 项): 读取配额的改动更大,且只在容器内有意义。
- 用
worker_threads并行解析文档(第 5 项): 它带来的复杂度高于每个文档只解析一次,而后者直接消除了重复工作。 - 在 pre-commit 钩子中重新生成第三方声明(第 2 项): 浏览器输入使每个包源码文件都成为生成器输入,钩子会让几乎每次提交都运行约 16 秒的生成器,而改变输出的提交很少;生成的第三方声明 Note 拥有该决策。
后果
无需重查时 pre-push typecheck 约 14–22 s,lint 与 doc-typecheck 的前置步骤约 12 s,改动 1 个文件的提交的钩子约 4 s。pnpm run test 在本地最多启动 48 个 fork,test:docs 与 doc-sync 最多同时运行 8 个门禁。Typert 输出、生成的页面、文件列表与引用判定都与基线逐字节一致。
代价:类型检查命令不刷新可运行的 bundle;第三方声明过期时在推送后于测试 lane 失败;本地默认值不使用超过上限的 CPU,而配额低于上限的容器仍会超额订阅;并发 8 个文档门禁抬高 doc-sync 的 RSS 峰值;CLI help 峰值约用 3.6 GB 内存;doc graphs 与 markdown links 假定运行期间工作树不变;除 Emitter 用例外,type-model.spec.ts 检验的是桩,而不是真实 zod、@types/node、DOM lib 与磁盘 I/O;增量 typecheck 继承 Issue #6022 跟踪的 Remote 声明缺口。
验证:generate-typert.spec.ts 固定与 workspace 模式插件逐字节一致的文件、对加载已编译工作区声明的 Host program 的拒绝(并指出缺失的别名),以及每个失败阶段;compile-referenced-projects.spec.ts 固定 host-contracts 的步骤及其失败报告;build.spec.ts 固定 typecheck scope 的步骤;parallelism.spec.ts、run-gates.spec.ts 与 run-oxlint.spec.ts 固定 CI 与本地预算以及覆盖变量;repo-files.spec.ts、markdown.spec.ts、translation-pairing.spec.ts、type-equiv-prefilter.spec.ts、verify-md-links.spec.ts、gen-cli-help.spec.ts 与 gen-doc-graphs.spec.ts 固定目录只列一次、语法树复用、配对守卫、预筛、预取顺序与失败路径,以及探测 memo 及其恢复。