fix(spec): the dist freshness rule can be answered — a declaration stamp acquits a tree whose sources were re-checked-out unchanged - #16176
Merged
huangyiirene merged 6 commits intoSep 6, 2026
Conversation
Contributor
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
This was referenced Sep 6, 2026
huangyiirene
marked this pull request as ready for review
September 6, 2026 05:13
huangyiirene
enabled auto-merge
September 6, 2026 05:13
This was referenced Sep 6, 2026
huangyiirene
deleted the
claude/issue-14985-dist-freshness-false-refusal
branch
September 6, 2026 05:53
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 free
to 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 #14985
The mtime rule keeps its power to convict and gains exactly one way to be answered. It is not #7122's rejected swap to
dist/.build-input-hash; that shape still fails, and its pin still passes for the reason it was written.The card's mechanism, measured — and one half of it corrected
The card named
packages/spec/dist/.build-input-hashas the thing the build short-circuits on. Measured onorigin/main1f2a02ba0, that is not what happens:packages/spec's own build never short-circuits. Its main tsup pass isclean: true, sopnpm --filter @objectstack/spec buildrewritesdist/every time..build-input-hashis an output of that build (check-dev-prereqs.mjs --stamp, its last step); nothing reads it to decide whether to build.touch packages/spec/src/data/query.zod.tswith the bytes unchanged,turbo run build --filter @objectstack/specreportedCached: 1 cached, 1 total/Time: 56ms >>> FULL TURBOand rewrote nothing, leaving the newestdist/**/*.d.tsmtime at1788662604317while the newestsrc/**/*.tsmoved to1788662606713.check:api-surfacethen exited 1.So deleting the hash file was never what cleared it — running the package build directly was, because that bypasses turbo. The defect is otherwise exactly as filed.
The direction, and why it is neither of the two the card offered
Both suggestions turn out to be unavailable:
dist/.build-input-hashis written by every build including underOS_SKIP_DTS=1, the one flag that guarantees the declarations this gate reads are stale.packages/spec/scripts/dist-freshness.test.tspins that.dist/mtimes cannot be implemented, because the failing case is a build that did not run at all. There is nothing in a turbo cache hit to hook.What is left is a third direction. The two primitives are wrong in opposite directions — mtimes false-red on a byte-identical rewrite, the content stamp false-greens under
OS_SKIP_DTS=1— and neither is wrong in the other's direction. So:packages/spec's build now records a second stamp beside the one it already wrote,dist/.build-input-hash-dts, holding the same digest over the same inputs — but written only by a build that actually emitted declarations.--stampskips it underOS_SKIP_DTS=1and leaves the previous file untouched, because whatever.d.tsare on disk still came from the build that wrote it, so the old digest is the true one.distIsStalethen reads it as an acquittal, and only as an acquittal:Three properties make that safe, and each is pinned:
unstamped— absent, unreadable, not 64 hex characters, digest uncomputable, or a package whose build does not stamp at all — leaves the mtime verdict standing. Nothing that passed before can start failing, and no path that refused before refuses for a new reason.OS_SKIP_DTS=1build never refreshes the new file, so the digest it holds is the old one and the tree is refused. The existing pin now writes the real digest intodist/.build-input-hashrather than a placeholder — the strongest form of that shape — and still expects a refusal.src/**plus the package build config plus turbo.json's ownglobalDependencies; a refusal prints both digests.Cost: ~30ms, spent only on the branch that previously prescribed a multi-minute rebuild under the shared verify lock.
The wrong-cause half
The old refusal named
OS_SKIP_DTSregardless of cause — the card counts that as part of the defect, and it is now driven by what was measured. Three states, three sentences:recorded <digest> · packages/spec/src now hashes to <digest>.The remedy block also now warns that a repo-wide
pnpm buildmay be a cache hit that rewrites nothing, so it points at the package build. (Visible in this branch's own run:check:skill-examplesprinted it forpackages/client-react.)Why
scripts/build-input-hash.mjsexistsdistIsStaleneeds the digest, and it has to be the same function or the comparison means nothing. Importingcheck-dev-prereqs.mjswas wrong twice over, and the second one was caught by a gate rather than by reading:--self-test), andscripts/pm/dispatch-gates.mjsrefuses to follow a gate file for inherited watch hints — so the import silently subtracted thepackages/spechintcheck:merge-driverwould otherwise have inherited.pnpm check:pm-dispatch-gatesfailed on exactly that:LOST: check:merge-driver <- scripts/check-dev-prereqs.mjs (packages/spec);process.exited inside its importer, whose callers include the pre-commit hook.The new module has no CLI, declares no path population and spells no watch hint of its own, so following it subtracts nothing from anybody.
check-dev-prereqs.mjskeeps--stamp, the gate and its self-test, and its header'sOS_SKIP_DTSfalse-green entry now records that the hole is attended next door rather than merely noted.Evidence
Red first, then green — the shape constructed directly, never a replayed merge. With the acquittal ablated out of
distIsStale(return true;, injected text confirmed present and removed text confirmed absent on disk before reading anything):distIsStaleon a touched-but-unchanged treetruefalsecheck:api-surfacedist-freshness.test.tsThe one failure under ablation is
ACQUITS an mtime-stale tree whose declaration stamp matches the sources (#14985), so the new pin is non-vacuous. Restored bygit checkout HEAD -- scripts/check-regen-pending.mjs, proven by blob identity (9df32df9…both sides) and an emptygit diff HEAD— not by an exit code.End to end on the real package: a full build writes both stamps (
✓ .build-input-hash ← 42bc3cb1…,✓ .build-input-hash-dts ← 42bc3cb1…); a fresh tree passes; atouchwith unchanged bytes now passes where it used to refuse; and a genuine new byte undersrc/is still refused withmismatchand both digests printed.Tests.
pnpm --filter @objectstack/spec test— 482 files, 12954 tests, all pass.pnpm --filter @objectstack/spec typecheckgreen.node scripts/check-dev-prereqs.mjs --self-testgreen with a new battery 17 (9 assertions) covering the declaration stamp, including that--stampunderOS_SKIP_DTS=1does not adopt the new digest while the JS half still reads fresh.Gates. All 86 families
scripts/pm/dispatch-gates.mjsderives against this diff were run and reconciled:✓ dispatch-gates --ran: 86 derived famil(ies) accounted for — 86 run, 0 NOT-MEASURED. Two of the 86 exited 3 = PREREQUISITE NOT MET, not a finding:check:dual-build-cjs-loadsandcheck:type-check-debtboth require the whole workspace built (86 packages with nodist/), which CI does before running them.pnpm lint(repo-wideeslint . --no-inline-config) is green, run whole — no narrowing claimed.Out of scope, filed
#16175 —
schemaTreeIsStale(json-schema/) andbundlesAreStale(dist/*.mjs) carry the identical false refusal and are not fixed here. Measured on this branch: the same singletouchmadecheck:docsandcheck:browser-reachable-entriesrefuse too. They are not a mechanical extension — the bundles axis can reuse the existing stamp (OS_SKIP_DTS=1emits fresh bundles, so that flag is not a hole on that axis at all), while the json-schema axis has no stamp to read and would need a new one fromgen:schema. Two soundness arguments, two sets of pins, so they were filed rather than ridden in. That issue is not addressed here and remains open.🤖 Generated with Claude Code
Generated by Claude Code
Generated by Claude Code