Skip to content

feat(devx): a closure-wide declaration sweep after the workspace build names a package whose .d.ts vanished (#15042) - #15796

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-15042-dts-closure-sweep
Sep 5, 2026
Merged

feat(devx): a closure-wide declaration sweep after the workspace build names a package whose .d.ts vanished (#15042)#15796
baozhoutao merged 2 commits into
mainfrom
claude/issue-15042-dts-closure-sweep

Conversation

@claude

@claude claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Fixes #15042

What this is

scripts/check-dts-emitted.mjs (#12078) runs per package, immediately after that package's own tsup, and it is the last thing that ever looks. On the filing observation it answered correctly — check-dts-emitted: @objectstack/service-cluster - 3/3 declared declaration file(s) present. — and the declarations were gone later in the same tree, with no second build and no rm, while the .js / .cjs outputs and their maps survived.

This PR adds the closure-wide re-check the card and triage both asked for: turn the silent window into a named failure, without first establishing the cause.

⛔ It does not reproduce the vanishing, instrument it, or name a writer. It touches no tsup.config.ts and no clean: setting (#13013's guard is deliberate and was in place). The card's four "not established" points are not restated as facts anywhere in the diff.

Design decision: a sibling script, not a --sweep flag

The derivation is shared, not duplicateddeclaredDeclarationPaths and missingDeclarations are imported from check-dts-emitted.mjs, which already exports them behind its entrypoint guard for exactly this reason ("so they can be unit-tested and reused"). What is separate is the entry point, for four reasons that are properties of the tree rather than taste:

  1. The per-package guard is invoked positionally, with no argv, by every package build script in the workspace (... && tsup && node ../../../scripts/check-dts-emitted.mjs). Teaching that entry point a mode flag makes process.argv load-bearing inside ~70 build scripts, where a stray argument becomes a silent mode switch on a build. The sibling takes argv from CI and from one root package.json row.
  2. The two gates make claims about different instants and carry different remedies. The per-package guard's failure text is the Local turbo cache served a DTS-less @objectstack/plugin-auth build in a fresh worktree with OS_SKIP_DTS unset — its own typecheck then reds on a diff that never touched it #11907 tsup-worker diagnosis, correct there and wrong advice for a package that emitted its declarations and no longer has them.
  3. Self-test batteries are floored per file ([finding] Survey: which other scripts/* --self-test modes decide success with no assertion floor, or exit 0 via return selfTest() before their verdict #13489). Two contracts sharing one roster lets one contract's cases stand in for the other's at the floor.
  4. check-self-test-wired and the check:* family convention are per script — one script, one row, one --self-test CI runs.

So scripts/check-dts-emitted.mjs is untouched by this PR and the per-package contract is unchanged.

The gate

scripts/check-dts-closure.mjs, wired as check:dts-closure.

Population is derived, never listed: every workspace package (through the shared scripts/workspace-enumerator.mjs parse) that has a dist/. Two exclusions fall out of the derivation instead of an allowlist:

  • a package with no dist/ was not built in this job — a fact about the build filter, not a finding;
  • a built package whose manifest declares no declaration path owes none.

Exit codes follow the sibling convention (check-published-readme-exports.mjs, check-type-check-coverage.mjs, check-dual-build-cjs-loads.mjs), with the numbers imported from scripts/import-prerequisite.mjs rather than picked again:

code meaning
0 every built package's declared declarations are present
1 a finding: a package that has a dist/ is missing a declaration its own package.json points consumers at — named, per package, per file
3 PREREQUISITE NOT MET: not one workspace package has a dist/, so nothing was swept. Not a pass and not a finding

The negative control, quoted

packages/create-objectstack is the false positive the card names, and it reads clean without an exception row because the derivation asks the manifest:

$ ls packages/create-objectstack/dist/
chunk-ZIUW7UEA.js  created-summary.d.ts  created-summary.js  index.js  templates

packages/create-objectstack       -> ["dist/created-summary.d.ts"]
packages/services/service-cluster -> ["dist/index.d.cts","dist/index.d.ts","dist/testing.d.ts"]

index.js is present and index.d.ts is absent — the exact shape a naive scan reports — and no index.d.ts was ever promised to anyone, so nothing is owed and nothing is reported.

Verification

Self-test (fixtures in a temp dir, real directories, real workspacePackages() enumeration):

$ node scripts/check-dts-closure.mjs --self-test; echo EXIT=$?
check-dts-closure self-test: all assertions passed.
EXIT=0

Cases: (a) declarations present → green, and counted as swept; (b) index.js present / declarations absent → red naming package and file, plus the one-vanished-among-survivors case and the zero-byte empty case; (c) a built package whose exports declare no types → clean, in both spellings (no types condition at all, and a types condition for a non-index subpath — the create-objectstack shape); an unbuilt package skipped rather than reported; (d) no dist/ anywhere → run() returns 3. A second battery pins that the derivation is the per-package guard's own function.

Production leg, on a closure built through the verify lock (OS_VERIFY_LOCK_SLOT=issue-15042, VERDICT command-exit 0 · held the lock 194s), whose own build printed the line the card quotes (check-dts-emitted: @objectstack/service-cluster - 3/3 declared declaration file(s) present.):

$ node scripts/check-dts-closure.mjs; echo EXIT=$?
check-dts-closure: 6 built package(s) swept - 45/45 declared declaration file(s) present across 6 package(s); 0 built package(s) declare no declaration entry point and owe none.
EXIT=0

Ablation A — delete one built .d.ts in the probe. Mutation confirmed on disk before the reading (the file listing below is the script's own, taken after the rm):

MUTATION LANDED: .../service-cluster/dist/index.d.ts absent
  (index.cjs index.cjs.map index.d.cts index.js index.js.map testing.d.ts testing.js testing.js.map)
ABLATED_EXIT=1

x check-dts-closure: 1 built package(s) are MISSING declaration files their
  own package.json promises. The build reported success and these files are not there.

  @objectstack/service-cluster  (packages/services/service-cluster)
      missing packages/services/service-cluster/dist/index.d.ts

That is the card's shape exactly: the .js outputs survive, the declaration does not, and the package is named. Restored from a byte-copy and proven rather than assumed — dist/ is gitignored, so git status proves nothing here:

PRE  sha=0b5f636154ef15c47733d7b6688b9a05b077147318a97e6c2eba6977f50d7445 bytes=36069
POST sha=0b5f636154ef15c47733d7b6688b9a05b077147318a97e6c2eba6977f50d7445
RESTORE PROVEN: byte-identical to the pre-ablation blob
RESTORED_EXIT=0
check-dts-closure: 6 built package(s) swept - 45/45 declared declaration file(s) present ...

Ablation B — can the self-test go red? if (missing.length > 0) findings.push(if (false) findings.push(, anchor count 1 → 0 and marker count 1 confirmed on disk before the reading, restored with git checkout HEAD -- <path> and proven by an empty git diff HEAD:

ABLATED_SELFTEST_EXIT=1
x check-dts-closure self-test: 6 failure(s)
  - (b) REJECTS the #15042 artifact: JS present, every declaration gone
    expected ["@fixture/cluster|missing:dist/index.d.cts,missing:dist/index.d.ts,missing:dist/testing.d.ts"]
    actual   []
RESTORE PROVEN: git diff HEAD -- scripts/check-dts-closure.mjs is empty
RESTORED_SELFTEST_EXIT=0

The first run of this ablation exited 1 through a TypeError instead of a named failure, so the named case never reached the report. The second commit in this PR hardens that read; the reading above is from after it.

CI wiring

Three steps, each immediately after a closure build, each identical:

      - name: Sweep the built closure for vanished declarations
        run: pnpm check:dts-closure
workflow job sits after
lint.yml typecheck-debt (Type Check · debt ledger) Build the ledgered packages' dependencies
lint.yml typecheck-consumers (Type Check · consumer gates) Build the nested packages the consumer gates resolve through
ci.yml build-core Build packages (excluding docs)

Why three and not one. Jobs are separate runners with separate trees, so a sweep in one job says nothing about another job's tree; the two lint.yml lanes are the ones that take type verdicts after building the full ./packages/* + ./packages/*/* closure, and ci.yml's build-core is the canonical whole-workspace pnpm build. Each hunk is 13 lines and adds no required context (a step in an existing job — #9325). lint.yml's hunks sit at ~5015 and ~5202, well below the Lint & Repo Gates job that #15392 / #15331 touch.

The step is deliberately not in Lint & Repo Gates: nothing builds there, so the gate would answer exit 3 on every PR.

Wiring judged green by the gates that own it:

✓ check-self-test-wired: every one of the 174 script(s) CI runs that ship a `--self-test` has that self-test run by CI.
✓ check-self-test-workflow-commands: no self-test CI runs prints a line the Actions runner would parse as a workflow command.
✓ check-step-collectors: 407 `run:` steps across 31 workflow(s); 4 step(s) run 2+ independent self-tests, all of them through a collector.
OK: all 149 declared cross-package glob(s) (102 unique) are covered by `core` or `crosspkg` ...   [check-ci-filter-parity]

Gate family

node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackEXIT=0, 56 commands derived. All run. Also green: pnpm check:nul-bytes (plus a manual grep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]' over the four changed files — no hits), the repo-wide pnpm lint (eslint . --no-inline-config, EXIT=0 on 11c02ee0a, so no narrowing is claimed), and node scripts/pm/check-governed-merges.mjs --test <the four paths>✅ NOT governed.

NOT MEASURED, by name — both are the same build-dependent refusal this PR's gate copies, on a worktree where only the 6-package probe closure is built:

  • pnpm check:dual-build-cjs-loads — exit 3, PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/.
  • pnpm check:type-check-debt — exit 3, check-type-check-coverage: PREREQUISITE NOT MET, naming pnpm exec turbo run build --filter='./packages/*' --filter='./packages/*/*' as the remedy. pnpm check:type-check-coverage (the non-re-measuring half) ran green.

Eight further families are ⊘ NOT MEASURED by the derivation itself — their argv carries a workflow variable with no value outside a CI run (check-cross-package-test-inputs --union-into "$RUNNER_TEMP/...", four check-shard-attestation invocations, two check-test-completeness invocations).

node scripts/check-required-contexts.mjs --verify-required-set needed NODE_OPTIONS=--use-env-proxy to read the live set (401 without it); it then exits 0. Its direction B note about Governed Surface Queue Guard is pre-existing on main and untouched here.

Changeset

None, and skip-changeset is the right label rather than an omission: AGENTS.md — "that label is for a diff that publishes nothing from any released package." This diff is one new scripts/*.mjs, a scripts row in the private root manifest (@objectstack/spec-monorepo, private: true), and two workflow files. Nothing is published from any released package.


🤖 Generated with Claude Code

https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk


Generated by Claude Code

…he workspace build

`scripts/check-dts-emitted.mjs` runs per package, immediately after that
package's own `tsup`, and is the last thing that ever looks. On #15042's
observation it reported `3/3 declared declaration file(s) present` and the
files were gone later in the same tree, with no second build and no `rm`.

Adds `scripts/check-dts-closure.mjs`: a sibling that re-reads the whole built
workspace once, AFTER the closure build. The derivation is imported from the
per-package guard, not re-written, so the per-package contract is untouched
and there is still one answer to "which declarations does this manifest
promise". Exit 1 names the package and each missing file; exit 3
PREREQUISITE NOT MET when no package has a `dist/` at all, never a pass.

Wired as `check:dts-closure` and run directly after the closure build in
lint.yml's two full-closure lanes and ci.yml's `build-core`.

Does not touch any `tsup.config.ts` `clean` semantics and makes no claim
about what deleted the files.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
… crashing

Ablating the sweep predicate (`if (missing.length > 0)` -> `if (false)`) made
the self-test exit 1 through a TypeError on `findings[0].missing`, so the
named case that had already failed never reached the report. The optional
read keeps the exit code and restores the diagnostic.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012zGPuVVX3deAx9LdjK8jCk
@github-actions github-actions Bot added the size/l label Sep 5, 2026
@claude claude Bot added the skip-changeset PR has no user-facing published change; bypasses the changeset gate label Sep 5, 2026
@github-actions github-actions Bot added ci/cd dependencies Pull requests that update a dependency file labels Sep 5, 2026
@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

ACCEPT — PR #15796 (head 11c02ee0a, 4 files: new scripts/check-dts-closure.mjs +586, one root package.json row, one step in three CI jobs) reviewed against the GitHub three-dot diff, not the report.

The ruling's intent lands as a SIBLING gate rather than a --sweep mode, and the reason is accepted: the per-package guard is invoked positionally with no argv by ~70 build scripts, so a mode flag would make process.argv load-bearing inside every build; check-dts-emitted.mjs is byte-untouched and the derivation is IMPORTED (declaredDeclarationPaths, missingDeclarations at :118), not rewritten. Exit 1 names package + file; exit 3 PREREQUISITE NOT MET when nothing is built; create-objectstack reads clean by derivation (its exports owe only created-summary.d.ts). Wired as check:dts-closure (self-test then production) after the build step in lint.yml's two type-check jobs and ci.yml's build-core — three places because each lane builds its own tree; deliberately not in Lint & Repo Gates, where nothing is built. No tsup/clean change; no cause-chasing.

Measured by this seat: branch blob at scripts/--self-test EXIT=0 check-dts-closure self-test: all assertions passed.; production run on this unbuilt checkout → EXIT=3 (the prerequisite refusal, as designed); git merge-tree --write-tree clean against origin/main AND against the other seats' lint.yml PRs #15392 / #15331; governed test per the report NOT governed. NOT MEASURED here: the built-closure production leg (the dev's locked build + 45/45 declared declaration file(s) present across 6 package(s), ablation A deleting service-cluster/dist/index.d.ts → exit 1 naming it → restored by hash, ablation B → 6 self-test failures → restored, and the four CI-wiring judges green in the dev's box are the record).

Flip + arm when both required jobs report success on 11c02ee0a. On landing #15042 closes.


Generated by Claude Code

@claude

claude Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Flipped ready + auto-merge enabled (08:45Z, method: MERGE). Required jobs on 11c02ee0a: Lint & Repo Gates = success, TypeScript Type Check = success; git merge-tree --write-tree origin/main <branch> → EXIT=0 against the current main. Watched; on landing #15042 closes.


Generated by Claude Code

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

Labels

ci/cd dependencies Pull requests that update a dependency file size/l skip-changeset PR has no user-facing published change; bypasses the changeset gate

Projects

None yet

2 participants