diff --git a/packages/spec/scripts/build-api-surface.ts b/packages/spec/scripts/build-api-surface.ts index 3c452e09e7..b62cd20e46 100644 --- a/packages/spec/scripts/build-api-surface.ts +++ b/packages/spec/scripts/build-api-surface.ts @@ -42,12 +42,21 @@ * A REMOVED export or a CHANGED factory signature is breaking (bump major). An * ADDED export still requires regenerating, so every change is deliberate. Reads * the built dist — run after `pnpm --filter @objectstack/spec build`. + * + * That last sentence is a PRECONDITION, and since #7122 it is enforced rather + * than merely documented: both modes refuse to read a dist that is missing or + * older than `src/`. On a stale dist this script does not fail, it writes a + * baseline missing every export added since the build — and `--check` then + * agrees with it against the same stale dist, so the phantom breaking removal is + * green at every step. See lib/dist-freshness.ts for the mechanism and for why + * the mtime rule, not `dist/.build-input-hash`, is the primitive that covers it. */ import ts from 'typescript'; import { createHash } from 'node:crypto'; import { readFileSync, writeFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; +import { inspectDistFreshness } from './lib/dist-freshness'; import { API_SURFACE_DIR_NAME, aggregateApiSurfaceShards, @@ -60,6 +69,16 @@ const SURFACE_DIR = resolve(PKG_DIR, API_SURFACE_DIR_NAME); const SIG_SNAPSHOT = resolve(PKG_DIR, 'api-surface-signatures.json'); const CHECK = process.argv.includes('--check'); +// BEFORE a single `.d.ts` is read (#7122). Order is the whole point: once +// `ts.createProgram` has run over a stale dist, every answer below it is +// confidently wrong, and both writing it and checking against it are worse than +// stopping here. +const freshness = inspectDistFreshness(PKG_DIR, CHECK ? 'check' : 'generate'); +if (!freshness.fresh) { + console.error(freshness.message); + process.exit(1); +} + /** Public entry points → their built CJS `.d.ts`, read from the exports map. */ function collectEntries(): Record { const pkg = JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')); diff --git a/packages/spec/scripts/dist-freshness.test.ts b/packages/spec/scripts/dist-freshness.test.ts new file mode 100644 index 0000000000..39a3b0cf3e --- /dev/null +++ b/packages/spec/scripts/dist-freshness.test.ts @@ -0,0 +1,334 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Pins the precondition `gen:api-surface` / `check:api-surface` now enforce +// (#7122), and the ORDER in which it is enforced. +// +// ## The dangerous direction, and why every case below is written against it +// +// This guard has exactly one direction worth arguing about: **saying fresh when +// stale**. A false red costs a `pnpm build` — the command the docblock has +// always told you to run. A false green costs a committed baseline that DELETES +// a live export, is self-consistent (the `--check` half compares it against the +// same stale dist and passes), and rides into an unrelated PR where nobody reads +// `api-surface/contracts.json`. #7122 measured that: `JobRunOutcome (interface)` +// vanished from the baseline while the export was live the whole time. +// +// So every case here is written so that a rule answering "fresh" unconditionally +// fails it, and the two `fresh: true` cases exist so that a rule answering +// "stale" unconditionally fails too — a guard nobody can get green is a guard the +// next person deletes rather than obeys. +// +// The freshness RULE itself is `distIsStale` in `scripts/check-regen-pending.mjs`, +// shared with `check:generated --fix` and the pre-commit hook. It is exercised +// here through the real predicate rather than a stub: what #7122 broke was the +// wiring, not the arithmetic, and a stubbed predicate would pass with the wiring +// still absent. + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { spawnSync } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { inspectDistFreshness } from './lib/dist-freshness'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const PKG = path.resolve(HERE, '..'); +const REPO_ROOT = path.resolve(PKG, '../..'); +const TSX = path.join(PKG, 'node_modules', '.bin', 'tsx'); + +/** A throwaway `packages/spec`-shaped directory: `src/` plus `dist/`. */ +let sandbox: string; + +/** Write `rel` with `content`, creating parents, and stamp its mtime. */ +function write(rel: string, content: string, mtimeEpochSeconds: number): string { + const p = path.join(sandbox, rel); + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, content); + fs.utimesSync(p, mtimeEpochSeconds, mtimeEpochSeconds); + return p; +} + +// Explicit stamps rather than sleeps, for the reason the sibling rule's test +// gives: mtime resolution and scheduling are not what this is about, and a test +// that races them is a test that gets `.skip`ped later. +const OLD = Math.floor(Date.now() / 1000) - 3600; +const NEW = Math.floor(Date.now() / 1000) - 60; + +beforeEach(() => { + sandbox = fs.mkdtempSync(path.join(os.tmpdir(), 'os-dist-freshness-')); +}); + +afterEach(() => { + fs.rmSync(sandbox, { recursive: true, force: true }); +}); + +describe('inspectDistFreshness — the dist precondition gen:api-surface never enforced (#7122)', () => { + it('refuses a dist whose declarations are OLDER than src — THE case', () => { + // #7122 in miniature: the dist was built from a base 24 commits behind, so + // `src/contracts/job-service.ts` (which declares JobRunOutcome) is newer + // than every `.d.ts` describing it. Answering `fresh` here is what let the + // generator write a baseline with that export deleted. + write('dist/contracts/index.d.ts', 'export {};', OLD); + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + const verdict = inspectDistFreshness(sandbox, 'generate'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) return; + expect(verdict.state).toBe('stale'); + expect(verdict.message).toContain('OLDER than packages/spec/src'); + }); + + it('refuses in --check mode too, so CI cannot pass against a stale dist either', () => { + // The half that makes the phantom removal survive: the committed baseline is + // compared against THE SAME stale dist and agrees with it. Refusing only in + // the writing mode would leave the laundering path intact — a wrong baseline + // already on disk would keep passing forever. + write('dist/contracts/index.d.ts', 'export {};', OLD); + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + const verdict = inspectDistFreshness(sandbox, 'check'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) return; + // The two modes must not print the same damage: one WRITES a wrong baseline, + // the other AGREES with one. A reader who is told the wrong story looks in + // the wrong place. + expect(verdict.message).toContain('FALSE GREEN'); + expect(verdict.message).toContain('pnpm --filter @objectstack/spec check:api-surface'); + expect(verdict.message).not.toContain('WRITE a baseline'); + }); + + it('names the writing damage in generate mode, and prescribes the build', () => { + write('dist/contracts/index.d.ts', 'export {};', OLD); + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + const verdict = inspectDistFreshness(sandbox, 'generate'); + if (verdict.fresh) throw new Error('expected a refusal'); + expect(verdict.message).toContain('WRITE a baseline'); + expect(verdict.message).toContain('BREAKING'); + expect(verdict.message).toContain('pnpm --filter @objectstack/spec build'); + expect(verdict.message).toContain('gen:api-surface'); + }); + + it('reads a MISSING dist as its own condition, not as staleness', () => { + // A missing dist and a stale one are different facts with different fixes to + // suggest, and #7122's ruling asked for them to be distinguishable. A never + // built tree is also the ONE state where the old code failed honestly — the + // TypeScript program could not resolve a module symbol — so this must not + // regress into something quieter. + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + const verdict = inspectDistFreshness(sandbox, 'generate'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) return; + expect(verdict.state).toBe('missing'); + expect(verdict.message).toContain('no .d.ts declarations'); + }); + + it('reads a JS-only dist as missing — the OS_SKIP_DTS=1 shape on a virgin tree', () => { + // `OS_SKIP_DTS=1 pnpm build` emits JS and skips the declarations entirely. + // The dist directory exists and is newer than src, so "does dist exist" and + // "is dist newer" both answer the wrong question; only the `.d.ts` predicate + // sees that the artifact this generator reads was never produced. + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', OLD); + write('dist/contracts/index.js', 'export {};', NEW); + + const verdict = inspectDistFreshness(sandbox, 'generate'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) return; + expect(verdict.state).toBe('missing'); + }); + + it('refuses the OS_SKIP_DTS=1 shape on an ALREADY-BUILT tree — the case dist/.build-input-hash cannot see', () => { + // THE reason this guard reads mtimes instead of the content stamp #7122 + // suggested. `packages/spec`'s build runs the declaration pass only when + // OS_SKIP_DTS is empty, but stamps `dist/.build-input-hash` either way — so + // after `OS_SKIP_DTS=1 pnpm build` the stamp matches the sources exactly + // while the `.d.ts` on disk predate them. check-dev-prereqs.mjs lists that as + // a known false green and names this gate as the one it breaks. + // + // Fresh JS, fresh stamp, stale declarations: a stamp-based guard is GREEN + // here. This one is red. + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + write('dist/contracts/index.d.ts', 'export {};', OLD); + write('dist/contracts/index.js', 'export {};', NEW); + write('dist/.build-input-hash', `${'a'.repeat(64)}\n`, NEW); + + const verdict = inspectDistFreshness(sandbox, 'generate'); + expect(verdict.fresh).toBe(false); + if (verdict.fresh) return; + expect(verdict.state).toBe('stale'); + }); + + it('finds the newest source at ANY depth, not just the top level', () => { + // A walk that stopped one level down would call this fresh and hand the + // generator a dist that predates the only edit in the tree. + write('src/index.ts', 'export {};', OLD); + write('dist/index.d.ts', 'export {};', OLD + 60); + write('src/contracts/nested/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', NEW); + + expect(inspectDistFreshness(sandbox, 'generate').fresh).toBe(false); + }); + + it('lets a dist NEWER than src through, in both modes', () => { + // The state right after `pnpm --filter @objectstack/spec build`, and the + // state CI is in when lint.yml runs `check:api-surface` after the build step. + // If this direction were wrong the gate would be a permanent red on every + // run, which is how a guard gets removed instead of obeyed. + write('src/contracts/job-service.ts', 'export interface JobRunOutcome { ok: boolean }', OLD); + write('dist/contracts/index.d.ts', 'export {};', NEW); + + expect(inspectDistFreshness(sandbox, 'generate')).toEqual({ fresh: true }); + expect(inspectDistFreshness(sandbox, 'check')).toEqual({ fresh: true }); + }); +}); + +// ── The real failure mode, end to end ──────────────────────────────────────── +// +// A unit test on the rule cannot see the defect #7122 actually reported: the +// rule already existed (`check:generated --fix` and the pre-commit hook both +// consult it) and was simply never consulted on the path a person takes. What +// had to change is the WIRING, and only running the real script can show it. +// +// ## Why a sandbox rather than the real package +// +// `build-api-surface.ts` resolves every path from its own `__dirname`, so +// running it in place would rewrite the repo's tracked `api-surface/` shards — +// and making the real dist stale to drive the refusal would be destructive to +// whatever else is running. Each run therefore happens in a temp tree laid out +// like the repo (`/packages/spec` beside `/scripts`) that COPIES +// `scripts/` — so `__dirname` lands there — and symlinks the read-only inputs. +// No test-only seam is added to the gate: a seam is itself a place where the +// gate can differ from what CI runs (the same argument +// `build-schemas-check-mode.test.ts` makes for the same reason). +// +// ## Why the fixture dist is a set of trivial `.d.ts` files +// +// This is the laundering in miniature, and it is what makes these cases +// non-vacuous. The sandbox's declarations parse and resolve, so with a FRESH +// dist the script runs to completion and REWRITES the seeded baseline — the +// positive control below asserts exactly that. Which means that when the same +// tree with an OLD dist leaves the baseline untouched, the refusal is what +// stopped a write that would otherwise have happened: a baseline describing a +// build that no longer matches src. Measured against the pre-fix script, that +// run exited 0 and wrote the wrong shards. + +/** A repo-shaped temp tree whose `packages/spec` runs the real generator. */ +function sandboxSpec(): { root: string; spec: string } { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'os-api-surface-')); + const spec = path.join(root, 'packages', 'spec'); + fs.mkdirSync(spec, { recursive: true }); + // The root scripts/ the guard's shared rule lives in, at its real relative + // depth — `distIsStale` is imported as `../../../../scripts/…`. + fs.symlinkSync(path.join(REPO_ROOT, 'scripts'), path.join(root, 'scripts'), 'dir'); + fs.cpSync(path.join(PKG, 'scripts'), path.join(spec, 'scripts'), { recursive: true }); + fs.symlinkSync(path.join(PKG, 'node_modules'), path.join(spec, 'node_modules'), 'dir'); + // The real exports map: the entry points, and therefore the `.d.ts` paths the + // generator reads, are read from it. + fs.copyFileSync(path.join(PKG, 'package.json'), path.join(spec, 'package.json')); + return { root, spec }; +} + +/** Every `dist/**.d.ts` the exports map points the generator at. */ +function declaredDts(spec: string): string[] { + const pkg = JSON.parse(fs.readFileSync(path.join(spec, 'package.json'), 'utf8')) as { + exports?: Record; + }; + const out: string[] = []; + for (const [sub, val] of Object.entries(pkg.exports ?? {})) { + if (!sub.startsWith('.')) continue; + const dts = val?.require?.types ?? val?.import?.types; + if (typeof dts === 'string' && dts.endsWith('.d.ts')) out.push(path.resolve(spec, dts)); + } + return out; +} + +const SENTINEL = '{ "sentinel": "not regenerated" }\n'; + +/** Seed `src/`, the declared `dist/*.d.ts`, and a recognisable baseline. */ +function seed(spec: string, { distMtime, srcMtime }: { distMtime: number; srcMtime: number }): void { + fs.mkdirSync(path.join(spec, 'src'), { recursive: true }); + fs.writeFileSync(path.join(spec, 'src/index.ts'), 'export const live = 1;\n'); + fs.utimesSync(path.join(spec, 'src/index.ts'), srcMtime, srcMtime); + + for (const dts of declaredDts(spec)) { + fs.mkdirSync(path.dirname(dts), { recursive: true }); + fs.writeFileSync(dts, 'export {};\n'); + fs.utimesSync(dts, distMtime, distMtime); + } + + fs.mkdirSync(path.join(spec, 'api-surface'), { recursive: true }); + fs.writeFileSync(path.join(spec, 'api-surface/root.json'), SENTINEL); +} + +function runGenerator(spec: string, args: string[]) { + return spawnSync(TSX, ['scripts/build-api-surface.ts', ...args], { + cwd: spec, + encoding: 'utf8', + env: { ...process.env, OS_SKIP_DTS: '' }, + }); +} + +const baseline = (spec: string): string => + fs.readFileSync(path.join(spec, 'api-surface/root.json'), 'utf8'); + +describe('build-api-surface.ts refuses a stale dist end to end (#7122)', () => { + let tree: { root: string; spec: string }; + + beforeEach(() => { + tree = sandboxSpec(); + }); + + afterEach(() => { + fs.rmSync(tree.root, { recursive: true, force: true }); + }); + + it('POSITIVE CONTROL: a fresh dist runs to completion and rewrites the baseline', () => { + // Without this case every assertion below could hold because the sandbox + // cannot reach the write path at all, and the guard would be untested. + seed(tree.spec, { distMtime: NEW, srcMtime: OLD }); + + const run = runGenerator(tree.spec, []); + expect(run.status).toBe(0); + expect(baseline(tree.spec)).not.toBe(SENTINEL); + }); + + it('gen: refuses on a stale dist and writes NOTHING', () => { + // The whole issue: the same tree the positive control just wrote from, + // aged. Pre-fix this exited 0 and committed a baseline missing every export + // added since that build — each one reading as a BREAKING removal. + seed(tree.spec, { distMtime: OLD, srcMtime: NEW }); + + const run = runGenerator(tree.spec, []); + expect(run.status).toBe(1); + expect(run.stderr).toContain('OLDER than packages/spec/src'); + expect(run.stderr).toContain('pnpm --filter @objectstack/spec build'); + expect(baseline(tree.spec)).toBe(SENTINEL); + }); + + it('--check refuses on the same stale dist, so CI cannot pass against it either', () => { + // Step 2 of the laundering. If `--check` still ran here it would compare the + // committed baseline against the same stale dist, agree with it, and report + // the public API unchanged — the green that carries the phantom removal all + // the way to main. + seed(tree.spec, { distMtime: OLD, srcMtime: NEW }); + + const run = runGenerator(tree.spec, ['--check']); + expect(run.status).toBe(1); + expect(run.stderr).toContain('FALSE GREEN'); + expect(run.stdout).not.toContain('unchanged'); + }); + + it('refuses a MISSING dist in both modes, naming the build rather than a phantom removal', () => { + seed(tree.spec, { distMtime: OLD, srcMtime: NEW }); + fs.rmSync(path.join(tree.spec, 'dist'), { recursive: true, force: true }); + + for (const args of [[], ['--check']]) { + const run = runGenerator(tree.spec, args); + expect(run.status).toBe(1); + expect(run.stderr).toContain('no .d.ts declarations'); + } + expect(baseline(tree.spec)).toBe(SENTINEL); + }); +}); diff --git a/packages/spec/scripts/lib/dist-freshness.ts b/packages/spec/scripts/lib/dist-freshness.ts new file mode 100644 index 0000000000..5d90c9703a --- /dev/null +++ b/packages/spec/scripts/lib/dist-freshness.ts @@ -0,0 +1,150 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * dist-freshness.ts — the precondition `build-api-surface.ts` had written down + * in prose and enforced nowhere (#7122). + * + * ## The failure this refuses + * + * `gen:api-surface` reads the BUILT `dist/*.d.ts`. Its docblock has always said + * "run after `pnpm --filter @objectstack/spec build`", and nothing checked it. + * On a stale dist the generator does not fail — it emits a plausible baseline + * with every export added since the last build MISSING, and by that generator's + * own rule a removed export is a BREAKING change: + * + * 1. stale dist ⇒ `gen:api-surface` writes a baseline missing a live export; + * 2. `check:api-surface` then compares that baseline against THE SAME stale + * dist and passes; + * 3. the deletion rides into an unrelated PR where no reviewer reads + * `api-surface/contracts.json`, and the next honest regeneration re-adds + * the line — reading as an ADDITION rather than as a repair. + * + * Green at every step. #7122 measured it: a dist built from a base 24 commits + * behind `origin/main` deleted `JobRunOutcome (interface)` from + * `api-surface/contracts.json` while `JobRunOutcome` was a live export the whole + * time. #4687 is the same trap landing unnoticed, caught only by diffing the + * generated files against `main`. AGENTS.md §9 records the class. + * + * So the answer is a REFUSAL, in both modes, and a wrong baseline is worse than + * no baseline. `--check` is included deliberately: a check that reports "surface + * unchanged" against a build nobody made is a FALSE GREEN on exactly the export + * change the gate exists to catch, and it is the step that lets CI agree with a + * laundered removal. + * + * ## Why the guard lives in the GENERATOR and not in its callers + * + * It already lived in two callers and neither covers the path #7122 took: + * `check:generated --fix` refuses `readsDist` generators on a stale dist, and + * `scripts/check-regen-pending.mjs` refuses them at pre-commit. Running + * `pnpm --filter @objectstack/spec gen:api-surface` (or `check:api-surface`) + * directly — which is what the reporter did, what AGENTS.md tells you to do + * after changing an export, and what lint.yml runs — reaches neither. + * + * `build-docs.ts` already made this exact move one artifact over (#4723): it + * carries `schemaTreeIsStale` itself "so EVERY caller is covered rather than + * this one". Same shape, same reason. + * + * ## Why the mtime rule and NOT `dist/.build-input-hash` + * + * #7122 suggested reusing the content stamp that `scripts/check-dev-prereqs.mjs` + * writes. Measured, it is the wrong primitive FOR THIS CONSUMER, in the + * dangerous direction: + * + * - The stamp is written by `packages/spec`'s build unconditionally, INCLUDING + * under `OS_SKIP_DTS=1` — that build emits JS, leaves whatever `.d.ts` was + * there before, and stamps anyway. check-dev-prereqs.mjs lists this as a + * known FALSE GREEN and names this gate as the one it breaks; AGENTS.md + * §"Added or removed a `packages/spec` export?" says the same in the other + * direction ("skips exactly the artifact the gate inspects, and the check + * passes locally while failing in CI"). A stamp-based guard would therefore + * be green on the one local build flag that guarantees the declarations this + * generator reads are stale. + * - `distIsStale` keys on `dist/**` + `.d.ts` mtimes against `src/**` + `.ts` + * — the artifact this generator actually consumes — so it catches the + * `OS_SKIP_DTS=1` shape as well as #7122's rebase-behind-the-dist shape. + * - It is also the rule the other three consumers already read, so this adds + * no second notion of "is packages/spec/dist current"; a second copy drifts, + * and the direction it drifts in is the one that writes a confident wrong + * baseline (#4675). + * + * The stamp's own strength — content, not mtime (#5864) — is real and NOT + * discarded: it still guards `pnpm dev`. It is simply blind to the half of the + * dist that this generator is made of. Closing the `OS_SKIP_DTS` hole in the + * stamp itself is a separate change to a separate script. + */ +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; + +import { distIsStale } from '../../../../scripts/check-regen-pending.mjs'; + +/** What the generator is about to do, so the refusal can name the real damage. */ +export type DistReadMode = 'generate' | 'check'; + +export type DistFreshness = + | { fresh: true } + | { fresh: false; state: 'missing' | 'stale'; message: string }; + +/** + * Does `dist` hold any declaration at all? + * + * Only ever used to pick the right SENTENCE — "missing" and "stale" are one + * refusal with two causes and two different fixes to suggest. The freshness + * verdict itself is never decided here; that stays in the one shared rule. + * + * A dist with JS and no `.d.ts` is the `OS_SKIP_DTS=1`-on-a-virgin-tree shape, + * and it reads as missing rather than stale because that is what it is. + */ +function hasDeclarations(dir: string, depth = 0): boolean { + if (depth > 12 || !existsSync(dir)) return false; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue; + const child = join(dir, entry.name); + if (entry.isDirectory()) { + if (hasDeclarations(child, depth + 1)) return true; + } else if (entry.name.endsWith('.d.ts')) return true; + } + return false; +} + +/** + * The precondition, as data: may this run read `/dist` and believe it? + * + * Returns the verdict rather than exiting so the rule and its wording can be + * driven from a test in both directions — a guard only ever observed green is + * indistinguishable from one that matches nothing (#4690). + */ +export function inspectDistFreshness(pkgDir: string, mode: DistReadMode): DistFreshness { + if (!distIsStale(pkgDir)) return { fresh: true }; + + const state: 'missing' | 'stale' = hasDeclarations(join(pkgDir, 'dist')) ? 'stale' : 'missing'; + + const damage = + mode === 'generate' + ? `Regenerating now would WRITE a baseline describing a build that no longer matches src:\n` + + ` every export added since that build reads as a REMOVAL, and this generator's own rule\n` + + ` calls a removed export a BREAKING change. The wrong baseline is then self-consistent —\n` + + ` check:api-surface compares it against the same stale dist and passes (#7122, #4687).` + : `A verdict now would be computed against a build that no longer matches src, so this\n` + + ` check would report the public API "unchanged" without ever reading the exports under\n` + + ` test — a FALSE GREEN on exactly the change it exists to catch (#7122).`; + + const cause = + state === 'missing' + ? `packages/spec/dist holds no .d.ts declarations — the package is not built (or was built\n` + + ` with OS_SKIP_DTS=1, which emits JS and skips exactly the artifact this reads).` + : `packages/spec/dist/**/*.d.ts is OLDER than packages/spec/src — the declarations on disk\n` + + ` predate the sources. If you built with OS_SKIP_DTS=1, that build did not rebuild them.`; + + return { + fresh: false, + state, + message: + `\n❌ ${cause}\n\n` + + ` ${damage}\n\n` + + ` Build first, then re-run:\n\n` + + ` pnpm --filter @objectstack/spec build\n` + + ` pnpm --filter @objectstack/spec ${mode === 'generate' ? 'gen' : 'check'}:api-surface\n\n` + + ` (Do NOT use OS_SKIP_DTS=1 for this one — AGENTS.md §9 names it as the flag that cannot\n` + + ` serve gen:api-surface.)`, + }; +}