DSH / Atlas
2026-10-11implementedprocess

Build speed-ups that keep outputs unchanged

不改变产物的构建提速

A cold build carries three fixed costs that do not affect output contents: - **JSX signatures in the Client test aggregate program.** The Check phase of `tsc -p tsconfig.client.json` takes 59.1 s, more than half of it computing the `@types/react` module specifier for every JSX file that imports React. TypeScript's `computeModuleSpecifiers` visits every symlink location of that declaration file and scans all 491 tscon

English

Problem

A cold build carries three fixed costs that do not affect output contents:

  • JSX signatures in the Client test aggregate program. The Check phase of tsc -p tsconfig.client.json takes 59.1 s, more than half of it computing the @types/react module specifier for every JSX file that imports React. TypeScript's computeModuleSpecifiers visits every symlink location of that declaration file and scans all 491 tsconfig paths entries at each location. pnpm creates one node_modules/@types/react link for each workspace package that declares @types/react itself; 66 manifests declare it, so the Client test aggregate program sees 65 locations. Counters measured 88 JSX files × 2 modules (index.d.ts, jsx-runtime.d.ts) × 65 locations: 11,457 tryGetModuleNameFromPaths calls and 5,623,782 entry scans, 32.6 s in total. A dose-response run at five truncation levels of the visible location count N (N = 1 to 65; Check 26.92 s to 58.40 s) fits Check ≈ 26.9 + 0.494·N s.
  • Typert inside the Host tsdown pass. The Host tsdown pass takes about 21.6–22.6 s. Typert discovers and generates artifacts synchronously inside the first writeBundle; the CPU profile shows it blocking the tsdown main thread for about 12.4 s while every package build waits. Discovery takes 8.06 s: it parses all 362 package tsconfigs referenced by the two face aggregates (about 3.8 s), expands the Host aggregate's 2,064 literal include entries on every parse (1.6 s; the analyzer never reads those file names), and walks the public-export surface of 322 registrations (2.7–3.2 s). Only the 28 packages that declare a Typert export produce Host artifacts.
  • Manifest lookup in the Client tsdown pass. The Client tsdown pass takes about 11.7 s with a saturated main thread (7.1% idle); 34.5% of it (3.95 s) is workspaceManifest: every package config runs globSync('packages/*/*/package.json') for its package name and parses manifests until the name matches. The module-level cache has no effect because tsdown's native config loader imports every package config through import-without-cache, creating a fresh module instance per config. Each glob takes 40–125 ms and runs inside the neverBundle/alwaysBundle callbacks of tsdown:deps, blocking every build.

#5849 merged Host fixture type narrowing and Typert memoization; this note's baseline is the master that follows it.

Decision

The following eight speed-ups change neither the bytes of lib/ and apps/web/dist outputs nor the set of type diagnostics that repository builds report. The only change in diagnostic ownership comes from item 4: Typert does not validate tsconfigs of packages it does not analyze, and only tsc -b reports those errors. The build split note records the command split and concurrent scheduling.

#DecisionAffected stageMeasured medianOutputs and diagnosticsIntended behavior difference
1Only the root manifest declares React type packagesClient type checkingTest aggregate Check 59.1 → 26.3 sProgram file count and diagnostics unchangedPackage manifests do not list React type packages
2Typert selects packages by manifest firstHost tsdown22.62 → 19.94 sByte-identicalNone
3Typert does not expand aggregate includeHost tsdown19.94 → 16.99 sByte-identical; registration inventory identicalNone
4Typert parses package tsconfigs lazilyHost tsdown16.99 → 16.25 s (within noise); Typert discovery 5.9–7.1 → 0.36–0.40 sByte-identical; registration inventory identicalOnly tsc -b reports tsconfig errors in unanalyzed packages
5The Typert workspace root also requires packages/Host tsdownWithin noiseByte-identicalDual-face packages get artifacts in package mode
6Typert generates in a Worker ThreadHost tsdown15.39 → 10.25 sByte-identical; failures still fail tsdownHost tsdown max RSS about +0.6 GiB
7The Typert transform hook filters by module idHost tsdownWithin noise; hook calls 1,566 → 195Byte-identicalNone
8Client tsdown presets read the manifest from the package directoryClient tsdown12.78 → 8.73 sByte-identical; external decisions identicalThe presets' first argument is the package directory

The published ./tsdown export typertPlugin() changes with items 5–7 (transform as { filter, handler }, an asynchronous writeBundle, and a workspace root that must contain packages/); rolldown and tsdown consumers accept both hook forms, and the analyzer already required <root>/packages, so no external workspace that worked before stops working.

Measurement conditions

All numbers come from one shared Linux host (256 vCPU, 4 TB memory, load average 23–111 during runs) with Node v24.18.0, TypeScript 6.0.3, and tsdown 0.22.2 (rolldown 1.1.1).

  • Host tsdown: node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host, deleting every lib/typert.* before each run; variants run interleaved and sequentially, 3–4 samples per group. Items 2–4 come from the first round (starting from the baseline) and items 5–7 from the second round (starting from the code with items 2–4). Output comparison covers SHA-256 of the 743 top-level files of packages/*/*/lib, apps/*/lib, and vendor/*/lib (140 of them Typert artifacts). Medians of two groups running identical code differ by 0–0.11 s; samples within a group spread by up to 2.5 s.
  • Client test aggregate: tsc -p tsconfig.client.json --extendedDiagnostics with prebuilt references and a fresh tsBuildInfo file; six samples of identical code spread by 3.8 s in Check (about 6%).
  • Client tsdown: node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client on a built tree. The item 8 prototype ran three rounds in rotated order with a baseline spread of 1.56 s; the final implementation and the original ran interleaved, three samples each. Output comparison covers every file of {packages,apps,vendor,native}/**/lib/**, excluding *.tsbuildinfo.

1. Only the root manifest declares React type packages

  • Purpose: Reduce the number N of @types/react locations that the Client test aggregate program sees from 65 to 1, removing module-specifier work that grows linearly with N.

  • Change: The root package.json declares @types/react ~18.3.1 and @types/react-dom ~18.3.0 in devDependencies, the ranges every package used before; manifests under apps/*, packages/*/*, and vendor/* declare neither in any dependency section. TypeScript resolves the root node_modules/@types/react from every package. Packages still declare runtime react and react-dom under the Client dependency declaration rules; packages/client/ui-slots, which references React only through import type, declares react in devDependencies. pnpm dedupeDirectDeps stays off. Lockfile changes are confined to the importers section, plus one reordered papaparse entry with the same integrity.

  • Regression guard: verify-package-dependencies runs in hygiene and CI. It requires the root manifest's devDependencies to declare both packages and rejects either one in the dependencies, devDependencies, optionalDependencies, or peerDependencies of any manifest under apps/*, packages/*/*, or vendor/*; the diagnostic names the entry to delete and links Client dependency declaration rule 8. The rule matches only these two exact names; other @types/* packages remain DefinitelyTyped type providers. Dependency-section inference does not treat these two packages as type providers for react/react-dom: when a package lacks a react declaration, the verifier reports react as missing and --fix refuses to repair it until a version range is declared; --fix never writes back or deletes the two packages. verify-package-dependencies.spec.ts covers rejection in each dependency section, a root manifest missing the declarations, the accepted layout, and the type-provider exclusion.

  • Performance:

    MeasurementPer-package declarations (N = 65)Root manifest declaration (N = 1)
    Client test aggregate Check59.08 s (median of 3)26.28 s (median of 5), −56%
    Client test aggregate wall68–71 s33.6–34.9 s
    Client test aggregate user CPU79–83 s44.5–45.3 s
    Check sum of the 76 client-only package projects34.6 s (median of 2)26.5 s (median of 2), −23%
    Wall of the 76 client-only package projects55.7 s (median of 2)44.5 s (median of 2)
  • Why it is lossless: The 66 package-level links and the root link share one realpath (.pnpm/@[email protected]), so the program reads the same declarations; the program keeps 2,613 files, and tsc -b --force tsconfig.client.json exits 0 without diagnostics. devDependencies are not installed with published packages, so the published dependency faces conclusion that independent TypeScript consumers install referenced type packages themselves is unchanged; runtime resolution never reads @types/*.

  • Trade-off: Package manifests do not list their own React type dependency, and every workspace package shares one React types version; a package that needs a different version requires a new Agent Note overturning this item. The check covers only apps/*, packages/*/*, and vendor/*; other pnpm members such as website, benchmarks, native/system, and python/sdk-runtime are out of scope, and none of them currently declares React type packages.

2. Typert selects packages by manifest first

  • Purpose: Workspace mode generates artifacts only for packages that declare a Typert export, so it skips the surface walk for every other candidate.
  • Change: WorkspaceAnalyzer.discoverPackages(includeManifest?) and WorkspaceTypertGenerator.discover(faces?, includeManifest?) accept an optional package.json predicate applied before the surface walk; a rejected package has neither its sources read nor its project config parsed. WorkspaceTypertGenerator.generateContributors(faces?) passes hasTypertExport (exports contains ./typert, ./client/typert, or ./remote). Callers that pass no predicate (the Cordis catalog, generate() without packages) behave as before.
  • Performance: Host tsdown 22.62 s → 19.94 s (medians of 3).
  • Why it is lossless: The predicate is the filter previously applied after discovery, and a package it rejects never produced artifacts; the 743 output files are byte-identical except the Typert generator's own lib/index.js, whose source is this change.

3. Typert does not expand aggregate include

  • Purpose: The analyzer reads only an aggregate's compiler options and project references; expanding include only fills fileNames, which it never uses.
  • Change: WorkspaceCaches.aggregate(path) returns and caches AggregateConfig { path, options, projectReferences }. An aggregate that declares its own references is parsed with a ParseConfigHost whose readDirectory returns an empty list; an aggregate without references is expanded as usual.
  • Performance: Host tsdown 19.94 s → 16.99 s (medians of 3).
  • Why it is lossless: TypeScript reports TS18003 (no inputs) only for a config with neither files nor references, so an aggregate that declares references yields the same diagnostics, options, and references without expansion. The registration inventory dumped after forcing every lazy config (408 registrations, including fileNames and options, plus the aggregates' options and references) is byte-identical to the baseline, and the outputs are byte-identical. The tests reads aggregate options and references without expanding include patterns and reports an aggregate without references or inputs pin both kinds of config.

4. Typert parses package tsconfigs lazily

  • Purpose: Parse only the tsconfigs of packages selected for analysis, not every package that an aggregate references.
  • Change: PackageRegistration.config is a getter over caches.config(configPath) that parses on first read. The registration inventory keeps its order, faces, names, roots, and export subpaths.
  • Performance: Host tsdown 16.99 s → 16.25 s (medians of 3), within the 2.5 s within-group spread. Together, items 2–4 reduce Typert discovery in an isolated benchmark from 5.9–7.1 s to 0.36–0.40 s, tsconfig parses from 362 to 28, surface walks from 322 to 28, and total Typert time from 9.7–11.8 s to 4.4–4.5 s.
  • Why it is lossless: The inventory comparison is the one in item 3 (byte-identical), and the outputs are byte-identical. The test reads only the project configs of manifests selected for discovery pins that only selected configs are parsed.
  • Trade-off (not lossless): Typert does not parse tsconfigs of packages it does not analyze, so a syntax or configuration error in such a tsconfig does not fail Typert. In the same build, the Host package compilation tsc -b that runs before the Host bundle and the Client package compilation tsc -b parse every referenced tsconfig and reject those errors; the PackageRegistration.config JSDoc and the Typert generator README state this ownership. A consumer that runs Typert without tsc -b does not receive those diagnostics.

5. The Typert workspace root also requires packages/

  • Purpose: Correct workspace-root resolution. 24 dual-face packages carry their own tsconfig.host.json leaf; searching upward for tsconfig.host.json alone took each of them as a root, so workspace mode ran one empty discovery per such package per build, and package mode silently produced no artifacts for dual-face packages.
  • Change: The workspace root is the nearest ancestor of the bundle output that contains both tsconfig.host.json and packages/.
  • Performance: Host tsdown 14.82 s → 15.39 s (medians of 3), within noise.
  • Why it is lossless: The repository root tsdown.config.ts uses only workspace mode, and the 743 output files are byte-identical. The test resolves the workspace root above a dual-face package leaf pins the rule.
  • Behavior difference: A package-mode consumer building a dual-face package receives that package's Typert artifacts; repository builds do not use package mode.

6. Typert generates in a Worker Thread

  • Purpose: Overlap Typert analysis with bundling on the tsdown main thread instead of blocking every package build inside the first writeBundle.
  • Change: src/worker.ts is the Worker Thread entry; it runs generateContributors(faces) and posts one artifacts or failure message. In workspace mode, the build's first buildStart locates the workspace root from options.cwd and starts generation; writeBundle is asynchronous, the first finishing bundle claims emission and awaits the result, and later bundles return immediately. An analysis error, a worker crash or early exit, a malformed or undeserializable response, or a worker that cannot be terminated fails the claiming bundle with a TypertWorkerError naming the workspace and cause, which fails the whole tsdown pass; the worker is terminated before the outcome settles, and an unclaimed outcome never becomes an unhandled rejection. Built output starts the worker from ./worker.js; source runs (tests) load worker.ts through tsx/esm/api. package mode still generates on the main thread.
  • Performance: Host tsdown 15.39 s → 10.25 s (medians of 3). Profile of the final configuration (items 2–7): main thread 10.1 s, first buildStart at 4.18 s (tsdown config resolution), writeBundle on the main thread only 0.37 s; worker 5.0 s (module load about 0.4 s, discovery 0.28 s, generation 3.88 s including createProgram 2.21 s), ending at about 9.3 s. Host tsdown wall is about max(bundling, 4.2 s + Typert).
  • Why it is lossless: All 54 second-round runs exit 0, and each run's 743 output files match the baseline except the generator's own lib/index.js. The test writes the artifacts in-process generation produces compares worker and in-process artifacts; in a scratch workspace with one package's files broken, tsdown exits 1 within 2.4 s, reporting TypertWorkerError with the worker's TypertAnalysisError, and writes no artifacts. The typertPlugin workspace mode test group covers each failure path.
  • Trade-off: The worker heap is about 300–420 MiB; the final configuration's median Host tsdown max RSS is 6,296–6,408 MiB against 5,676 MiB at baseline. Under heavy shared-host load the worker can land on the critical path: one sample took 21.91 s with the artifact-owning package build waiting about 14 s for the worker, not reproduced in 10 later samples; at load average about 100, two runs took 17.86 s and 14.3 s. The worker also completes under a 1,024 MiB heap limit, so GC pressure is not the cause; attribution to host contention is unproven.

7. The Typert transform hook filters by module id

  • Purpose: Stop every module from crossing from rolldown into the JavaScript transform hook only to be rejected by an extension check.
  • Change: transform declares filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ }, and its handler still checks the id with the same regular expression.
  • Performance: Hook calls per Host build fall from 1,566 to 195 (195 TypeScript modules, 4 of them with decorators). Wall medians are 11.07 s (3 samples) and 9.98 s (4 samples) against 10.25 s for item 6, while the identical-code control groups give 11.07 s and 9.87 s; the difference is within noise.
  • Why it is lossless: The regular expression uses only ASCII constructs and matches the same id set in rolldown's Rust regex engine and in JavaScript; the handler applies the same check, so hosts that ignore filters behave the same. Outputs are byte-identical; the test filters transform calls to TypeScript module ids pins the filter.

8. Client tsdown presets read the manifest from the package directory

  • Purpose: Each package config reads only its own package.json, removing the glob over every manifest by package name.
  • Change: The first argument of clientBundle, staticLinked, and clientLibrary in packages/client/tsdown.client.ts is the package directory, normally the config file's import.meta.dirname; the preset reads <packageDir>/package.json once and uses its name as the plugin id and diagnostic name; when that manifest is missing, unreadable, or has no name, config loading fails with an error naming the path and cause. 82 package configs call these presets. The presets keep no module-level manifest cache; the Node half's production-dependency external list is computed from that manifest on first resolve and cached in that config's closure.
  • Performance: Client tsdown falls from 12.78 s to 8.73 s (medians of 3), −4.05 s (−31.7%), with disjoint sample ranges; user CPU falls from 26.1 s to 25.2 s and sys from 5.9 s to 4.9 s, so the saving comes mainly from unblocking the main thread rather than from less total CPU. The prototype (one glob building a name index) measured 11.72 s → 7.46 s (−36%). In Host tsdown only 5 hostPhase packages reach the lookup; one sample pair measured 11.92 s → 10.71 s, with low confidence.
  • Why it is lossless: The 11,566 lib files are byte-identical across 11 runs. After the 82 call sites switched to package directories, no resolved package name differs from the name previously written at the call site. An in-process comparison of the external decisions for 82 configs × 3 faces × 1,600 specifiers matches the original implementation exactly; a negative control with one extra external changes 75 decisions. client-bundle-purity.spec.ts covers these manifest errors.
  • Behavior difference: The presets' calling convention changes from a package name to a package directory.

Alternatives considered

  • Enable pnpm dedupeDirectDeps (item 1): It applies to every direct dependency and changes the link layout of every workspace package, a global side effect.
  • Patch TypeScript (item 1): An upstream-style patch (precomputed candidate endings and cached normalized paths patterns) lowers Check by 31.9 s, comparable to the hoist; on the hoisted tree it adds nothing (references Check 25.76 s against 26.47 s) and must be maintained across every TypeScript upgrade.
  • Alias react in tsconfig paths (item 1): tsx, vitest, and vite also read paths, so the alias changes their resolution of runtime react.
  • Narrow paths to each face's reference closure (item 1): The 328-entry version that keeps wildcard keys checks in 55.73 s against 59.08 s (one sample), within noise; the version without wildcard keys produces 18 diagnostics.
  • Split the Client test aggregate program (item 1): Every sub-program must inject the 242 files carrying module augmentations to pass, and the visible location count stays at 33–49; splitting into 2, 4, or 8 groups leaves the Check sum nearly flat while user CPU rises by 9%, 30%, and 57%, lowering only wall, and the 4- and 8-group splits each produce 1–2 diagnostics.
  • Cache resolution results and make the discovery queue O(1) (Typert): After items 2–4 only 28 surface walks remain, about 0.18 s, below noise.
  • Move Typert into a separate build step in artifact builds (Typert): After item 6 the worker already overlaps bundling, leaving at most about 1 s. Type checks, which need no bundle, run a source-only generation step (local gate speed-ups).
  • Filter transform by code content (item 7): rolldown interprets ^ and \s with Rust regex semantics, which differ from JavaScript on lone CR, U+2028, and BOM, so a decorated module could be missed.
  • Build a process-wide manifest index on globalThis (item 8): The prototype saves about as much time as item 8, but it is process-global state without an owner, violating the shared-state ownership rule in the package rules.
  • Disable tsdown's gzip report (item 8): --no-report lowers Client tsdown by 0.95 s, within the 1.56 s baseline spread.
  • Add rolldown filters to DSH resolveId/load hooks (item 8): Lowers it by 1.16 s, within noise.
  • Memoize the input-isolation check per module id (item 8): Lowers it by 0.93 s, within noise; on top of the manifest index it saves only another 0.18 s.

Consequences

Client type checking spends substantially less CPU and wall time, and the Host and Client tsdown passes take substantially less wall time, without changing runtime resolution, published installation semantics, or any output byte. In the serial build segments (master → branch, one sample each), Host tsdown falls from 22.5 to 11.7 s, Client package compilation from 55.6 to 48.7 s, the Client aggregate check from 68.2 to 35.9 s, and Client tsdown from 13.9 to 10.5 s; the serial segments sum to 308.5 s on master and 254.9 s on the branch (see the build split note). Host tsdown lies on the critical path of both build and build:artifacts, Client tsdown on that of build:artifacts, and the Client aggregate check is the last step of the build critical path.

Costs: every workspace package shares one React types version; Typert does not validate tsconfigs of packages it does not analyze, and tsc -b owns those errors; Host tsdown max RSS grows by about 0.6 GiB, and under heavy shared-host load the worker can become Host tsdown's critical path.

Reevaluation conditions: remeasure item 1 when migrating to TypeScript 7, and reconsider per-package declarations if the location count no longer affects Check time. tsdown's own config loading still takes 1.8–2.3 s per Client build (one uncached module instance per config) and needs an upstream change. The first buildStart arrives about 4.2 s into the Host build; starting the worker at plugin construction with an explicit root would start Typert earlier.

Verification: the Typert generator's tsdown-plugin.spec.ts, tsdown-plugin-worker.spec.ts, and type-model.spec.ts pin the behavior of items 2–7; the generator tests report 209 passed and 28 skipped, and tsc -b tsconfig.host.json exits 0. The repository coverage gate excludes packages/typert/*/src/**; under a scratch coverage config, tsdown-plugin.ts reaches 100% statements, branches, functions, and lines. Five negative controls (the old root rule, eager package configs, an expanded aggregate, no emission claim, a skipped worker termination) each fail the test that guards them; the real tsdown failure path exits 1 within 2.4 s and reports TypertWorkerError. Item 1: verify-package-dependencies exits 0 (74 packages), its spec passes 47/47, verify-client-packages exits 0, tsc -b tsconfig.client.json reports no diagnostics, and the Client aggregate Check takes 26.48 s. Item 8: all 72 focused specs pass.

中文

问题

冷启动构建中有三处与产物内容无关的固定开销:

  • Client 测试聚合程序的 JSX 签名。 tsc -p tsconfig.client.json 的 Check 阶段为 59.1 s,其中一半以上用于为每个导入 React 的 JSX 文件计算 @types/react 的 module specifier。TypeScript 的 computeModuleSpecifiers 遍历该声明文件的每个符号链接位置,并在每个位置扫描全部 491 条 tsconfig paths。pnpm 为每个自行声明 @types/react 的 workspace 包创建一个 node_modules/@types/react 链接;66 个 manifest 声明了它,Client 测试聚合程序因此看到 65 个位置。计数器测得 88 个 JSX 文件 × 2 个模块(index.d.ts、jsx-runtime.d.ts)× 65 个位置,共 11,457 次 tryGetModuleNameFromPaths 调用、5,623,782 次条目扫描,合计 32.6 s。按可见位置数 N 截断的剂量实验在五个截断级别(N 从 1 到 65,Check 从 26.92 s 到 58.40 s)上拟合出 Check ≈ 26.9 + 0.494·N s。
  • Host tsdown 阶段中的 Typert。 Host tsdown 阶段约 21.6–22.6 s。Typert 在第一个 writeBundle 中同步发现并生成产物,CPU profile 显示它阻塞 tsdown 主线程约 12.4 s,期间所有包构建都在等待。发现阶段占 8.06 s:它解析两个 face 聚合配置所引用的 362 个包 tsconfig(约 3.8 s),每次解析 Host 聚合配置都展开 2,064 条字面 include(1.6 s,分析器从不读取这些文件名),并对 322 个注册项做公开导出表面遍历(2.7–3.2 s);最终只有 28 个声明 Typert 导出的包产生 Host 产物。
  • Client tsdown 阶段的 manifest 查找。 Client tsdown 阶段约 11.7 s,主线程饱和(空闲仅 7.1%),其中 34.5%(3.95 s)在 workspaceManifest:每个包配置按包名 globSync('packages/*/*/package.json') 并逐个解析 manifest,直到名字匹配。模块级缓存无效,因为 tsdown 的 native 配置加载器通过 import-without-cache 为每个包配置创建新的模块实例;每次 glob 耗时 40–125 ms,且发生在 tsdown:deps 的 neverBundle/alwaysBundle 回调中,阻塞全部构建。

#5849 已合并 Host fixture 类型收窄与 Typert memoization;本 Note 的基线是其后的 master。

决策

以下八项提速不改变 lib/、apps/web/dist 的产物字节,也不改变仓库构建报告的类型诊断集合。唯一的诊断归属变化来自第 4 项:Typert 不校验它不分析的包 tsconfig,这些错误只由 tsc -b 报告。命令拆分与并发调度由构建拆分 Note记录。

#决策受影响阶段实测中位数产物与诊断有意的行为差异
1React 类型包只由根 manifest 声明Client 类型检查测试聚合 Check 59.1 → 26.3 s程序文件数与诊断不变包 manifest 不列出 React 类型包
2Typert 先按 manifest 选择包Host tsdown22.62 → 19.94 s逐字节一致无
3Typert 不展开聚合配置的 includeHost tsdown19.94 → 16.99 s逐字节一致;注册清单一致无
4Typert 惰性解析包 tsconfigHost tsdown16.99 → 16.25 s(噪声内);Typert 发现 5.9–7.1 → 0.36–0.40 s逐字节一致;注册清单一致未分析包的 tsconfig 错误只由 tsc -b 报告
5Typert 工作区根目录同时要求 packages/Host tsdown噪声内逐字节一致package 模式下双 face 包获得产物
6Typert 在 Worker Thread 中生成Host tsdown15.39 → 10.25 s逐字节一致;失败仍使 tsdown 失败Host tsdown 最大 RSS 约 +0.6 GiB
7Typert transform 钩子按模块 id 过滤Host tsdown噪声内;钩子调用 1,566 → 195逐字节一致无
8Client tsdown 预设按包目录读取 manifestClient tsdown12.78 → 8.73 s逐字节一致;external 判定一致预设第一个参数是包目录

第 5–7 项改变了发布的 ./tsdown 导出 typertPlugin()(transform 为 { filter, handler }、writeBundle 为异步、工作区根目录必须包含 packages/);rolldown 与 tsdown 的使用方接受两种钩子形式,分析器本来就要求 <root>/packages,因此此前可用的外部工作区都不会失效。

测量条件

所有数字取自同一台共享 Linux 主机(256 vCPU、4 TB 内存,运行期间 load average 23–111),Node v24.18.0、TypeScript 6.0.3、tsdown 0.22.2(rolldown 1.1.1)。

  • Host tsdown: node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE host,每次运行前删除全部 lib/typert.*;各变体交替顺序运行,每组 3–4 个样本。第 2–4 项取自第一轮测量(起点为基线),第 5–7 项取自第二轮测量(起点为第 2–4 项合入后的代码)。产物比较覆盖 packages/*/*/lib、apps/*/lib、vendor/*/lib 顶层的 743 个文件(含 140 个 Typert 产物)的 SHA-256。相同代码的两组中位数相差 0–0.11 s,组内样本跨度最大 2.5 s。
  • Client 测试聚合: references 预先构建后运行 tsc -p tsconfig.client.json --extendedDiagnostics,使用全新的 tsBuildInfo 文件;相同代码 6 个样本的 Check 跨度为 3.8 s(约 6%)。
  • Client tsdown: 已构建的树上运行 node node_modules/tsdown/dist/run.mjs --config-loader native --env.DSH_BUILD_FACE client。第 8 项的原型 3 轮轮换顺序,基线跨度 1.56 s;最终实现与原实现交替运行,各 3 个样本。产物比较覆盖 {packages,apps,vendor,native}/**/lib/**(排除 *.tsbuildinfo)的全部文件。

1. React 类型包只由根 manifest 声明

  • 目的: 把 Client 测试聚合程序看到的 @types/react 位置数 N 从 65 降到 1,消除随 N 线性增长的 module specifier 计算。

  • 改动: 根 package.json 的 devDependencies 声明 @types/react ~18.3.1 与 @types/react-dom ~18.3.0,沿用各包原先统一的版本范围;apps/*、packages/*/* 与 vendor/* 的 manifest 在任何依赖区段都不声明它们。TypeScript 从任一包向上查找时都解析到根 node_modules/@types/react。runtime react 与 react-dom 仍按 Client 依赖声明规则由各包声明;只通过 import type 引用 React 的 packages/client/ui-slots 在 devDependencies 中声明 react。pnpm dedupeDirectDeps 保持关闭。lockfile 的变化集中在 importers 区段(另有一条 integrity 相同的 papaparse 条目重排)。

  • 防回退: verify-package-dependencies 在 hygiene 与 CI 中要求根 manifest 的 devDependencies 声明这两个包,并拒绝 apps/*、packages/*/*、vendor/* 中任一 manifest 在 dependencies、devDependencies、optionalDependencies 或 peerDependencies 中声明它们;诊断指明要删除的条目,并链接 Client 依赖声明规则第 8 条。规则只匹配这两个精确包名,其他 @types/* 包仍按 DefinitelyTyped 类型提供方处理。依赖区段推导不把这两个包视为 react/react-dom 的类型提供方:包缺少 react 声明时,验证器报告 react 缺失,--fix 拒绝修复并要求先声明版本范围;--fix 也不写回或删除这两个包。verify-package-dependencies.spec.ts 覆盖各依赖区段的拒绝、根 manifest 缺失声明、被接受的布局,以及类型提供方排除。

  • 性能:

    测量项各包自声明(N = 65)根 manifest 声明(N = 1)
    Client 测试聚合 Check59.08 s(3 个样本的中位数)26.28 s(5 个样本的中位数),−56%
    Client 测试聚合 wall68–71 s33.6–34.9 s
    Client 测试聚合 user CPU79–83 s44.5–45.3 s
    76 个 client-only 包项目的 Check 总和34.6 s(2 个样本的中位数)26.5 s(2 个样本的中位数),−23%
    76 个 client-only 包项目的 wall55.7 s(2 个样本的中位数)44.5 s(2 个样本的中位数)
  • 无损依据: 66 个包级链接与根链接指向同一个 realpath(.pnpm/@[email protected]),因此程序读取的声明内容相同;程序文件数保持 2,613,tsc -b --force tsconfig.client.json 以 0 退出且无诊断。devDependencies 不随已发布的包安装,发布依赖面中独立 TypeScript 消费者自行安装所引用类型包的结论不变;runtime 解析不读取 @types/*。

  • 取舍: 包 manifest 不列出自己的 React 类型依赖,所有 workspace 包共用同一个 React 类型版本;某个包需要不同版本时,需要新的 Agent Note 推翻本项。检查只覆盖 apps/*、packages/*/* 与 vendor/*;website、benchmarks、native/system 与 python/sdk-runtime 等其他 pnpm 成员不在范围内,目前它们都不声明 React 类型包。

2. Typert 先按 manifest 选择包

  • 目的: workspace 模式只为声明 Typert 导出的包生成产物,避免对其余候选包做表面遍历。
  • 改动: WorkspaceAnalyzer.discoverPackages(includeManifest?) 与 WorkspaceTypertGenerator.discover(faces?, includeManifest?) 接受可选的 package.json 谓词,在表面遍历之前应用;被拒绝的包既不读取源码,也不解析项目配置。WorkspaceTypertGenerator.generateContributors(faces?) 以 hasTypertExport(exports 含 ./typert、./client/typert 或 ./remote)为谓词。不传谓词的调用方(Cordis 目录、不指定包的 generate())行为不变。
  • 性能: Host tsdown 22.62 s → 19.94 s(各 3 个样本的中位数)。
  • 无损依据: 谓词就是原先在发现之后应用的同一过滤条件,被它拒绝的包本来就不产生产物;743 个产物文件逐字节一致(Typert generator 自身的 lib/index.js 除外,其源码即本项改动)。

3. Typert 不展开聚合配置的 include

  • 目的: 分析器只读取聚合配置的编译选项与 project references,展开 include 只为填充从不使用的 fileNames。
  • 改动: WorkspaceCaches.aggregate(path) 返回并缓存 AggregateConfig { path, options, projectReferences }。解析自身声明 references 的聚合配置时,使用 readDirectory 返回空列表的 ParseConfigHost;不声明 references 的聚合配置照常展开。
  • 性能: Host tsdown 19.94 s → 16.99 s(各 3 个样本的中位数)。
  • 无损依据: TypeScript 只对既无 files 也无 references 的配置报告 TS18003(无输入),因此声明 references 的聚合配置在不展开时得到相同的诊断、选项与 references。强制读取每个惰性配置后导出的注册清单(408 个注册项,含 fileNames 与选项,以及聚合配置的选项和 references)与基线逐字节一致;产物逐字节一致。测试 reads aggregate options and references without expanding include patterns 与 reports an aggregate without references or inputs 固定这两种配置的行为。

4. Typert 惰性解析包 tsconfig

  • 目的: 只解析被选中分析的包的 tsconfig,而不是聚合配置引用的全部包。
  • 改动: PackageRegistration.config 是 caches.config(configPath) 的 getter,首次读取时解析。注册清单的顺序、face、包名、根目录与导出子路径不变。
  • 性能: Host tsdown 16.99 s → 16.25 s(各 3 个样本的中位数),在 2.5 s 的组内跨度以内。第 2–4 项合计在隔离基准中把 Typert 发现从 5.9–7.1 s 降到 0.36–0.40 s,tsconfig 解析从 362 次降到 28 次,表面遍历从 322 次降到 28 次,Typert 总耗时从 9.7–11.8 s 降到 4.4–4.5 s。
  • 无损依据: 注册清单比较与第 3 项相同(逐字节一致),产物逐字节一致。测试 reads only the project configs of manifests selected for discovery 固定只解析所选包的配置。
  • 取舍(非无损): Typert 不解析它不分析的包的 tsconfig,因此这类 tsconfig 的语法或配置错误不会使 Typert 失败。同一构建中,先于 Host bundle 运行的 Host 包编译 tsc -b 与 Client 包编译 tsc -b 解析每个被引用的 tsconfig,并拒绝其中的错误;PackageRegistration.config 的 JSDoc 与 Typert generator README 写明这一归属。单独调用 Typert 而不运行 tsc -b 的使用方不会得到这些诊断。

5. Typert 工作区根目录同时要求 packages/

  • 目的: 修正工作区根目录的判定。24 个双 face 包带有自己的 tsconfig.host.json 叶子配置;只按 tsconfig.host.json 向上查找时,每个这样的包都被当作根目录,workspace 模式每次构建为它们各做一次空发现,package 模式下双 face 包得不到产物且没有报错。
  • 改动: 工作区根目录是打包输出最近的、同时包含 tsconfig.host.json 与 packages/ 的祖先目录。
  • 性能: Host tsdown 14.82 s → 15.39 s(各 3 个样本的中位数),在噪声内。
  • 无损依据: 仓库根 tsdown.config.ts 只使用 workspace 模式,743 个产物文件逐字节一致。测试 resolves the workspace root above a dual-face package leaf 固定该判定。
  • 行为差异: package 模式的使用方构建双 face 包时得到该包的 Typert 产物;仓库构建不使用 package 模式。

6. Typert 在 Worker Thread 中生成

  • 目的: 让 Typert 分析与 tsdown 主线程上的打包重叠,而不是在第一个 writeBundle 中阻塞全部包构建。
  • 改动: src/worker.ts 是 Worker Thread 入口,运行 generateContributors(faces) 并回传一条 artifacts 或 failure 消息。workspace 模式下,本次构建的第一个 buildStart 以 options.cwd 定位工作区根目录并启动生成;writeBundle 是异步的,第一个完成的打包认领写入并等待结果,之后的打包立即返回。分析错误、worker 崩溃或提前退出、格式错误或无法反序列化的响应、worker 无法终止,都以指明工作区与原因的 TypertWorkerError 使认领写入的打包失败,从而使整个 tsdown 阶段失败;结果敲定前 worker 已终止,未被认领的结果不会成为未处理的 rejection。构建产物从 ./worker.js 启动 worker;源码运行(测试)通过 tsx/esm/api 加载 worker.ts。package 模式仍在主线程中生成。
  • 性能: Host tsdown 15.39 s → 10.25 s(各 3 个样本的中位数)。最终配置(第 2–7 项)的 profile:主线程 10.1 s,第一个 buildStart 出现在 4.18 s(tsdown 配置解析),主线程上的 writeBundle 只占 0.37 s;worker 5.0 s(模块加载约 0.4 s、发现 0.28 s、生成 3.88 s,其中 createProgram 2.21 s),约在 9.3 s 结束。Host tsdown 的 wall 约为 max(打包, 4.2 s + Typert)。
  • 无损依据: 第二轮的 54 次运行全部以 0 退出,每次的 743 个产物文件均与基线一致(generator 自身的 lib/index.js 除外)。测试 writes the artifacts in-process generation produces 比较 worker 与进程内生成的产物;在临时工作区中把一个包的 files 改坏后,tsdown 在 2.4 s 内以 1 退出,报告 TypertWorkerError 及 worker 内的 TypertAnalysisError,不写入任何产物。各失败路径由 typertPlugin workspace mode 测试组覆盖。
  • 取舍: worker 堆约 300–420 MiB;最终配置的 Host tsdown 最大 RSS 中位数为 6,296–6,408 MiB,基线为 5,676 MiB。共享主机高负载时 worker 可能落在关键路径上:一次样本为 21.91 s,拥有产物的包构建等待 worker 约 14 s,其后 10 个样本未复现;load average 约 100 时两次运行为 17.86 s 与 14.3 s。worker 在 1,024 MiB 堆上限下也能完成,因此不是 GC 压力;归因于主机争用尚未证实。

7. Typert transform 钩子按模块 id 过滤

  • 目的: 避免每个模块都从 rolldown 进入 JavaScript transform 钩子,只为被扩展名检查拒绝。
  • 改动: transform 声明 filter: { id: /^[^?]*\.[cm]?tsx?(?:\?|$)/ },处理函数仍以同一正则检查 id。
  • 性能: 每次 Host 构建的钩子调用从 1,566 次降到 195 次(195 个 TypeScript 模块,其中 4 个含装饰器)。wall 中位数为 11.07 s(3 个样本)与 9.98 s(4 个样本),第 6 项为 10.25 s,相同代码的对照组为 11.07 s 与 9.87 s,差异在噪声内。
  • 无损依据: 该正则只含 ASCII 构造,在 rolldown 的 Rust 正则与 JavaScript 中匹配相同的 id 集合;处理函数应用同一检查,因此不读取过滤器的宿主行为相同。产物逐字节一致;测试 filters transform calls to TypeScript module ids 固定过滤器。

8. Client tsdown 预设按包目录读取 manifest

  • 目的: 每个包配置只读取自己的 package.json,消除按包名遍历全部 manifest 的 glob。
  • 改动: packages/client/tsdown.client.ts 的 clientBundle、staticLinked 与 clientLibrary 的第一个参数是包目录(通常为配置文件的 import.meta.dirname),预设读取一次 <packageDir>/package.json,以其中的 name 作为插件 id 与诊断名称;该 manifest 缺失、不可读或缺少 name 时,配置加载以指明路径与原因的错误失败。82 个包配置调用这些预设。预设不保留模块级 manifest 缓存;Node 半边的生产依赖 external 列表在首次 resolve 时由该 manifest 计算,并缓存在该配置的闭包中。
  • 性能: Client tsdown 从 12.78 s 降到 8.73 s(各 3 个样本的中位数),−4.05 s(−31.7%),两组样本区间不重叠;user CPU 从 26.1 s 降到 25.2 s,sys 从 5.9 s 降到 4.9 s,因此收益主要来自解除主线程阻塞,而不是减少 CPU 总量。原型(一次 glob 建立按包名索引)为 11.72 s → 7.46 s(−36%)。Host tsdown 中只有 5 个 hostPhase 包经过该查找,一对样本为 11.92 s → 10.71 s,置信度低。
  • 无损依据: 11 次运行的 11,566 个 lib 文件逐字节一致。82 个调用处改为传入包目录后,没有一个解析出的包名与原先手写的包名不同。进程内比较 82 个配置 × 3 个 face × 1,600 个 specifier 的 external 判定,结果与原实现完全一致;负对照(多加一个 external)使 75 个判定不同。client-bundle-purity.spec.ts 覆盖这些 manifest 错误。
  • 行为差异: 预设的调用约定从包名改为包目录。

考虑过的替代方案

  • 开启 pnpm dedupeDirectDeps(第 1 项): 作用于所有直接依赖,改变每个 workspace 包的链接布局,副作用是全局的。
  • 给 TypeScript 打补丁(第 1 项): 上游式补丁(预先计算候选扩展名并缓存规范化的 paths 模式)使 Check 降低 31.9 s,与提升相当;在已提升的树上不再带来收益(refs Check 25.76 s 对 26.47 s),且需随每次 TypeScript 升级维护。
  • 在 tsconfig paths 中为 react 加别名(第 1 项): tsx、vitest 与 vite 也读取 paths,别名会改变它们对 runtime react 的解析。
  • 把 paths 收窄为各 face 的 reference closure(第 1 项): 保留通配键的 328 条版本 Check 为 55.73 s 对 59.08 s(1 个样本),在噪声内;去掉通配键的版本产生 18 个诊断。
  • 拆分 Client 测试聚合程序(第 1 项): 每个子程序都必须注入 242 个含模块增强的文件才能通过检查,可见位置数仍为 33–49;拆成 2、4、8 组时 Check 总和基本不变,user CPU 分别增加 9%、30%、57%,只降低 wall,且 4 组与 8 组各出现 1–2 个诊断。
  • 缓存解析结果并把发现队列改为 O(1)(Typert): 第 2–4 项之后只剩 28 次表面遍历,约 0.18 s,低于噪声。
  • 在产物构建中把 Typert 移到独立构建步骤(Typert): 第 6 项之后 worker 已与打包重叠,剩余收益不超过约 1 s。不需要 bundle 的类型检查运行只读源码的生成步骤(本地门禁提速)。
  • 按代码内容过滤 transform(第 7 项): rolldown 以 Rust 正则语义解释 ^ 与 \s,在孤立 CR、U+2028 与 BOM 上与 JavaScript 不同,可能漏掉含装饰器的模块。
  • 在 globalThis 上建立进程级 manifest 索引(第 8 项): 原型收益与第 8 项相当,但它是没有 owner 的进程全局状态,违反包规则中的共享状态归属要求。
  • 关闭 tsdown 的 gzip 报告(第 8 项): --no-report 使 Client tsdown 降低 0.95 s,在 1.56 s 的基线跨度内。
  • 为 DSH 的 resolveId/load 钩子加 rolldown 过滤器(第 8 项): 降低 1.16 s,在噪声内。
  • 按模块 id 缓存输入隔离检查(第 8 项): 降低 0.93 s,在噪声内;叠加在 manifest 索引之上只再降低 0.18 s。

后果

Client 类型检查的 CPU 与 wall 时间、Host tsdown 与 Client tsdown 的 wall 时间都明显下降,且不改变 runtime 解析、发布安装语义或任何产物字节。串行构建的各段中(master → 本分支,各一个样本),Host tsdown 从 22.5 s 降到 11.7 s,Client 包编译从 55.6 s 降到 48.7 s,Client 聚合检查从 68.2 s 降到 35.9 s,Client tsdown 从 13.9 s 降到 10.5 s;串行各段合计在 master 上为 308.5 s,在本分支上为 254.9 s(见构建拆分 Note)。Host tsdown 位于 build 与 build:artifacts 的关键路径上,Client tsdown 位于 build:artifacts 的关键路径上,Client 聚合检查是 build 关键路径的最后一步。

代价:所有 workspace 包共用一个 React 类型版本;Typert 不校验它不分析的包 tsconfig,这些错误由 tsc -b 负责;Host tsdown 的最大 RSS 约增加 0.6 GiB,共享主机高负载时 worker 可能成为 Host tsdown 的关键路径。

重新评估条件:迁移到 TypeScript 7 时重新测量第 1 项,若位置数不再影响 Check 时间,可以重新评估包级声明。tsdown 的配置加载本身每次 Client 构建仍占 1.8–2.3 s(每个配置一个无缓存的模块实例),需要上游改动。第一个 buildStart 在 Host 构建开始约 4.2 s 后才出现;在插件构造时以显式根目录启动 worker 可以进一步提前 Typert。

验证:Typert generator 的 tsdown-plugin.spec.ts、tsdown-plugin-worker.spec.ts 与 type-model.spec.ts 固定第 2–7 项的行为;generator 测试 209 个通过、28 个跳过,tsc -b tsconfig.host.json 以 0 退出。仓库覆盖率 gate 不包含 packages/typert/*/src/**,在临时覆盖率配置下 tsdown-plugin.ts 的 statements、branches、functions 与 lines 均为 100%。五个负对照(旧根目录规则、立即解析包配置、展开聚合配置、不认领写入、跳过 worker 终止)各自使守护它的测试失败;真实 tsdown 失败路径在 2.4 s 内以 1 退出并报告 TypertWorkerError。第 1 项:verify-package-dependencies 以 0 退出(74 个包),其 spec 47/47 通过,verify-client-packages 以 0 退出,tsc -b tsconfig.client.json 无诊断,Client 聚合 Check 为 26.48 s。第 8 项:72 个相关 spec 全部通过。