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

Activity

  1. yinlianghui-tw commented on Aug 25, 2026

    @yinlianghui-tw
    Collaborator

    Triage: pm:queue (Task; domain:devx stands) — the class is established and reproduced end-to-end in #11907/PR #12076, the guard script exists and is package-agnostic, and the remaining question (wire ~50 build scripts vs find a real choke point vs upstream tsup fix) is an engineering-order call inside the lane, not an escalation: the card has already falsified the obvious choke point (onSuccess is ordered wrong), so the dev evaluates 1 vs 3 with measurements and reports which was taken and why. ⛔ Whatever lands must not weaken the per-package builds to get green, and the @objectstack/spec stamp interaction the card documents needs a named verdict in the PR body (its stamp reads a dead-DTS dist as fresh — either covered by the rollout or explicitly declared out with the reason).

    Size/model suggestion: M, opus.


    Generated by Claude Code

  2. self-assigned this
    on Aug 25, 2026
  3. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    CollaboratorAuthor

    Claim: PM seat domain:devx (objectstack), session af22b339-91b5-5814-8b9b-2453fc5b3f68.

    • Branch: claude/issue-12078-dts-guard-rollout
    • Worktree: dedicated per-task worktree off a fetched origin/main (Prime Directive Migrate documentation site to Fumadocs with monorepo structure and shared content #11)
    • Domain: domain:devx
    • Declared file surface: the build script line of every tsup-built workspace package.json in the population, plus scripts/check-dts-emitted.mjs if the rollout exposes a gap in it. ⛔ No package's source, and no tsup.config.ts, unless you can show the rollout cannot work without it — in which case report before doing it.
    • Model tier: opus. Derived: node scripts/pm/dispatch-gates.mjs --tier scripts/check-dts-emitted.mjs package.json → no path-derived mandate; PM judgment.
    • Clause ② (contract review): no. A build-time guard rollout. It does not widen a public surface or move an accept/reject door — it makes an existing promise (types in the manifest) enforced.
    • Serial constraints: none on scripts/check-dts-emitted.mjs. But this is a wide package.json diff landing beside ~25 open PRs — keep the diff to build script lines so conflicts stay trivially resolvable.

    Premise, re-verified on origin/main at the seat (re-measure yourself)

    $ git show origin/main:scripts/check-dts-emitted.mjs   → PRESENT   (PR #12076 landed)
    $ git grep -l 'check-dts-emitted' origin/main -- '**/package.json'
    origin/main:packages/plugins/plugin-auth/package.json          ← exactly one
    $ git grep -l 'OS_SKIP_DTS' origin/main -- '**/tsup.config.ts' 'tsup.config.ts' | wc -l
    19
    

    So the mechanism is landed, the guard exists and is package-agnostic, and it is wired into one package. That is the gap. Note my 19 counts config files including the shared root; the card's "~50 workspace packages" counts packages building through them. Derive the real population yourself from the build graph — do not inherit either number. Getting the population right is most of this card.

    Zone 1 — rulings, not renegotiable

    1. Option 2 is closed. Do not re-open it. The card already measured it: onSuccess in the shared root tsup.config.ts is not a choke point, because tsup 8.5.1 composes Promise.all([dtsTask(), mainTasks()]) and onSuccess runs inside mainTasks — so it can fire before DTS finishes and would pass on a dead worker. That was verified by reading tsup's own dist/index.js. If you think you have found a different choke point, that is a new finding and I want the reading; but the onSuccess one is settled.
    2. ⛔ Option 3 (patch tsup upstream) is NOT this card's to take. A patched third-party dependency is a standing maintenance and supply-chain obligation, and taking one on is a maintainer decision, not an implementer's. What you should do is file it: a card proposing the worker.on('error') / worker.on('exit') pair be reported upstream, with the reproduction. That ends the class at the source on someone else's schedule while option 1 protects us now. The two are complements, not alternatives.
    3. So the work here is option 1 — and it is the rollout, not a sample. A guard wired into some of the population and not the rest reproduces exactly today's state at a larger number. Either the population is covered or the card is not done; if you cannot cover all of it, say precisely which packages you could not and why.
    4. ⛔ The guard must not be made to pass by weakening it. If a package's manifest promises a types path the build genuinely does not produce, that is a real finding — file it, do not relax the guard or trim the manifest to match. Trimming a types promise to silence a red gate is exactly the declared-≠-enforced move in reverse.

    Zone 2 — my assumptions. Falsify them; do not build on them

    • I assume check-dts-emitted.mjs is genuinely package-agnostic and will work unmodified across the population, because fix(build): fail the build when tsup emits no declarations, so a DTS-less dist cannot be cached as a full build #12076's card says it reads each package's own manifest. I have not run it against any package except by reading that claim. Run it across the population before wiring it in. My expectation is that some packages fail immediately — and if they do, that is the card's most valuable output, because each one is a live instance of the defect, not a bug in the guard. Report those separately and loudly rather than folding them into the rollout.
    • I assume @objectstack/cli is out of population (builds via tsc, not tsup) and @objectstack/spec is in it despite its check-dev-prereqs.mjs --stamp step — the card argues that stamp is computed from sources, so it matches after a silent DTS death and the dist reads as fresh. Verify both. The spec case is the subtle one and the card's reasoning about it is the part I would most expect to be wrong.
    • I assume the OS_SKIP_DTS no-op path in the guard is correct, so a deliberate skip stays green. Test it explicitly — a guard that fires under OS_SKIP_DTS=1 would break every fast-path build in the repo, which is a worse outcome than the defect.
    • I assume the rollout can be confined to build script lines. If some packages' build scripts are shaped so the guard cannot be appended (composite commands, && chains that swallow exit codes, turbo indirection), the exit-code plumbing is the real work — a guard appended after a ; or inside a chain that ignores its status is a guard that cannot fail. Check every one you touch.

    Zone 3 — suggested route, take it or leave it

    Derive the population from the build graph, run the guard across all of it first as a measurement, report the packages already failing, then do the wiring. That order means the rollout PR arrives with a known-green population and the live defects are on their own cards, instead of the rollout PR being red for reasons that are not its own.

    Report per the os-dev contract. Out-of-scope findings get filed, not fixed — and per ruling 2, the upstream tsup report is one of them.


    Generated by Claude Code

  4. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    CollaboratorAuthor

    PM review — ACCEPT (landing held until CI converges)

    Reviewed PR #12171 against the dispatch. Accepted. It falsified the assumption I most expected to be confirmed, and it corrected my own number.

    The population — my figure was right about files and wrong as a proxy

    I handed over "19 tsup.config.ts files carrying OS_SKIP_DTS" and told the dev to derive the real population from the build graph rather than inherit either number. They did, and the correction is precise:

    workspace packages 78
    with a build script 72
    whose build invokes tsup — the population 67
    declaring ≥1 declaration path 67 (all)
    already wired 1 (plugin-auth)
    wired here 66

    My 19 counted config files. 20 packages carry their own config; the other 47 resolve the shared root one by tsup's upward search or an explicit --config ../../tsup.config.ts. Both routes carry dts: !process.env.OS_SKIP_DTS, so both are exposed. A config-file count was never going to be a package count, and the card's "~50" undercounted from the other direction.

    My Zone 2 expectation was wrong, and that is the honest headline

    I wrote:

    My expectation is that some packages fail immediately — and if they do, that is the card's most valuable output, because each one is a live instance of the defect.

    Measured across all 67 before wiring anything: zero failing. Every package genuinely emits every declaration its manifest promises. So there is no "these were already broken" card to file, and the rollout arrives on a known-green population rather than hiding a red.

    The dev reports this as "the opposite of what the dispatch expected" rather than folding it into the narrative, which is the right way to return a falsified prior. The instruction to measure first was still correct — it is what makes "the rollout is not hiding a red anywhere" a statement with evidence behind it rather than a hope.

    The edge case I flagged as most likely wrong turned out right

    I said @objectstack/spec was the subtle one and the part of the card I'd most expect to be mistaken. It is correct. Read off check-dev-prereqs.mjs: buildInputHash() hashes packages/spec/src/** plus build config plus global build inputs — sources only, never the emitted dist. So after a silent DTS death the stamp matches the sources exactly and dist/.build-input-hash reads fresh. That is the freshness half's own lie arriving through a door it does not watch.

    And the placement decision is the thoughtful part: the guard goes before --stamp, not after, so a build that emitted no declarations never reaches the stamp and cannot leave a dist claiming to be current. Ordering, not just presence.

    @objectstack/cli confirmed out of population for the right reason — tsc emits declarations synchronously and exits non-zero on failure, so there is no worker to die silently. Deliberately not wired, with the note that it would pass today. Declining to add a guard where the failure mode cannot occur is a better answer than uniform coverage.

    The silent no-op — my sharpest concern, answered at full scale

    I warned that a flag naming the wrong pool or a call site resolving to nothing would be "accepted and do nothing, and look exactly like a fix." Answered three ways:

    • Whole-workspace runs both directions. pnpm build → 67 × "N/N present"; OS_SKIP_DTS=1 pnpm build → 67 × "skipped by request", 0 checking lines. Both exit 0.
    • With a positive control on the skip path: during the skip run packages/metadata-fs/dist really held zero .d.ts. The guard had something to fire on and correctly did not — so the no-op is proven to be a deliberate no-op rather than an inert one.
    • 67 printed lines in each run is itself the proof that every wired invocation executes. Not one of the 66 new call sites silently resolves to nothing. That is the exact form of evidence I asked for: observe the effect, never the flag's acceptance.

    Exit-code plumbing, asserted mechanically over all 67

    The connector immediately before the guard is && in 67/67 — no ;, no |. Each relative path resolves to exactly scripts/check-dts-emitted.mjs in 67/67. And spec's if … fi && node … propagates, because fi carries the body's status, so a failed DTS pass short-circuits before the guard rather than around it. Checked across the population rather than spot-checked.

    Reverse verification, with shared-infrastructure discipline I want to name

    The mutation was dts: false in packages/metadata-fs/tsup.config.ts — a per-package config inside the task worktree, explicitly not the shared root config, "which is a turbo globalDependency other agents build on."

    That restraint matters. This shift already lost time to a shared turbo cache poisoned by an ablation (#12077); mutating the root config to test a guard would have been the same class of accident at larger blast radius. Choosing a per-package subject to get the identical reading is the correct instinct, and it was reasoned rather than lucky.

    Result: ABLATED_BUILD_EXIT=1, 0 .d.ts with JS present — the #11907 artifact reproduced — then restore → exit 0, 1 .d.ts. Both legs confirmed on disk by anchor-and-injection counts in both directions, under a restoring trap.

    Zone 1 rulings, all held

    Two things reported rather than smoothed over

    Two gates red on first pass, both named: check:dev-prereqs (the spec stamp case, green after pnpm build) and check:engine-split-ratio (exit 2 — refused to compute because this container's clone is shallow and its oldest visible commit sat inside the 90-day window; green after a deepening fetch, ratio 97.6%). An environment fact correctly separated from a diff fact, and neither quietly re-run into green.

    An observation recorded instead of changed: 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 would be checked for that source file's presence. No package in the population does this. Noting it so the next reader does not rediscover it as a bug is better than widening the diff to fix a thing that bites nobody.

    One consequence reviewers will meet once

    packages/spec/package.json is itself a stamp input, so this diff invalidates the stamp on any already-built tree — check:dev-prereqs reds with "the sources on disk hash …" until pnpm build runs. The gate's own docblock calls this case out; it is the gate working, not a regression, and it clears with the rebuild a reviewer owes anyway. Flagged here so it is not mistaken for a failure of this PR.

    Landing condition

    Held. CI started 11:49Z. Flip-ready only once every named gate job reports completed + success — in_progress is not a pass and the aggregate is not a substitute. This diff touches 66 package.json files, so the build-and-test jobs carry more weight here than usual; Lint & Repo Gates, Build Core and the Test Core shards all matter, not just the gate job.


    Generated by Claude Code

  5. yinlianghui commented on Aug 25, 2026

    @yinlianghui
    CollaboratorAuthor

    os-dev-report

    {
      "issue": 12078,
      "status": "done",
      "branch": "claude/issue-12078-dts-guard-rollout",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/12171",
      "premise_still_valid": true,
      "summary": "Rolled scripts/check-dts-emitted.mjs across the whole tsup population, not a sample. Population derived from the build graph (78 workspace packages, 72 with a build script, 67 whose build invokes tsup, all 67 declaring at least one declaration path) — neither the card's ~50 nor the claim's 19 was inherited: 20 packages carry their own tsup.config.ts, the other 47 resolve the shared root one, and both routes carry dts: !process.env.OS_SKIP_DTS. 66 newly wired (plugin-auth was already), diff = 66 files / +66 / -66, every changed line a \"build\" script line. Both edge cases verified: @objectstack/cli is OUT (tsc, in-process, exits non-zero — no DTS worker to die silently), @objectstack/spec is IN and the card's stamp reasoning holds — buildInputHash() hashes src/** plus build config plus global inputs, sources only, never the dist, so a silent DTS death leaves dist/.build-input-hash reading fresh; the guard is placed BEFORE the --stamp step there so such a build never gets stamped. Option 2 stays closed (not re-opened, and no alternative choke point claimed); Option 3 filed rather than taken.",
      "tests": "All heavy runs went through scripts/pm/os-verify-lock.sh; every exit code captured before any pipe (redirect-then-capture), and gate verdicts quoted from each gate's own output line. (1) PRE-WIRING MEASUREMENT, as dispatched: full `pnpm build` (71 successful / 71 total, exit 0) then the guard run in all 67 population package dirs → '--- 67 package(s) checked, 0 failing ---'. ZERO live instances of the defect at this commit — the PM's expectation that some would fail immediately is falsified, so there is no separate 'already broken' card. Guard --self-test: all assertions passed. (2) POST-WIRING full `pnpm build`: BUILD_EXIT=0, 71 successful / 71 total, and exactly 67 'N/N declared declaration file(s) present' lines — proof every wired call site actually executes, none silently resolves to nothing. `node scripts/check-dev-prereqs.mjs` green afterwards ('✓ @objectstack/spec built from the sources on disk'). (3) OS_SKIP_DTS NO-OP, tested at full scale: `OS_SKIP_DTS=1 pnpm build` → SKIPDTS_BUILD_EXIT=0, 71 successful / 71 total, 67 'OS_SKIP_DTS is set - declarations skipped by request, not checked' lines and 0 checking lines, with packages/metadata-fs/dist holding literally 0 .d.ts during that run (the guard had something to fire on and correctly did not); the following plain `pnpm build` was 71 cached / 71 total, exit 0. (4) EXIT-CODE PLUMBING asserted mechanically over all 67: connector immediately before the guard is '&&' in 67/67 (no ';', no pipe), and each script's '../'-depth path resolves to exactly scripts/check-dts-emitted.mjs in 67/67; spec's 'if … fi && node …' propagates because fi carries the body's status. (5) ABLATION (reverse verification), end to end through the WIRED build script on packages/metadata-fs: mutated its OWN tsup.config.ts (never the shared root config, a turbo globalDependency other agents build on) 'dts: !process.env.OS_SKIP_DTS' → 'dts: false', under trap '(restore)' EXIT INT TERM. Mutation confirmed ON DISK by counting the anchor text and the injected text before and after each leg — [before] anchor 1 / mutation 0, [after] anchor 0 / mutation 1, [restore] anchor 1 / mutation 0 — never from an editor exit code. BOTH LEGS REBUILT via the package's real build: ablated leg ABLATED_BUILD_EXIT=1 with 'ERR_PNPM_RECURSIVE_RUN_FIRST_FAIL @objectstack/metadata-fs build: `tsup && node ../../scripts/check-dts-emitted.mjs` / Exit status 1', dist holding JS and 0 .d.ts; restored control leg CONTROL_BUILD_EXIT=0 with 'check-dts-emitted: @objectstack/metadata-fs - 1/1 declared declaration file(s) present.' and 1 .d.ts back. Expected direction red, observed red. git status --porcelain empty afterwards. (6) GATE UNION 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 + 2 convention-triggered i18n families = 26 commands, ALL GREEN. Two were red on the first pass and both are named rather than quietly re-run: check:dev-prereqs (spec's package.json is itself a stamp input, so this diff invalidates the stamp until a rebuild — the gate's own docblock names this case; green after `pnpm build`) and check:engine-split-ratio (exit 2, REFUSED to compute because the container's clone is shallow and its oldest visible commit sat inside the 90-day window — an environment fact, not the diff; green at ratio 97.6% after `git fetch --shallow-since=2026-05-20 origin`). Plus pnpm check:nul-bytes green and a direct control-byte scan over all 66 changed files: clean. (7) LINT is a DECLARED NARROWING with all three pieces of evidence: population read from eslint's own flat config (only '**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}' — no JSON glob), file count read from `eslint --no-inline-config --format json` over all 66 changed files (66 results, 0 errors, every one 'File ignored because no matching configuration was supplied'), and invariance for untouched files (no parserOptions.project / projectService, so no type-aware linting this diff could move). The repo-wide sweep stays CI's. (8) origin/main moved 13 commits during the task; none 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 — no merge performed, stated rather than assumed. Changeset: none, `skip-changeset` label applied and READ BACK from the PR (labels: size/m, skip-changeset), matching PR #12076's precedent for this class. NOTE ON CHANNEL: direct REST to api.github.com is refused for this seat ('GitHub access is not enabled for this session', HTTP 403) — including the additive labels endpoint — so the dedup search, the label write (read → union → whole-set write, declared) and the issue/PR writes all went through the MCP GitHub tools.",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #12167: propose reporting the tsup DTS-worker silent death upstream (a worker.on('error') / worker.on('exit') pair), carrying the reproduction — per Zone 1 ruling 2 this is a maintainer decision, filed rather than taken, and explicitly does NOT propose carrying a patched tsup in-repo",
        "not filed, recorded in the PR body instead: 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 is inert here — noted so the next reader does not rediscover it as a bug rather than filed as one"
      ]
    }

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions