feat(devx): a closure-wide declaration sweep after the workspace build names a package whose .d.ts vanished (#15042) - #15796
Conversation
…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
|
ACCEPT — PR #15796 (head The ruling's intent lands as a SIBLING gate rather than a Measured by this seat: branch blob at Flip + arm when both required jobs report success on Generated by Claude Code |
|
Flipped ready + auto-merge enabled (08:45Z, Generated by Claude Code |
Fixes #15042
What this is
scripts/check-dts-emitted.mjs(#12078) runs per package, immediately after that package's owntsup, 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 norm, while the.js/.cjsoutputs 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.tsand noclean: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
--sweepflagThe derivation is shared, not duplicated —
declaredDeclarationPathsandmissingDeclarationsare imported fromcheck-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:... && tsup && node ../../../scripts/check-dts-emitted.mjs). Teaching that entry point a mode flag makesprocess.argvload-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 rootpackage.jsonrow.@objectstack/plugin-authbuild in a fresh worktree withOS_SKIP_DTSunset — 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.scripts/*--self-test modes decide success with no assertion floor, or exit 0 viareturn selfTest()before their verdict #13489). Two contracts sharing one roster lets one contract's cases stand in for the other's at the floor.check-self-test-wiredand thecheck:*family convention are per script — one script, one row, one--self-testCI runs.So
scripts/check-dts-emitted.mjsis untouched by this PR and the per-package contract is unchanged.The gate
scripts/check-dts-closure.mjs, wired ascheck:dts-closure.Population is derived, never listed: every workspace package (through the shared
scripts/workspace-enumerator.mjsparse) that has adist/. Two exclusions fall out of the derivation instead of an allowlist:dist/was not built in this job — a fact about the build filter, not a finding;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 fromscripts/import-prerequisite.mjsrather than picked again:dist/is missing a declaration its ownpackage.jsonpoints consumers at — named, per package, per filedist/, so nothing was swept. Not a pass and not a findingThe negative control, quoted
packages/create-objectstackis the false positive the card names, and it reads clean without an exception row because the derivation asks the manifest:index.jsis present andindex.d.tsis absent — the exact shape a naive scan reports — and noindex.d.tswas 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):Cases: (a) declarations present → green, and counted as swept; (b)
index.jspresent / declarations absent → red naming package and file, plus the one-vanished-among-survivors case and the zero-byteemptycase; (c) a built package whose exports declare no types → clean, in both spellings (notypescondition at all, and atypescondition for a non-indexsubpath — thecreate-objectstackshape); an unbuilt package skipped rather than reported; (d) nodist/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.):Ablation A — delete one built
.d.tsin the probe. Mutation confirmed on disk before the reading (the file listing below is the script's own, taken after therm):That is the card's shape exactly: the
.jsoutputs survive, the declaration does not, and the package is named. Restored from a byte-copy and proven rather than assumed —dist/is gitignored, sogit statusproves nothing here: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 withgit checkout HEAD -- <path>and proven by an emptygit diff HEAD:The first run of this ablation exited 1 through a
TypeErrorinstead 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:
lint.ymltypecheck-debt(Type Check · debt ledger)Build the ledgered packages' dependencieslint.ymltypecheck-consumers(Type Check · consumer gates)Build the nested packages the consumer gates resolve throughci.ymlbuild-coreBuild 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.ymllanes are the ones that take type verdicts after building the full./packages/*+./packages/*/*closure, andci.yml'sbuild-coreis the canonical whole-workspacepnpm 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 theLint & Repo Gatesjob 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:
Gate family
node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstack→EXIT=0, 56 commands derived. All run. Also green:pnpm check:nul-bytes(plus a manualgrep -naP '[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]'over the four changed files — no hits), the repo-widepnpm lint(eslint . --no-inline-config,EXIT=0on11c02ee0a, so no narrowing is claimed), andnode 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, namingpnpm 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 MEASUREDby 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/...", fourcheck-shard-attestationinvocations, twocheck-test-completenessinvocations).node scripts/check-required-contexts.mjs --verify-required-setneededNODE_OPTIONS=--use-env-proxyto read the live set (401 without it); it then exits 0. Itsdirection Bnote aboutGoverned Surface Queue Guardis pre-existing onmainand untouched here.Changeset
None, and
skip-changesetis 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 newscripts/*.mjs, ascriptsrow 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