fix(build): fail the build when tsc leaves an incomplete dist - #7001
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
The exports-map pin asserted `scripts.build === 'tsc'` to mean 'no bundler that could emit a second format'. Appending a non-emitting post-build assertion turned it red while the CJS question was untouched. The emit step must still be bare `tsc`; every later step is enumerated in the test, so a bundler still cannot arrive without editing it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk
|
Patch round —
It now reads the build as a chain: the emitting step must be bare Importing A sweep across the repo for every test that pins a Test surface this time was the changed files rather than the declared file surface: all twelve touched packages plus Generated by Claude Code |
✅ Console Performance Budget
The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it. 📦 Bundle Size Report
Size Limits
|
Fixes #6703
A
tscpackage'sdist/must now hold every filetscsays it emits, and the build fails when it does not.The reproduction, established failing first
The card's binding instruction was to establish a reproduction that fails before trusting one that passes. That reproduction is deterministic, not concurrency-dependent — measured on
4357ec7,packages/types:Zero of 40 top-level
.d.ts, exit 0, 3s instead of 7s. Truncating to 4 of 40 instead of 0 and rebuilding reproduces the reported shape exactly: still 4 of 40, still exit 0.packages/permissionsthen reports 13 TypeScript errors, and the ones that cost the most name nothing at all about@object-ui/types:The mechanism — measured, not assumed
All twelve
"build": "tsc"packages setcomposite: true, which impliesincremental. Each therefore records what it emitted in atsconfig.tsbuildinfothat TypeScript resolves next totsconfig.json, outside theoutDirit describes. When those two disagreetscbelieves the record and never stats its own outputs.Note what the dispatch flagged as unproven: an in-place emit is not the mechanism, and it does not explain the exit code. The buildinfo does, and it explains both halves.
rm -rf distis not a hypothetical door into that state — it is literally thecleanscript of six of these packages, sopnpm clean && pnpm buildwas a two-command recipe for an emptydist/and a green exit code.Why it reached other people's checkouts — the concurrency the card describes
turbo.jsoncachesbuild. Atscthat short-circuits still exits 0, so turbo records that run as a successful build and stores whateverdist/happened to be there. The cache entry this produced for@object-ui/types:buildheld exactly two paths:No
dist/at all — and it replays ascache hit … FULL TURBO, exit 0. The entry is a fixed point: restoring it puts the buildinfo back without adist/, which is precisely the desync that makes the nexttscshort-circuit again..github/workflows/lint.ymlalready carries an independent sighting of the replay half; this names where such an entry comes from.Turbo also reports
using shared worktree cache— that store is keyed off the common.gitdirectory, so one poisoned entry is visible to every worktree of the repository at once. That is the concurrency dependence: not two writers racing inside onedist/, but one short-circuited build recording a green, empty artifact that every other checkout then replays. It also explains why isolating the builds sequentially looked like a fix — a cache-busting change is what actually cleared it.The remedy, and why it sits inside
buildBecause turbo only caches a task that exited 0, checking inside the build script is what matters: it stops the poisoned entry from ever being recorded, rather than detecting it afterwards.
Reverse-verified on the committed implementation — the original 4-of-40 state, rebuilt:
turbo exit 1, and the only cached manifest for that task still holds the healthy 120-entry / 118-
dist-file version. No poisoned entry was recorded. Before this change the same tree state exited 0.Is "40" stable enough to assert against?
No — and it is also not the real number. 40 is the count of
packages/types/src/*.tsat depth one; a complete build is 118 files (59 inputs,.js+.d.tseach). Nothing is hardcoded. The expected set is derived from TypeScript's own API —parseJsonConfigFileContentfor the input set the package's own tsconfig selects, thengetOutputFileNamesper input. That is the same computationtsc --builduses to decide whether a project is up to date, so the gate cannot disagree with the compiler about what "complete" means, and it followsdeclaration,declarationMap,sourceMapandoutDirper package without being told about any of them.Verified against a full build of all twelve packages: 1,586 expected outputs derived, 0 missing. No false positives on a tree that is actually built.
Does this share a root cause with
check:self-import?No — same consequence class, different defect.
check:self-importcloses an ordering defect:type-checkdepends on^build, never the package's own, so a self-import resolves to adist/whose production nothing sequenced. Here thedist/is ordered and the build does run; the defect is a state desync between an artifact and the record that stands in for it. Both end in the same place — a green reading taken against adist/that is not what the reader assumes — which is why the remedies are siblings rather than one gate.Scope note — this touches twelve packages, not one
The card names
@object-ui/types; the hazard is provably identical in all twelve packages whosebuildistsc(allcomposite, all with the buildinfo outsideoutDir), and the fix is one mechanical clause per script. Fixing onlytypeswould leave eleven open with the same measurement already in hand.packages/fieldsandapps/consoleopen their build withtsctoo, but as--noEmittype checks that emit through vite — the gate classifies themno-emitand does nothing, and reports that apart from "not built" so a package that stops emitting cannot hide as one that merely was not built yet.Six
cleanscripts now take the buildinfo with thedist/they describe. Without that,clean && buildwould go red on a documented workflow instead of working; with it, that sequence rebuilds correctly (measured: 0 to 40/40, exit 0).What is deliberately not here, per the scope fence: no staging directory, no atomic swap, no change to turbo's task graph. The one architectural option worth a ruling is relocating
tsBuildInfoFileintooutDir, which would make the desync structurally impossible for any route that removesdist/wholesale rather than merely detectable. That is a build-config change to twelve packages on a card with no triage ruling, so it is reported rather than built.Verification
Union run at
cd90cd1a:pnpm exec vitest run scripts/__tests__/— 90 files, 2546 tests, all passing, withpackages/app-shell/distmoved aside (see below).check-dist-completeness --all(12 complete, 1586 files verified),check-entry-guard+ its--self-test,check-control-bytes,check-changeset-no-major,check-changeset-fixed,check-lint-coverage,check-type-check-coverage,check-package-self-import,check-phantom-dependencies.pnpm exec eslinton both new files, exit 0.pnpm type-check:scripts, exit 0 — and--listFilesconfirms both new files are inside that program rather than excluded from it.check-changeset-presence: nothing owed; an empty-frontmatter changeset is included anyway as an explicit "no release" declaration, since twelve published manifests change.Two failures seen during verification are pre-existing and already filed, and neither is touched by this diff:
scripts/__tests__/check-sdui-registration-pins.test.tsfails on any tree wherepackages/app-shell/distexists — objectui#6893. Proven by ablation rather than asserted: with the directory moved aside the file passes 11/11 and the whole tree is 90/90; moved back, that one case fails again (892 files moved and restored, confirmed by observing state).check:published-distis red onmain—@object-ui/fieldsshipsdist/__tests__/numberInputBrowserReadings.d.ts, whose source landed 2026-08-30 in fix(fields): announce bad input across the type="number" widget class #6801, a day before this branch. objectui#6943 and objectui#6861. This diff touches zero files underpackages/fields.Generated in session https://claude.ai/code/session_01GgDDqh6YnkXqsnVTCa7wHk
Generated by Claude Code