Uh oh!
There was an error while loading. Please reload this page.
fix(spec): gen:api-surface / check:api-surface refuse a stale dist - #7183
Merged
Conversation
…7122) `build-api-surface.ts` reads the built `dist/*.d.ts` and has always documented that as a precondition without enforcing it. On a stale dist it does not fail: it writes a baseline missing every export added since the build, each of which reads as a BREAKING removal by this generator's own rule -- and `--check` then compares that baseline against the same stale dist and agrees. Green at every step. Both modes now refuse before a single `.d.ts` is read, using the freshness rule `check:generated --fix` and the pre-commit hook already share. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KJATVrh6V2ysutYUJigh3B
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
Contributor
📓 Docs Drift CheckNo hand-written docs reference the 0 changed package(s). ✅ |
This was referenced Aug 10, 2026
os-help
marked this pull request as ready for review
August 10, 2026 02:20
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#7122
build-api-surface.tsreads the builtdist/**declarations and has always documented that as a precondition — "Reads the built dist — run afterpnpm --filter @objectstack/spec build" — while enforcing nothing. Both modes now refuse before a single declaration file is read.Premise check first: half of the card's premise is false
The card names two generators. Measured on
origin/main@5087ac6:build-api-surface.tsreads the dist. Confirmed — the exports map points its 16 entry points at./dist/index.d.ts,./dist/data/index.d.ts, and so on.build-export-origins.tsdoes NOT read the dist. Its docblock says so in as many words at:75-77: "Readssrc/, NOT the built dist — deliberately. … Nothing here needspnpm buildto have run."collectSourceEntriesresolvessrc/index.tsandsrc/NAME/index.ts, neverdist.Triage's positive control on that file was itself a false positive. The "5
disthits inbuild-export-origins.ts" that made the scan look trustworthy are a substring match:Word-boundary search returns exactly the two docblock lines. So the stale-dist laundering mechanism cannot apply to
gen:export-origins, and this PR scopes to the one generator where it does. Thegen:export-originshalf of #7122 is disproved rather than deferred.What lands
A guard in the generator, refusing in both modes per the ruling:
packages/spec/scripts/lib/dist-freshness.ts—inspectDistFreshness(pkgDir, mode)returns a verdict plus the message, rather than exiting, so both directions can be driven from a test.packages/spec/scripts/build-api-surface.ts— calls it immediately afterCHECKis resolved and beforets.createProgram. Once the program exists over a stale dist, every answer below it is already wrong.missingandstaleare distinguished, as the ruling asked. They are one refusal with two causes and two fixes worth suggesting, and a never-built tree is the one state the old code already failed honestly in (the type checker could not resolve a module symbol) — that must not regress into something quieter.No bootstrap path is broken.
packages/spec'sbuildscript runsgen:schema,gen:openapiandtsup; it never runsgen:api-surface. CI runscheck:api-surfaceafter the build step (lint.yml comment: "Runs after the build step above"). Nothing invokes this generator before a dist exists.Why the mtime rule and NOT
dist/.build-input-hash— the suggested route, falsifiedThe card and the dispatch both suggested reusing the content stamp
scripts/check-dev-prereqs.mjswrites. Measured, that stamp is the wrong primitive for this consumer, and wrong in the dangerous direction:packages/spec's build script is... && tsup && if [ -z "$OS_SKIP_DTS" ]; then BUILD_DTS=true tsup; fi && node ../../scripts/check-dev-prereqs.mjs --stamp— the declaration pass is conditional, the stamp is not. After
OS_SKIP_DTS=1 pnpm buildthe stamp matches the sources exactly while the declarations on disk are arbitrarily old.check-dev-prereqs.mjs:103-107lists this as a known FALSE GREEN and names this gate as the one it breaks; AGENTS.md says the same from the other side ("skips exactly the artifact the gate inspects, and the check passes locally while failing in CI"). A stamp-based guard would be green on the one local build flag that guarantees a stale input.check-dev-prereqs.mjsends in an unguarded top-levelprocess.exit(report(inspect(ROOT))), so importing it runs the whole gate. Using it needs either an extraction from that script or a second copy of the hash definition..d.mts:distIsStaleinscripts/check-regen-pending.mjs, whose own docblock names this trap — "gen:api-surfacereads the BUILTdist/*.d.ts. On a stale dist it does not fail." It keys ondist/**declaration mtimes againstsrc/**sources, so it catches theOS_SKIP_DTS=1shape as well as the card's rebase-behind-the-dist shape.So this adds no second notion of "is
packages/spec/distcurrent". The stamp's own strength — content, not mtime (#5864) — is real and untouched; it still guardspnpm dev. It is simply blind to the half of the dist this generator is made of. Closing that hole in the stamp is a separate change to a script this card was told to treat as read-only, andscripts/check-dev-prereqs.mjsis unmodified here.Why the guard is in the generator, not in another caller
It already lived in two callers and neither covers the path the reporter took:
check:generated --fixrefusesreadsDistgenerators on a stale dist, andscripts/check-regen-pending.mjsrefuses them at pre-commit. Runningpnpm --filter @objectstack/spec gen:api-surfacedirectly — what AGENTS.md tells you to do after changing an export, and what lint.yml runs — reaches neither.build-docs.tsmade this exact move one artifact over in #4723, carryingschemaTreeIsStaleitself "so EVERY caller is covered rather than this one".Reverse verification — direction predicted before running
Predicted: on one stale tree, the pre-fix script renders a verdict and the post-fix script refuses. Both halves measured on the real package, with the guard taken out via
git checkout origin/main -- FILE(nevergit stash).The tree:
packages/specbuilt, thenpackages/spec/src/contracts/job-service.ts— the file the card names — aged by mtime only, bytes untouched.distIsStale()flipsfalsetotrue.Pre-fix,
--check, stale dist:Post-fix, same tree,
--check:Post-fix, same tree, generate mode: exit 1, damage stated as
WRITE a baseline, andgit statusempty afterwards — the refused run wrote nothing.Honest scope of that control: this reproduction ages the dist by mtime while its contents still match
src, so the pre-fix green above is materially correct — what it demonstrates is that the gate renders a verdict on a dist it never vouched for. The wrong-baseline half is demonstrated instead by the end-to-end positive control below, and was measured in the field by the card itself (JobRunOutcome (interface)deleted fromapi-surface/contracts.jsonwhile the export was live).Tests
packages/spec/scripts/dist-freshness.test.ts, 12 cases, 4.6s.Eight unit cases drive the rule on real temp fixtures with explicit mtimes (no sleeps), including the two that a rule answering "fresh" unconditionally would pass and the two that a rule answering "stale" unconditionally would fail. One pins the
OS_SKIP_DTS=1-on-an-already-built-tree shape — fresh JS, fresh.build-input-hash, stale declarations — which is green under a stamp-based guard and red under this one.Four end-to-end cases spawn the real script in a repo-shaped sandbox that copies
scripts/and symlinks the read-only inputs, followingbuild-schemas-check-mode.test.tsand its argument that a test-only seam is itself a place where the gate can differ from what CI runs. The first is a positive control: with a fresh fixture dist the script runs to completion and rewrites the seeded baseline. That is what makes the other three non-vacuous — the same tree, aged, leaves the baseline byte-identical, so the refusal is stopping a write that would otherwise have happened.Gates run locally
pnpm --filter @objectstack/spec testpnpm --filter @objectstack/spec typechecktsc,check:scripts-typecheck,check:test-typecheck)check:api-surfacecheck:export-originscheck:generatedcheck:generated --reconcile-onlycheck:+ 14gen:classified (no script added or renamed)node scripts/check-nul-bytes.mjseslinton the three changed filesNo changeset:
packages/spec's publishedfileslist does not includescripts/, so nothing here ships.skip-changesetapplied.packages/spec/scripts/lib/sharded-artifacts.tsis not touched — it stays whole for #7076.Out of scope, filed
#7181 —
check:dual-source-exports,check:exported-anyandcheck:skill-examplesread the built declarations with the same unenforced precondition. All three are check-only, so none can launder a baseline into a commit, and CI runs them after the build; filed as an observation with the cheap adoption path rather than fixed here.Generated by Claude Code