Skip to content

build(dts): roll the declaration-emitted guard across all 67 tsup-built packages - #12171

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-12078-dts-guard-rollout
Aug 25, 2026
Merged

build(dts): roll the declaration-emitted guard across all 67 tsup-built packages#12171
yinlianghui merged 1 commit into
mainfrom
claude/issue-12078-dts-guard-rollout

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

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 build script invokes
tsup now ends that build with a node …/scripts/check-dts-emitted.mjs step 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.yaml glob and reading each manifest's build script and its
declared declaration paths (types, typings, and types conditions inside exports):

count
workspace packages78
with a build script72
whose build invokes tsup — the population67
of those, declaring at least one declaration path67 (all of them)
already wired before this PR1 (@objectstack/plugin-auth)
wired here66

The card's "~50 packages" undercounts and the claim's "19 tsup.config.ts files carrying
OS_SKIP_DTS" counts config files: 20 packages have their own config, the other 47
resolve the shared root one (tsup searches upward, and the --config ../../tsup.config.ts
spellings name it explicitly). Both routes carry dts: !process.env.OS_SKIP_DTS, so both
are exposed. Every declared path in the population points at dist/*.d.[cm]ts — no
package 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:

full workspace build (71 tasks, exit 0), then the guard run in all 67 package dirs:
--- 67 package(s) checked, 0 failing ---

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 is
tsc -p tsconfig.build.json (with an OS_SKIP_DTS branch that passes --declaration false). tsc emits 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 is
correct.
Read off scripts/check-dev-prereqs.mjs: buildInputHash() hashes
packages/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
--stamp matches the sources exactly and dist/.build-input-hash reads fresh, which is
precisely the lie the freshness half exists to prevent, arriving through a door it does
not watch. packages/spec/scripts/lib/dist-freshness.ts already describes the
JS-without-declarations shape but attributes it to a deliberate OS_SKIP_DTS=1 on a virgin
tree — the same artifact, the wrong cause.

Spec is therefore wired, and the guard is placed before the stamp step, not after:

pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z "$OS_SKIP_DTS" ]; then … BUILD_DTS=true tsup; fi \
&& node ../../scripts/check-dts-emitted.mjs \
&& node ../../scripts/check-dev-prereqs.mjs --stamp

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., and check:dev-prereqs green afterwards.

⚠️ One consequence reviewers will meet once: packages/spec/package.json is itself a
stamp input, so this very diff invalidates the stamp on any already-built tree.
check:dev-prereqs reds with "the sources on disk hash …" until pnpm build runs. That
is 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 via objectstack build,
and their types points at a .ts source, not a declaration) and apps/docs (next build, declares no declaration entry point at all).

OS_SKIP_DTS no-op — tested explicitly, at full scale

A 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:

runbuild exitturboguard lines
pnpm build071 successful, 71 total67 × "N/N declared declaration file(s) present"
OS_SKIP_DTS=1 pnpm build071 successful, 71 total67 × "OS_SKIP_DTS is set - declarations skipped by request, not checked"; 0 checking lines

During the skip run packages/metadata-fs/dist really did hold zero.d.ts — the
guard had something to fire on and correctly did not. The following plain pnpm build was
71 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 that
swallows status) is a guard that cannot fail. Asserted mechanically over all 67:

  • the connector immediately before the guard is && in 67/67 — no ;, no |;
  • the relative path in each script (../ × package depth) resolves to exactly
    scripts/check-dts-emitted.mjs in 67/67;
  • spec's if … fi && node … shape propagates too: fi carries the body's status, so a
    failed 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_DTSdts: false in
packages/metadata-fs/tsup.config.ts (a per-package config inside this worktree only —
never the shared root config, which is a turbo globalDependency other 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 TERM restore:

[before] anchor present: 1 (expect 1); mutation present: 0 (expect 0)
[after ] anchor present: 0 (expect 0); mutation present: 1 (expect 1)
ABLATED_BUILD_EXIT=1
ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @objectstack/metadata-fs build: `tsup && node ../../scripts/check-dts-emitted.mjs`
Exit status 1
dist .d.ts count after ablated build: 0 (JS present: yes)
[restore] anchor present: 1 (expect 1); mutation present: 0 (expect 0)
CONTROL_BUILD_EXIT=0
check-dts-emitted: @objectstack/metadata-fs - 1/1 declared declaration file(s) present.
dist .d.ts count after control build: 1

Expected direction, observed direction: red. Both legs rebuilt; the tree was clean
(git status --porcelain empty) before the final commit.

What was NOT done, and why

Option 2 (a choke point in the shared root tsup.config.ts's onSuccess) stays closed
— tsup composes Promise.all([dtsTask(), mainTasks()]) and onSuccess runs inside
mainTasks, so it can fire before DTS finishes and would pass on a dead worker. Not
re-opened, and no alternative choke point is claimed: the only other candidates are a turbo
outputs assertion (turbo has no "these outputs must be non-empty" predicate) and a
post-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 the
reproduction. 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 types promise was trimmed, and the
guard is unmodified from what PR #12076 landed — the rollout exposed no gap in it. One
observation worth recording rather than changing: the types / typings branch of
declaredDeclarationPaths does not filter on a .d.ts extension the way the exports
branch does, so a manifest pointing types at a .ts source (the examples/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 3fb39ca3a with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (no hand-written
path 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 after pnpm build.
  • check:engine-split-ratio — refused to compute (exit 2) because this container's clone
    is 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 origin it computes and
    passes (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 — and
running eslint --no-inline-config --format json over all 66 changed files reports 66
results, 0 errors, every one of them "File ignored because no matching configuration
was supplied". The config enables no type-aware linting (no parserOptions.project, no
projectService), so this diff cannot move the verdict on any file it does not touch. The
repo-wide sweep remains CI's run.

origin/main moved 13 commits while this was in flight; none of them touches any
package.json, tsup.config.ts, turbo.json, pnpm-workspace.yaml or pnpm-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

… 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
@yinlianghuiyinlianghui added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Aug 25, 2026 — with Claude
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 71f9cd1d753a595d130406d72367c1069274de37packageMentionDocs.

@yinlianghuiClaude

Copy link
Copy Markdown
CollaboratorAuthor

Dequeued — base-branch failure, not this PR's. Nothing pushed here.

CI_FAILURE at 12:51Z. Verified before attributing: the cause is still main, not this diff. packages/client/exported-any-returns.json continues to carry the stale ObjectStackClient.packages.update row that PR #12062 (22c42c9b21) closed the gap for but never deleted; check:exported-any-returns judges the ledger exactly, so every branch built from main fails Type Check · consumer gates.

main has been frozen at 22c42c9b21 since 11:50Z — no commit has landed repo-wide in over an hour.

This diff is 66 files, every changed line a "build": script line adding the declaration guard. It contains no TypeScript, nothing under packages/client, and no mechanism by which a build-script step could change an SDK method's return type. Its own head was verified green across all 39 jobs before queueing, and the pre-wiring measurement in the PR body found 0 of 67 packages failing the guard.

⛔ Already filed by other seats — #12180 (priority:p0) and #12184 — with repairs #12186 and #12192, both now in the merge-queue build set. packages/client is domain:cli's surface; I filed no card and dispatched no dev. Full diagnosis on #12146; the duplicate-repair race is flagged on #12186.

Waits for main to recover, then re-queues unchanged.


Generated by Claude Code

@yinlianghui
yinlianghui added this pull request to the merge queueAug 25, 2026
Merged via the queue into main with commit 5838b7aAug 25, 2026
41 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-12078-dts-guard-rollout branch August 25, 2026 14:17
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependenciesPull requests that update a dependency filesize/mskip-changesetPR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants

@yinlianghui@claude