Uh oh!
There was an error while loading. Please reload this page.
build(dts): roll the declaration-emitted guard across all 67 tsup-built packages - #12171
Conversation
… build #11907 established that tsup 8.5.1 can report build success having emitted zero declarations: DTS generation runs in a `worker_threads.Worker` whose promise is settled only from that worker's `message` events (no `error`, no `exit` handler), so a worker that dies without posting one settles neither branch, node exits 0, and esbuild has already written `dist/`. turbo caches only successful tasks — so the declaration-less `dist/**` is cached under the hash of an ordinary full build and replays until `--force`. PR #12076 landed the package-agnostic guard and wired it into `@objectstack/plugin-auth` only. This rolls it across the whole population: every workspace package whose `build` script invokes tsup — 67 in total, 66 newly wired — now ends its build with `node <root>/scripts/check-dts-emitted.mjs`, joined with `&&` so the guard's status is the build's status. The diff is confined to `build` script lines (66 files, 66 insertions, 66 deletions) so it stays trivially resolvable against the open PR population. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UjM2ia8Av1v5NqfqQEQmC6
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
Uh oh!
There was an error while loading. Please reload this page.
yinlianghui
commented
Aug 25, 2026
Dequeued — base-branch failure, not this PR's. Nothing pushed here.
This diff is 66 files, every changed line a ⛔ Already filed by other seats — #12180 ( Waits for Generated by Claude Code |
Fixes#12078
PR #12076 established the mechanism and closed it for one package. This rolls the
guard across the whole population: every workspace package whose
buildscript invokestsup now ends that build with a
node …/scripts/check-dts-emitted.mjsstep pointing at the repo-root script.The diff is 66 files, 66 insertions, 66 deletions — every changed line is a
"build":script line, nothing else, so it stays trivially resolvable against the open PR
population.
The population, derived rather than inherited
Neither number in the card was taken on trust. Derived from the build graph by walking
every
pnpm-workspace.yamlglob and reading each manifest'sbuildscript and itsdeclared declaration paths (
types,typings, andtypesconditions insideexports):buildscript@objectstack/plugin-auth)The card's "~50 packages" undercounts and the claim's "19
tsup.config.tsfiles carryingOS_SKIP_DTS" counts config files: 20 packages have their own config, the other 47resolve the shared root one (tsup searches upward, and the
--config ../../tsup.config.tsspellings name it explicitly). Both routes carry
dts: !process.env.OS_SKIP_DTS, so bothare exposed. Every declared path in the population points at
dist/*.d.[cm]ts— nopackage promises a declaration from anywhere else.
Measured across the population BEFORE wiring anything
Requested explicitly, and the result is the opposite of what the dispatch expected:
Zero live instances at this commit. Every package in the population genuinely emits
every declaration its manifest promises. So there is no separate "these were already
broken" card to file — the rollout is not hiding a red anywhere, and it arrives on a
known-green population. (The guard's own
--self-test: all assertions pass.)The two edge cases, verified
@objectstack/cli— out of population, confirmed. Its build istsc -p tsconfig.build.json(with anOS_SKIP_DTSbranch that passes--declaration false).tscemits declarations synchronously in-process and exits non-zero on failure;there is no DTS worker to die silently. Not wired — this guard would be pure redundancy
over a toolchain that cannot produce the artifact it catches. For the record it would
pass today:
@objectstack/cli - 1/1 declared declaration file(s) present.@objectstack/spec— IN the population, and the card's reasoning about its stamp iscorrect. Read off
scripts/check-dev-prereqs.mjs:buildInputHash()hashespackages/spec/src/**plus the package's build config plus the global build inputs —sources only, never the emitted dist. So after a silent DTS death the stamp written by
--stampmatches the sources exactly anddist/.build-input-hashreads fresh, which isprecisely the lie the freshness half exists to prevent, arriving through a door it does
not watch.
packages/spec/scripts/lib/dist-freshness.tsalready describes theJS-without-declarations shape but attributes it to a deliberate
OS_SKIP_DTS=1on a virgintree — the same artifact, the wrong cause.
Spec is therefore wired, and the guard is placed before the stamp step, not after:
A build that emitted no declarations now never reaches the stamp, so it cannot leave a
dist that claims to be current. Verified:
check-dts-emitted: @objectstack/spec - 34/34 declared declaration file(s) present., andcheck:dev-prereqsgreen afterwards.packages/spec/package.jsonis itself astamp input, so this very diff invalidates the stamp on any already-built tree.
check:dev-prereqsreds with "the sources on disk hash …" untilpnpm buildruns. Thatis the gate working as designed (its docblock calls this case out), not a regression, and
it clears with the rebuild every reviewer owes anyway.
Also checked and left alone: the three
examples/app-*(build viaobjectstack build,and their
typespoints at a.tssource, not a declaration) andapps/docs(next build, declares no declaration entry point at all).OS_SKIP_DTSno-op — tested explicitly, at full scaleA guard that fired under the fast-path flag would be worse than the defect, so this got a
whole-workspace run rather than an argument:
pnpm buildOS_SKIP_DTS=1 pnpm buildDuring the skip run
packages/metadata-fs/distreally did hold zero.d.ts— theguard had something to fire on and correctly did not. The following plain
pnpm buildwas71 cached, 71 total, exit 0.That the guard printed 67 lines in each run is also the proof that every wired
invocation actually executes: not one of the 66 new call sites is a path that silently
resolves to nothing.
Exit-code plumbing — checked on every script touched
The failure mode named in dispatch (a guard appended after
;, or inside a chain thatswallows status) is a guard that cannot fail. Asserted mechanically over all 67:
&&in 67/67 — no;, no|;../× package depth) resolves to exactlyscripts/check-dts-emitted.mjsin 67/67;if … fi && node …shape propagates too:ficarries the body's status, so afailed DTS pass short-circuits before the guard rather than around it.
Reverse verification: the wired build really reds
Not an argument from shell semantics — measured end to end, on one wired package, by
mimicking the #11907 artifact (JS written, zero declarations, tsup exit 0). The mutation
was
dts: !process.env.OS_SKIP_DTS→dts: falseinpackages/metadata-fs/tsup.config.ts(a per-package config inside this worktree only —never the shared root config, which is a turbo
globalDependencyother agents build on),confirmed on disk in both directions by counting the anchor text and the injected text
before and after each leg, under a
trap … EXIT INT TERMrestore:Expected direction, observed direction: red. Both legs rebuilt; the tree was clean
(
git status --porcelainempty) before the final commit.What was NOT done, and why
Option 2 (a choke point in the shared root
tsup.config.ts'sonSuccess) stays closed— tsup composes
Promise.all([dtsTask(), mainTasks()])andonSuccessruns insidemainTasks, so it can fire before DTS finishes and would pass on a dead worker. Notre-opened, and no alternative choke point is claimed: the only other candidates are a turbo
outputsassertion (turbo has no "these outputs must be non-empty" predicate) and apost-build repo-wide sweep, which is a second gate over the same fact that still lets the
poisoned entry be cached first. Per-package, inside the failing build, before the cache
write, is the only ordering that stops the artifact from existing.
Option 3 (patching tsup) is not taken. Filed instead as #12167, proposing the
worker.on('error')/worker.on('exit')pair be reported upstream, carrying thereproduction. Complement, not alternative: the guard protects us now, an upstream fix ends
the class for everyone.
Nothing was weakened to get green. No manifest's
typespromise was trimmed, and theguard is unmodified from what PR #12076 landed — the rollout exposed no gap in it. One
observation worth recording rather than changing: the
types/typingsbranch ofdeclaredDeclarationPathsdoes not filter on a.d.tsextension the way theexportsbranch does, so a manifest pointing
typesat a.tssource (theexamples/app-*shape)would be checked for that source file's presence. No package in the population does this,
so it changes nothing here; noted so the next reader does not rediscover it as a bug.
Gates
Derived at the final commit
3fb39ca3awithnode scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-writtenpath list; the script took the change set from the merge base itself). 24 matched
families plus the 2 convention-triggered i18n families — 26 commands, all green, each
exit code captured before any pipe.
Two were red on the first pass and both are named here rather than quietly re-run:
check:dev-prereqs— the spec stamp case described above. Green afterpnpm build.check:engine-split-ratio— refused to compute (exit 2) because this container's cloneis shallow and its oldest visible commit sat inside the 90-day window. An environment
fact, not this diff: after
git fetch --shallow-since=2026-05-20 originit computes andpasses (ratio 97.6%).
Lint is a declared narrowing, with the measurement rather than an assertion: eslint's
own flat config matches only
**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}— no JSON glob — andrunning
eslint --no-inline-config --format jsonover all 66 changed files reports 66results, 0 errors, every one of them "File ignored because no matching configuration
was supplied". The config enables no type-aware linting (no
parserOptions.project, noprojectService), so this diff cannot move the verdict on any file it does not touch. Therepo-wide sweep remains CI's run.
origin/mainmoved 13 commits while this was in flight; none of them touches anypackage.json,tsup.config.ts,turbo.json,pnpm-workspace.yamlorpnpm-lock.yaml,so the population is still complete and the diff has zero textual overlap with them.
Build orchestration only — no published package's shipped behaviour changes — so
skip-changeset, matching PR #12076's precedent for the same class of change.Generated by Claude Code