diff --git a/scripts/lib/removal-fixture.ts b/scripts/lib/removal-fixture.ts index 3d9bf58..87ee028 100644 --- a/scripts/lib/removal-fixture.ts +++ b/scripts/lib/removal-fixture.ts @@ -78,6 +78,9 @@ export async function runtimeImportGraph( const files = (await filesBelow(root)) .filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file)) .map((file) => path.resolve(file)); + if (files.length === 0) { + throw new Error("removal fixture scanned module universe is empty"); + } const sourceSet = new Set(files); const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry)); const imports = new Map(); @@ -124,6 +127,9 @@ export async function removeRuntimeDependentTests( runtimeSourceRoots: readonly string[], ): Promise { const graph = await runtimeImportGraph(root, runtimeSourceRoots); + if (graph.dependentTests.length === 0) { + throw new Error("removal fixture: no runtime-dependent tests discovered"); + } await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true }))); return graph.dependentTests.length; } diff --git a/tests/unit/ci-workflow-generation.test.ts b/tests/unit/ci-workflow-generation.test.ts index 5b65a2c..e0f9e48 100644 --- a/tests/unit/ci-workflow-generation.test.ts +++ b/tests/unit/ci-workflow-generation.test.ts @@ -195,7 +195,26 @@ describe("CI gate contract", () => { await writeFile(path.join(root, "package.json"), await readFile("package.json")); await expect(loadCiGateContract(root)).rejects.toThrow( - /canonical gate semantic shape|lacks a bound producer command/i, + /lacks a bound producer command/i, + ); + }); + + it("rejects canonical gate name drift through the semantic-shape digest", async () => { + const root = await mkdtemp(path.join(tmpdir(), "ci-contract-gate-name-shape-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "config/ci"), { recursive: true }); + const contract = JSON.parse( + JSON.stringify(await loadCiGateContract(process.cwd())), + ) as Record; + const gate = contract.gates.find( + (candidate: Record) => candidate.id === "FE-GATE-013", + ); + gate.name = `${gate.name}-renamed`; + await writeFile(path.join(root, "config/ci/gates.json"), `${JSON.stringify(contract)}\n`); + await writeFile(path.join(root, "package.json"), await readFile("package.json")); + + await expect(loadCiGateContract(root)).rejects.toThrow( + /canonical gate semantic shape/i, ); }); diff --git a/tests/unit/removal-fixture.test.ts b/tests/unit/removal-fixture.test.ts index b88155b..9e303be 100644 --- a/tests/unit/removal-fixture.test.ts +++ b/tests/unit/removal-fixture.test.ts @@ -6,7 +6,10 @@ import { afterEach, expect, it } from "vitest"; import { loadCiGateContract } from "../../scripts/contracts/ci-gates.ts"; import { + assertNoRuntimeImports, pruneRemovalFixtureCiContract, + removeRuntimeDependentTests, + runtimeImportGraph, } from "../../scripts/lib/removal-fixture.ts"; const temporaryRoots: string[] = []; @@ -17,6 +20,39 @@ afterEach(async () => { ); }); +it("rejects an empty scanned module universe", async () => { + const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-empty-modules-")); + temporaryRoots.push(root); + + await expect(runtimeImportGraph(root, ["src/runtime"])).rejects.toThrow( + /scanned module universe is empty/i, + ); +}); + +it("rejects a runtime removal scan that discovers no dependent tests", async () => { + const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-zero-dependent-tests-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "src/runtime"), { recursive: true }); + await mkdir(path.join(root, "tests"), { recursive: true }); + await writeFile(path.join(root, "src/runtime/index.ts"), "export const runtime = true;\n"); + await writeFile(path.join(root, "tests/unrelated.test.ts"), "export const unrelated = true;\n"); + + await expect(removeRuntimeDependentTests(root, ["src/runtime"])).rejects.toThrow( + /no runtime-dependent tests discovered/i, + ); +}); + +it("allows zero runtime imports after the runtime has been removed", async () => { + const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-post-removal-")); + temporaryRoots.push(root); + await mkdir(path.join(root, "tests"), { recursive: true }); + await writeFile(path.join(root, "tests/unrelated.test.ts"), "export const unrelated = true;\n"); + + await expect( + assertNoRuntimeImports(root, ["src/runtime"], "fixture runtime"), + ).resolves.toBeUndefined(); +}); + it("prunes removed command and evidence references from a reduced CI contract", async () => { const root = await mkdtemp(path.join(tmpdir(), "removal-fixture-contract-")); temporaryRoots.push(root);