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
6 changes: 5 additions & 1 deletion packages/spec/scripts/build-openapi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { z } from 'zod';
import * as API from '../src/api';
import * as Data from '../src/data';
import { assertRefsResolve, assertNoDegradedSchemas } from './lib/openapi-self-consistency';
// The name this generator writes is DECLARED next to `build-schemas.ts`'s clean
// step, which shares this directory and must not sweep it away (#5371). Imported
// rather than spelled again so a rename here moves the declaration with it.
import { OPENAPI_ARTIFACT_NAME } from './lib/json-schema-out-dir';

const OUT_DIR = path.resolve(__dirname, '../json-schema');
const pkg = JSON.parse(fs.readFileSync(path.resolve(__dirname, '../package.json'), 'utf-8'));
Expand Down Expand Up @@ -175,7 +179,7 @@ if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true });
}

const outPath = path.join(OUT_DIR, 'openapi.json');
const outPath = path.join(OUT_DIR, OPENAPI_ARTIFACT_NAME);
fs.writeFileSync(outPath, JSON.stringify(openapi, null, 2));
console.log(`✅ Generated OpenAPI spec: ${outPath}`);
console.log(` Version: ${SPEC_VERSION}`);
Expand Down
101 changes: 101 additions & 0 deletions packages/spec/scripts/build-schemas-check-mode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,6 +1267,107 @@ describe('build-schemas.ts — --update-base moves the anchor forward or not at
);
});

// ─────────────────────────────────────────────────────────────────────────────
// #5371 — the output clean is scoped to THIS generator's artifacts.
//
// `packages/spec/json-schema/` has two writers: this script emits
// `<category>/<Name>.json` and `objectstack.json`, and `gen:openapi`
// (build-openapi.ts) emits `openapi.json`. The clean used to remove the
// DIRECTORY, so it took the sibling's file with it. `pnpm build` never shows
// it (`gen:schema && gen:openapi` rewrites the file last), but every entry
// point that stops after this script leaves the artifact gone — and the tree is
// gitignored with no gate over it (`check:generated` says so itself: "Generated
// but ungated (2): gen:openapi, gen:sbom"), so nothing reports the hole.
//
// It surfaces two packages away instead, as `@objectstack/rest`'s openapi route
// tests failing `expected 503 to be 200` against a diff that never touched
// them. Four independent reports, each one an attribution lap: #5371 itself,
// then mid-#5126, mid-#5588 and mid-#5672.
//
// `--check` gets its own case because it is the reported trigger, not a variant
// of the write path: `check:authorable-surface` IS this script with that flag,
// `check:generated` runs it, and a command whose name says "check" destroying a
// build artifact is precisely what made the cause so hard to reach.
//
// The unit-level behaviour of the clean (nested trees, back-off, an entry it
// cannot remove) is pinned in scripts/json-schema-out-dir.test.ts. What these
// two cases add is the half that file cannot assert: that this script still
// routes its clean through it.
describe('build-schemas.ts — the output clean spares a sibling generator (#5371)', () => {
const OUT = () => path.join(sandbox, 'json-schema');
const OPENAPI = 'openapi.json';
/** Bytes only `gen:openapi` could have written — identity, not just presence. */
const OPENAPI_BYTES = JSON.stringify(
{ openapi: '3.1.0', 'x-fixture': 'written by gen:openapi, never by gen:schema' },
null,
2,
);
/** Output of this generator that no current build emits — the clean's own job. */
const STALE_FILE = 'zzStaleFromAnEarlierRun.json';
const STALE_DIR = 'zzretiredcategory';

/** Seed json-schema/ the way a completed `pnpm build` leaves it, plus stale debris. */
function seedOutDir(): void {
fs.mkdirSync(path.join(OUT(), STALE_DIR), { recursive: true });
fs.writeFileSync(path.join(OUT(), STALE_DIR, 'Gone.json'), '{}');
fs.writeFileSync(path.join(OUT(), STALE_FILE), '{}');
fs.writeFileSync(path.join(OUT(), OPENAPI), OPENAPI_BYTES);
}

/** The clean really ran, over a tree this run really regenerated. */
function expectCleanedAndRegenerated(): void {
expect(fs.existsSync(path.join(OUT(), STALE_FILE))).toBe(false);
expect(fs.existsSync(path.join(OUT(), STALE_DIR))).toBe(false);
expect(fs.existsSync(path.join(OUT(), 'objectstack.json'))).toBe(true);
expect(fs.existsSync(path.join(OUT(), 'data', 'Object.json'))).toBe(true);
}

beforeEach(() => {
// A current, self-consistent tree, so the run exits 0 and the assertions
// below are about the clean rather than about some ratchet upstream of it.
seedManifest((s) => s);
const tip = seedBase((s) => s);
seedSurface((s) => s);
seedSurfaceBase(tip, (k) => k);
});

it(
"leaves gen:openapi's artifact byte-identical while still sweeping its own stale output",
{ timeout: SPAWN_TIMEOUT_MS },
() => {
seedOutDir();

const { status, output } = run([]);

expect(status).toBe(0);
// "No stale files remain" is unchanged — this is not a narrower clean.
expectCleanedAndRegenerated();
// …and the sibling's artifact is neither deleted nor rewritten.
expect(fs.readFileSync(path.join(OUT(), OPENAPI), 'utf8')).toBe(OPENAPI_BYTES);
// Announced, so an exemption is never indistinguishable from a miss.
expect(output).toContain(`kept ${OPENAPI}`);
expect(output).toContain('gen:openapi');
},
);

it(
'--check does the same — the reported trigger was a command named check: (#5371)',
{ timeout: SPAWN_TIMEOUT_MS },
() => {
seedOutDir();

const { status } = run(['--check']);

expect(status).toBe(0);
// `--check` still rebuilds the gitignored tree it computes from (that is
// #4711's boundary: it may not write a TRACKED file), so the clean runs
// here too — which is exactly why this path could delete the artifact.
expectCleanedAndRegenerated();
expect(fs.readFileSync(path.join(OUT(), OPENAPI), 'utf8')).toBe(OPENAPI_BYTES);
},
);
});

// ─────────────────────────────────────────────────────────────────────────────
// #4659 — check (b) registers a tombstone by its EXACT key, not by its leaf.
//
Expand Down
59 changes: 33 additions & 26 deletions packages/spec/scripts/build-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ import { RENAMED_DEFS, carryAuthorableKey, checkRenameTable } from './lib/rename
// at #5317 so the pipe-direction rule (#4488) is assertable without running the
// whole generator — see scripts/zod-graph.test.ts.
import { zodChildSchemas, zodShapeOf } from './lib/zod-graph';
// Who owns what under json-schema/. This generator shares that directory with
// gen:openapi, and used to clear it by deleting the directory itself (#5371).
import {
FOREIGN_JSON_SCHEMA_ARTIFACTS,
clearOwnedOutputs,
} from './lib/json-schema-out-dir';
import {
AUTHORABLE_SURFACE_DESCRIPTION,
AUTHORABLE_SURFACE_DIR_NAME,
Expand Down Expand Up @@ -272,36 +278,37 @@ function writeFileWithRetry(filePath: string, content: string, retries = MAX_RET
}
}

// Clean output directory ensures no stale files remain
// Clean THIS generator's outputs, so no stale file of ours remains — and only
// ours (#5371). `json-schema/` is shared with `gen:openapi`, which writes
// `openapi.json` there and is the last step of `pnpm build`; deleting the
// directory itself (what this block used to do) left every entry point that
// stops after `gen:schema` — `check:authorable-surface`, and therefore
// `check:generated` — with the artifact gone, and `@objectstack/rest`'s openapi
// route tests failing `expected 503 to be 200` in a package nobody had touched.
// The ownership registry and the deny-list reasoning live in
// scripts/lib/json-schema-out-dir.ts.
if (fs.existsSync(OUT_DIR)) {
console.log(`Cleaning output directory: ${OUT_DIR}`);

// Use a more robust cleanup with multiple retries and longer delays
// to handle filesystem race conditions in CI environments
for (let attempt = 0; attempt < MAX_RETRIES * 2; attempt++) {
try {
// Try removing with native Node.js rmSync
if (fs.existsSync(OUT_DIR)) {
fs.rmSync(OUT_DIR, { recursive: true, force: true, maxRetries: 5, retryDelay: RETRY_DELAY_BASE_MS * 2 });
}

// Verify the directory is actually gone
if (!fs.existsSync(OUT_DIR)) {
break;
}
// Retries and back-off unchanged: filesystem races in CI are why they exist.
const cleaned = clearOwnedOutputs(OUT_DIR, {
maxAttempts: MAX_RETRIES * 2,
retryDelayBaseMs: RETRY_DELAY_BASE_MS,
sleep: sleepSync,
onUnremovable: (entry, error) => {
// Continue rather than abort — ensureDir/writeFileWithRetry will regenerate
// over whatever is left, which is what this block did before #5371 too.
console.warn(
`Warning: Failed to fully clean ${entry} after ${MAX_RETRIES * 2} attempts:`,
error,
);
},
});

// If still exists, wait before retrying with exponential backoff
sleepSync(RETRY_DELAY_BASE_MS * (attempt + 1));
} catch (error) {
// If this is the last attempt, log but continue (we'll try to work with what's there)
if (attempt === (MAX_RETRIES * 2 - 1)) {
console.warn(`Warning: Failed to fully clean directory after ${attempt + 1} attempts:`, error);
// Try to continue anyway - ensureDir will create missing parts
break;
}
// Wait before retry with exponential backoff
sleepSync(RETRY_DELAY_BASE_MS * (attempt + 1));
}
// Say what was spared and who owns it. Silence here would make the exemption
// indistinguishable from a clean that quietly missed a file.
for (const entry of cleaned.preserved) {
console.log(` ↳ kept ${entry} — owned by ${FOREIGN_JSON_SCHEMA_ARTIFACTS.get(entry)} (#5371)`);
}

// Wait a bit to ensure file system has synced
Expand Down
164 changes: 164 additions & 0 deletions packages/spec/scripts/json-schema-out-dir.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// Unit pins for the `json-schema/` ownership rule (#5371): `gen:schema` clears
// its own outputs, and leaves the artifacts a sibling generator declares alone.
//
// These run against a scratch directory in milliseconds. The END-TO-END half —
// that `build-schemas.ts` actually routes its clean through this function, in
// both write and `--check` mode — is pinned in build-schemas-check-mode.test.ts,
// because a unit test over an extracted helper stays green forever if the caller
// goes back to `fs.rmSync(OUT_DIR, { recursive: true })`. Neither half is
// sufficient on its own.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';

import {
FOREIGN_JSON_SCHEMA_ARTIFACTS,
OPENAPI_ARTIFACT_NAME,
clearOwnedOutputs,
} from './lib/json-schema-out-dir';

let dir: string;

/** The bytes a real `gen:openapi` leaves behind, in miniature. */
const OPENAPI_BYTES = JSON.stringify({ openapi: '3.1.0', 'x-fixture': 'written by gen:openapi' }, null, 2);

/** Seed a directory shaped like a populated `json-schema/` tree. */
function seedOutDir(): void {
fs.mkdirSync(path.join(dir, 'data'), { recursive: true });
fs.writeFileSync(path.join(dir, 'data', 'Object.json'), '{"title":"Object"}');
fs.mkdirSync(path.join(dir, 'zzretiredcategory', 'nested'), { recursive: true });
fs.writeFileSync(path.join(dir, 'zzretiredcategory', 'nested', 'Gone.json'), '{}');
fs.writeFileSync(path.join(dir, 'objectstack.json'), '{"$defs":{}}');
fs.writeFileSync(path.join(dir, OPENAPI_ARTIFACT_NAME), OPENAPI_BYTES);
}

beforeEach(() => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-schema-out-dir-'));
});

afterEach(() => {
fs.rmSync(dir, { recursive: true, force: true });
});

describe('FOREIGN_JSON_SCHEMA_ARTIFACTS — the declared registry', () => {
it('declares openapi.json, and names the command that writes it back', () => {
// The entry is an EXEMPTION from a clean, so a reader who finds the file
// missing has to be able to learn the remedy from this line alone.
expect(FOREIGN_JSON_SCHEMA_ARTIFACTS.has(OPENAPI_ARTIFACT_NAME)).toBe(true);
for (const [entry, owner] of FOREIGN_JSON_SCHEMA_ARTIFACTS) {
expect(entry, 'entries are matched as top-level names, never as paths').not.toContain('/');
expect(owner, `${entry} must name the generator command that owns it`).toMatch(/^gen:/);
expect(owner).toMatch(/packages\/spec\/scripts\/.+\.ts/);
}
});
});

describe('clearOwnedOutputs — this generator clears its own outputs only (#5371)', () => {
it("removes every entry it owns, including nested category trees, and keeps the directory", () => {
seedOutDir();

const result = clearOwnedOutputs(dir);

// "No stale files remain" is the whole point of the clean and is unchanged:
// a category folder no build emits any more still goes, subtree and all.
expect(fs.existsSync(path.join(dir, 'zzretiredcategory'))).toBe(false);
expect(fs.existsSync(path.join(dir, 'data'))).toBe(false);
expect(fs.existsSync(path.join(dir, 'objectstack.json'))).toBe(false);
expect(result.removed.sort()).toEqual(['data', 'objectstack.json', 'zzretiredcategory']);
expect(result.failed).toEqual([]);
// The directory itself survives — the old code deleted it and rebuilt it,
// which is exactly how a sibling's file went down with it.
expect(fs.existsSync(dir)).toBe(true);
});

it("leaves gen:openapi's openapi.json byte-identical", () => {
seedOutDir();

const result = clearOwnedOutputs(dir);

expect(fs.readFileSync(path.join(dir, OPENAPI_ARTIFACT_NAME), 'utf8')).toBe(OPENAPI_BYTES);
expect(result.preserved).toEqual([OPENAPI_ARTIFACT_NAME]);
expect(result.removed).not.toContain(OPENAPI_ARTIFACT_NAME);
});

it('is the behaviour the old whole-directory rm did NOT have — the same fixture, the other way', () => {
// Reverse verification, written as a contrast rather than left implicit:
// the deleted limb is `fs.rmSync(OUT_DIR, { recursive: true })`, and this
// records what it does to the same seeded tree. Predicted direction: the
// sibling's artifact disappears. If this case ever fails, `rmSync` stopped
// being recursive-by-directory and the whole premise of #5371 has moved.
seedOutDir();

fs.rmSync(dir, { recursive: true, force: true });

expect(fs.existsSync(path.join(dir, OPENAPI_ARTIFACT_NAME))).toBe(false);
fs.mkdirSync(dir, { recursive: true });
});

it('no-ops on a directory that does not exist yet — the first build in a fresh worktree', () => {
const missing = path.join(dir, 'not-generated-yet');

const result = clearOwnedOutputs(missing);

expect(result).toEqual({ removed: [], preserved: [], failed: [] });
expect(fs.existsSync(missing)).toBe(false);
});

it('reports an entry it could not remove instead of swallowing it', () => {
// A build that cannot delete one stale file must regenerate over it rather
// than refuse to run (the pre-#5371 behaviour), but it may not pretend the
// file was cleaned: `failed` and `onUnremovable` are how the caller can tell
// an exemption from a miss.
seedOutDir();
const stuck = path.join(dir, 'data');
const reported: string[] = [];
const rmSync = fs.rmSync;
const spy = ((target: fs.PathLike, opts?: fs.RmOptions) => {
if (String(target) === stuck) throw new Error('EBUSY: resource busy or locked');
return rmSync(target, opts);
}) as typeof fs.rmSync;
fs.rmSync = spy;
try {
const result = clearOwnedOutputs(dir, {
maxAttempts: 3,
onUnremovable: (entry) => reported.push(entry),
});

expect(result.failed).toEqual(['data']);
expect(reported).toEqual(['data']);
// Everything else still went, and the sibling artifact is still spared.
expect(result.removed.sort()).toEqual(['objectstack.json', 'zzretiredcategory']);
expect(result.preserved).toEqual([OPENAPI_ARTIFACT_NAME]);
} finally {
fs.rmSync = rmSync;
}
});

it('backs off between attempts only, never after the last one', () => {
seedOutDir();
const stuck = path.join(dir, 'data');
const delays: number[] = [];
const rmSync = fs.rmSync;
const spy = ((target: fs.PathLike, opts?: fs.RmOptions) => {
if (String(target) === stuck) throw new Error('EBUSY');
return rmSync(target, opts);
}) as typeof fs.rmSync;
fs.rmSync = spy;
try {
clearOwnedOutputs(dir, {
maxAttempts: 3,
retryDelayBaseMs: 10,
sleep: (ms) => delays.push(ms),
});
// Linear back-off, one sleep fewer than attempts — a build script that
// sleeps after giving up is pure wall-clock nobody gets back.
expect(delays).toEqual([10, 20]);
} finally {
fs.rmSync = rmSync;
}
});
});
Loading
Loading