fix(tooling): the bundle freshness rule can be answered — the build stamp acquits a tree whose bytes never moved - #16240
Conversation
…tamp acquits a tree whose sources were re-checked-out unchanged `bundlesAreStale` compared `dist/**/*.mjs`/`*.js` mtimes against `src/**/*.ts` and `tsup.config.ts` mtimes and had no way to be answered. A `git merge`, `git checkout` or `git worktree add` re-checks-out a source file with identical bytes and bumps its mtime; the build that follows correctly does not run (turbo's cache hashes content, so it is a cache hit that rewrites nothing) and every `dist/` mtime stays where the previous build left it. The rule then refused `check:browser-reachable-entries` over bundles that were exactly current, and the only remedy was a multi-minute rebuild under the shared verify lock. The mtime rule keeps its power to convict and gains one way to be answered: `dist/.build-input-hash`, already written by every build of the package. That is the file #7122 proposed for the DECLARATION rule, where it was measured wrong and stays rejected — `OS_SKIP_DTS=1` writes it while skipping the declarations. On the bundle axis that same flag emits exactly the artifact being vouched for, and what makes the evidence sound is the build script's order rather than the flag: the unconditional `tsup` pass runs before `--stamp` in one `&&` chain, so nothing writes this stamp without having emitted bundles first. The evidence may only ever ACQUIT: a missing, unreadable or mismatched stamp leaves the mtime verdict standing, so nothing that passed before can start failing. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
…on-vacuous Three cases in `check:browser-reachable-entries --self-test`, beside the mtime cases that already drive `inspectBundleFreshness`: the acquittal itself, the conviction that returns the moment a source byte changes (so the rule is keyed on the digest and not on the stamp's presence), and a stamp that is not a digest at all leaving the refusal standing. A fourth asserts the sandbox digest can be computed, so a fixture that silently stopped producing one cannot pass the rest by accident. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vbw3RPgdtqesx4azk9SbW8
📓 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): |
|
Out-of-scope finding filed while working this branch, not addressed here: #16241 — the bundle-freshness refusal names one cause for three distinguishable states, unlike the declaration-axis sibling above it in the same file. It is a message-quality gap, not a wrong verdict, and it sits outside this card's declared file surface ( Generated by Claude Code |
|
Not flipped — Readings on head Why it is not this PR's: the diff changes no package source ( Generated by Claude Code |
Part of #16175. The
bundlesAreStalehalf only — theschemaTreeIsStalehalf is not addressed here and #16175 remains open for it; the open decision is stated at the bottom.What was false, measured on this branch before the change
A built worktree,
git statusempty, onetouch packages/spec/src/data/query.zod.tswith no content change:Both on a tree whose bytes never moved. That is the blind spot #14985 measured for the declarations, and the remedy it prescribed here was a multi-minute rebuild under the shared verify lock of bundles that were already exactly current.
What lands
bundlesAreStalekeeps its power to convict and gains one way to be answered:dist/.build-input-hash, the stamp every build of the package already writes.unstamped— absent, unreadable, not 64 hex characters, or a package whose build does not stamp — leaves the mtime verdict standing, so nothing that passes today can start failing.scripts/build-input-hash.mjs—inspectDeclarationStampand the newinspectBuildStampnow share oneinspectStamp(root, pkgDir, basename), so the two stamps cannot drift into two comparisons.scripts/check-regen-pending.mjs—buildStamp()wrapper besidedeclarationStamp(), consulted bybundlesAreStaleonly after the mtime rule has already accused.scripts/check-regen-pending.d.mts— the declaration mirror, in step.packages/spec/scripts/check-browser-reachable-entries.ts— four cases in its existing bundle-freshness self-test.Why this is not the direction #7122 had rejected
packages/spec/scripts/lib/dist-freshness.tscarries a section titled "Why the mtime rule CONVICTS, and what may acquit", anddist-freshness.test.tspins the refusal of "theOS_SKIP_DTS=1shape on an ALREADY-BUILT tree — the casedist/.build-input-hashcannot see". That ruling is about the declaration axis and is untouched:--stampwritesdist/.build-input-hashunderOS_SKIP_DTS=1, which emits JS and leaves whatever.d.tswas there before, so it says fresh over stale declarations. Its pin still passes.On the bundle axis the same fact points the other way, and three measured things make the evidence sound:
OS_SKIP_DTS=1emits exactly the artifact being vouched for.inspectBundleFreshness's own refusal text already told the reader so: "OS_SKIP_DTS=1 is fine for THIS gate — it still emits every bundle this reads."packages/spec'sbuildis one&&chain:gen:schema && gen:openapi && tsup && [conditional BUILD_DTS pass] && check-dts-emitted && check-dev-prereqs --stamp. The unconditionaltsup(the JS pass) always precedes--stamp, so nothing writes this stamp without having emitted bundles first. Only the declaration pass is conditional, and skipping it cannot refresh a stamp written after both. There is noOS_SKIP_JScounterpart.buildInputHashhashes every file undersrc/(.test.tsincluded),PACKAGE_BUILD_CONFIG(package.json,tsconfig.json,tsconfig.build.json,tsup.config.ts,tsdown.config.ts) and turbo'sglobalDependencies; the rule measuressrc/**/*.tsminus.test.ts, plustsup.config.ts. Sotsup.config.ts— the card's one caveat to confirm rather than assume — is in the digest, and a superset can only ever withhold an acquittal, never grant one it should not.Evidence
After, same tree, same touch:
check:browser-reachable-entriesexit 0.check:docsstill exit 1 — the untouched sibling axis, an in-tree control that this is not a blanket pass.Negative control (real content change must still convict). Appending one line to
packages/spec/src/data/query.zod.ts(blob341bb947becomes1d578c93, one on-disk marker hit):check:browser-reachable-entriesexit 1, "NOT MEASURED". Restored, blob back to341bb947,git diff HEADempty.Ablation (the new pins can fail). Reverting
bundlesAreStaleto the plain mtime comparison — deleted text 1 hit becomes 0, injected text 0 becomes 1, blobc413dc0fbecomesbcf0d494— reddens exactly one case:exit 1. Restored byte-identical (blob back to
c413dc0f,git diff HEADempty). Direction observed: turns red, as expected. No build/dist leg: every module in this loop is resolved by relative source path throughtsx, never through a package'sexportsintodist/.Not addressed here: the
json-schemaaxisStopping that half rather than guessing, per the direction the card itself sets. Two forks are resolvable and one is not:
json-schema/**is a turbo output ofbuild, so a stamp inside it is cached, restored and cleaned with the tree it describes;json-schema/is gitignored and carries nomerge=os-regenattribute, andbuild-schemas.tsclears its own outputs by deny-list, so a stamp it writes is its own artifact and needs noFOREIGN_JSON_SCHEMA_ARTIFACTSregistration. One write point covers all three modes: the tree is regenerated unconditionally at the top of the script, before the--check/--update-basebranching.buildInputHashis the digest of the inputs the tsup build consumes; it does not hashpackages/spec/scripts/build-schemas.tsor itslib/, which are real inputs to thejson-schematree. For the declaration stamp the analogous generator input (tsup.config.ts) is hashed, so reusing this digest would make the schema stamp measurably weaker than the one it mirrors: edit the generator, run nothing, then let a checkout bump asrc/mtime, and the rule would acquit a tree the old generator produced. The card's own bar is "the stamp records the digest of the inputsgen:schemaactually consumed".json-schemais inpackages/spec'sfiles, so any stamp there ships in the published tarball. fix(spec): the dist freshness rule can be answered — a declaration stamp acquits a tree whose sources were re-checked-out unchanged #16176 made that call deliberately fordist/and said so in its changeset; the same call forjson-schema/is a maintainer's, and it flips this PR'sskip-changesetanswer.Options and a recommendation are in the dev report on #16175.
Verification
All 70 derived gate commands ran in this worktree (
node scripts/pm/dispatch-gates.mjs --changed --commands --repo objectstack-ai/objectstackatacef174a). Green except four NOT MEASURED, all of them PREREQUISITE-NOT-MET on an unbuilt closure in this worktree and none reachable from this diff, which touches no package source:check:dts-closureandcheck:generated'scheck:api-surfaceleg (this worktree'spackages/specwas built withOS_SKIP_DTS=1, so it holds no.d.ts),check:dual-build-cjs-loads(exit 3, "some package has no dist/"),check:type-check-debt(exit 3, "33 workspace dependencies have no built type entry point").check:plugin-teardown-shape --self-testrefuses on a shallow clone that cannot reach its pinned positive-control commit. CI builds fresh and runs all of them.pnpm check:merge-driver(this file's own self-test) green, andpnpm check:pm-dispatch-gatesgreen in 675 s (1511 self-test cases). Run in the foreground without the shared verify lock: the standing rule is thatcheck:*gates do not take it, and wait plus run would have exceeded the container's foreground cap.pnpm check:declaration-mirrors,check:scripts-symbol-anchors,check:self-test-wired,check:nul-bytes,check:cross-package-test-inputs,check:published-files,check:test-source-alias,check:dev-prereqs --self-testall green.Generated by Claude Code