Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/16175-schema-tree-freshness-stamp.md
Original file line number Diff line number Diff line change
@@ -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 `<pkg>/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.
122 changes: 113 additions & 9 deletions packages/spec/scripts/build-schemas-check-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');

/**
Expand Down Expand Up @@ -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: `<tmp-root>/packages/spec`,
* with `<tmp-root>/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
Expand All @@ -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
// `<repo>/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));
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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 } {
Expand Down Expand Up @@ -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);
},
);
});

// ─────────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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']) {
Expand Down Expand Up @@ -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(
Expand Down
42 changes: 42 additions & 0 deletions packages/spec/scripts/build-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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.`,
);
}

17 changes: 15 additions & 2 deletions packages/spec/scripts/def-key-collisions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 `<root>/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']) {
Expand Down
Loading
Loading