diff --git a/.changeset/16175-schema-tree-freshness-stamp.md b/.changeset/16175-schema-tree-freshness-stamp.md new file mode 100644 index 0000000000..127e6194b3 --- /dev/null +++ b/.changeset/16175-schema-tree-freshness-stamp.md @@ -0,0 +1,60 @@ +--- +'@objectstack/spec': patch +--- + +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) + +`scripts/check-regen-pending.mjs` exports three freshness predicates over the +same `newestMtime(artifact) < newestMtime(src)` 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. + +Two of them were answered already — `distIsStale` by `dist/.build-input-hash-dts` +(#14985/#16176) and `bundlesAreStale` by `dist/.build-input-hash` (#16240). +`schemaTreeIsStale` was the third, and the one with **no evidence of any kind to +read**: nothing recorded which sources `packages/spec/json-schema/` came from. +Measured on a checkout whose `git status` was empty, after a bare +`touch packages/spec/src/data/query.zod.ts`: + +``` +pnpm --filter @objectstack/spec check:docs exit 1 + packages/spec/json-schema is older than packages/spec/src. +``` + +The only remedy on offer was a full `gen:schema` — minutes under a shared verify +lock — for a tree that needed nothing. The same command now exits 0 with no +rebuild, and a genuine source edit still refuses. + +**The evidence is new, because neither `dist/` stamp could 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. +`build-schemas.ts` now writes `json-schema/.build-input-hash-schema` as the last +thing it does: one write point, after the unconditional whole-tree regeneration +that precedes its `--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, never accuse.** A missing, unreadable or +non-64-hex stamp is `unstamped` — no evidence — and leaves the mtime refusal +exactly where it stood (#4690). Nothing that passes today can start failing, and +the rule keeps its only conviction instrument: mtimes still see the hand-edited +tree and the toolchain change a content digest is blind to. + +**Why this ships, and why it is a changeset rather than `skip-changeset`.** +`json-schema` is in `@objectstack/spec`'s published `files[]`, so the new stamp +travels in the tarball — measured with `npm pack --dry-run`: +`json-schema/.build-input-hash-schema` is present alongside the two existing +`dist/` stamps. One 65-byte file is added to the published package. No export, no +schema key, no runtime behaviour and no authorable surface moves. + +**One other published-adjacent change**, for the same soundness reason: the build +digest (`scripts/build-input-hash.mjs`) now also hashes `/scripts/**` for +packages that have it. `packages/spec`'s generators live there and were in none of +the previous input sets, so an edited generator kept a digest that had not moved — +and a stamp written by the OLD generator would then acquit a tree the new one +emits differently. Widening a digest can only ever WITHHOLD an acquittal, never +grant one, so the two `dist/` stamps become strictly more honest as well; the +first build after this lands re-stamps all three. diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index f8a03b9788..8c98fec6a3 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -54,6 +54,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { schemaStamp } from '../../../scripts/check-regen-pending.mjs'; import { RENAMED_DEFS } from './lib/renamed-defs'; import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; import { @@ -83,6 +84,9 @@ import { const HERE = path.dirname(fileURLToPath(import.meta.url)); const PKG = path.resolve(HERE, '..'); +/** The repo root — the fixture root mirrors it, so the generator's own + * `../../../scripts` import resolves inside the sandbox (#16175). */ +const REPO_ROOT = path.resolve(PKG, '..', '..'); const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx'); /** @@ -376,6 +380,35 @@ function mountUnemittedLedger(dir: string): void { fs.cpSync(path.join(PKG, UNEMITTED_BASELINE_FILE), path.join(dir, UNEMITTED_BASELINE_FILE)); } +/** + * A fixture package directory at the repo's own DEPTH: `/packages/spec`, + * with `/scripts` symlinked to this repo's root scripts. + * + * Every fixture in this file runs the real `build-schemas.ts` out of a copied + * `scripts/`, which works because that script resolves everything from its own + * `__dirname`. Since #16175 it resolves ONE thing from above the package — the + * repo-root freshness module that writes the generation stamp + * (`../../../scripts/check-regen-pending.mjs`) — and from a flat `/tmp/x/scripts` + * that path walks off the top of the filesystem: the spawn dies with + * MODULE_NOT_FOUND before any assertion runs, which is a fixture reporting on + * its own shape rather than on the generator. + * + * Depth rather than a stub, because the header's rule holds: no test-only seam. + * The root scripts are SYMLINKED rather than copied — they are read-only here, + * and a copy would be a second definition of the digest whose single definition + * is the entire point of that module. + */ +function fixtureTree(prefix: string): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + const dir = path.join(root, 'packages', 'spec'); + fs.mkdirSync(dir, { recursive: true }); + fs.symlinkSync(path.join(REPO_ROOT, 'scripts'), path.join(root, 'scripts')); + return dir; +} + +/** The fixture ROOT a package dir sits under — what teardown removes. */ +const sandboxRoot = (pkgDir: string): string => path.resolve(pkgDir, '..', '..'); + /** * Build a sandbox — a temp tree that COPIES `scripts/` (so `__dirname` lands * there) and symlinks the read-only inputs — mount it, and seed it to the state @@ -391,7 +424,17 @@ function mountUnemittedLedger(dir: string): void { * costs a `cpSync` and a `git init` each. */ function createSandbox(prefix: string): string { - const dir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + // The package sits TWO levels under a fixture root, mirroring the real + // `/packages/spec` (#16175). A flat sandbox was faithful enough while + // the generator resolved everything from its own `__dirname`; it now also + // imports the repo-root freshness module (`../../../scripts/ + // check-regen-pending.mjs`, the writer of the generation stamp), and from a + // flat `/tmp/x/scripts` that path walks off the top of the filesystem — the + // spawn dies with MODULE_NOT_FOUND before a single assertion runs. Keeping + // the depth is what lets these fixtures run the production import graph + // instead of a reduced one, which is the property the header's "no test-only + // seam" paragraph is about. + const dir = fixtureTree(prefix); fs.cpSync(path.join(PKG, 'scripts'), path.join(dir, 'scripts'), { recursive: true }); for (const entry of ['src', 'node_modules', 'package.json']) { fs.symlinkSync(path.join(PKG, entry), path.join(dir, entry)); @@ -424,7 +467,7 @@ function createSandbox(prefix: string): string { /** Take a block's own sandbox down and hand the handles back to the shared one. */ function releaseSandbox(dir: string): void { mountSandbox(sharedSandbox); - fs.rmSync(dir, { recursive: true, force: true }); + fs.rmSync(sandboxRoot(dir), { recursive: true, force: true }); } beforeAll(() => { @@ -450,7 +493,7 @@ beforeAll(() => { }); afterAll(() => { - if (sharedSandbox) fs.rmSync(sharedSandbox, { recursive: true, force: true }); + if (sharedSandbox) fs.rmSync(sandboxRoot(sharedSandbox), { recursive: true, force: true }); }); 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 expect(status).toBe(0); }, ); + + // ── #16175: the ONE write point, pinned where it can actually go missing ─── + // + // `schema-tree-freshness.test.ts` pins what the freshness rule does with a + // stamp. Nothing there can notice if the generator stops WRITING one — and + // that failure is invisible by construction: the rule degrades to `unstamped`, + // which is the conservative verdict, so every gate stays green and the only + // symptom is that the mtime false refusal quietly comes back. These two cases + // are the half that goes red when the write point is removed. + it( + 'writes the generation stamp as its last step, in --check mode too', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + const stampPath = path.join(sandbox, 'json-schema', '.build-input-hash-schema'); + seedManifest((s) => s); + + // A plain generation: `gen:schema`, the entry point `check:docs`'s own + // remedy line names. + fs.rmSync(stampPath, { force: true }); + expect(run([]).status).toBe(0); + expect(fs.existsSync(stampPath), 'gen:schema wrote no freshness stamp').toBe(true); + const written = fs.readFileSync(stampPath, 'utf8').trim(); + expect(written).toMatch(/^[0-9a-f]{64}$/); + // The digest the READER computes for this tree, not a literal: writer and + // reader disagreeing is the one failure that cannot be seen from either + // side alone, and it fails in the acquitting direction only by accident. + expect(schemaStamp(sandbox).state).toBe('match'); + + // …and `--check` too. It runs the same unconditional regeneration before + // its fork, so a tree it leaves behind is as current as `gen:schema`'s and + // must be as believable. This is also the case that proves the stamp is + // not the #4711 defect returning: `json-schema/` is this generator's own + // gitignored output, cleared by it and rewritten by it, never a tracked + // file a check repairs — the manifest assertion above still holds. + const current = readManifest(); + fs.rmSync(stampPath, { force: true }); + expect(run(['--check']).status).toBe(0); + expect(fs.existsSync(stampPath), '--check wrote no freshness stamp').toBe(true); + expect(schemaStamp(sandbox).state).toBe('match'); + expect(readManifest()).toBe(current); + }, + ); + + it( + 'never leaves a stamp behind for a generation that was REFUSED', + { timeout: SPAWN_TIMEOUT_MS }, + () => { + // The soundness argument for the write point's POSITION, asserted. The + // stamp is the last line of the script, after every ratchet that can exit + // 1 — so a run that refused vouches for nothing, and the next reader sees + // `unstamped`, which is no evidence, which leaves the mtime rule standing. + const stampPath = path.join(sandbox, 'json-schema', '.build-input-hash-schema'); + const stale = seedManifest((s) => s.filter((k) => k !== KNOWN_KEY)); + fs.rmSync(stampPath, { force: true }); + + expect(run(['--check']).status).toBe(1); + + expect(fs.existsSync(stampPath), 'a refused run stamped the tree anyway').toBe(false); + expect(readManifest()).toBe(stale); + }, + ); }); // ───────────────────────────────────────────────────────────────────────────── @@ -2693,7 +2797,7 @@ describe('build-schemas.ts — check (b) matches the exact retired key, not its expect(baselineKeys, `${STILL_LIVE_KEY} is no longer a live authorable key`).toContain(STILL_LIVE_KEY); expect(baselineKeys.some((k) => k.startsWith(AGED_OUT_KEY))).toBe(false); - box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-retired-keys-')); + box = fixtureTree('build-schemas-retired-keys-'); fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true }); 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 }); afterAll(() => { - if (box) fs.rmSync(box, { recursive: true, force: true }); + if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true }); }); it( @@ -2992,7 +3096,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' expect(declared, `${def} is now registered for real — pick an unregistered fixture`).not.toContain(def); } - box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-manifest-removal-')); + box = fixtureTree('build-schemas-manifest-removal-'); fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true }); for (const entry of ['node_modules', 'package.json']) { @@ -3048,7 +3152,7 @@ describe('build-schemas.ts — a deleted manifest key must prove itself (#4725)' }); afterAll(() => { - if (box) fs.rmSync(box, { recursive: true, force: true }); + if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true }); }); it( @@ -3358,7 +3462,7 @@ describe('build-schemas.ts — check (c) dates a tombstone by its exact key (#58 } expect(CURRENT_MAJOR - AGED_DECLARED_MAJOR).toBeGreaterThanOrEqual(2); - box = fs.mkdtempSync(path.join(os.tmpdir(), 'build-schemas-tombstone-age-')); + box = fixtureTree('build-schemas-tombstone-age-'); fs.cpSync(path.join(PKG, 'scripts'), path.join(box, 'scripts'), { recursive: true }); fs.cpSync(path.join(PKG, 'src'), path.join(box, 'src'), { recursive: true }); 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 }); afterAll(() => { - if (box) fs.rmSync(box, { recursive: true, force: true }); + if (box) fs.rmSync(sandboxRoot(box), { recursive: true, force: true }); }); it( diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index e16316dfa5..3cb8119931 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -46,6 +46,10 @@ import { FOREIGN_JSON_SCHEMA_ARTIFACTS, clearOwnedOutputs, } from './lib/json-schema-out-dir'; +// The ONE write point for `json-schema/`'s freshness stamp (#16175). Imported +// from the module that also READS it, because a digest written by one function +// and compared by another is a comparison that means nothing the day they drift. +import { recordSchemaStamp } from '../../../scripts/check-regen-pending.mjs'; import { AUTHORABLE_SURFACE_DIR_NAME, SCHEMA_MANIFEST_DIR_NAME, @@ -2875,3 +2879,41 @@ console.log(`\n✅ Generated bundled schema: objectstack.json (${Object.keys(def console.log(`\n✅ Successfully generated ${count} schemas.`); +// ─── The generation stamp (#16175) ─────────────────────────────────────────── +// +// The LAST thing this script does, and that position is the whole argument. +// `schemaTreeIsStale` in scripts/check-regen-pending.mjs asks whether +// `json-schema/` may be believed, and answered it from mtimes alone: a `git +// merge`, `git checkout` or `git worktree add` re-checks-out a source file with +// IDENTICAL bytes, bumps its mtime, and the build that follows correctly does +// not run (turbo's cache hashes content) — so the rule refused a tree that was +// exactly current, and `check:docs` cost a full regeneration for nothing. +// +// Nothing recorded which sources this tree came from, so the rule had no +// evidence of any kind to answer with. This is that evidence, and it is written +// HERE rather than by the build for two reasons this file is the proof of: +// +// - this 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` alike; +// - every ratchet above exits 1 on refusal, and the clean at the top removes +// the previous stamp with the rest of this generator's outputs. So a stamp +// exists only for a run that emitted the tree beside it AND reached this +// line — a generation that died halfway leaves none, which is no evidence, +// which leaves the refusal standing. +// +// ⛔ It may only ever ACQUIT a tree the mtime rule has already accused. A failure +// to write it is therefore reported and never thrown: no stamp is the +// conservative state, and killing a successful generation over a missing +// performance stamp would trade a slow gate for a broken build. +const schemaStampDigest = recordSchemaStamp(PKG_DIR); +if (schemaStampDigest) { + console.log(`✓ json-schema/.build-input-hash-schema ← ${schemaStampDigest.slice(0, 16)}…`); +} else { + console.warn( + `⚠ json-schema/.build-input-hash-schema could not be written — the tree is generated and correct,\n` + + ` but nothing records which sources from, so the mtime freshness rule will keep refusing it\n` + + ` until the next build. Gates stay conservative; nothing here is wrong, only slower.`, + ); +} + diff --git a/packages/spec/scripts/def-key-collisions.test.ts b/packages/spec/scripts/def-key-collisions.test.ts index 1d3fe51fb1..cfe2b749ce 100644 --- a/packages/spec/scripts/def-key-collisions.test.ts +++ b/packages/spec/scripts/def-key-collisions.test.ts @@ -265,6 +265,9 @@ describe('formatDefKeyCollisions', () => { // symlinked directory resolves its relative imports against the real path, so // `ui/view.zod.ts` would keep reading the unmutated `shared/http.zod.ts`. const PKG = path.resolve(__dirname, '..'); +/** The repo root. The fixture below mirrors its depth so the generator's own + * `../../../scripts` import resolves inside the sandbox (#16175). */ +const REPO_ROOT = path.resolve(PKG, '..', '..'); /** One full spec surface (~1600 schemas) per run; a timeout must mean "hung". */ const SPAWN_TIMEOUT_MS = 180_000; @@ -283,8 +286,18 @@ describe('build-schemas.ts refuses a second write of one def key (#5832)', () => 'goes red when `HttpMethodSchema` is re-declared next to the 7-value `HttpMethod`', { timeout: SPAWN_TIMEOUT_MS }, () => { - const dir = path.join(sandbox, 'restored-limb'); - fs.mkdirSync(dir); + // The fixture package sits at `/packages/spec`, mirroring this repo's + // own depth (#16175): `build-schemas.ts` resolves everything from its own + // `__dirname` except the repo-root freshness module it now imports to write + // the generation stamp, and from a flat tmpdir that specifier walks off the + // top of the filesystem — the spawn then dies with MODULE_NOT_FOUND and this + // case reports on its own shape instead of on the def-key guard. + const limbRoot = path.join(sandbox, 'restored-limb'); + const dir = path.join(limbRoot, 'packages', 'spec'); + fs.mkdirSync(dir, { recursive: true }); + // Read-only here, and a copy would be a second definition of the digest + // whose single definition is that module's whole point. + fs.symlinkSync(path.join(REPO_ROOT, 'scripts'), path.join(limbRoot, 'scripts')); fs.cpSync(path.join(PKG, 'scripts'), path.join(dir, 'scripts'), { recursive: true }); fs.cpSync(path.join(PKG, 'src'), path.join(dir, 'src'), { recursive: true }); for (const entry of ['node_modules', 'package.json']) { diff --git a/packages/spec/scripts/lib/json-schema-out-dir.ts b/packages/spec/scripts/lib/json-schema-out-dir.ts index 274e6402bf..556f4bbd84 100644 --- a/packages/spec/scripts/lib/json-schema-out-dir.ts +++ b/packages/spec/scripts/lib/json-schema-out-dir.ts @@ -5,9 +5,15 @@ * generators write into — and the clearing rule that stops them deleting each * other's work (#5371). * - * `gen:schema` (`scripts/build-schemas.ts`) emits `/.json` plus - * the bundled `objectstack.json`. `gen:openapi` (`scripts/build-openapi.ts`) - * emits exactly one file into the same directory: `openapi.json`. Neither + * `gen:schema` (`scripts/build-schemas.ts`) emits `/.json`, the + * bundled `objectstack.json`, and — since #16175 — `.build-input-hash-schema`, + * the digest of the inputs that generation consumed. That last one is the + * generator's own output like the others and is CLEARED like the others: it must + * die with the tree it vouches for, so ⛔ it may never be given a + * FOREIGN_JSON_SCHEMA_ARTIFACTS entry — exempting it from the sweep would leave + * a stamp acquitting a tree nobody emitted. `gen:openapi` + * (`scripts/build-openapi.ts`) emits exactly one file into the same directory: + * `openapi.json`. Neither * generator has a gate — `check:generated` prints `Generated but ungated (2): * gen:openapi, gen:sbom` on every run — and the whole tree is gitignored, so * nothing in this repo notices when one of these files goes missing. diff --git a/packages/spec/scripts/schema-tree-freshness.test.ts b/packages/spec/scripts/schema-tree-freshness.test.ts index 9747de1e7c..5042888d52 100644 --- a/packages/spec/scripts/schema-tree-freshness.test.ts +++ b/packages/spec/scripts/schema-tree-freshness.test.ts @@ -28,13 +28,34 @@ // consumers read it (`build-docs.ts`, the pre-commit hook, the merge driver's // prescription), and two copies of "is this older than src" drift in the // direction that renders a confident page from a tree nobody rebuilt (#4675). +// +// ── Re-judged at #16175, not rewritten ────────────────────────────────────── +// +// The rule gained a second half: an mtime accusation can now be ANSWERED by +// `json-schema/.build-input-hash-schema`, the digest `build-schemas.ts` writes +// at the end of a generation. That was needed because the mtime half alone +// refuses a tree whose bytes never moved — `git merge`, `git checkout` and +// `git worktree add` re-check-out unchanged sources and bump their mtimes, the +// build correctly does not run, and `check:docs` then costs a full regeneration +// for a tree that is exactly current (measured: exit 1 on a checkout whose +// `git status` was empty). +// +// ⛔ Every case below was re-judged against that change rather than deleted, and +// every one of them still asserts what it was written to assert — because NONE +// of these sandboxes carries a stamp. That is not an accident of the fixtures, +// it is the property being pinned: no stamp is `unstamped`, `unstamped` is NO +// EVIDENCE, and no evidence leaves the mtime verdict exactly where it stood +// (#4690). So the original six cases now pin one MORE thing than they were +// written for — that the acquittal channel cannot be reached without evidence — +// and the block added after them supplies the evidence and pins what it may and +// may not do with it. import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs'; +import { schemaStamp, schemaTreeIsStale } from '../../../scripts/check-regen-pending.mjs'; /** A throwaway `packages/spec`-shaped directory: `src/` plus `json-schema/`. */ let sandbox: string; @@ -88,6 +109,12 @@ describe('schemaTreeIsStale — the json-schema/ freshness rule (#4723)', () => // on every run; after it, this is what an unrebuilt tree looks like, and // answering `false` here is what would let `check:docs` report the docs in // sync with a `.describe()` it never read. + // + // Re-judged at #16175 and kept verbatim: this sandbox has no stamp, so the + // accusation has nothing to answer it and stands. It is now BOTH the + // false-green pin it always was and the pin for "absence of evidence is not + // licence to acquit" — which is exactly the shape the #16175 acquittal must + // not be able to reach on its own. write('json-schema/data/Object.json', '{}', OLD); write('src/data/object.zod.ts', 'export const x = 1;', NEW); expect(schemaTreeIsStale(sandbox)).toBe(true); @@ -130,3 +157,123 @@ describe('schemaTreeIsStale — the json-schema/ freshness rule (#4723)', () => expect(schemaTreeIsStale(sandbox)).toBe(true); }); }); + +// ───────────────────────────────────────────────────────────────────────────── +// #16175 — the accusation can now be ANSWERED, and only in one direction. +// +// `distIsStale` (#14985/#16176) and `bundlesAreStale` (#16240) each gained a +// digest that may acquit a tree whose bytes never moved. This rule had nothing +// to read: the two `dist/` stamps are written at the END of the build, while +// `gen:schema` is its FIRST step and is also run standalone and by +// `check:authorable-surface` — so a `dist/` stamp is evidence about `dist/` and +// would have been silent in the common case. The evidence here is therefore 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. +// +// The direction is the whole ruling, in triage's words: 摘要只能赦免、不能指控 +// — the digest may only ever ACQUIT a tree the mtime rule has already accused. +// So the cases below come in pairs: one that must go green with evidence, and +// one that must STAY red without it, or with evidence that does not fit. +// ───────────────────────────────────────────────────────────────────────────── + +/** + * The digest a generation of `sandbox` would record, right now. + * + * Asked of the rule's own reader rather than hardcoded, for the reason + * `dist-freshness.test.ts` gives: the input set includes turbo.json's + * `globalDependencies` and the repo-relative path of every file, so a literal + * would rot on the next unrelated edit — and a rotted literal fails as + * `mismatch`, which reads exactly like the refusal these cases distinguish from. + * + * Two steps, because the reader computes `actual` only when a valid digest is + * recorded: seed a syntactically valid placeholder, read what the inputs really + * hash to, then let the caller write that. + */ +function currentDigest(): string { + write('json-schema/.build-input-hash-schema', `${'0'.repeat(64)}\n`, OLD); + const { actual } = schemaStamp(sandbox); + if (!actual) throw new Error('the sandbox digest could not be computed — the fixture is wrong'); + return actual; +} + +describe('schemaTreeIsStale — the generation stamp may acquit, never accuse (#16175)', () => { + it('clears a tree older than src when the stamp MATCHES — THE case', () => { + // The measured defect: `git merge` / `git checkout` / `git worktree add` + // re-checks-out a source file with identical bytes and bumps its mtime; the + // build correctly does not run (turbo hashes content); the tree is exactly + // current and the rule refused it, costing a full `gen:schema`. + write('json-schema/data/Object.json', '{}', OLD); + write('src/data/object.zod.ts', 'export const x = 1;', NEW); + // Without evidence this is the false-green case above, and stays refused. + expect(schemaTreeIsStale(sandbox)).toBe(true); + + write('json-schema/.build-input-hash-schema', `${currentDigest()}\n`, OLD); + expect(schemaStamp(sandbox).state).toBe('match'); + expect(schemaTreeIsStale(sandbox)).toBe(false); + }); + + it('keeps refusing when the stamp MISMATCHES — a real content change', () => { + // The half that makes the case above non-vacuous. If the acquittal were + // unconditional, this would pass too — and the rule would have gone blind + // rather than got smarter, which is indistinguishable from fixed by any + // assertion that only ever watches it stop refusing. + write('json-schema/data/Object.json', '{}', OLD); + write('src/data/object.zod.ts', 'export const x = 1;', NEW); + write('json-schema/.build-input-hash-schema', `${'a'.repeat(64)}\n`, OLD); + + expect(schemaStamp(sandbox).state).toBe('mismatch'); + expect(schemaTreeIsStale(sandbox)).toBe(true); + }); + + it('keeps refusing on a stamp that is not a digest — absence of evidence is not licence (#4690)', () => { + // Every way of not knowing collapses to `unstamped`: a truncated write, a + // merge marker, a half-flushed file. None of them may read as vouched for. + write('json-schema/data/Object.json', '{}', OLD); + write('src/data/object.zod.ts', 'export const x = 1;', NEW); + write('json-schema/.build-input-hash-schema', 'not-a-digest\n', OLD); + + expect(schemaStamp(sandbox).state).toBe('unstamped'); + expect(schemaTreeIsStale(sandbox)).toBe(true); + }); + + it('cannot conjure a tree: a MISSING tree stays stale however good the stamp', () => { + // The stamp speaks for a tree; it is not a substitute for one. A leftover + // stamp beside an emptied `json-schema/` — a failed clean, a partial cache + // restore — must not turn "nothing to render from" into "current". + write('src/data/object.zod.ts', 'export const x = 1;', OLD); + write('json-schema/.build-input-hash-schema', `${currentDigest()}\n`, NEW); + + expect(schemaStamp(sandbox).state).toBe('match'); + expect(schemaTreeIsStale(sandbox)).toBe(true); + }); + + it('never accuses: a MISMATCHED stamp cannot overturn an mtime verdict of fresh', () => { + // The one-way property stated as an assertion rather than as prose. The + // mtime rule is the only thing that convicts — the digest cannot see a + // hand-edited tree, a toolchain change or dependency drift, so letting it + // convict would make a rule that passes today start failing for reasons + // nobody measured. + write('src/data/object.zod.ts', 'export const x = 1;', OLD); + write('json-schema/data/Object.json', '{}', NEW); + write('json-schema/.build-input-hash-schema', `${'b'.repeat(64)}\n`, NEW); + + expect(schemaStamp(sandbox).state).toBe('mismatch'); + expect(schemaTreeIsStale(sandbox)).toBe(false); + }); + + it('cannot vouch for itself — the stamp is invisible to both sides of the mtime rule', () => { + // `newestMtime` skips dotted entries and the artifact side matches `.json`, + // so the stamp counts as neither artifact nor source. Were it counted on the + // artifact side, writing it would make every tree look newer than src and + // the rule would clear itself unconditionally. + write('src/data/object.zod.ts', 'export const x = 1;', NEW); + // A stamp NEWER than the sources, and nothing else in the tree. + write('json-schema/.build-input-hash-schema', `${currentDigest()}\n`, NEW + 60); + expect(schemaTreeIsStale(sandbox)).toBe(true); + + // With one real artifact present but OLDER, the accusation still stands on + // its own terms and is answered only by the digest, never by the file's date. + write('json-schema/data/Object.json', '{}', OLD); + expect(schemaTreeIsStale(sandbox)).toBe(false); + }); +}); diff --git a/packages/spec/vitest.repo-tests.json b/packages/spec/vitest.repo-tests.json index 65d15639b8..2562b8c7f0 100644 --- a/packages/spec/vitest.repo-tests.json +++ b/packages/spec/vitest.repo-tests.json @@ -1,6 +1,8 @@ [ + "scripts/build-schemas-check-mode.test.ts", "scripts/category-title.test.ts", "scripts/check-generated-ledger.test.ts", + "scripts/def-key-collisions.test.ts", "scripts/dist-freshness-adoption.test.ts", "scripts/dist-freshness.test.ts", "scripts/escape-mdx.test.ts", diff --git a/scripts/build-input-hash.mjs b/scripts/build-input-hash.mjs index b8a1aeecf3..046bbd286d 100644 --- a/scripts/build-input-hash.mjs +++ b/scripts/build-input-hash.mjs @@ -30,15 +30,18 @@ * of its own: every path it touches arrives as an argument, so following it * subtracts nothing from anybody. * - * ## The two stamps, and why there are two + * ## The three stamps, and why there are three * - * Both hold the SAME digest over the SAME inputs. The difference is which build - * writes them, and that difference is the whole reason the second one exists — - * see each constant's docblock, and the two `inspect*Stamp` readers at the - * bottom for what each stamp may and may not be believed about. + * All three hold the SAME digest over the SAME inputs. The difference is WHICH + * RUN writes each one and WHICH ARTIFACT it therefore speaks for — two `dist/` + * stamps written by the build (one of them only when the declaration pass + * actually ran) and one written into `json-schema/` by the generator that + * emitted it. That difference is the whole reason there is more than one: see + * each constant's docblock, and the three `inspect*Stamp` readers at the bottom + * for what each stamp may and may not be believed about. */ import { createHash } from 'node:crypto'; -import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; /** @@ -86,6 +89,56 @@ export const STAMP_BASENAME = '.build-input-hash'; */ export const DTS_STAMP_BASENAME = '.build-input-hash-dts'; +/** + * Where a GENERATION records that it produced `/json-schema/`, and over + * which inputs. + * + * Same digest, same three verdicts and the same one-way meaning as the two + * stamps above. What differs is WHO writes it and WHERE it lives, and both + * differences are the soundness argument rather than an implementation detail: + * the two `dist/` stamps are written by the BUILD, as its last step; this one is + * written by the GENERATOR itself, at the end of + * `packages/spec/scripts/build-schemas.ts`, into the very tree that run emitted. + * + * Written because a third consumer had NOTHING to read. `schemaTreeIsStale` in + * scripts/check-regen-pending.mjs answers "may a gate render from + * `/json-schema/` and believe it" from mtimes, and shares the blind spot + * its two siblings document: a `git merge`, `git checkout` or `git worktree add` + * re-checks-out an UNCHANGED source and bumps its mtime, the build correctly + * does not run (turbo's cache hashes content), and the rule then refuses a tree + * that is exactly current. Measured on this axis, on a tree whose `git status` + * was empty: `pnpm --filter @objectstack/spec check:docs` exits 1 with + * `packages/spec/json-schema is older than packages/spec/src` after a bare + * `touch` of one `.zod.ts`. + * + * ⛔ Neither `dist/` stamp can answer that accusation, and reaching for one + * would be #7122's mistake relocated. Both are written at the END of the build, + * long after its first step `gen:schema` ran — and `gen:schema` is also run + * STANDALONE (`check:docs`'s own remedy line says so) and again by + * `check:authorable-surface`. So a `dist/` stamp is evidence about `dist/`, says + * nothing about which sources the json-schema tree on disk came from, and in the + * standalone case there would be no `dist/` stamp to read at all. + * + * What makes THIS file believable is co-location with its subject. + * `json-schema/` is a turbo build output, gitignored, and `build-schemas.ts` + * clears it by deny-list at the start of every run — so the stamp is destroyed + * together with the tree it speaks for and rewritten only by a run that reached + * the END of generation. A generation that crashed halfway leaves no stamp, + * which is `unstamped`, which is no evidence, which leaves the refusal standing. + * That is the same argument `--stamp` makes for writing inside `dist/`. + * + * ⛔ And the same one-way property governs: it may only ever ACQUIT a tree the + * mtime rule has already accused, never accuse one the mtime rule cleared. + */ +export const SCHEMA_STAMP_BASENAME = '.build-input-hash-schema'; + +/** + * The generated tree this third stamp lives in and vouches for — named once, so + * the writer at the end of `build-schemas.ts` and the reader in + * `schemaTreeIsStale` cannot come to mean different directories. + */ +export const SCHEMA_TREE_DIR_NAME = 'json-schema'; + /** Per-package build configuration that changes the output without being under src/. */ export const PACKAGE_BUILD_CONFIG = ['package.json', 'tsconfig.json', 'tsconfig.build.json', 'tsup.config.ts', 'tsdown.config.ts']; @@ -152,6 +205,28 @@ export function buildInputHash(root, pkgDir) { throw new CoverageError(`${posixRel(root, pkgDir)}/src does not exist, so there is nothing to hash — this gate cannot vouch for its dist.`); } const inputs = [...filesUnder(src)]; + // The package's own GENERATOR sources, when it has any (#16175). They are + // build inputs by every measure that matters here and were in none of the sets + // above: `packages/spec/json-schema/` is emitted by `scripts/build-schemas.ts` + // and its `dist/` by a build whose first two steps are `gen:schema && + // gen:openapi` — all of them files under `/scripts/`, none under `src/`, + // none named in PACKAGE_BUILD_CONFIG. Leaving them out let an EDITED generator + // keep a digest that had not moved, so a stamp written by the old generator + // would ACQUIT a tree the new one emits differently. That is an acquittal the + // evidence does not support, and the one direction #4690 forbids. + // + // Widening can only ever WITHHOLD an acquittal, never grant one: a strict + // superset of inputs turns `match` into `mismatch` and never the reverse, and + // `mismatch` leaves the mtime verdict standing. So no gate that passes today + // can start failing for a reason other than a real content change. + // + // The whole directory is taken rather than a curated subset, on the same + // reasoning that makes `filesUnder(src)` take `.test.ts`: a rule that has to + // decide which files under `scripts/` are "really" generator inputs decides + // wrong the day someone extracts a helper, and it decides wrong in the + // acquitting direction. + const generatorDir = path.join(pkgDir, 'scripts'); + if (existsSync(generatorDir)) inputs.push(...filesUnder(generatorDir)); for (const name of PACKAGE_BUILD_CONFIG) inputs.push(path.join(pkgDir, name)); inputs.push(...globalBuildInputs(root)); @@ -182,7 +257,7 @@ export function buildInputHash(root, pkgDir) { } /** - * Read ONE of the two stamps and say what it vouches for. Shared by both + * Read ONE of the three stamps and say what it vouches for. Shared by all three * readers below, because "the stamp and the reader must compute the same * digest" is exactly as load-bearing between the two stamps as it is between a * stamp and its reader: two copies of this comparison would drift, and the @@ -213,9 +288,9 @@ export function buildInputHash(root, pkgDir) { * `actual` is computed only when there is a valid digest to compare it against, * so the ~30ms hash stays off the path where no amplifier stamp exists at all. */ -function inspectStamp(root, pkgDir, basename) { +function inspectStamp(root, pkgDir, artifactDir, basename) { const none = { state: 'unstamped', recorded: null, actual: null }; - const stampFile = path.join(pkgDir, 'dist', basename); + const stampFile = path.join(pkgDir, artifactDir, basename); let recorded; try { if (!existsSync(stampFile)) return none; @@ -242,7 +317,7 @@ function inspectStamp(root, pkgDir, basename) { * that keeps `--stamp` in this file rather than in a script of its own). */ export function inspectDeclarationStamp(root, pkgDir) { - return inspectStamp(root, pkgDir, DTS_STAMP_BASENAME); + return inspectStamp(root, pkgDir, 'dist', DTS_STAMP_BASENAME); } /** @@ -276,5 +351,74 @@ export function inspectDeclarationStamp(root, pkgDir) { * mtime rule remains the only thing that convicts. */ export function inspectBuildStamp(root, pkgDir) { - return inspectStamp(root, pkgDir, STAMP_BASENAME); + return inspectStamp(root, pkgDir, 'dist', STAMP_BASENAME); +} + +/** + * Did a GENERATION produce THIS `json-schema/` tree from THESE sources? + * + * The reader for SCHEMA_STAMP_BASENAME, exported for `schemaTreeIsStale` in + * scripts/check-regen-pending.mjs — a THIRD artifact, produced by neither of the + * two `dist/` passes, and until #16175 the one freshness rule in this repo with + * no evidence of any kind to read. + * + * ## Why the generator's own stamp is the only file that can answer here + * + * `gen:schema` is the build's first step, so a matching `dist/` stamp does imply + * the tree was regenerated from these inputs — but `gen:schema` is also run + * STANDALONE, and `check:authorable-surface` runs the same generator in place. + * Answering from a `dist/` stamp would therefore be right in the case the build + * just ran and silent in the common case, which is the shape that makes a + * freshness feature vacuous rather than wrong. A stamp written by the generator, + * into the tree the generator emitted, is true in every one of those entry + * points and false in none. + * + * ## Why a `match` is sound + * + * `build-schemas.ts` rebuilds the WHOLE tree unconditionally, before its + * `--check` / `--update-base` fork and before every ratchet that can refuse, and + * the stamp is written at the very END — so a stamp exists only for a run that + * emitted the tree beside it and then survived every check. The digest's input + * set is a strict SUPERSET of the source set `schemaTreeIsStale` measures (it + * counts `.test.ts`, `/scripts/**`, PACKAGE_BUILD_CONFIG and turbo's + * `globalDependencies` as well), so a `match` implies every input that rule + * counts is byte-identical to the one the tree was generated from. A superset + * can only ever withhold an acquittal, never grant one it should not. + * + * The same one-way property governs as next door: `unstamped` — absent, + * unreadable, not 64 hex characters, or a package whose generator does not stamp + * — leaves the mtime verdict standing (#4690). + */ +export function inspectSchemaStamp(root, pkgDir) { + return inspectStamp(root, pkgDir, SCHEMA_TREE_DIR_NAME, SCHEMA_STAMP_BASENAME); +} + +/** + * Record, at the END of a generation, the digest of the inputs it consumed. + * + * The writer for SCHEMA_STAMP_BASENAME, and deliberately in this module rather + * than in the generator that calls it: the writer and the reader must compute + * the SAME digest or the comparison means nothing — the argument that already + * keeps `--stamp`'s hash here instead of beside its own CLI. + * + * Refuses to stamp a tree that is not there. A stamp without its subject is the + * one file shape this scheme cannot survive: it would outlive a clean, a failed + * generation or a cache eviction and go on acquitting a tree nobody emitted. + * Absent is handled everywhere else in this module as "no evidence", which is + * safe; a stamp for nothing is not. + * + * Returns the digest written, so the caller can print evidence a reader can + * recompute rather than an assertion they must take on trust. + */ +export function writeSchemaStamp(root, pkgDir) { + const treeDir = path.join(pkgDir, SCHEMA_TREE_DIR_NAME); + if (!existsSync(treeDir)) { + throw new CoverageError( + `${posixRel(root, treeDir)} does not exist, so there is no generated tree to stamp — ` + + `this runs at the END of generation, not before it.`, + ); + } + const hash = buildInputHash(root, pkgDir); + writeFileSync(path.join(treeDir, SCHEMA_STAMP_BASENAME), `${hash}\n`); + return hash; } diff --git a/scripts/check-dev-prereqs.mjs b/scripts/check-dev-prereqs.mjs index 332f2ebb50..a25ac86487 100644 --- a/scripts/check-dev-prereqs.mjs +++ b/scripts/check-dev-prereqs.mjs @@ -91,6 +91,11 @@ * * THE INPUT SET, and why each part is in it: * - every file under `/src/` — what the build compiles; + * - every file under `/scripts/` when it has any (#16175) — the + * package's own generators. `packages/spec`'s build opens with + * `gen:schema && gen:openapi`, both of them scripts there, so an edited + * generator that left this digest unmoved let a stamp written by the OLD + * one vouch for output the new one emits differently; * - `/package.json` — entry points, exports map, build script itself; * - `/tsconfig.json`, `/tsup.config.ts` when present — how it compiles; * - turbo.json's own `globalDependencies` — READ from turbo.json, not copied diff --git a/scripts/check-regen-pending.d.mts b/scripts/check-regen-pending.d.mts index f6c976263b..986d5a75b1 100644 --- a/scripts/check-regen-pending.d.mts +++ b/scripts/check-regen-pending.d.mts @@ -1,19 +1,22 @@ -// Types for the two freshness predicates `check-regen-pending.mjs` exports to -// other gates (#5475). +// Types for the freshness predicates and stamp accessors +// `check-regen-pending.mjs` exports to other gates (#5475). // // The module itself stays `.mjs`: `pre-commit` and `check:merge-driver` invoke // it with bare `node`, and every root script here is authored that way. What -// changed is that three files under `packages/spec/scripts/` import from it — -// `build-docs.ts`, `check-generated.ts` and `schema-tree-freshness.test.ts` — -// and since #5475 those are inside a tsc program (`tsconfig.scripts.json`), -// where an untyped `.mjs` import is TS7016: the predicate silently becomes -// `any`, and `if (distIsStale)` — the missing call, the exact mistake this -// guard exists to prevent — would type-check clean. +// changed is that files under `packages/spec/scripts/` import from it — +// `build-docs.ts`, `check-generated.ts`, `check-browser-reachable-entries.ts`, +// `build-schemas.ts` and the freshness tests — and since #5475 those are inside +// a tsc program (`tsconfig.scripts.json`), where an untyped `.mjs` import is +// TS7016: the predicate silently becomes `any`, and `if (distIsStale)` — the +// missing call, the exact mistake this guard exists to prevent — would +// type-check clean. // // Declared rather than inferred (no `allowJs`) because the module sits at the -// repo root, outside the consuming program's `rootDir`. The surface is five +// repo root, outside the consuming program's `rootDir`. The surface is seven // functions with one optional argument; keep this file in step with them by -// hand, and keep it small enough that doing so stays trivial. +// hand, and keep it small enough that doing so stays trivial. `check:declaration-mirrors` +// asserts the name, kind and required arity of each — never the types, which +// stay yours. /** * Is `packages/spec/dist` older than the sources it claims to describe? @@ -44,10 +47,44 @@ export function declarationStamp(specDir?: string): { * Is `packages/spec/json-schema` older than the sources it was generated from? * Missing counts as stale. * + * Answerable since #16175: an mtime accusation is cleared when — and only when — + * `schemaStamp` below reports `'match'`. Read the function's own docblock before + * reusing it. + * * @param specDir Absolute path to the spec package; defaults to this repo's. */ export function schemaTreeIsStale(specDir?: string): boolean; +/** + * Did a generation produce `specDir/json-schema` from the sources on disk right + * now? The generated tree's counterpart to `declarationStamp` and `buildStamp`, + * reading the THIRD stamp file (`json-schema/.build-input-hash-schema`, written + * by `build-schemas.ts` at the end of its generation, not by the build). + * `'match'` is the only verdict that clears an mtime accusation; `'unstamped'` + * is "no evidence". Read the function's own docblock before reusing it — neither + * `dist/` stamp is evidence about this tree. + * + * @param specDir Absolute path to the spec package; defaults to this repo's. + */ +export function schemaStamp(specDir?: string): { + state: 'match' | 'mismatch' | 'unstamped'; + recorded: string | null; + actual: string | null; +}; + +/** + * Write the stamp `schemaStamp` reads — the ONE write point, called from the end + * of `packages/spec/scripts/build-schemas.ts` once the tree beside it exists. + * + * ⛔ Reports rather than throws: `null` means nothing could be written, which is + * the conservative state (no evidence, so the mtime refusal stands) and must + * never be escalated into a failed generation. + * + * @param specDir Absolute path to the spec package; defaults to this repo's. + * @returns the digest written, or `null`. + */ +export function recordSchemaStamp(specDir?: string): string | null; + /** * Are `packages/spec`'s emitted JS bundles (`dist/**\/*.mjs`, `*.js`) older than * the sources — or than `tsup.config.ts` — they were bundled from? Missing diff --git a/scripts/check-regen-pending.mjs b/scripts/check-regen-pending.mjs index 203605c911..b0ca371b69 100755 --- a/scripts/check-regen-pending.mjs +++ b/scripts/check-regen-pending.mjs @@ -90,7 +90,12 @@ import { dirname, join, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { PENDING_MARKER, entryForPath, ownerDir, ownerOf, ownerRunCommand } from './regen-artifacts.mjs'; -import { inspectBuildStamp, inspectDeclarationStamp } from './build-input-hash.mjs'; +import { + inspectBuildStamp, + inspectDeclarationStamp, + inspectSchemaStamp, + writeSchemaStamp, +} from './build-input-hash.mjs'; import { gitFreeEnv } from './git-env.mjs'; import { isEntrypoint } from './invoked-as.mjs'; import { @@ -194,6 +199,55 @@ export function buildStamp(specDir = SPEC_DIR) { } } +/** + * Did a GENERATION produce `specDir/json-schema` from the sources on disk right + * now? Same three verdicts, same one-way meaning, same wrapping argument as the + * two stamp readers above — the repo root is supplied here so no caller can hash + * against the wrong one. + * + * The THIRD stamp file: `json-schema/.build-input-hash-schema`, written by + * `build-schemas.ts` at the end of its generation rather than by the build. + * `inspectSchemaStamp`'s docblock is the authority on why the generator has to + * be the writer and why neither `dist/` stamp can stand in for it. + */ +export function schemaStamp(specDir = SPEC_DIR) { + try { + return inspectSchemaStamp(REPO_ROOT, specDir); + } catch { + return { state: 'unstamped', recorded: null, actual: null }; + } +} + +/** + * Write the stamp `schemaStamp` reads — the ONE write point, called from the end + * of `packages/spec/scripts/build-schemas.ts`. + * + * Here rather than imported straight from `build-input-hash.mjs` for two + * reasons, and the first is the same one the readers give: the repo root is + * supplied at this single site, so a caller cannot hash against the wrong one — + * and a writer that hashed against the wrong root would not merely refuse, it + * would record a digest no reader can ever match, turning the acquittal channel + * off with nothing visible to notice. The second is mechanical: the generator + * imports from inside a tsc program (`tsconfig.scripts.json`), where an untyped + * `.mjs` import is TS7016, and this module is the one here that ships a + * hand-written `.d.mts` mirror (kept honest by `check:declaration-mirrors`). + * + * ⛔ It reports rather than throws, and the caller must keep it that way. Failing + * a generation because a performance stamp could not be written would convert an + * acquittal channel into a new way for the build to die; the conservative + * default already covers the failure — no stamp is `unstamped`, `unstamped` is + * no evidence, and no evidence leaves the mtime refusal standing. + * + * @returns the digest written, or `null` when nothing could be written. + */ +export function recordSchemaStamp(specDir = SPEC_DIR) { + try { + return writeSchemaStamp(REPO_ROOT, specDir); + } catch { + return null; + } +} + /** * Is `packages/spec/dist` older than the sources it claims to describe? Missing * counts as stale. Deliberately conservative: a false "stale" costs a build, a @@ -268,12 +322,42 @@ export function distIsStale(specDir = SPEC_DIR) { * `build-schemas.ts` (it imports the namespace barrels), so counting them * would send every test-only spec PR to a `gen:schema` it does not need — * and a guard that cries wolf is a guard someone deletes. - * - the artifact side matches `.json`, the tree's only content. + * - the artifact side matches `.json`, the tree's only content. The stamp + * below is deliberately not `.json` and starts with a dot, so it is + * invisible to `newestMtime` on both counts and cannot vouch for itself. + * + * ## The mtime rule accuses; the GENERATION stamp may acquit + * + * The blind spot both siblings document is shared here, and until #16175 this + * was the one rule of the three with NO evidence to answer it with. Measured on + * a tree whose `git status` was empty, after a bare `touch` of one `.zod.ts`: + * `pnpm --filter @objectstack/spec check:docs` exits 1 with `packages/spec/ + * json-schema is older than packages/spec/src`, and the only remedy on offer was + * a full `gen:schema` — minutes under the shared verify lock — for a tree that + * was exactly current. + * + * ⛔ Neither `dist/` stamp could answer it, and reaching for one would be #7122's + * rejected direction relocated rather than a relaxation of it: 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/`. + * + * So the evidence had to be made, and `build-schemas.ts` makes it: one write + * point at the end of generation records the digest of the inputs that + * generation consumed, into the tree it just emitted. `inspectSchemaStamp`'s + * docblock is the authority on why that write point is sound for all three + * entry points and why the digest's input set (a strict superset of the sources + * this rule counts) can only ever withhold an acquittal. + * + * And it may only ACQUIT. `unstamped` — no stamp, unreadable, not 64 hex + * characters, or a generation that died before the end — leaves the mtime + * verdict standing (#4690), so nothing that passes today can start failing. */ export function schemaTreeIsStale(specDir = SPEC_DIR) { const tree = newestMtime(join(specDir, 'json-schema'), (n) => n.endsWith('.json')); if (!tree) return true; - return newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts') && !n.endsWith('.test.ts')) > tree; + if (newestMtime(join(specDir, 'src'), (n) => n.endsWith('.ts') && !n.endsWith('.test.ts')) <= tree) return false; + return schemaStamp(specDir).state !== 'match'; } /**