Local gate, hook, and test speed-ups
本地门禁、钩子与测试提速
On the baseline, master at the merge of #6013, local runs of the Git hooks, `pnpm run test`, `test:docs`, and `doc-sync` spend most of their wall time on work that their results do not depend on: - **Typert declarations from the bundler.** `typecheck`, the command the pre-push hook runs, executes `build:lib:host` (Host `tsc -b tsconfig.host.json`, the Host tsdown pass, and the Desktop bundle) before `tsc -b tsconfig.
English
Problem
On the baseline, master at the merge of #6013, local runs of the Git hooks, pnpm run test, test:docs, and doc-sync spend most of their wall time on work that their results do not depend on:
- Typert declarations from the bundler.
typecheck, the command the pre-push hook runs, executesbuild:lib:host(Hosttsc -b tsconfig.host.json, the Host tsdown pass, and the Desktop bundle) beforetsc -b tsconfig.client.json. The prerequisite oflint,lint:fix,doc-typecheck, and the CItypert-contractsgate runs Host package compilation and then the Host tsdown pass (compile-referenced-projects.ts host-libraries). Type checks read only package declarations and Typert declarations, yettypecheckwith nothing to recheck takes 34.1/35.0 s and the prerequisite takes 52.2/29.6 s. - Third-party notices on every commit. The pre-commit hook regenerates
THIRD_PARTY_NOTICES.mdwhenever a staged file is a generator input. To find browser-side third-party imports, the generator test-bundles 75 Client packages and runs one Vite build, so every package source file is an input; on a commit that stages one file under a packagesrc/subdirectory, the hooks take 16.5/17.8 s, of which the notices job takes 12.4/13.4 s. - Host CPUs as the local worker count.
os.availableParallelism()reports the host's CPUs (256 on the measurement host) rather than the container's CPU quota (64). With that default,pnpm run teststarts 255 Vitest forks, peaks at 55.3 GiB RSS, and fails 12 additional files (14 timeouts) intermittently, whiledoc-sync,test:docs,hygiene, andcheck:allrun at most 4 gates concurrently. - One Git prefix lookup per candidate.
verify-repository-referencesresolves each of about 26,000 abbreviated hexadecimal candidates with a Git prefix lookup throughgit cat-file --batch-check; the lookups take 135.7 s of the gate's 166.8 s. - Repeated Markdown parsing. markdown links parses each anchor target twice more and probes target existence without a cache; translation pairing globs four times and parses each document again after link normalization; the package README model-experience and limitations checks each parse every README three times; type equivalence parses all 1,835 documents; Session format references parses each pair four times.
- Repeated directory reads.
uniqueRepoFiles, the walker behind markdown wrap, doc refs, and package paths, reads each directory once per pattern and**level and resolves every matched file withrealpath. - Serial help launches and repeated compiler probes.
gen-cli-helplaunches its 12 top-level source-modedsh … --helpprocesses one after another, each waiting mostly on profile boot I/O (49.9/53.9 s);gen-doc-graphsbuilds several compiler hosts and programs that repeat the samets.sysfile-system probes (48.7/44.7 s). - A test file bound by file-system probes. Each of the 61 cases of the Typert generator's
type-model.spec.tscopies its fixture to disk and runs a full analysis whose four TypeScript programs each load the DOM library,@types/node, and zod v4, about 22,000 stat calls per analysis. The file takes 316.3/329.1 s and is the long tail ofpnpm run test.
Decision
The eight decisions below shorten those runs. Each item states the evidence that its outputs and diagnostics stay the same, or the intended difference it accepts.
| # | Decision | Affected commands | Measured wall | Outputs and diagnostics | Intended behavior difference |
|---|---|---|---|---|---|
| 1 | Type checks generate Typert declarations without bundling | typecheck, lint, lint:fix, doc-typecheck, CI typert-contracts | typecheck 34.1/35.0 → 21.9/20.0 s; prerequisite 52.2/29.6 → 12.5/11.3 s | 140 Typert files byte-identical; the same programs are checked | No runnable bundle and no client build record |
| 2 | The pre-commit hook does not regenerate third-party notices | pre-commit | 16.5/17.8 → 4.3/4.5 s | Freshness asserted in the test lane | Stale notices fail in the test lane, not at commit |
| 3 | Local default worker budget | run-gates aggregates, build, typecheck, pnpm run test, Oxlint, publint | test at 255 workers: 243.7 s, 55.3 GiB; test:docs 74.7 → 50.9 s | Same checks | Local defaults ignore CPUs above 12 or 48 |
| 4 | Repository-reference candidates resolve against one object listing | verify-repository-references | 166.8 → 15.7 s | 0 classification differences over 27,050 candidates | None |
| 5 | Documentation gates parse each document once | markdown links, translation pairing, README model experience and 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 | Byte-identical gate output | None |
| 6 | Repository file walks list each directory once | markdown wrap, doc refs, package paths | doc refs 8.8/7.0 → 3.2/3.4 s | Identical file lists | doc refs does not read nested lib directories |
| 7 | CLI help reads run concurrently; doc-graph probes are memoized | verify-cli-help, verify-doc-graphs | 49.9/53.9 → 6.9/7.2 s; 48.7/44.7 → 32.7/34.2 s | Byte-identical pages | CLI help uses about 3.6 GB at peak |
| 8 | type-model.spec.ts runs against an in-memory workspace | pnpm run test | 316.3/329.1 → 14.65/15.04 s | Same 61 cases and assertions; one snapshot line differs | Stubs replace zod, @types/node, and the DOM library outside the Emitter case |
Measurement conditions
All numbers are wall times from one shared Linux container with a 64-CPU quota on a host that reports 256 CPUs, at a one-minute load average of 20–150, so samples carry noise. The working tree lives on a network filesystem, where each file-system probe costs more than on a local disk. a/b lists two samples, and x → y compares the baseline with this implementation.
1. Type checks generate Typert declarations without bundling
- Purpose: Give
typecheckand the prerequisite oflint,lint:fix,doc-typecheck, and the CItypert-contractsgate the declarations they read, package declarations fromtscand Typert declarations, without the Host tsdown pass or the Desktop bundle. - Change:
typecheckistsx scripts/build.ts --scope typecheck, which runs the steps below through the run-gates scheduler (scripts/build.ts). The scope builds no native addon, tsdown bundle, Desktop bundle, or Web output, writes no client build record, and leaves runnablelib/output as the lastbuild:artifactsleft it; runbuild:artifactsbefore launching or testing built output. The prerequisite oflint,lint:fix,doc-typecheck, andtypert-contractsiscompile-referenced-projects.ts host-contracts, which runs host-src beside host-typert (compile-referenced-projects.ts).build,build:artifacts, benchmark builds, and the PR rehearsal inrelease-vendor.ymlrun Typert inside the Host tsdown pass.
| Step | Command | needs |
|---|---|---|
| host-src | node --max-old-space-size=4096 <tsc> -b <references of tsconfig.host.json> | — |
| host-typert | node --import tsx/esm scripts/generate-typert.ts | — |
| client-src | <tsc> -b <references of tsconfig.client.json> | host-src, host-typert |
| host-check | node --max-old-space-size=4096 <tsc> -b tsconfig.host.json | host-src |
| client-check | <tsc> -b tsconfig.client.json | client-src |
- Shared generation:
scripts/generate-typert.tscallsWorkspaceTypertGenerator.generateContributors()forWORKSPACE_TYPERT_FACES(scripts/typert-faces.ts), the faces the Host tsdown plugin also generates, and writes the result withwriteWorkspaceArtifacts()fromartifact-files.ts, the writer the plugin uses. Like the plugin, it skips TypeScript diagnostics, which Host package compilation reports; a Typert analysis error fails host-typert as it fails the Host bundle inbuild. host-typert runs under Node with tsx's ESM hook rather than throughpnpm exec, whose startup would lengthen the path to Client compilation. - Aggregate checks in build mode: host-check and client-check start after their own face's package compilation, so build mode finds every reference up to date, checks only the aggregate root program, and skips a root program whose tsbuildinfo is current.
- Source-only analysis: host-typert can run beside host-src because its Host program reads no workspace build output.
tsconfig.base.jsonmaps@deepseek-ai/dsh-client-file-upload/typesto source; without that alias the specifier resolves through packageexportstolib/types/types.d.ts. With it, the Host program loads 1,289 files, none of them under a workspace package'slib/.generate-typert.tsenablesrejectBuildOutputs:build-output-guard.tsfails analysis with aTypertAnalysisErrorwhen a face program loads a file from a workspace package'slib/directory, or cannot resolve an import of a workspace package, as on a tree whose packages are not yet compiled, naming each such import specifier and thepathsentry that maps it to source. Either way a missing alias fails generation whether or not host-src has run. The tsdown plugin leaves the option off because inbuildTypert always runs after Host package compilation. The Typert generator README documents the writers and the option. - Why it is lossless: No workspace tsconfig sets
allowJs, everyexportstypescondition points intolib/types/or atlib/typert.*, and every tsdown config setsdts: false, so no type check reads tsdown output. The 140 Typert files (28 packages × 5 outputs) are byte-identical across the baseline tsdown pass, the current tsdown pass,generate-typert.tsafter every Typert output is deleted, andgenerate-typert.tswith all 344lib/typesdirectories hidden; with one Remote method temporarily added topackages/goal/goal, the tsdown pass andgenerate-typert.tsalso write identical files.typecheckchecks every package project of both faces and both aggregate root programs, the programs thatbuildchecks. - Performance:
typecheckwith nothing to recheck takes 34.1/35.0 → 21.9/20.0 s. When both aggregate root programs must be rechecked, it takes 83.5 → 76.0/72.1 s, and its critical path is the Host aggregate check at about 66–70 s. The prerequisite oflintanddoc-typechecktakes 52.2/29.6 → 12.5/11.3 s. - Trade-off:
typecheck,lint,lint:fix,doc-typecheck, andtypert-contractswrite Typert declarations but no bundle, so runnablelib/JavaScript can lag behind those declarations until the nextbuild:artifacts. - Relation to the build: The lossless build speed-ups note rejects moving Typert out of the Host tsdown pass for builds, where its Worker Thread already overlaps bundling and a separate step saves at most about 1 s. Type-check commands run no bundler, so generating without one removes the whole Host tsdown pass and the Desktop bundle from their critical path. The build split note lists every caller and its command.
- Known gap: Incremental
tsc -bdoes not recordlib/typert.remote-client.d.ts, which Client sources reach through packageexports, as an input of the consuming project; when a Remote signature changes and no Client source does, an incremental check does not recheck the consumers. The gap is independent of which pass writes the declarations; Issue #6022 tracks it.
2. The pre-commit hook does not regenerate third-party notices
- Change: The
pre-commithook inlefthook.ymlhas no third-party notices job.gen-third-party-notices.spec.tsasserts the committed bytes ofTHIRD_PARTY_NOTICES.mdin the test lane, including the requirednode 24 / coveragelane, and namespnpm run gen-third-party-noticeswhen they are stale. The generated third-party notices note owns this freshness decision. - Performance: A commit that stages one file under a package
src/subdirectory spends 4.3/4.5 s in the hooks instead of 16.5/17.8 s, of which the notices job took 12.4/13.4 s. - Trade-off: A commit that leaves the notices stale fails in the test lane after push rather than being corrected at commit time.
3. Local default worker budget
- Purpose: Size default parallelism for a shared workstation or container, where Node reports host CPUs rather than the CPU quota.
- Change:
scripts/parallelism.tsowns the default. WithCI=trueit is the CPU count Node reports. Otherwise it is capped at 12 (LOCAL_PARALLELISM_CAP) for the run-gates scheduler,build,typecheck, Oxlint, and publint, and at 48 (LOCAL_TEST_PARALLELISM_CAP) for Vitest forks and the local coverage budget.doc-sync,test:docs,hygiene, andcheck:all, whose gates each build a fullts.Program, run at most 8 gates concurrently, in CI as well. ExplicitDSH_GATE_CONCURRENCY,DSH_OXLINT_THREADS,DSH_COVERAGE_MAX_WORKERS, and Vitest--maxWorkerstake precedence; in CI, Vitest and Oxlint keep their own defaults unless the run-gates CI environment sets these variables. The development guide states the defaults for contributors.
pnpm run test at four fork counts, with the disk-backed type-model.spec.ts that item 8 replaces, which sets the wall of the 32-worker run:
| Vitest workers | Wall | Peak RSS | Additional intermittent failures | Every file except the slowest finished at |
|---|---|---|---|---|
| 255 (the reported CPU count) | 243.7 s | 55.3 GiB | 12 files, 14 timeouts | about 135–170 s |
| 32 | 310.4 s | 15.7 GiB | 4 files, 4 timeouts | 193 s |
| 24 | 337.2 s | 13.2 GiB | 5 files, 5 timeouts | 233 s |
| 12 | 410.5 s | 8.4 GiB | 2 files, 3 timeouts | 406 s |
- Oxlint: Oxlint's own default is 64 threads, read from the cgroup quota; at 12 threads its wall stays about 87–90 s with about 15% less CPU, and at 8 threads it takes about 4 s longer.
- Documentation aggregates:
test:docstakes 50.9 s at 8 concurrent gates against 74.7 s at 4.doc-sync, measured without item 4, takes 341.8, 295.0, and 253.2 s at 4, 8, and 12 concurrent gates, with peak RSS of 10.0, 13.5, and 16.3 GiB. - Trade-off: A machine whose CPU quota exceeds the caps gets no more default parallelism than 12 or 48 and needs the override variables; a container whose quota is below a cap still oversubscribes, because Node reports host CPUs. At 8 concurrent gates
doc-syncpeaks at 13.5 GiB RSS against 10.0 GiB at 4; 12 concurrent gates finish 41.8 s sooner at 16.3 GiB. The CI documentation workflow also runsdoc-syncat 8 concurrent gates.
4. Repository-reference candidates resolve against one object listing
- Change:
verify-repository-references.tslists every object name once withgit cat-file --batch-all-objects --batch-check=%(objectname), about 1.2 million objects in 0.6 s, sorts the list, and binary-searches each lowercase candidate prefix in memory; one--batch-check=%(objectname) %(objecttype)call then reads the types of the matched objects. A candidate is a commit reference only when its prefix matches exactly one available object and that object is a commit, the rule Git's own prefix lookup applies; the listing covers unreachable and alternate objects, as Git's lookup does. - Performance: 166.8 → 15.7 s.
- Why it is lossless: Git's per-candidate prefix lookup and the in-memory search classify all 27,050 real and synthetic candidates identically, including ambiguous prefixes, blob and tree prefixes, and random strings.
- Trade-off: The listing is about 49 MiB of Git output for 1.2 million objects, read under a 1 GiB output limit.
5. Documentation gates parse each document once
- Change: Each gate below parses a Markdown document once and passes the tree to every consumer in that run.
| Gate | Single-parse mechanism | Wall |
|---|---|---|
markdown links (verify-md-links.ts) | One per-run cache parses each file once for its destinations and anchors and probes each resolved target once | 53.4/51.6 → 25.9/25.1 s |
translation pairing (verify-translation-pairing.ts) | One glob covers every scope pattern; the record reuses the authored tree under the guard below | 51.0/47.1 → 30.3/29.3 s |
| package README model experience, package README limitations | Each check parses a README once and passes the tree to markdownProseLines() and markdownHeadingLines() | about 12 → 5.9 s each |
type equivalence (verify-type-equiv.ts) | A text prefilter skips documents that cannot hold a source-equivalence fence; each manifest TypeScript source is parsed once | 23.0/22.5 → 6.0/5.5 s |
| Session format references | The persistence renderer passes the trees it parses to the pairing record | 10.2 → 5.3 s |
- Pairing reuse guard:
normalizedTopLevelBlocks()intranslation-pairing-record.tsreuses the authored tree for normalized text only when every link-normalization edit replaces a complete, title-free destination that does not start a line, the authored text equals the node'surl, and both the authored and the replacement text contain only[A-Za-z0-9._/:#%-]. Those characters cannot end a destination or start or end another construct, so the normalized text parses to the same nodes with shifted offsets. Any other edit falls back to a full parse of the normalized text. - Type-equivalence prefilter:
type-equiv-prefilter.tskeeps a document for parsing when it containstype-equivorpublic-api, or when a line containing```or~~~also contains&or\. Parsing changes an info string only by decoding character references and backslash escapes, sots type-equivandts type\-equivkeep their documents;type-equiv-prefilter.spec.tscovers both forms. - Why it is lossless: Every gate's output on the repository is byte-identical, and seeded faults are reported identically. For pairing, the top-level blocks of all 1,886 corpus documents and the records of all 943 pairs match those of a full reparse, as do randomized destinations.
6. Repository file walks list each directory once
- Change:
uniqueRepoFiles()inscripts/repo-files.tsshares one directory-listing cache across all patterns of a call, so each directory is read at most once, and callsrealpathonly for matches reached through a symbolic link; a regular file reached through real directories canonicalizes to the repository's realpath joined with its relative path. Its optionalskipDirectorypredicate keeps the walker out of a directory whose every path the caller excludes;verify-doc-refs.tsskips everylibdirectory below the first path segment, whose files it excludes in any case. - Performance: 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.
- Why it is lossless: All three gates receive identical file lists, and a broken or cyclic symbolic link still fails through
realpath. - Behavior difference: doc refs does not list a skipped
libdirectory, so an unreadable one does not fail the gate with EACCES.
7. CLI help reads run concurrently; doc-graph probes are memoized
- CLI help:
prefetchHelp()inscripts/gen-cli-help.tsstarts the 12 top-leveldsh … --helpreads (the launcher,plugin, and each profile's shorthand and--profileforms) within the default worker budget, each with its own temporaryDSH_HOMEandDSH_AGENTS_HOME, and the in-order traversal consumes them; nested reads share the same limit. A failed read reaches the traversal when it requests that invocation, andclose()aborts queued and running reads and waits for every started child. The gate takes 49.9/53.9 → 6.9/7.2 s. - Doc graphs:
withMemoizedFileSystemProbes()inscripts/gen-doc-graphs.tsmemoizests.sysfileExists,directoryExists,realpath,readDirectory, andgetDirectorieswhilerenderDocsruns, so every compiler host and config parse in one run shares one view of the file system. The workspace must not change during the run, and the original methods are restored before pages are written. The gate takes 48.7/44.7 → 32.7/34.2 s. - Why it is lossless: Both generators produce byte-identical pages.
- Trade-off: The concurrent help processes use about 3.6 GB of memory combined at peak.
8. type-model.spec.ts runs against an in-memory workspace
- Change:
type-model.spec.tsmocksnode:fswithmemoryFileSystem()and thets.syshost fromtype-model-workspace.ts, so fixture copies, case edits, write-mode annotations, tsconfig expansion, and module resolution under the in-memory root never touch the disk. Each workspace mounts minimalzodand@types/nodedeclaration stubs; only the Emitter case keeps real zod. The fixture'stsconfig.base.jsonsetslib: ["ES2024"], which excludes the DOM library, and cases that read the unmodified fixture share one deep-frozen analysis. The generator source has no test-specific path. - Performance: The file alone takes 316.3/329.1 → 14.65/15.04 s.
- Why the behavior coverage stays: The 61 case names and their assertions are the same. The snapshot differs in one line, the external zod symbol path, which names the stub (
<external>:node_modules/zod/index.d.ts#ZodType) rather than a pnpm store version. - Trade-off: Outside the Emitter case, the spec does not exercise the analyzer against the real file system, the DOM library, or the real zod and
@types/nodedeclarations. The memory file system implements only thenode:fscalls the analyzer makes (existsSync,readFileSync,realpathSync, andwriteFileSync); another call on an in-memory path reaches the real file system, where the path does not exist.
Repository-wide comparison
Both trees were measured in the same session, alternating baseline and implementation runs of each command after pnpm run build:artifacts (pnpm run test fails the same environment-dependent files on both trees, 42 on the baseline and a subset of 28 here):
| Command | Baseline | This implementation | Change | Items |
|---|---|---|---|---|
pre-commit hooks, one staged file under a package src/ subdirectory | 16.5/17.8 s | 4.3/4.5 s | −75% | 2 |
pnpm run typecheck (pre-push), nothing to recheck | 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 workers) | 148.1 s (48 workers) | −34% | 3, 8 |
lint time is Oxlint itself; its prerequisite falls as item 1 states. CPU time falls with wall: typecheck user time 39.5 → 14.2 s, test:docs 212.6 → 126.3 s, doc-sync 628.2 → 503.2 s, and test 3,417 → 3,014 s.
Alternatives considered
- Cap every local default at 12, including Vitest forks (item 3):
pnpm run testtakes 410.5 s at 12 workers against 243.7 s at 255, 68% longer. - Use half of the cgroup CPU quota as the local budget (item 3): Reading the quota is a larger change and helps only inside containers.
- Parse documents in parallel
worker_threads(item 5): It adds more complexity than parsing each document once, which removes the repeated work itself. - Regenerate third-party notices in the pre-commit hook (item 2): Browser inputs make every package source file a generator input, so the hook runs the 16-second generator on nearly every commit while few commits change its output; the generated third-party notices note owns the decision.
Consequences
The pre-push typecheck with nothing to recheck takes about 14–22 s, the lint and doc-typecheck prerequisite about 12 s, and the hooks of a one-file commit about 4 s. pnpm run test starts at most 48 forks locally, and test:docs and doc-sync run up to 8 gates at once. Typert outputs, generated pages, file lists, and reference classifications are byte-identical to the baseline.
Costs: type-check commands do not refresh runnable bundles; a stale notices file fails in the test lane after push; local defaults ignore CPUs above the caps and still oversubscribe a container whose quota is below them; 8 concurrent documentation gates raise the doc-sync RSS peak; CLI help uses about 3.6 GB at peak; doc graphs and markdown links assume the tree does not change during a run; type-model.spec.ts exercises stubs rather than real zod, @types/node, the DOM library, and disk I/O outside the Emitter case; and incremental typecheck inherits the Remote-declaration gap that Issue #6022 tracks.
Verification: generate-typert.spec.ts pins byte-identical files against the workspace-mode plugin, rejection of a Host program that loads a compiled workspace declaration with the missing alias named, and each failure stage; compile-referenced-projects.spec.ts pins the host-contracts steps and their failure reporting; build.spec.ts pins the typecheck scope's steps; parallelism.spec.ts, run-gates.spec.ts, and run-oxlint.spec.ts pin the CI and local budgets and the overrides; 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, and gen-doc-graphs.spec.ts pin single directory listing, parse reuse, the pairing guard, the prefilter, the prefetch order and failure path, and probe memoization with restoration.
中文
问题
在基线(合并 #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 构建与release-vendor.yml的 PR(Pull Request)演练在 Host tsdown 阶段内运行 Typert。
| 步骤 | 命令 | needs |
|---|---|---|
| host-src | node --max-old-space-size=4096 <tsc> -b <references of tsconfig.host.json> | — |
| host-typert | node --import tsx/esm scripts/generate-typert.ts | — |
| client-src | <tsc> -b <references of tsconfig.client.json> | host-src、host-typert |
| host-check | node --max-old-space-size=4096 <tsc> -b tsconfig.host.json | host-src |
| client-check | <tsc> -b tsconfig.client.json | client-src |
- 共用生成:
scripts/generate-typert.ts对WORKSPACE_TYPERT_FACES(scripts/typert-faces.ts,即 Host tsdown 插件同样生成的 face)调用WorkspaceTypertGenerator.generateContributors(),并用artifact-files.ts中插件同样使用的writeWorkspaceArtifacts()写出结果。与插件一样,它跳过 TypeScript 诊断,这些诊断由 Host 包编译报告;Typert 分析错误使 host-typert 失败,正如它在build中使 Host bundle 失败。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 插件不开启该选项,因为在build中 Typert 总在 Host 包编译之后运行。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 否决了在构建中把 Typert 移出 Host tsdown 阶段:构建中它的 Worker Thread 已与打包重叠,独立步骤最多只能省约 1 s。类型检查命令不运行打包器,因此不打包地生成会把整个 Host tsdown 阶段与 Desktop bundle 移出它们的关键路径。构建拆分 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 及其恢复。