Repository navigation
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 |
…neration stamp acquits a tree whose sources were re-checked-out unchanged (objectstack-ai#16175) (objectstack-ai#17789) Closes objectstack-ai#16175 Clause-②: no `scripts/check-regen-pending.mjs` exports three freshness predicates over the same "the newest artifact mtime is older than the newest source mtime" comparison, and all three share one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a source file with **identical bytes** and bump its mtime, the build that follows correctly does not run (turbo's cache hashes content), and the rule then refuses an artifact that is exactly current. ## What each predicate could actually read — measured first, on a built tree at the merge base The card says the two unfixed siblings "need different evidence, and one of them has no evidence to read at all". Measured before writing anything, by calling each predicate and each stamp reader against the real `packages/spec` after a clean build and then after `touch packages/spec/src/data/query.zod.ts` with `git status` empty: | predicate | evidence file on disk | reader verdict | after the touch | |:--|:--|:--|:--| | `distIsStale` | `dist/.build-input-hash-dts` PRESENT | `match` | `false` — acquitted | | `bundlesAreStale` | `dist/.build-input-hash` PRESENT | `match` | `false` — acquitted | | `schemaTreeIsStale` | none | no reader exists | **`true` — the false refusal** |⚠️ **One premise of the card body is stale, and triage had already recorded it.** The card's table marks `bundlesAreStale` "not fixed". It was fixed by PR objectstack-ai#16240 (`6971170791`), and triage's `pm:retriage` answer says so in as many words: 「已落地的一半(`bundlesAreStale`,PR objectstack-ai#16240,`697117079`)⛔ 不在范围内; 本卡剩下的**只有 `schemaTreeIsStale` 这一支**。」 Measured on `origin/main` at the merge base — the predicate ends `return buildStamp(specDir).state !== 'match';` — so this PR carries only the schema half, which is exactly the file surface triage declared. `bundlesAreStale` is still exercised here as a control. ## The evidence given to the one that could read nothing, and where it comes from ⛔ Neither `dist/` stamp could stand in, and reaching for one would be objectstack-ai#7122's rejected direction relocated rather than relaxed: both are written at the **END** of the build, whereas `gen:schema` is its **FIRST** step and is also run standalone (this rule's own refusal message says so) and again by `check:authorable-surface`. A `dist/` stamp is evidence about `dist/`, and in the standalone case there would be none at all. So the evidence is new. `packages/spec/scripts/build-schemas.ts` writes `json-schema/.build-input-hash-schema` as **the last thing it does**, over the digest of the inputs that generation consumed — computed by the same `buildInputHash` the readers use, so the two cannot drift. Why that write point is sound, and why one is enough: - the script rebuilds the **whole tree unconditionally**, before the `--check` / `--update-base` fork, so one write point covers `gen:schema`, `check:authorable-surface` and `gen:authorable-surface-base`; - it sits after every ratchet that can `exit 1`, and `clearOwnedOutputs` removes the previous stamp at the top of every run — so a stamp exists only for a run that emitted the tree beside it **and** reached the end. A generation that died halfway leaves none; - `json-schema/` is a turbo build output, gitignored, cleared by this generator and no other, so the stamp lives and dies with the tree it speaks for. That is the same argument `--stamp` makes for writing inside `dist/`. ⭐ **It may only ACQUIT.** Absent, unreadable, or not 64 hex characters is `unstamped` — no evidence — and leaves the mtime refusal exactly where it stood (objectstack-ai#4690). Nothing that passes today can start failing, and mtimes remain the only instrument that convicts. **The digest's input set widens in the same commit**, which is why this is one PR and not two: the generators live under `packages/spec/scripts/`, which was in none of the previous input sets (`src/**`, `PACKAGE_BUILD_CONFIG`, turbo's `globalDependencies`). An edited generator kept a digest that had not moved, so a stamp written by the OLD generator would acquit a tree the new one emits differently. A superset can only ever WITHHOLD an acquittal, never grant one, so the two `dist/` stamps become strictly more honest too. ## Acceptance, measured **1 — the false refusal is gone.** After a clean build, `touch` on three sources with `git status` empty: all three predicates `false`, all three stamps `match`. End to end, the gate the card names: ``` BEFORE pnpm --filter @objectstack/spec check:docs exit 1 packages/spec/json-schema is older than packages/spec/src. AFTER pnpm --filter @objectstack/spec check:docs exit 0 222 generated files in sync with packages/spec (no rebuild) ``` ⭐ **2 — positive control.** A *genuine* byte-level source change (appended line; blob `341bb947` → `42c300d1`, `git status` shows ` M`) still convicts all three: `distIsStale`/`schemaTreeIsStale`/`bundlesAreStale` all `true`, all three stamps `mismatch`. Without this row, a predicate that stopped refusing and a predicate that went blind read identically. Restored by `git checkout HEAD --`, verified by blob hash equality. **3 — ablation.** The acquittal line removed from `schemaTreeIsStale` (on-disk landing proved by an occurrence count 1 → 0 and a changed blob hash; restored under a trap and verified back to the HEAD blob): acceptance 1 reverts to `schemaTreeIsStale = true` while its two siblings stay `false` — the differential isolates the change to this predicate alone. No build/`dist` leg applies: `check-regen-pending.mjs` is a root script the probe imports by path, resolved through no package `exports`. ## Declared file surface — and where I went past it Triage declared three implementation sites. All three, and nothing else on that axis: - `scripts/build-input-hash.mjs` — widened input set, `SCHEMA_STAMP_BASENAME`, `inspectSchemaStamp`, `writeSchemaStamp` - `scripts/check-regen-pending.mjs` — `schemaTreeIsStale` wired; `schemaStamp` / `recordSchemaStamp` wrappers · `scripts/check-regen-pending.d.mts` follows (`check:declaration-mirrors` green) - `packages/spec/scripts/build-schemas.ts` — **one** write point at the end of generation⚠️ Touching `packages/spec` is the cross-domain exception triage authorized (precedent PR objectstack-ai#16240); the `domain:spec` seat carries the notification duty from this declaration.⚠️ **Beyond it, and why** — reported rather than assumed. Five further files, none of them a second implementation site: 1. `packages/spec/scripts/schema-tree-freshness.test.ts` and `packages/spec/scripts/build-schemas-check-mode.test.ts` — **tests**, required by triage in its own words (「照已落地那一半的做法用消融证明单向性」). The second is the only place that can notice the write point going missing: without it the rule degrades to `unstamped`, which is the conservative verdict, so every gate stays green and the only symptom is the false refusal quietly returning. 2. `packages/spec/scripts/def-key-collisions.test.ts` — **mechanically forced.** Both suites spawn the real generator out of a copied `scripts/` in a flat tmpdir; its new repo-root import walks off the top of the filesystem from there, and the spawn dies with `MODULE_NOT_FOUND` before any assertion runs. Both fixtures now sit at the repo's own depth with the root scripts symlinked beside them, so they keep running the production import graph instead of a reduced one. 3. `packages/spec/vitest.repo-tests.json` — **a gate named it.** Both suites now genuinely read outside their package; `check:cross-package-test-inputs` refused until they were declared. 4. `scripts/check-dev-prereqs.mjs` and `packages/spec/scripts/lib/json-schema-out-dir.ts` — **comment-only**, and both are reverse-read hits (below). ## Reverse-read — which existing sentence does this make false Scanned before pushing; both hits fixed in place, zeros reported as zeros. **Made false, fixed:** - `scripts/check-dev-prereqs.mjs` — "THE INPUT SET, and why each part is in it" enumerates the digest's inputs and is that gate's stated authority on them. The widening makes the list incomplete. One bullet added. - `packages/spec/scripts/lib/json-schema-out-dir.ts` — "gen:schema emits `(category)/(Name).json` plus the bundled `objectstack.json`" reads as exhaustive and no longer is. Corrected, with the one thing a future reader could get actively wrong stated: ⛔ the stamp may never get a `FOREIGN_JSON_SCHEMA_ARTIFACTS` entry — exempting it from the sweep would leave a stamp acquitting a tree nobody emitted. - `scripts/check-regen-pending.d.mts` header ("five functions"), `scripts/build-input-hash.mjs` header ("the two stamps") and `inspectStamp`'s docblock ("ONE of the two stamps") — all updated with the edit. **Zeros — read and unaffected:** - `packages/spec/scripts/lib/dist-freshness.ts` — cites `schemaTreeIsStale` as precedent for guarding in the generator rather than its callers. Still true; that is exactly what this write point does. - `packages/spec/scripts/lib/json-schema-out-dir.ts` module title — "the one output directory **two generators** write into". Still two: the stamp is `gen:schema`'s own output, not a third writer. - `check-generated.ts`'s `readsSchemaTree` ordering rules, `schema-closure.ts`'s empty-category rule, `regen-artifacts.mjs`'s `packages/spec/json-schema/**` row — none says anything this changes. (That last row's `.gitignore:61` line number is drift that predates this branch — the pattern is at `:63`; noted, not filed, no PR or reader is routed through it.) ## Tests re-judged in place, never deleted All six original cases in `schema-tree-freshness.test.ts` still assert exactly what they were written to assert, because **none of those sandboxes carries a stamp** — and that is now the property being pinned: no stamp is `unstamped`, `unstamped` is no evidence, and no evidence leaves the mtime verdict standing. The re-judgement is recorded in the file header and on the false-green case itself. Six new cases supply the evidence and pin what it may and may not do: acquittal on `match`; refusal held on `mismatch`; refusal held on a non-digest stamp; a missing tree stays stale however good the stamp; a mismatched stamp may not overturn a verdict of fresh; and the stamp is invisible to both sides of the mtime rule, so it cannot vouch for itself. ## Changeset — measured, not defaulted `skip-changeset` would have been wrong. `json-schema` is in `@objectstack/spec`'s published `files[]`, so the stamp travels in the tarball — `npm pack --dry-run --json`, on the built tree: ``` STAMP FILES IN TARBALL: dist/.build-input-hash, dist/.build-input-hash-dts, json-schema/.build-input-hash-schema positive control (json-schema/ ships): json-schema/.build-input-hash-schema, json-schema/ai/Agent.json, … ``` One 65-byte file joins the published package; no export, schema key, registry entry or runtime behaviour moves. `@objectstack/spec: patch`. ## Verification `dispatch-gates --ran` reconciliation on the final commit (`git rev-parse --short HEAD` = `7d1292f393`): **87 derived, 84 run, 3 NOT MEASURED, 0 unrun.** - `pnpm --filter @objectstack/spec build` — green (all three stamps written, identical digest, which also cross-checks the new writer against `--stamp`'s) - `pnpm --filter @objectstack/spec typecheck` — green (`tsc`, `check:scripts-typecheck`, `check:test-typecheck`) - `pnpm --filter @objectstack/spec test` — 470 passed, 1 skipped (471 files) · `test:repo` — 29 files, 503 passed - spec gates green: `check:authorable-surface`, `check:docs`, `check:generated` (15/15 artifacts current), `check:browser-reachable-entries` - root gates green: `check:declaration-mirrors` (+self-test), `check:merge-driver`, `check:nul-bytes`, `check:cross-package-test-inputs`, `check:published-files`, `check-dev-prereqs --self-test`, `check:self-test-wired`, `check:scripts-symbol-anchors`, `check:type-check-coverage`, `check:test-source-alias`, `check:tier-file-adoption`, changeset gates, and the rest of the derived 84 - ⊘ **NOT MEASURED (3+1), left to CI:** `check:dual-build-cjs-loads`, `check:lean-entry-closure` and `check:type-check-debt` each exit **3** — PREREQUISITE NOT MET, they read a whole-repo `dist/` this container has not built (`Build Core` owns that run). `check:pm-dispatch-gates` exceeded the container's foreground cap at 560s (exit 124) — a checker-health self-test for a file this diff does not touch. ⛔ None of the four is a pass or a failure. **A measured cost, reported rather than buried:** because the digest now covers `packages/spec/scripts/**`, editing anything there — including a script test — moves the digest and makes `check:browser-reachable-entries`, `check:generated` and the boot gate demand a rebuild first. Observed twice during this run. It is the same ergonomics that `src/**/*.test.ts` already carries (those are in the digest today), and the alternative — a rule deciding which files under `scripts/` are "really" inputs — decides wrong the day someone extracts a helper, in the acquitting direction. Triage's stop condition was 大量无关包的 boot gate 失效; measured, `AMPLIFIERS = ['packages/spec']`, so exactly one package stamps and the blast radius is that one package. Draft, not armed. --- _Generated by [Claude Code](https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU)_ Co-authored-by: Claude <noreply@anthropic.com>
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