Skip to content

Commit 482d34d

Browse files
claude[bot]claude
andauthored
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)
Closes #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 #16240 (`6971170791`), and triage's `pm:retriage` answer says so in as many words: 「已落地的一半(`bundlesAreStale`,PR #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 #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 (#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 #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>
1 parent 56103b7 commit 482d34d

11 files changed

Lines changed: 683 additions & 39 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
---
2+
'@objectstack/spec': patch
3+
---
4+
5+
fix(devx): the json-schema tree's freshness rule can be answered — a generation stamp acquits a tree whose sources were re-checked-out unchanged (#16175)
6+
7+
`scripts/check-regen-pending.mjs` exports three freshness predicates over the
8+
same `newestMtime(artifact) < newestMtime(src)` comparison, and all three share
9+
one blind spot: `git merge`, `git checkout` and `git worktree add` re-check-out a
10+
source file with **identical bytes** and bump its mtime, the build that follows
11+
correctly does not run (turbo's cache hashes content), and the rule then refuses
12+
an artifact that is exactly current.
13+
14+
Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts`
15+
(#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240).
16+
`schemaTreeIsStale` was the third, and the one with **no evidence of any kind to
17+
read**: nothing recorded which sources `packages/spec/json-schema/` came from.
18+
Measured on a checkout whose `git status` was empty, after a bare
19+
`touch packages/spec/src/data/query.zod.ts`:
20+
21+
```
22+
pnpm --filter @objectstack/spec check:docs exit 1
23+
packages/spec/json-schema is older than packages/spec/src.
24+
```
25+
26+
The only remedy on offer was a full `gen:schema` — minutes under a shared verify
27+
lock — for a tree that needed nothing. The same command now exits 0 with no
28+
rebuild, and a genuine source edit still refuses.
29+
30+
**The evidence is new, because neither `dist/` stamp could stand in.** Both are
31+
written at the END of the build, whereas `gen:schema` is its FIRST step and is
32+
also run standalone and again by `check:authorable-surface` — so a `dist/` stamp
33+
is evidence about `dist/`, and in the standalone case there would be none at all.
34+
`build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last
35+
thing it does: one write point, after the unconditional whole-tree regeneration
36+
that precedes its `--check` / `--update-base` fork, so all three entry points are
37+
covered, and after every ratchet that can exit 1, so a refused run vouches for
38+
nothing.
39+
40+
**⛔ The digest may only ACQUIT, never accuse.** A missing, unreadable or
41+
non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal
42+
exactly where it stood (#4690). Nothing that passes today can start failing, and
43+
the rule keeps its only conviction instrument: mtimes still see the hand-edited
44+
tree and the toolchain change a content digest is blind to.
45+
46+
**Why this ships, and why it is a changeset rather than `skip-changeset`.**
47+
`json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp
48+
travels in the tarball — measured with `npm pack --dry-run`:
49+
`json-schema/.build-input-hash-schema` is present alongside the two existing
50+
`dist/` stamps. One 65-byte file is added to the published package. No export, no
51+
schema key, no runtime behaviour and no authorable surface moves.
52+
53+
**One other published-adjacent change**, for the same soundness reason: the build
54+
digest (`scripts/build-input-hash.mjs`) now also hashes `<pkg>/scripts/**` for
55+
packages that have it. `packages/spec`'s generators live there and were in none of
56+
the previous input sets, so an edited generator kept a digest that had not moved —
57+
and a stamp written by the OLD generator would then acquit a tree the new one
58+
emits differently. Widening a digest can only ever WITHHOLD an acquittal, never
59+
grant one, so the two `dist/` stamps become strictly more honest as well; the
60+
first build after this lands re-stamps all three.

packages/spec/scripts/build-schemas-check-mode.test.ts

Lines changed: 113 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import os from 'node:os';
5454
import path from 'node:path';
5555
import { fileURLToPath } from 'node:url';
5656

57+
import { schemaStamp } from '../../../scripts/check-regen-pending.mjs';
5758
import { RENAMED_DEFS } from './lib/renamed-defs';
5859
import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry';
5960
import {
@@ -83,6 +84,9 @@ import {
8384

8485
const HERE = path.dirname(fileURLToPath(import.meta.url));
8586
const PKG = path.resolve(HERE, '..');
87+
/** The repo root — the fixture root mirrors it, so the generator's own
88+
* `../../../scripts` import resolves inside the sandbox (#16175). */
89+
const REPO_ROOT = path.resolve(PKG, '..', '..');
8690
const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx');
8791

8892
/**
@@ -376,6 +380,35 @@ function mountUnemittedLedger(dir: string): void {
376380
fs.cpSync(path.join(PKG, UNEMITTED_BASELINE_FILE), path.join(dir, UNEMITTED_BASELINE_FILE));
377381
}
378382

383+
/**
384+
* A fixture package directory at the repo's own DEPTH: `<tmp-root>/packages/spec`,
385+
* with `<tmp-root>/scripts` symlinked to this repo's root scripts.
386+
*
387+
* Every fixture in this file runs the real `build-schemas.ts` out of a copied
388+
* `scripts/`, which works because that script resolves everything from its own
389+
* `__dirname`. Since #16175 it resolves ONE thing from above the package — the
390+
* repo-root freshness module that writes the generation stamp
391+
* (`../../../scripts/check-regen-pending.mjs`) — and from a flat `/tmp/x/scripts`
392+
* that path walks off the top of the filesystem: the spawn dies with
393+
* MODULE_NOT_FOUND before any assertion runs, which is a fixture reporting on
394+
* its own shape rather than on the generator.
395+
*
396+
* Depth rather than a stub, because the header's rule holds: no test-only seam.
397+
* The root scripts are SYMLINKED rather than copied — they are read-only here,
398+
* and a copy would be a second definition of the digest whose single definition
399+
* is the entire point of that module.
400+
*/
401+
function fixtureTree(prefix: string): string {
402+
const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
403+
const dir = path.join(root, 'packages', 'spec');
404+
fs.mkdirSync(dir, { recursive: true });
405+
fs.symlinkSync(path.join(REPO_ROOT, 'scripts'), path.join(root, 'scripts'));
406+
return dir;
407+
}
408+
409+
/** The fixture ROOT a package dir sits under — what teardown removes. */
410+
const sandboxRoot = (pkgDir: string): string => path.resolve(pkgDir, '..', '..');
411+
379412
/**
380413
* Build a sandbox — a temp tree that COPIES `scripts/` (so `__dirname` lands
381414
* there) and symlinks the read-only inputs — mount it, and seed it to the state
@@ -391,7 +424,17 @@ function mountUnemittedLedger(dir: string): void {
391424
* costs a `cpSync` and a `git init` each.
392425
*/
393426
function createSandbox(prefix: string): string {
394-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));
427+
// The package sits TWO levels under a fixture root, mirroring the real
428+
// `<repo>/packages/spec` (#16175). A flat sandbox was faithful enough while
429+
// the generator resolved everything from its own `__dirname`; it now also
430+
// imports the repo-root freshness module (`../../../scripts/
431+
// check-regen-pending.mjs`, the writer of the generation stamp), and from a
432+
// flat `/tmp/x/scripts` that path walks off the top of the filesystem — the
433+
// spawn dies with MODULE_NOT_FOUND before a single assertion runs. Keeping
434+
// the depth is what lets these fixtures run the production import graph
435+
// instead of a reduced one, which is the property the header's "no test-only
436+
// seam" paragraph is about.
437+
const dir = fixtureTree(prefix);
395438
fs.cpSync(path.join(PKG, 'scripts'), path.join(dir, 'scripts'), { recursive: true });
396439
for (const entry of ['src', 'node_modules', 'package.json']) {
397440
fs.symlinkSync(path.join(PKG, entry), path.join(dir, entry));
@@ -424,7 +467,7 @@ function createSandbox(prefix: string): string {
424467
/** Take a block's own sandbox down and hand the handles back to the shared one. */
425468
function releaseSandbox(dir: string): void {
426469
mountSandbox(sharedSandbox);
427-
fs.rmSync(dir, { recursive: true, force: true });
470+
fs.rmSync(sandboxRoot(dir), { recursive: true, force: true });
428471
}
429472

430473
beforeAll(() => {
@@ -450,7 +493,7 @@ beforeAll(() => {
450493
});
451494

452495
afterAll(() => {
453-
if (sharedSandbox) fs.rmSync(sharedSandbox, { recursive: true, force: true });
496+
if (sharedSandbox) fs.rmSync(sandboxRoot(sharedSandbox), { recursive: true, force: true });
454497
});
455498

456499
function run(args: string[] = []): { status: number; output: string } {
@@ -574,6 +617,67 @@ describe('build-schemas.ts --check — a check reports, it does not write (#4711
574617
expect(status).toBe(0);
575618
},
576619
);
620+
621+
// ── #16175: the ONE write point, pinned where it can actually go missing ───
622+
//
623+
// `schema-tree-freshness.test.ts` pins what the freshness rule does with a
624+
// stamp. Nothing there can notice if the generator stops WRITING one — and
625+
// that failure is invisible by construction: the rule degrades to `unstamped`,
626+
// which is the conservative verdict, so every gate stays green and the only
627+
// symptom is that the mtime false refusal quietly comes back. These two cases
628+
// are the half that goes red when the write point is removed.
629+
it(
630+
'writes the generation stamp as its last step, in --check mode too',
631+
{ timeout: SPAWN_TIMEOUT_MS },
632+
() => {
633+
const stampPath = path.join(sandbox, 'json-schema', '.build-input-hash-schema');
634+
seedManifest((s) => s);
635+
636+
// A plain generation: `gen:schema`, the entry point `check:docs`'s own
637+
// remedy line names.
638+
fs.rmSync(stampPath, { force: true });
639+
expect(run([]).status).toBe(0);
640+
expect(fs.existsSync(stampPath), 'gen:schema wrote no freshness stamp').toBe(true);
641+
const written = fs.readFileSync(stampPath, 'utf8').trim();
642+
expect(written).toMatch(/^[0-9a-f]{64}$/);
643+
// The digest the READER computes for this tree, not a literal: writer and
644+
// reader disagreeing is the one failure that cannot be seen from either
645+
// side alone, and it fails in the acquitting direction only by accident.
646+
expect(schemaStamp(sandbox).state).toBe('match');
647+
648+
// …and `--check` too. It runs the same unconditional regeneration before
649+
// its fork, so a tree it leaves behind is as current as `gen:schema`'s and
650+
// must be as believable. This is also the case that proves the stamp is
651+
// not the #4711 defect returning: `json-schema/` is this generator's own
652+
// gitignored output, cleared by it and rewritten by it, never a tracked
653+
// file a check repairs — the manifest assertion above still holds.
654+
const current = readManifest();
655+
fs.rmSync(stampPath, { force: true });
656+
expect(run(['--check']).status).toBe(0);
657+
expect(fs.existsSync(stampPath), '--check wrote no freshness stamp').toBe(true);
658+
expect(schemaStamp(sandbox).state).toBe('match');
659+
expect(readManifest()).toBe(current);
660+
},
661+
);
662+
663+
it(
664+
'never leaves a stamp behind for a generation that was REFUSED',
665+
{ timeout: SPAWN_TIMEOUT_MS },
666+
() => {
667+
// The soundness argument for the write point's POSITION, asserted. The
668+
// stamp is the last line of the script, after every ratchet that can exit
669+
// 1 — so a run that refused vouches for nothing, and the next reader sees
670+
// `unstamped`, which is no evidence, which leaves the mtime rule standing.
671+
const stampPath = path.join(sandbox, 'json-schema', '.build-input-hash-schema');
672+
const stale = seedManifest((s) => s.filter((k) => k !== KNOWN_KEY));
673+
fs.rmSync(stampPath, { force: true });
674+
675+
expect(run(['--check']).status).toBe(1);
676+
677+
expect(fs.existsSync(stampPath), 'a refused run stamped the tree anyway').toBe(false);
678+
expect(readManifest()).toBe(stale);
679+
},
680+
);
577681
});
578682

579683
// ─────────────────────────────────────────────────────────────────────────────
@@ -2693,7 +2797,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its
26932797
expect(baselineKeys, `${STILL_LIVE_KEY} is no longer a live authorable key`).toContain(STILL_LIVE_KEY);
26942798
expect(baselineKeys.some((k) => k.startsWith(AGED_OUT_KEY))).toBe(false);
26952799

2696-
box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-retired-keys-'));
2800+
box = fixtureTree('build-schemas-retired-keys-');
26972801
fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true });
26982802
fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true });
26992803
for (const entry of ['node_modules', 'package.json']) {
@@ -2732,7 +2836,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its
27322836
});
27332837

27342838
afterAll(() => {
2735-
if (box) fs.rmSync(box, { recursive: true, force: true });
2839+
if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true });
27362840
});
27372841

27382842
it(
@@ -2992,7 +3096,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)'
29923096
expect(declared, `${def} is now registered for real — pick an unregistered fixture`).not.toContain(def);
29933097
}
29943098

2995-
box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-manifest-removal-'));
3099+
box = fixtureTree('build-schemas-manifest-removal-');
29963100
fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true });
29973101
fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true });
29983102
for (const entry of ['node_modules', 'package.json']) {
@@ -3048,7 +3152,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)'
30483152
});
30493153

30503154
afterAll(() => {
3051-
if (box) fs.rmSync(box, { recursive: true, force: true });
3155+
if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true });
30523156
});
30533157

30543158
it(
@@ -3358,7 +3462,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58
33583462
}
33593463
expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(2);
33603464

3361-
box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-tombstone-age-'));
3465+
box = fixtureTree('build-schemas-tombstone-age-');
33623466
fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true });
33633467
fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true });
33643468
for (const entry of ['node_modules', 'package.json']) {
@@ -3393,7 +3497,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58
33933497
});
33943498

33953499
afterAll(() => {
3396-
if (box) fs.rmSync(box, { recursive: true, force: true });
3500+
if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true });
33973501
});
33983502

33993503
it(

packages/spec/scripts/build-schemas.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ import {
4646
FOREIGN_JSON_SCHEMA_ARTIFACTS,
4747
clearOwnedOutputs,
4848
} from './lib/json-schema-out-dir';
49+
// The ONE write point for `json-schema/`'s freshness stamp (#16175). Imported
50+
// from the module that also READS it, because a digest written by one function
51+
// and compared by another is a comparison that means nothing the day they drift.
52+
import { recordSchemaStamp } from '../../../scripts/check-regen-pending.mjs';
4953
import {
5054
AUTHORABLE_SURFACE_DIR_NAME,
5155
SCHEMA_MANIFEST_DIR_NAME,
@@ -2875,3 +2879,41 @@ console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(def
28752879

28762880
console.log(`\n✅ Successfully generated ${count} schemas.`);
28772881

2882+
// ─── The generation stamp (#16175) ───────────────────────────────────────────
2883+
//
2884+
// The LAST thing this script does, and that position is the whole argument.
2885+
// `schemaTreeIsStale` in scripts/check-regen-pending.mjs asks whether
2886+
// `json-schema/` may be believed, and answered it from mtimes alone: a `git
2887+
// merge`, `git checkout` or `git worktree add` re-checks-out a source file with
2888+
// IDENTICAL bytes, bumps its mtime, and the build that follows correctly does
2889+
// not run (turbo's cache hashes content) — so the rule refused a tree that was
2890+
// exactly current, and `check:docs` cost a full regeneration for nothing.
2891+
//
2892+
// Nothing recorded which sources this tree came from, so the rule had no
2893+
// evidence of any kind to answer with. This is that evidence, and it is written
2894+
// HERE rather than by the build for two reasons this file is the proof of:
2895+
//
2896+
// - this script rebuilds the WHOLE tree unconditionally, before the `--check`
2897+
// / `--update-base` fork, so one write point covers `gen:schema`,
2898+
// `check:authorable-surface` and `gen:authorable-surface-base` alike;
2899+
// - every ratchet above exits 1 on refusal, and the clean at the top removes
2900+
// the previous stamp with the rest of this generator's outputs. So a stamp
2901+
// exists only for a run that emitted the tree beside it AND reached this
2902+
// line — a generation that died halfway leaves none, which is no evidence,
2903+
// which leaves the refusal standing.
2904+
//
2905+
// ⛔ It may only ever ACQUIT a tree the mtime rule has already accused. A failure
2906+
// to write it is therefore reported and never thrown: no stamp is the
2907+
// conservative state, and killing a successful generation over a missing
2908+
// performance stamp would trade a slow gate for a broken build.
2909+
const schemaStampDigest = recordSchemaStamp(PKG_DIR);
2910+
if (schemaStampDigest) {
2911+
console.log(`✓ json-schema/.build-input-hash-schema ← ${schemaStampDigest.slice(0, 16)}…`);
2912+
} else {
2913+
console.warn(
2914+
`⚠ json-schema/.build-input-hash-schema could not be written — the tree is generated and correct,\n` +
2915+
` but nothing records which sources from, so the mtime freshness rule will keep refusing it\n` +
2916+
` until the next build. Gates stay conservative; nothing here is wrong, only slower.`,
2917+
);
2918+
}
2919+

packages/spec/scripts/def-key-collisions.test.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,9 @@ describe('formatDefKeyCollisions', () => {
265265
// symlinked directory resolves its relative imports against the real path, so
266266
// `ui/view.zod.ts` would keep reading the unmutated `shared/http.zod.ts`.
267267
const PKG = path.resolve(__dirname, '..');
268+
/** The repo root. The fixture below mirrors its depth so the generator's own
269+
* `../../../scripts` import resolves inside the sandbox (#16175). */
270+
const REPO_ROOT = path.resolve(PKG, '..', '..');
268271

269272
/** One full spec surface (~1600 schemas) per run; a timeout must mean "hung". */
270273
const SPAWN_TIMEOUT_MS = 180_000;
@@ -283,8 +286,18 @@ describe('build-schemas.ts refuses a second write of one def key (#5832)', () =>
283286
'goes red when `HttpMethodSchema` is re-declared next to the 7-value `HttpMethod`',
284287
{ timeout: SPAWN_TIMEOUT_MS },
285288
() => {
286-
const dir = path.join(sandbox, 'restored-limb');
287-
fs.mkdirSync(dir);
289+
// The fixture package sits at `<root>/packages/spec`, mirroring this repo's
290+
// own depth (#16175): `build-schemas.ts` resolves everything from its own
291+
// `__dirname` except the repo-root freshness module it now imports to write
292+
// the generation stamp, and from a flat tmpdir that specifier walks off the
293+
// top of the filesystem — the spawn then dies with MODULE_NOT_FOUND and this
294+
// case reports on its own shape instead of on the def-key guard.
295+
const limbRoot = path.join(sandbox, 'restored-limb');
296+
const dir = path.join(limbRoot, 'packages', 'spec');
297+
fs.mkdirSync(dir, { recursive: true });
298+
// Read-only here, and a copy would be a second definition of the digest
299+
// whose single definition is that module's whole point.
300+
fs.symlinkSync(path.join(REPO_ROOT, 'scripts'), path.join(limbRoot, 'scripts'));
288301
fs.cpSync(path.join(PKG, 'scripts'), path.join(dir, 'scripts'), { recursive: true });
289302
fs.cpSync(path.join(PKG, 'src'), path.join(dir, 'src'), { recursive: true });
290303
for (const entry of ['node_modules', 'package.json']) {

0 commit comments

Comments
 (0)