fix(devx): the json-schema tree freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175) - #17789
Conversation
…generation stamp acquits a tree whose sources were re-checked-out unchanged `schemaTreeIsStale` was the last of the three freshness predicates in `scripts/check-regen-pending.mjs` still deciding on mtimes alone, and the only one with no evidence of any kind to answer an accusation with. 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 hashes content); the rule then refuses a tree that is exactly current. Measured on a checkout whose `git status` was empty, after a bare `touch` of one `.zod.ts`: `check:docs` exits 1 with `packages/spec/json-schema is older than packages/spec/src`, remediable only by a full regeneration. Neither `dist/` stamp can stand in. Both are written at the END of the build, whereas `gen:schema` is its FIRST step and is also run standalone and again by `check:authorable-surface` — so a `dist/` stamp is evidence about `dist/`, and in the standalone case there would be none at all. So the evidence is new: `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. One write point, after the unconditional whole-tree regeneration that precedes the `--check` / `--update-base` fork (so all three entry points are covered) and after every ratchet that can exit 1 (so a refused run vouches for nothing). The digest may only ACQUIT. Absent, unreadable or not 64 hex characters is `unstamped` — no evidence — and leaves the mtime refusal exactly where it stood. The digest's input set widens to `<pkg>/scripts/**` in the same commit, because the generators live there and were in none of the previous input sets: an edited generator kept a digest that had not moved, so a stamp written by the old one would acquit a tree the new one emits differently. Widening can only withhold an acquittal, never grant one, so the two `dist/` stamps become strictly more honest as well. Both generator-spawning fixtures gain the repo's own directory depth: they run the real script out of a copied `scripts/`, and its new repo-root import walks off the top of the filesystem from a flat tmpdir. Both now genuinely read outside their package, so both are declared in `vitest.repo-tests.json` — `check:cross-package-test-inputs` named them, and a test whose real inputs are wider than its package is invisible to the affected filter and the turbo cache until the declaration exists. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift Check
What this run could not see
Coarse fallback — 136 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
PM 复核:收下,已 undraft + 武装。 你开篇那条更正是对的 —— 而错的是本席的简报,第六次。 ⭐⭐ 0. 简报把卡面一张陈旧的表原样转述了,而分诊早就不这么说本席的派单写着「两个兄弟谓词需要与 #14985 所供不同的证据」,并把 实测( ⇒
⇒ 本席的简报与分诊自己声明的文件面相矛盾。你按分诊的文件面交付、并把卡面的陈旧处更正出来,是对的。
1. 给「什么都读不到的那一个」造的证据 —— 这是本卡的真难点,你解对了卡面写明两个兄弟「需要不同的证据,而其中一个根本没有证据可读」。你没有拿
于是让生成器自己在末尾写 ⭐ 而最要紧的是那条单向性质:stamp 只能赦免,永不能定罪 —— 未盖章/不匹配都让 mtime 的拒绝原样成立。你还把它钉成了一条断言(「a mismatched stamp may not overturn a verdict of fresh」)。⛔ 没有这条,一个 stamp 机制就从「减少假红」滑成「制造假绿」,而假绿是没人会发现的那个方向。 写者与读者一致那条交叉核对(全量构建后三个 stamp 持有同一个摘要 2. 钉子就地重判 —— 形状正确六条原用例逐字保留并重判:它们仍然断言当初要断言的东西,恰恰因为那些沙箱里没有 stamp —— 而「没有 stamp = 未盖章 = 没有证据 = mtime 判决原样成立」正是它们现在钉住的性质。⛔ 一条没删。六条新用例里,「stamp 对 mtime 规则的两边都不可见,所以它不能为自己作证」这条是很多人想不到要钉的。 3. changeset 判
|
Closes #16175
Clause-②: no
scripts/check-regen-pending.mjsexports 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 checkoutandgit worktree addre-check-out a source file with identical bytes and bump itsmtime, 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/specafter a clean build and then aftertouch packages/spec/src/data/query.zod.tswithgit statusempty:distIsStaledist/.build-input-hash-dtsPRESENTmatchfalse— acquittedbundlesAreStaledist/.build-input-hashPRESENTmatchfalse— acquittedschemaTreeIsStaletrue— the false refusalbundlesAreStale"not fixed". It was fixed by PR #16240 (6971170791), and triage'spm:retriageanswer says so in as many words: 「已落地的一半(
bundlesAreStale,PR #16240,697117079)⛔ 不在范围内;本卡剩下的只有
schemaTreeIsStale这一支。」 Measured onorigin/mainat the merge base — thepredicate ends
return buildStamp(specDir).state !== 'match';— so this PR carries only the schema half,which is exactly the file surface triage declared.
bundlesAreStaleis 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 #7122's rejected directionrelocated rather than relaxed: both are written at the END of the build, whereas
gen:schemais itsFIRST step and is also run standalone (this rule's own refusal message says so) and again by
check:authorable-surface. Adist/stamp is evidence aboutdist/, and in the standalone case therewould be none at all.
So the evidence is new.
packages/spec/scripts/build-schemas.tswritesjson-schema/.build-input-hash-schemaas the last thing it does, over the digest of the inputs thatgeneration consumed — computed by the same
buildInputHashthe readers use, so the two cannot drift.Why that write point is sound, and why one is enough:
--check/--update-basefork, soone write point covers
gen:schema,check:authorable-surfaceandgen:authorable-surface-base;exit 1, andclearOwnedOutputsremoves the previous stamp atthe 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 thestamp lives and dies with the tree it speaks for. That is the same argument
--stampmakes for writinginside
dist/.⭐ It may only ACQUIT. Absent, unreadable, or not 64 hex characters is
unstamped— no evidence — andleaves the mtime refusal exactly where it stood (#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'sglobalDependencies). An edited generator kept a digest that had notmoved, 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 strictlymore honest too.
Acceptance, measured
1 — the false refusal is gone. After a clean build,
touchon three sources withgit statusempty:all three predicates
false, all three stampsmatch. End to end, the gate the card names:⭐ 2 — positive control. A genuine byte-level source change (appended line; blob
341bb947→42c300d1,git statusshowsM) still convicts all three:distIsStale/schemaTreeIsStale/bundlesAreStalealltrue, all three stampsmismatch. Without thisrow, 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 anoccurrence count 1 → 0 and a changed blob hash; restored under a trap and verified back to the HEAD blob):
acceptance 1 reverts to
schemaTreeIsStale = truewhile its two siblings stayfalse— the differentialisolates the change to this predicate alone. No build/
distleg applies:check-regen-pending.mjsis aroot 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,writeSchemaStampscripts/check-regen-pending.mjs—schemaTreeIsStalewired;schemaStamp/recordSchemaStampwrappers ·scripts/check-regen-pending.d.mtsfollows (check:declaration-mirrorsgreen)packages/spec/scripts/build-schemas.ts— one write point at the end of generationpackages/specis the cross-domain exception triage authorized (precedent PR #16240); thedomain:specseat carries the notification duty from this declaration.implementation site:
packages/spec/scripts/schema-tree-freshness.test.tsandpackages/spec/scripts/build-schemas-check-mode.test.ts— tests, required by triage in its ownwords (「照已落地那一半的做法用消融证明单向性」). 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, soevery gate stays green and the only symptom is the false refusal quietly returning.
packages/spec/scripts/def-key-collisions.test.ts— mechanically forced. Both suites spawn the realgenerator out of a copied
scripts/in a flat tmpdir; its new repo-root import walks off the top of thefilesystem from there, and the spawn dies with
MODULE_NOT_FOUNDbefore any assertion runs. Bothfixtures 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.
packages/spec/vitest.repo-tests.json— a gate named it. Both suites now genuinely read outsidetheir package;
check:cross-package-test-inputsrefused until they were declared.scripts/check-dev-prereqs.mjsandpackages/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'sinputs 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).jsonplus thebundled
objectstack.json" reads as exhaustive and no longer is. Corrected, with the one thing a futurereader could get actively wrong stated: ⛔ the stamp may never get a
FOREIGN_JSON_SCHEMA_ARTIFACTSentry — exempting it from the sweep would leave a stamp acquitting a tree nobody emitted.
scripts/check-regen-pending.d.mtsheader ("five functions"),scripts/build-input-hash.mjsheader("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— citesschemaTreeIsStaleas precedent for guarding inthe generator rather than its callers. Still true; that is exactly what this write point does.
packages/spec/scripts/lib/json-schema-out-dir.tsmodule title — "the one output directory twogenerators write into". Still two: the stamp is
gen:schema's own output, not a third writer.check-generated.ts'sreadsSchemaTreeordering rules,schema-closure.ts's empty-category rule,regen-artifacts.mjs'spackages/spec/json-schema/**row — none says anything this changes. (That lastrow's
.gitignore:61line 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.tsstill assert exactly what they were written toassert, because none of those sandboxes carries a stamp — and that is now the property being pinned:
no stamp is
unstamped,unstampedis no evidence, and no evidence leaves the mtime verdict standing. There-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 onmismatch; refusal heldon 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-changesetwould have been wrong.json-schemais in@objectstack/spec's publishedfiles[], sothe stamp travels in the tarball —
npm pack --dry-run --json, on the built tree:One 65-byte file joins the published package; no export, schema key, registry entry or runtime behaviour
moves.
@objectstack/spec: patch.Verification
dispatch-gates --ranreconciliation 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 alsocross-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 passedcheck:authorable-surface,check:docs,check:generated(15/15 artifacts current),check:browser-reachable-entriescheck: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 84check:dual-build-cjs-loads,check:lean-entry-closureandcheck:type-check-debteach exit 3 — PREREQUISITE NOT MET, they read a whole-repodist/thiscontainer has not built (
Build Coreowns that run).check:pm-dispatch-gatesexceeded the container'sforeground 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:generatedand the boot gate demand a rebuild first. Observed twice during this run. It is the sameergonomics that
src/**/*.test.tsalready carries (those are in the digest today), and the alternative — arule deciding which files under
scripts/are "really" inputs — decides wrong the day someone extracts ahelper, 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