Skip to content

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 into
mainfrom
claude/issue-14985-dist-freshness-false-refusal
Sep 6, 2026
Merged

fix(spec): the dist freshness rule can be answered — a declaration stamp acquits a tree whose sources were re-checked-out unchanged#16176
huangyiirene merged 6 commits into
mainfrom
claude/issue-14985-dist-freshness-false-refusal

Conversation

@huangyiirene

Copy link
Copy Markdown
Collaborator

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-hash as the thing the build short-circuits on. Measured on origin/main 1f2a02ba0, that is not what happens:

  • packages/spec's own build never short-circuits. Its main tsup pass is clean: true, so pnpm --filter @objectstack/spec build rewrites dist/ every time. .build-input-hash is an output of that build (check-dev-prereqs.mjs --stamp, its last step); nothing reads it to decide whether to build.
  • The short-circuit is turbo's cache, which hashes content. Measured: after touch packages/spec/src/data/query.zod.ts with the bytes unchanged, turbo run build --filter @objectstack/spec reported Cached: 1 cached, 1 total / Time: 56ms >>> FULL TURBO and rewrote nothing, leaving the newest dist/**/*.d.ts mtime at 1788662604317 while the newest src/**/*.ts moved to 1788662606713. check:api-surface then 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:

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. --stamp skips it under OS_SKIP_DTS=1 and leaves the previous file untouched, because whatever .d.ts are on disk still came from the build that wrote it, so the old digest is the true one.

distIsStale then reads it as an acquittal, and only as an acquittal:

const dist = newestMtime(join(specDir, 'dist'), (n) => n.endsWith('.d.ts'));
if (!dist) return true;
if (newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts')) <= dist) return false;
return declarationStamp(specDir).state !== 'match';

Three properties make that safe, and each is pinned:

  1. It can only ever narrow. 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.
  2. gen:api-surface / gen:export-origins read the dist without asserting it is FRESH — a stale dist launders a phantom breaking removal into the committed baseline #7122's shape still convicts. An OS_SKIP_DTS=1 build 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 into dist/.build-input-hash rather than a placeholder — the strongest form of that shape — and still expects a refusal.
  3. The evidence is recomputable. Every acquittal is a sha256 over src/** plus the package build config plus turbo.json's own globalDependencies; 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_DTS regardless of cause — the card counts that as part of the defect, and it is now driven by what was measured. Three states, three sentences:

  • mismatch — "describe DIFFERENT sources than the ones on disk … this is a real content change, not a timestamp artefact", followed by recorded <digest> · packages/spec/src now hashes to <digest>.
  • unstamped — says plainly that there is nothing to compare against, names the two ways that happens, and tells the reader one real build settles it and records the stamp, after which a re-checkout that only bumps mtimes stops being refused.
  • match while still refusing — reachable only if the tree moves between the rule's reading and the wording's; says so and asks for a re-run, rather than inventing a missing stamp that is right there.

The remedy block also now warns that a repo-wide pnpm build may be a cache hit that rewrites nothing, so it points at the package build. (Visible in this branch's own run: check:skill-examples printed it for packages/client-react.)

Why scripts/build-input-hash.mjs exists

distIsStale needs the digest, and it has to be the same function or the comparison means nothing. Importing check-dev-prereqs.mjs was wrong twice over, and the second one was caught by a gate rather than by reading:

  • it is a gate file (lint.yml runs its --self-test), and scripts/pm/dispatch-gates.mjs refuses to follow a gate file for inherited watch hints — so the import silently subtracted the packages/spec hint check:merge-driver would otherwise have inherited. pnpm check:pm-dispatch-gates failed on exactly that: LOST: check:merge-driver <- scripts/check-dev-prereqs.mjs (packages/spec);
  • it would have run a workspace scan and 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.mjs keeps --stamp, the gate and its self-test, and its header's OS_SKIP_DTS false-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):

ablated as committed
distIsStale on a touched-but-unchanged tree true false
check:api-surface exit 1 exit 0
dist-freshness.test.ts 1 failed, 18 passed 19 passed

The 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 by git checkout HEAD -- scripts/check-regen-pending.mjs, proven by blob identity (9df32df9… both sides) and an empty git 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; a touch with unchanged bytes now passes where it used to refuse; and a genuine new byte under src/ is still refused with mismatch and both digests printed.

Tests. pnpm --filter @objectstack/spec test — 482 files, 12954 tests, all pass. pnpm --filter @objectstack/spec typecheck green. node scripts/check-dev-prereqs.mjs --self-test green with a new battery 17 (9 assertions) covering the declaration stamp, including that --stamp under OS_SKIP_DTS=1 does not adopt the new digest while the JS half still reads fresh.

Gates. All 86 families scripts/pm/dispatch-gates.mjs derives 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-loads and check:type-check-debt both require the whole workspace built (86 packages with no dist/), which CI does before running them. pnpm lint (repo-wide eslint . --no-inline-config) is green, run whole — no narrowing claimed.

Out of scope, filed

#16175schemaTreeIsStale (json-schema/) and bundlesAreStale (dist/*.mjs) carry the identical false refusal and are not fixed here. Measured on this branch: the same single touch made check:docs and check:browser-reachable-entries refuse too. They are not a mechanical extension — the bundles axis can reuse the existing stamp (OS_SKIP_DTS=1 emits 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 from gen: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

@github-actions github-actions Bot added the size/l label Sep 6, 2026
@github-actions

github-actions Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 7d7ca6c0cc1ed46a6e236de3b0749aff1a69008bpackageMentionDocs.

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests tooling labels Sep 6, 2026
@huangyiirene
huangyiirene marked this pull request as ready for review September 6, 2026 05:13
@huangyiirene
huangyiirene added this pull request to the merge queue Sep 6, 2026
Merged via the queue into main with commit 89cf4d6 Sep 6, 2026
36 checks passed
@huangyiirene
huangyiirene deleted the claude/issue-14985-dist-freshness-false-refusal branch September 6, 2026 05:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants