import { spawnSync } from "node:child_process"; import { cp, mkdir, readFile, readdir, rm, symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; import { loadCiGateContract, parseCiGateContract, } from "../contracts/ci-gates.ts"; import { generateCiWorkflow } from "../generate-ci-workflow.ts"; export const REMOVAL_FIXTURE_COPY_TARGETS = Object.freeze([ "src", "tests", "recipes", "scripts", "schemas", "config", "public", ".gitea", ".storybook", "index.html", "package.json", "tsconfig.base.json", "tsconfig.json", "tsconfig.app.json", "tsconfig.node.json", "tsconfig.test.json", "tsconfig.recipes.json", "tsconfig.web-worker.json", "tsconfig.service-worker.json", "vite.service-worker.config.ts", "vite.config.ts", "vitest.config.ts", "playwright.config.ts", "playwright.capabilities.config.ts", "playwright.dev.config.ts", "playwright.storybook.config.ts", "playwright.visual.config.ts", "eslint.config.ts", ".dependency-cruiser.json", ".nvmrc", ] as const); export function requireRemovalFixtureEnvironment(name: string): string { const value = process.env[name]; if (!value) throw new Error(`${name} is required for removal verification`); return value; } export async function prepareRemovalFixture( root: string, copyTargets: readonly string[] = REMOVAL_FIXTURE_COPY_TARGETS, ): Promise { await rm(root, { recursive: true, force: true }); await mkdir(root, { recursive: true }); for (const target of copyTargets) { await cp(target, path.join(root, target), { recursive: true }); } await symlink(path.resolve("node_modules"), path.join(root, "node_modules"), "dir"); } export function runRemovalFixturePnpm( root: string, pnpmCli: string, script: string, extra: readonly string[] = [], ): boolean { return spawnSync(process.execPath, [pnpmCli, script, ...extra], { cwd: root, stdio: "inherit", env: { ...process.env, CI_CONTRACT_MODE: "removal-fixture" }, }).status === 0; } export async function filesBelow(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); return (await Promise.all(entries.map((entry) => { const target = path.join(directory, entry.name); return entry.isDirectory() ? filesBelow(target) : [target]; }))).flat(); } function isWithin(target: string, root: string): boolean { const relative = path.relative(root, target); return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)); } export async function runtimeImportGraph( root: string, runtimeSourceRoots: readonly string[], ): Promise> { const files = (await filesBelow(root)) .filter((file) => /\.(?:[cm]?ts|tsx)$/u.test(file)) .map((file) => path.resolve(file)); const sourceSet = new Set(files); const runtimeRoots = runtimeSourceRoots.map((entry) => path.resolve(root, entry)); const imports = new Map(); for (const file of files) { const source = await readFile(file, "utf8"); const specifiers = [...source.matchAll(/(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu)] .map((match) => match[1]) .filter((specifier): specifier is string => typeof specifier === "string" && specifier.startsWith(".")); imports.set(file, specifiers.map((specifier) => { const base = path.resolve(path.dirname(file), specifier); return [base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx")] .find((candidate) => sourceSet.has(candidate)) ?? base; })); } const memo = new Map(); const reachesRuntime = (file: string, visiting = new Set()): boolean => { if (runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot))) return true; const known = memo.get(file); if (known !== undefined) return known; if (visiting.has(file)) return false; visiting.add(file); const reaches = (imports.get(file) ?? []).some((dependency) => runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) || (sourceSet.has(dependency) && reachesRuntime(dependency, visiting)) ); visiting.delete(file); memo.set(file, reaches); return reaches; }; const testsRoot = path.resolve(root, "tests"); return Object.freeze({ dependentTests: Object.freeze(files.filter((file) => isWithin(file, testsRoot) && reachesRuntime(file))), importingFiles: Object.freeze(files.filter((file) => !runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot)) && (imports.get(file) ?? []).some((dependency) => runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot)) ) )), }); } export async function removeRuntimeDependentTests( root: string, runtimeSourceRoots: readonly string[], ): Promise { const graph = await runtimeImportGraph(root, runtimeSourceRoots); await Promise.all(graph.dependentTests.map((file) => rm(file, { force: true }))); return graph.dependentTests.length; } export async function assertNoRuntimeImports( root: string, runtimeSourceRoots: readonly string[], capability: string, ): Promise { const graph = await runtimeImportGraph(root, runtimeSourceRoots); if (graph.importingFiles.length > 0) { throw new Error(`Removed ${capability} runtime is still imported by: ${graph.importingFiles.map((file) => path.relative(root, file)).join(", ")}`); } } export function pruneScriptOrchestration( scripts: Record, orchestrationScript: string, removedScripts: ReadonlySet, ): void { const command = scripts[orchestrationScript]; if (!command) return; scripts[orchestrationScript] = command.split(" && ").filter((segment) => ![...removedScripts].some((removed) => new RegExp(`(?:^|\\s)(?:corepack\\s+)?pnpm\\s+${removed.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&")}(?:\\s|$)`, "u").test(segment) ) ).join(" && "); } export async function regenerateRemovalFixtureWorkflow(root: string): Promise { const contract = await loadCiGateContract(root, { mode: "removal-fixture" }); await generateCiWorkflow({ root, contract, check: false }); } export async function pruneRemovalFixtureCiContract(options: Readonly<{ root: string; removedScripts: ReadonlySet; removedEvidencePathFragments: readonly string[]; }>): Promise { const packagePath = path.join(options.root, "package.json"); const gatesPath = path.join(options.root, "config/ci/gates.json"); const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { scripts: Record; }; for (const script of options.removedScripts) delete packageDocument.scripts[script]; packageDocument.scripts["check:ci-workflow"] = "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture"; packageDocument.scripts["check:ci"] = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow"; const contract = structuredClone( parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), ); const removedCommandIds = new Set( contract.commands .filter(({ script }) => options.removedScripts.has(script)) .map(({ id }) => id), ); const missing = [...options.removedScripts].filter( (script) => !contract.commands.some((command) => command.script === script), ); if (missing.length > 0) { throw new Error(`removal fixture CI command set is incomplete: ${missing.join(", ")}`); } const removedArtifactIds = new Set( contract.artifacts .filter(({ path: artifactPath }) => options.removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment)) ) .map(({ id }) => id), ); for (const fragment of options.removedEvidencePathFragments) { if (!contract.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment))) { throw new Error(`removal fixture CI evidence is missing: ${fragment}`); } } contract.commands = contract.commands.filter(({ id }) => !removedCommandIds.has(id)); contract.artifacts = contract.artifacts .filter(({ id }) => !removedArtifactIds.has(id)) .map((artifact) => artifact.production === "command-generated" ? { ...artifact, producerCommandIds: artifact.producerCommandIds.filter( (commandId) => !removedCommandIds.has(commandId), ), } : artifact) .filter((artifact) => artifact.production !== "command-generated" || artifact.producerCommandIds.length > 0 ); const retainedArtifactIds = new Set(contract.artifacts.map(({ id }) => id)); for (const gate of contract.gates) { gate.commandIds = gate.commandIds.filter((commandId) => !removedCommandIds.has(commandId)); gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter((artifactId) => retainedArtifactIds.has(artifactId) ); } const referencedSchemaIds = new Set(contract.artifacts.map(({ schemaId }) => schemaId)); contract.artifactSchemas = contract.artifactSchemas.filter(({ id }) => referencedSchemaIds.has(id) ); const validated = parseCiGateContract(contract); await Promise.all([ writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`), writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`), ]); }