Skip to content

Commit 57ceb9d

Browse files
Samclaude
andauthored
feat(scripts): check-dts-references — catch a partially-emitted DTS pass at the build (#19434)
Part of #19402 Clause-②: no `#19402` is a **composition gap**, not a defect in either guard, and this PR does not change either one. It adds the third reading that was missing between them. ## The seam, and the measurement that chose it The card named the gap and deliberately declined to name the fix, so the three candidate seams were measured rather than preferred. **Candidate A — tsup's own emission record. Ruled out: there is none.** On the installed tsup 8.5.1, `metafile` occurs **0** times in `dist/rollup.js`, the DTS worker. Lit control on the same grep over the same file: **16** `rollup`, **45** `dts` — so the zero is an absence the instrument could see, not a grep that missed the file. `metafile` is an esbuild-only option, written from `result.metafile` in `dist/index.js` as `metafile-FORMAT.json`, and the JS pass is not the pass that can die mid-emission. A record written by the DTS pass would in any case be evidence from the run whose message-less death is the thing being guarded: a pass that stops mid-emission does not write its closing record either. **Candidate B — derive the chunk set from the entry declarations' own imports. Chosen.** rollup-dts writes every chunk reference into the file that needs it, as an ordinary relative specifier (`import { X } from './view.zod-CIU5lVgU.js'`), so the set of files a build was supposed to emit is the transitive closure of those references from the manifest-declared declarations. Derived on every run, no number to maintain. Measured on `packages/spec` at `a0e62e69` (2026-09-20T19:09Z): | reading | value | |---|---| | manifest-declared roots | 34 | | transitive closure of their relative references | **124** | | declaration files actually on disk under `dist/` | **124** | | emitted declarations OUTSIDE the closure (orphans) | **0** | | relative references, all resolved | 366 | | AST positions carrying a specifier | 318 import, 164 export, 0 of the other four | ⭐ 0 orphans is the load-bearing number: on this package the closure is not a sample of the emission, it **is** the emission — so a constant like "there should be 46 chunks" buys nothing the derivation does not already give, and costs a maintenance obligation. **Candidate C — make the build's last step (`check-dev-prereqs.mjs --stamp`) the owner. Ruled out on two measurements.** Population: **68** package build scripts run `check-dts-emitted.mjs`; exactly **4** of them are freshness AMPLIFIERS, the only packages where `--stamp` runs at all; **12** of the 68 declare more than one entry point, which is the chunk-capable set. So the stamp's population is the wrong one by measurement. Subject: `--stamp` records which **inputs** a dist was built from, and answering an emission question with an input digest is the direction `#7122` already had rejected one artifact over. **Why a build step and not the post-build sweep.** `check-dts-closure.mjs` already sweeps the built workspace after the closure build — and it shares `check-dts-emitted.mjs`'s manifest criterion (it imports `declaredDeclarationPaths` from it), so it is blind to chunks in exactly the same way, one instant later. Later is too late for the reason `#11907` recorded: turbo caches only **successful** tasks, so a build that exits non-zero never becomes a cache entry, while a build that exits 0 over a partial emission is cached under the ordinary hash and replayed by every later run in that worktree. ## Both directions, proved Measured 2026-09-20T19:17–19:19Z in one worktree of `origin/main` `a0e62e69`, every exit code captured before any pipe, `dist/` being gitignored so sha256 is the restore proof. **RED — 2 emitted chunk declarations moved aside** (`dist/view.zod-CIU5lVgU.d.ts` 497,743 B and `dist/object.zod-ThXvwL_J.d.ts` 401,806 B; mutation confirmed on disk, 62 `.d.ts` down to 60): | instrument | reading | |---|---| | `node scripts/check-dts-emitted.mjs` | exit **0** — "34/34 declared declaration file(s) present" | | `distIsStale()` | **false** · `declarationStamp()` → **match** | | `pnpm --filter @objectstack/spec check:api-surface` | exit **1** — **233 breaking (removed/narrowed)**, 227 added, **229 removal lines** | | `node scripts/check-dts-references.mjs` (this PR) | exit **1**, naming every referrer and the path it looked for | ⭐ **Lit control on the same instrument and subject**: removing one **declared entry** instead (`dist/meta-spelling/index.d.ts`) makes `check-dts-emitted.mjs` exit **1** and name it. So its zero above is a real coverage gap, not a dead instrument. ⚠️ The card recorded 70 breaking / 64 removal lines on a different chunk pair; 233 / 229 is this pair, re-taken today. The number is a property of which chunks go missing, and the point is its order of magnitude: a `check:generated --fix` on that tree deletes live exports from the committed baseline under one `✓ gen:api-surface`. **GREEN — the complete emission**: exit **0** in 4.9 s, "124 declaration file(s) reachable from 34 declared entry point(s); 366/366 relative reference(s) resolved (116 bare specifier(s) are not this guard's business). Positions read: 164 export, 318 import." **Restores proved**: `sha256sum -c` exit 0 on all three files, and `check-dts-emitted`, `check-dts-references` and `check:api-surface` all back to exit 0 afterwards. **It ran inside the real build.** The closure build (`turbo run build`, 72/72 tasks, exit 0) re-ran `packages/spec`'s build after this edit, and `dist/.build-input-hash-dts` moved from `aa5477ba04273022…` to `9acfe0644da66724…`, which equals `buildInputHash()` over the current inputs. The stamp is the LAST link of that `&&` chain, so every step before it — this guard included — ran and exited 0. ## Design notes worth reading before changing it - **The parse is load-bearing, and a regex would be wrong.** `packages/spec`'s own emitted declarations carry **4** relative specifiers inside TSDoc prose (a `{@link import(...)}` and a fenced example) that name files no build was ever supposed to emit. A scan that reads comments reports a healthy build as a partial emission. It parses through `scripts/ts-parse.mjs`, the one sanctioned parse entry point under `scripts/**`, which also means a **truncated** declaration — the other shape a half-finished emission leaves — REFUSES and names the file instead of silently reporting fewer references. - **No cross-extension fallback, on purpose.** `./x.js` looks only for `x.d.ts`, never for `x.d.mts`. A build emits both from one chunk, so a tolerant rule would be satisfied by a tree that lost every `.d.ts` chunk and kept every `.d.mts` one — which is the shape the card measured. Generosity there reads as tolerance and is blindness; it is pinned by a self-test case. - **What it does NOT claim**: it does not re-run TypeScript's module resolution and does not assert `tsc` would pick the file found. The claim is exactly "every relative reference an emitted declaration writes resolves to a non-empty declaration file on disk". Freshness, the JS bundles, and declarations the manifest never names are all outside it — and the orphan count is printed every run rather than assumed. ## Radius, and the targets outside it Wired into **`packages/spec`** only — the package the card measured, and the one whose partial emission was measured at 233 breaking removals. ⚠️ **Measured but deliberately not acted on**: with the whole closure built (72/72, 2026-09-20T19:47Z), `check-dts-references.mjs` was run from each of the **68** package directories whose build already runs `check-dts-emitted.mjs`. **68/68 exit 0.** So adopting it in the other 67 builds is measured-safe today — it is one `&&` link per `package.json`, exactly as the sibling guard was adopted — but it is a rollout this card did not ask for, it invalidates 68 turbo build hashes at once, and it is named in the report for the seat to file rather than taken here. ## What is NOT addressed here `#19086` is not addressed here. That card is the `distIsStale()` predicate and its repair direction, it is `needs-user-decision`, and nothing in this PR touches `distIsStale()`, its legs or its acceptance set. This reading is **upstream** of that choice and survives every option on it, including the strongest one considered there: a digest over the emitted `dist/**/*.d.ts` would be computed by the same build whose last step already believes it finished. ## Changeset `skip-changeset` — measured, not asserted. Grep over everything `@objectstack/spec`'s `files[]` ships (`dist json-schema liveness prompts llms.txt README.md src/**/*.zod.ts CHANGELOG.md api-surface spec-changes.json`) for `check-dts-references`: **0** files. Positive control on the same grep over the same trees for `defineStack`: **256** files. And `check:api-surface` exits 0 — "public API surface + factory signatures unchanged". The only byte that ships differently is the `scripts.build` string inside `package.json`, which no consumer executes. ## Acceptance notes Noted while here, not filed and not fixed in this PR: - `scripts/check-dts-closure.mjs` (the post-build closure sweep) imports its criterion from `check-dts-emitted.mjs` and is therefore blind to chunk declarations in the same way. Wiring this PR's reading into that sweep would give the whole built workspace the same answer in one place. Observation, not a defect of that gate: its docblock declares the manifest criterion it uses. - The other 67 adopter builds (measurement above). One `&&` link each. - The clone in this container is shallow (`.git/shallow` at `ae8edd2c`), which is why `node scripts/check-plugin-teardown-shape.mjs --self-test` exits **3** here: its positive-control fixture is pinned to a commit outside the horizon. Environment, not this diff — the same command is unaffected by any path in it. ## Gates `node scripts/pm/dispatch-gates.mjs --commands` derived **105** families for these 3 paths; **104 ran green**, **1 NOT MEASURED** (`check-plugin-teardown-shape --self-test`, exit 3, shallow clone — see above). Reconciled with `--ran`. `origin/main` was not merged: re-deriving after a fetch produced a byte-identical 105-command list, so the one gate source that moved in that range (`scripts/pm/check-half-states.mjs`, which no path here touches) did not change the answer. `packages/spec`: `pnpm test` 505 files / 14,734 tests passed, `pnpm typecheck` exit 0. --- _Generated by [Claude Code](https://claude.ai/code/session_01HnRAeVTLJevtQ5iCPX6JSm)_ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 23f1de0 commit 57ceb9d

3 files changed

Lines changed: 638 additions & 1 deletion

File tree

‎.github/workflows/lint.yml‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -641,6 +641,21 @@ jobs:
641641
node scripts/check-comment-mask-adoption.mjs --self-test
642642
node scripts/check-comment-mask-adoption.mjs
643643
644+
# The declaration-emission completeness guard (#19402). Its PRODUCTION run
645+
# is a step of `packages/spec`'s own build, in another job, for the reason
646+
# #11907 recorded: turbo caches only SUCCESSFUL tasks, so the verdict has
647+
# to land inside the build or a partial emission is cached under the
648+
# ordinary hash and replayed. What runs HERE is the `--self-test`, and it
649+
# is the only instrument on this gate's rule: a healthy tree is green by
650+
# construction, so the production run cannot tell a working walk from a
651+
# dead one. The self-test supplies the partial emission a real tree does
652+
# not contain — every declared entry present, a chunk gone, which is the
653+
# tree both neighbouring guards acquit.
654+
# Hermetic: real fixture files in a temp dir, no network, no node_modules
655+
# beyond `typescript`; ~1s.
656+
- name: Declaration references resolve (#19402) — self-test
657+
run: node scripts/check-dts-references.mjs --self-test
658+
644659
# A package's published `src/**` may only import workspace packages its own
645660
# manifest DECLARES (#10062). The class was filed with one member and a
646661
# mitigation — "type-only, so nothing lands in the emitted JavaScript" —

‎packages/spec/package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,7 +243,7 @@
243243
"spec-changes.json"
244244
],
245245
"scripts": {
246-
"build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp",
246+
"build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=6144\" BUILD_DTS=true tsup; fi && node ../../scripts/check-dts-emitted.mjs && node ../../scripts/check-dts-references.mjs && node ../../scripts/check-dev-prereqs.mjs --stamp",
247247
"dev": "tsc --watch",
248248
"clean": "rm -rf dist",
249249
"gen:schema": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts",

0 commit comments

Comments
 (0)