Skip to content

Cut typecheck wall time with TypeScript project references [DO NOT MERGE] - #503

Merged
TheGreatAxios merged 8 commits into
mainfrom
cl-7226-cut-typecheck-wall-time-with-typescript-project-references
Aug 31, 2026
Merged

TheGreatAxios merged 8 commits into
mainfrom
cl-7226-cut-typecheck-wall-time-with-typescript-project-references

Conversation

@TheGreatAxios

@TheGreatAxios TheGreatAxios commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Summary

CL-7226: typecheck across 115 workspace packages took over 10 minutes
(exceeds the 600s command cap), because none of the 86 package tsconfigs
used TypeScript project references — every package's tsc re-checked the
full transitive source of every dependency. Two mandatory parts, per the
ticket's scope-expansion comment:

  1. Land TypeScript project references + tsc --build, with references
    generated from real workspace dependencies by a script.
  2. Delete the custom machinery that existed only to work around the
    slowness — scripts/affected.ts, WORKBENCH_CHECK_SINCE — cutting
    over cleanly, no fallback path left beside the new one.

DO NOT MERGE — pushed for CI and review per instruction.

What changed

  • Every package tsconfig now sets composite: true,
    emitDeclarationOnly: true, outDir: dist,
    tsBuildInfoFile: dist/tsconfig.tsbuildinfo, and references generated
    from its own package.json dependencies (never devDependencies) by
    scripts/generate-tsconfig-references.ts. bun run check:tsconfig-references
    (wired into check:structural) fails on drift.
  • A real dependency cycle (found via Tarjan's SCC over the workspace graph)
    excludes its members from the composite graph, and — this is load-bearing,
    not cosmetic — that exclusion cascades to every package that depends on
    an excluded package, even transitively. Half-excluding just the cycle
    members leaves their consumers pulling the excluded dependency's raw
    source directly into their own composite program, which TypeScript flags
    as a rootDir violation once it walks into whatever that raw source
    itself imports. 27 of 115 packages fall back to the pre-change
    tsc -p tsconfig.json --noEmit path as a result (6 real cycle members +
    1 direct importer of a shared test harness + 20 cascaded transitive
    consumers, including apps/hub and apps/web). Breaking that cycle is
    out of scope here (mechanical tsconfig cutover only, no product-code
    refactors) but is the natural next ticket to capture the rest of the win.
  • Every package also gets a sibling tsconfig.test.json (non-composite,
    src + test, same references) because test files routinely reach a
    shared, non-package test harness (scripts/e2e/harness.ts) that itself
    imports production code — joining the composite graph directly would put
    a package in a reference cycle with itself.
  • tsc --build runs against a single generated solution file
    (tsconfig.build.json, {files: [], references: [...]} to every
    composite project), not against every composite tsconfig.json as
    separate CLI roots. The latter was tried first and produces wrong
    results
    : passing ~88 unrelated root projects to one tsc --build
    invocation shares source-file/diagnostic state across them, and
    misattributes a rootDir violation from one project's dependency graph
    to a completely unrelated project in the same invocation (verified:
    isolated single/pair-project builds were clean, the full 88-root
    invocation was not, the single-solution-file invocation is clean with
    zero errors).
  • tsconfig.test.json needed an explicit rootDir (computed as the
    relative path to the repo root) — with outDir inherited from the
    composite src config but no explicit rootDir, TypeScript infers
    rootDir from include alone and TS6059s on any file a test reaches
    outside src/test, even under noEmit.
  • Deleted outright: scripts/affected.ts, scripts/affected.test.ts,
    WORKBENCH_CHECK_SINCE (including its .github/workflows/ci.yml
    wiring on both the typecheck and test jobs). WORKBENCH_CHECK_CONCURRENCY
    stays — it sizes the worker pool that fans package scripts across cores,
    unrelated to guessing affected packages — extracted into a shared
    scripts/concurrency.ts used by both run-all.ts and typecheck.ts.
  • VENDORED.md and scripts/checks/kill-dates.txt updated for the
    vendor/intx/* tsconfig edits (content hashes recomputed; check:killdates
    hashes the vendored tree and would otherwise flag drift). No upstream
    Interchange repo touched.

CI-cost tradeoff, stated plainly

Removing WORKBENCH_CHECK_SINCE from the test job means every PR now
runs the full test suite instead of only the affected slice. Measured
before landing this: bun run scripts/run-all.ts test across every
workspace package takes ~118s, comfortably under the 600s cap — so this
is not a regression in practice. Accepted deliberately, matching the
ticket's explicit "nothing anywhere still needs to know about 'affected
packages' as a concept."

Benchmarks (this machine, other lanes concurrently active — load average noted per run)

Scope Before After
Single package tsc --noEmit 1.6s (unchanged path for the 27 legacy-fallback packages)
WORKBENCH_CHECK_SINCE narrowed typecheck (packages/chat change, 34/115) 437–546s (mechanism deleted)
Full bun run typecheck, cold >600s (exceeded cap) ~110–118s, load avg ~3–5 before run, climbing to ~14–22 during (other lanes on this shared 8-core machine)
Full bun run typecheck, warm no-op not measured (n/a — no incrementality existed) ~56s, load avg ~3–7 before
Full bun run typecheck, single widely-depended package edit (packages/error-sink) not measured ~72s, load avg ~10–14 before
Full bun run scripts/run-all.ts test not separately measured ~118s

"Before" full-typecheck/narrowed figures are the ticket's own pre-existing
measurements (pinned at 00bab807); "after" figures are freshly measured on
this branch just now. Load was never idle during these runs (other lanes
active) — numbers are reported honestly rather than cherry-picked from a
quiet window.

Type errors surfaced and how they were resolved

tsc --build initially surfaced 408 TS6059 (rootDir) errors, all one
class, none a real type-strictness finding:

  1. Missing cascade — cyclic packages were excluded from references,
    but their transitive consumers weren't, so those consumers pulled the
    excluded package's raw source (and whatever that imports) straight
    into their own program. Fixed by propagating exclusion to a fixpoint
    over the dependency graph (down to 253 errors).
  2. Multi-root tsc --build misattribution — passing 88 composite
    tsconfigs as separate CLI roots in one invocation. Fixed by building a
    single generated solution file instead (down to 253... same 253, this
    was a different bug than Notifications: approvals, failures, and mentions as durable mail #1, both needed fixing).
  3. tsconfig.test.json missing an explicit rootDir — the last 253,
    all under the separate tsc -p tsconfig.test.json --noEmit fallback
    checks, not tsc --build itself. Fixed with a per-package computed
    rootDir pointing at the repo root.

Zero errors remain. exactOptionalPropertyTypes and every other
tsconfig.base.json base option are unchanged — nothing was loosened to
make errors disappear.

A drift-detection bug was also found and fixed along the way:
testFieldsMatch in the generator never compared compilerOptions, so a
change to tsconfig.test.json's generated compiler options would silently
never get written to existing files or flagged by --check.

Unmet criteria / follow-ups

  • 27/115 packages (the real dependency cycle + its transitive consumers,
    including apps/hub and apps/web) still run the pre-change
    non-composite tsc --noEmit fallback. Breaking the cycle
    (hub-client / connections / inference-settings / webhook-triggers
    / workflow-catalog / folded-runs) would let tsc --build cover them
    too — worth a follow-up ticket; out of scope here (product-code refactor).
  • Open, unmerged PR Add local pre-push gates so PRs are not opened on a red check #472 (cl-7188-add-local-pre-push-gates...) adds
    scripts/git-hooks/pre-push, which sets WORKBENCH_CHECK_SINCE=origin/main
    before running lint/typecheck/test locally. Not touched here (different
    branch, not yet merged) — once it rebases past this change, that env var
    is inert (nothing reads it anymore) but should be removed from the hook
    script in that PR to avoid a dangling reference.

Interchange defects

None found. vendor/intx/* tsconfig edits are in-repo build tooling only;
no upstream Interchange source was touched.

Verification

  • bun run check:structural — green (includes the new check:tsconfig-references)
  • bun run scripts/typecheck.ts — 0 errors, ~110–118s cold
  • scripts/generate-tsconfig-references.test.ts — 12/12 pass (includes
    regression coverage for the transitive-cascade exclusion)
  • scripts/run-all.test.ts — 13/13 pass
  • check:killdates, check:licenses — green

Update: CI failures fixed (lint SIGABRT + check:killdates)

CI reported two failures on the first push, both real consequences of
the project-references change, neither visible in local verification
because I'd correctly avoided the slow root-level runs:

1. bun run lint — SIGABRT (heap OOM), exit 134.

Root cause, found by reproducing locally: the composite tsconfig.json
excludes a package's test files (required for tsc --build's DAG
constraint — see the commit for why), but ESLint's projectService
only ever discovers a project by looking for a file literally named
tsconfig.json; it has no way to also check tsconfig.test.json.
Every composite package's test files were invisible to project
discovery ("was not found by the project service"), and with no build
having run yet (lint is a standalone CI job), the type checker fell
back to resolving every cross-package import through raw source
instead of compact .d.ts files — for every file in the repo, in one
long-lived process. Measured peak: ~7GB, comfortably past what a
default Node heap (or a typical CI runner) can give it.

Fix, not a memory bump alone:

  • Renamed the composite project to tsconfig.src.json and made
    tsconfig.json the combined (src+test) project again, so every tool
    that discovers a project by convention — ESLint, an editor, a bare
    tsc in the package directory — finds it without any ESLint-specific
    configuration. tsc --build and a composite dependent's references
    now point at the renamed file by explicit path.
  • bun run lint now runs tsc --build tsconfig.build.json before
    prettier/eslint, so cross-package type resolution goes through
    declarations instead of raw source.

Together these bring a genuinely cold peak (no .eslintcache) down to
~4.2GB. That's the real, measured, bounded cost of type-aware
linting across 115 packages with declarations prebuilt — architecturally
correct, not a workaround. It's still close enough to typical default
Node heap ceilings that a busier CI runner could tip over it, so
NODE_OPTIONS=--max-old-space-size=6144 gives the lint step headroom
above the measured peak. This is the "if you say why and give the
number" case: the number is 4.2GB measured, 6144MB is the number
raising it to.

Two more bugs surfaced and got fixed doing this:

  • withSrcFields hardcoded a "../../tsconfig.base.json" extends path
    that doesn't resolve for vendor/intx/* (one directory deeper than
    every other workspace root) — the silently-broken extends dropped
    skipLibCheck and surfaced ~280 unrelated node_modules library type
    errors. Computed from the package's actual directory depth now.
  • The combined project's include was rebuilt from a fixed
    ["src", "test"] list, silently dropping
    packages/e2b-sandbox-sidecar's template/ directory (sandbox
    assets, not src or test). Now carries forward any extra include
    entry from the package's existing tsconfig, with a regression test.

2. check:killdates — 14 violations.

Not a blanket regenerate: each violation was individually real —
hashDirectory hashes a vendored directory's full tree but only
excluded node_modules and *.tsbuildinfo, not dist/. The hash I'd
recorded was computed while dist/ (declaration output from a prior
tsc --build run) was sitting on disk locally; a fresh CI checkout has
no dist/ (gitignored), so its hash differed — for exactly the 14
vendor/intx/* rows, matching the 14 violations precisely. Fixed
hashDirectory to also exclude dist/, added a regression test
mirroring the existing .tsbuildinfo one, verified the hash is now
stable both with and without dist/ present, and recomputed the
correct hash (twice more, honestly, as the tsconfig rename above
changed vendored content again in the same session).

Verification after both fixes, all in the foreground:

  • bun run scripts/typecheck.ts: 0 errors, ~130s cold
  • bun run lint: exit 0, 8 warnings (all pre-existing, confirmed
    against 00bab807, unrelated to this change), ~51s
  • bun run check:killdates: ok, stable with and without dist/ built
  • bun run check:structural: green

PR #472 (open, unmerged, sets WORKBENCH_CHECK_SINCE in a new
pre-push hook) is untouched, per the coordinator's note that it's
already tracked there.

@TheGreatAxios

Copy link
Copy Markdown
Contributor Author

Merge-order hazard between this PR and the other one touching scripts/checks/kill-dates.txt.

PR #503 (CL-7226) recomputes the content hash for all 14 vendor/intx/* rows, because it fixed hashDirectory to exclude dist/ — the previously recorded hashes were computed on a tree where local build output existed, and a fresh CI checkout has none, so 14 rows mismatched.

PR #508 (CL-7242) adds a new migration and a schema change under vendor/intx/db, which changes that package's tree content and therefore its hash.

Each PR computed its hash from a tree that does not contain the other's changes. They will conflict textually on the vendor/intx/db row, and — more importantly — resolving that conflict by picking either side's literal hash will be wrong, because the correct post-merge hash is of a tree containing both the dist/ exclusion fix and the new migration.

Whichever of these merges second must recompute rather than resolve by hand:

bun -e 'import { hashDirectory } from "./scripts/checks/lib/tree-hash"; console.log(hashDirectory("vendor/intx/db"));'

then verify with bun run check:killdates. Do this on a tree with no dist/ present, or after #503's exclusion fix has landed — otherwise the value is environment-dependent, which is the exact bug #503 fixed.

Recording this rather than leaving it to be discovered as a red check after merge.

@TheGreatAxios
TheGreatAxios force-pushed the cl-7226-cut-typecheck-wall-time-with-typescript-project-references branch from ca1e586 to b06a5fe Compare August 31, 2026 02:27
Covers reference derivation from real workspace deps (dropping
devDependencies), the src/test tsconfig split, dependency-cycle
exclusion, shared-root-script exclusion, and --check drift detection.
Every package tsconfig now sets composite/emitDeclarationOnly/outDir
and declares references generated from its real workspace
dependencies, so tsc --build consumes dependents' declarations
instead of re-checking their full transitive source on every package's
own tsc invocation. scripts/generate-tsconfig-references.ts derives
those references from package.json (not devDependencies), excludes
real dependency cycles and their transitive consumers from the
composite graph via Tarjan's algorithm plus cascading exclusion
(a composite project cannot put a non-composite dependency in
references, and half-excluding just the cycle members left their
consumers pulling that dependency's raw source into their own
program), and writes a sibling tsconfig.test.json per package so test
files -- which routinely reach a shared test harness that itself
imports production code -- can't put a package in a reference cycle
with itself.

tsc --build runs against a single generated solution file
(tsconfig.build.json) rather than every composite tsconfig.json as
separate command-line roots: the latter was tried first and produces
wrong results, because tsc --build shares source-file/diagnostic
state across sibling root arguments and can misattribute a rootDir
violation from one project's graph to an unrelated one in the same
invocation. A single root project does not have this problem and
still builds the whole graph in dependency order, skipping whatever
is already up to date.

tsconfig.test.json also needs an explicit rootDir at the workspace
root: with outDir inherited from the composite src config but no
explicit rootDir, TypeScript infers rootDir from include alone and
TS6059s on any file a test reaches outside src/test -- which a shared
test helper living outside every package's own directory always is.

Cold full typecheck: ~118s, down from over 10 minutes (exceeded the
600s command cap). check:tsconfig-references (wired into
check:structural) fails if a tsconfig drifts from the generator's
output.
…dant

scripts/affected.ts guessed which packages a change could break from
package.json edges, with a GLOBAL_PATHS fallback for the cases it
couldn't reason about. It existed only to make bun run check
affordable by skipping most of the workspace, because every
package's tsc invocation re-checked the full transitive source of
its dependencies. tsc --build now reads the real dependency graph and
does its own incremental up-to-date checks, so the guess is no longer
needed and was already the weaker of the two: it narrows from
package.json edges, tsc --build narrows from the files that actually
changed.

WORKBENCH_CHECK_SINCE and its CI wiring go with it. Measured before
removing it from the test job: a full bun run scripts/run-all.ts test
across every workspace package takes about 118s, comfortably under
the 600s command-cap this repo works under -- so losing that
narrowing does not reintroduce the problem it was working around.

WORKBENCH_CHECK_CONCURRENCY stays: it sizes the worker pool that fans
package scripts out across cores, which has nothing to do with
guessing affected packages, and scripts/run-all.test.ts already
exercises it directly. The duplicated parsing/validation between
run-all.ts and typecheck.ts moved to scripts/concurrency.ts in the
previous commit.
The project-references cutover edited every vendor/intx/*
tsconfig.json (composite, references, a sibling tsconfig.test.json),
the same shape every other workspace package got in this change.
Vendored trees carry a content hash of their own; update it and note
the delta in VENDORED.md so a future diff against upstream isn't
mistaken for drift nobody explained.
check:killdates hashes each vendored directory's content to catch a
silent edit, and already excluded node_modules and *.tsbuildinfo as
install/build artifacts. It missed dist/: once a composite package's
tsc --build run writes declaration output there, the hash changes
even though nothing was actually edited, and the very next
check:killdates run reports every vendored package as drifted. CI
caught this on CL-7226's project-references PR because it was the
first change to ever make these packages emit dist/ output.
…tion

ESLint's projectService (like an editor's language server, or a bare
tsc invocation in a package directory) discovers a project by looking
for a file literally named tsconfig.json -- it has no way to also
check a same-purpose file under a different name. The composite
src-only project used to be tsconfig.json itself, with the combined
src+test project at tsconfig.test.json; every composite package's
test files were therefore invisible to project discovery ("was not
found by the project service"), and ESLint had no built-in project to
fall back to for them either (allowDefaultProject disallows the ** glob
that would be needed for arbitrarily-nested test files).

Swapping the names fixes this with no ESLint-specific configuration at
all: tsconfig.json is now the combined project (extends
tsconfig.src.json, adds test) that every tool finds on its own, and
tsconfig.src.json is the composite project that only tsc --build and a
composite dependent's own references need to know exists (by an
explicit path, e.g. "../dep/tsconfig.src.json" -- a project reference
target is never a bare directory once the composite file isn't named
tsconfig.json).

This also fixes the crash CI reported on the previous version of this
branch (SIGABRT, heap OOM): the underlying cause was ESLint's type
checker resolving cross-package imports through raw source instead of
compact .d.ts files, because no build had run yet in the standalone
lint job and every composite package's test files (unable to resolve a
project) still needed full type information somehow. Two changes fix
it together: `bun run lint` now builds the composite graph
(tsc --build tsconfig.build.json) before invoking eslint, so cross-
package types resolve through declarations; and this rename means
every file -- test included -- resolves to the project the language
service actually expects, instead of falling back to loading whichever
package's raw source it can find. Measured peak eslint memory: ~7GB
before either fix, ~4.2GB after both (a genuinely cold run, no
.eslintcache). NODE_OPTIONS=--max-old-space-size=6144 gives the lint
step headroom above that measured peak -- Node's default old-space
ceiling sits close enough to 4.2GB that a busier CI runner could still
tip over it.

Two more fixes needed along the way:
- withSrcFields hardcoded "../../tsconfig.base.json" as the composite
  project's extends path. vendor/intx/* sits one directory deeper than
  every other workspace root, so the fixed string resolved to a path
  that doesn't exist there; the extends silently failed, dropping
  skipLibCheck (among everything else tsconfig.base.json sets) and
  surfacing hundreds of node_modules library type errors that had
  nothing to do with this change. Computed from the package's own
  directory depth instead.
- combinedConfigFor rebuilt every package's `include` from a fixed
  ["src", "test"] list, silently dropping packages/e2b-sandbox-sidecar's
  `template/` directory (sandbox assets read at runtime, not src or
  test). Carries forward any include entry beyond src/test from the
  package's existing tsconfig.json now, the same way the composite
  side already preserved package-specific compilerOptions.
vendor/intx/*'s tsconfig content changed again with the
tsconfig.src.json rename (and the extends-path fix it needed);
scripts/checks/kill-dates.txt's recorded hash has to move with it or
check:killdates reports every vendored package as edited-without-
recording. VENDORED.md's delta note updated to describe the current
shape.
…CONCURRENCY instead of re-reading process.env
@TheGreatAxios
TheGreatAxios force-pushed the cl-7226-cut-typecheck-wall-time-with-typescript-project-references branch from b06a5fe to 9a970c4 Compare August 31, 2026 02:34
@TheGreatAxios
TheGreatAxios merged commit c1aaf27 into main Aug 31, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant