Skip to content

Every tsup-built package can report build success having emitted zero declarations — #11907's repair is wired into plugin-auth only, ~50 packages still exposed #12078

Description

@yinlianghui

Split out of #11907 (PR #12076), which established the mechanism and closed it for one package. Filing the class separately because rolling the guard across every build script is a different-sized change than the card asked for.

The mechanism (established in #11907, not re-litigated here)

tsup 8.5.1 runs DTS generation in a worker_threads.Worker and settles the build promise only from that worker's message events. It registers no error handler and no exit handler, so a worker that dies without posting a message (OOM under memory pressure being the observed shape) settles neither branch: the promise never settles, the event loop drains, and node exits 0 with the esbuild pass having already written dist/. turbo caches only successful tasks — so the declaration-less dist/** is cached under the hash of an ordinary full build, and every later run replays it. A plain rebuild is a cache hit that restores the same artifact; only --force replaces it.

Reproduced end to end on @objectstack/plugin-auth: build exit 0, turbo 26 successful, 26 total, dist/ with index.js / index.mjs / maps and zero.d.ts, cached under the same hash a healthy build produces.

The population

dts: !process.env.OS_SKIP_DTS appears in the shared root tsup.config.ts and in ~20 per-package tsup.config.ts files; roughly 50 workspace packages build through one or the other. Every one of them can exit 0 having emitted no declarations.

PR #12076 adds scripts/check-dts-emitted.mjs — package-agnostic, reads the package's own manifest and asserts every declaration path it promises (types, typings, types conditions inside exports) is present and non-empty, no-op under OS_SKIP_DTS — but wires it into @objectstack/plugin-auth only, the package the card measured.

Two packages already have something in this area and neither closes it:

  • @objectstack/spec ends its build with node ../../scripts/check-dev-prereqs.mjs --stamp. It stamps dist/.build-input-hash from the sources, so after a silent DTS death the stamp matches the sources exactly and the dist reads as fresh. packages/spec/scripts/lib/dist-freshness.ts already documents the JS-without-declarations shape as the OS_SKIP_DTS=1-on-a-virgin-tree case — the same artifact, attributed to the deliberate flag rather than to a failed build.
  • @objectstack/cli builds via tsc, not tsup, and so is not exposed to this particular worker death.

Why it is not just "wire it everywhere and be done"

Adding a build step to ~50 packages is mechanical but touches every package's build path at once, which is its own verification surface — hence the split rather than a rider on #12076. Worth deciding as one question rather than per package:

  1. wire check-dts-emitted.mjs into all ~50 build scripts (mechanical, big diff, each package's build gains a step); or
  2. find a single choke point. Note onSuccess in the shared root tsup.config.ts is not one: tsup composes Promise.all([dtsTask(), mainTasks()]) and onSuccess runs inside mainTasks, so it can fire before DTS finishes. Verified by reading tsup 8.5.1's dist/index.js; a guard there would be ordered wrong and pass on a dead worker.
  3. or fix upstream — a worker.on('error') / worker.on('exit') pair in tsup would end the class at the source. No patch is carried here.

Detection value even without the rollout

The shape is cheap to scan for in the turbo cache: any entry whose manifest lists a dist/ with .js/.mjs files and zero .d.ts/.d.mts is a poisoned entry. That scan found one live example while #11907 was being reproduced. See #12077 for why cache entries reach across worktrees.


Generated by Claude Code

Metadata

Metadata

Assignees

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions