From eb68f09f64c08a5983a391068d23e86812999b25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 7 Aug 2026 10:38:04 +0000 Subject: [PATCH] fix(spec): gen:schema clears only its own outputs, sparing gen:openapi's openapi.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `build-schemas.ts` opened by removing `packages/spec/json-schema/` itself, but that directory has two writers: this generator emits `/.json` plus `objectstack.json`, and `gen:openapi` emits `openapi.json`. `pnpm build` hid the damage (`gen:schema && gen:openapi` rewrites it last), while every entry point that stops after `gen:schema` — `check:authorable-surface`, and therefore `check:generated` — left the artifact deleted, with no gate over it and the tree gitignored. The clean is now scoped by a declared ownership registry (`scripts/lib/json-schema-out-dir.ts`): the sweep stays total, and exempts only top-level entries another generator declares. `build-openapi.ts` imports the artifact name from that registry so the declaration cannot drift from it. Fixes #5371 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_014wsZeReNTqiceBfLb5Pyf5 --- packages/spec/scripts/build-openapi.ts | 6 +- .../scripts/build-schemas-check-mode.test.ts | 101 +++++++++++ packages/spec/scripts/build-schemas.ts | 59 ++++--- .../spec/scripts/json-schema-out-dir.test.ts | 164 ++++++++++++++++++ .../spec/scripts/lib/json-schema-out-dir.ts | 155 +++++++++++++++++ 5 files changed, 458 insertions(+), 27 deletions(-) create mode 100644 packages/spec/scripts/json-schema-out-dir.test.ts create mode 100644 packages/spec/scripts/lib/json-schema-out-dir.ts diff --git a/packages/spec/scripts/build-openapi.ts b/packages/spec/scripts/build-openapi.ts index 0a50a2fb57..29e03fdcd7 100644 --- a/packages/spec/scripts/build-openapi.ts +++ b/packages/spec/scripts/build-openapi.ts @@ -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')); @@ -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}`); diff --git a/packages/spec/scripts/build-schemas-check-mode.test.ts b/packages/spec/scripts/build-schemas-check-mode.test.ts index 22018b7eb7..16863b5f07 100644 --- a/packages/spec/scripts/build-schemas-check-mode.test.ts +++ b/packages/spec/scripts/build-schemas-check-mode.test.ts @@ -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 +// `/.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. // diff --git a/packages/spec/scripts/build-schemas.ts b/packages/spec/scripts/build-schemas.ts index b829671dab..db642b411f 100644 --- a/packages/spec/scripts/build-schemas.ts +++ b/packages/spec/scripts/build-schemas.ts @@ -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, @@ -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 diff --git a/packages/spec/scripts/json-schema-out-dir.test.ts b/packages/spec/scripts/json-schema-out-dir.test.ts new file mode 100644 index 0000000000..bd5923da07 --- /dev/null +++ b/packages/spec/scripts/json-schema-out-dir.test.ts @@ -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; + } + }); +}); diff --git a/packages/spec/scripts/lib/json-schema-out-dir.ts b/packages/spec/scripts/lib/json-schema-out-dir.ts new file mode 100644 index 0000000000..274e6402bf --- /dev/null +++ b/packages/spec/scripts/lib/json-schema-out-dir.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Ownership of `packages/spec/json-schema/` — the one output directory two + * 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 + * 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. + * + * Until #5371 `build-schemas.ts` opened by removing the directory ITSELF + * (`fs.rmSync(OUT_DIR, { recursive: true })`), which took `openapi.json` with + * it. `pnpm build` hides the damage because it runs `gen:schema && gen:openapi` + * in that order, so the artifact is always rewritten last — but every OTHER + * entry point stops halfway. `check:authorable-surface` runs + * `build-schemas.ts --check` in place; `check:generated` runs that gate; + * `check:docs` used to open with `gen:schema`. The result was an agent's + * verification loop that starts with a green `@objectstack/rest` suite, runs a + * command whose name says `check:`, and finds 5–12 tests red with `expected 503 + * to be 200` — because `loadOpenApiSpec()`, in a package three directories + * away, can no longer find a file a *check* deleted. Reported four separate + * times (#5371 itself, then in the middle of #5126, #5588 and #5672), each one + * costing a full attribution lap against a diff that had nothing to do with it. + * + * So the rule is ownership: a generator clears its own outputs and leaves + * whatever a SIBLING generator declares here alone. + * + * ## Why the exclusion is a deny-list, not an allow-list + * + * "Clear only the paths I emit" reads like the more literal spelling of + * ownership, and it is the one shape that quietly breaks what the clean is + * FOR. The category folders are derived from `build-schemas.ts`'s own + * `Protocol` map, so the day a namespace leaves that map its folder also leaves + * the allow-list — and the stale `json-schema//` tree then survives every + * future build and ships in the published package (`json-schema` is in spec's + * `files` whitelist) indefinitely. A deny-list keeps the sweep total, which is + * what "no stale files remain" has always meant here, and admits exactly the + * artifacts another generator has DECLARED below — nothing implicit, nothing + * inherited from a name pattern. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * The single file `gen:openapi` writes into `json-schema/`. + * + * `build-openapi.ts` imports this name rather than spelling it again, so the + * registry below cannot drift from the generator it protects: renaming the + * artifact moves the declaration with it, instead of leaving a deny-list entry + * that names a file nobody writes any more. + */ +export const OPENAPI_ARTIFACT_NAME = 'openapi.json'; + +/** + * Artifacts living under `json-schema/` that belong to a generator OTHER than + * `build-schemas.ts`, mapped to the command that owns each one. + * + * Matched by exact top-level entry name — a nested path is part of whatever + * top-level entry contains it, and no entry here may be a directory another + * generator merely writes *into* (that would hand it the whole subtree's + * staleness). Adding an entry is a deliberate act: it exempts real bytes from + * the clean, so the next reader must be able to see, from this line alone, + * which command puts them back. + */ +export const FOREIGN_JSON_SCHEMA_ARTIFACTS: ReadonlyMap = new Map([ + [OPENAPI_ARTIFACT_NAME, 'gen:openapi (packages/spec/scripts/build-openapi.ts)'], +]); + +export interface ClearOwnedOutputsOptions { + /** Attempts per entry before giving up on it and reporting. Default 1. */ + maxAttempts?: number; + /** Back-off base in ms, multiplied by the attempt number. Default 100. */ + retryDelayBaseMs?: number; + /** + * Blocking sleep between attempts. Defaults to a no-op so unit tests cost no + * wall-clock time; `build-schemas.ts` passes its own `sleepSync`. + */ + sleep?: (ms: number) => void; + /** Reporter for an entry that survived every attempt. Default: silent — the caller decides how loud a stuck file is. */ + onUnremovable?: (entry: string, error: unknown) => void; +} + +export interface ClearOwnedOutputsResult { + /** Top-level entries this generator removed, in directory order. */ + removed: string[]; + /** Top-level entries left in place because `FOREIGN_JSON_SCHEMA_ARTIFACTS` declares another owner. */ + preserved: string[]; + /** Top-level entries that survived every attempt — a real failure, not an exemption. */ + failed: string[]; +} + +/** + * Remove everything under `outDir` except the artifacts + * `FOREIGN_JSON_SCHEMA_ARTIFACTS` declares, leaving `outDir` itself in place. + * + * The retry shape is the one `build-schemas.ts` has carried since its CI + * filesystem races: `rmSync` with its own `maxRetries`, then an outer + * attempt loop with linear back-off, and a report rather than a throw when an + * entry is still there — a build that cannot delete one stale file is better + * off regenerating over it than refusing to run at all. + */ +export function clearOwnedOutputs( + outDir: string, + options: ClearOwnedOutputsOptions = {}, +): ClearOwnedOutputsResult { + const { + maxAttempts = 1, + retryDelayBaseMs = 100, + sleep = () => {}, + onUnremovable, + } = options; + + const result: ClearOwnedOutputsResult = { removed: [], preserved: [], failed: [] }; + if (!fs.existsSync(outDir)) return result; + + for (const entry of fs.readdirSync(outDir)) { + if (FOREIGN_JSON_SCHEMA_ARTIFACTS.has(entry)) { + result.preserved.push(entry); + continue; + } + + const target = path.join(outDir, entry); + let lastError: unknown; + for (let attempt = 0; attempt < Math.max(1, maxAttempts); attempt++) { + try { + fs.rmSync(target, { + recursive: true, + force: true, + maxRetries: 5, + retryDelay: retryDelayBaseMs * 2, + }); + if (!fs.existsSync(target)) break; + } catch (error) { + lastError = error; + } + // Still there (or it threw). Back off before the next attempt; the last + // iteration falls straight through to the verdict below. + if (attempt < Math.max(1, maxAttempts) - 1) sleep(retryDelayBaseMs * (attempt + 1)); + } + + if (fs.existsSync(target)) { + result.failed.push(entry); + onUnremovable?.(entry, lastError); + } else { + result.removed.push(entry); + } + } + + return result; +}