import { spawnSync } from "node:child_process"; import { cp, mkdir, readFile, readdir, rm, symlink, writeFile, } from "node:fs/promises"; import path from "node:path"; import { parseCiGateContract } from "./contracts/ci-gates.ts"; import { generateCiWorkflow } from "./generate-ci-workflow.ts"; const fixtureRoot = path.resolve( ".tmp/browser-file-storage-runtime-removal", ); const pnpmCli = requireEnvironment("npm_execpath"); const runtimePaths = [ "src/application/ports/browser-file-storage", "src/application/ports/browser-transfer", "src/adapters/browser-file-storage", "src/adapters/browser-files", "src/adapters/browser-transfer", "src/adapters/cache-storage", "src/adapters/storage/indexeddb", "src/adapters/storage/opfs", "tests/browser-capabilities", "tests/fixtures/browser-file-storage-boundaries", ] as const; const runtimeSourceRoots = runtimePaths.filter((entry) => entry.startsWith("src/"), ); const removedScripts = new Set([ "test:browser-capabilities", "verify:browser-capability-evidence", "check:browser-file-storage-boundaries", "test:browser-file-storage-removal", ]); const removedEvidencePathFragments = [ "browser-capabilities", "browser-file-storage-runtime-removal", ] as const; const copyTargets = [ "src", "tests", "recipes", "scripts", "config", "schemas", "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; function requireEnvironment(name: string): string { const value = process.env[name]; if (!value) { throw new Error(`${name} is required for runtime removal verification`); } return value; } function runPnpm(script: string): boolean { return ( spawnSync(process.execPath, [pnpmCli, script], { cwd: fixtureRoot, stdio: "inherit", }).status === 0 ); } async function sourceFiles(directory: string): Promise { const entries = await readdir(directory, { withFileTypes: true }); return ( await Promise.all( entries.map(async (entry): Promise => { const target = path.join(directory, entry.name); if (entry.isDirectory()) { return await sourceFiles(target); } return /\.(?:[cm]?ts|tsx)$/u.test(entry.name) ? [path.resolve(target)] : []; }), ) ).flat(); } function staticImportSpecifiers(source: string): string[] { return [ ...source.matchAll( /(?:from\s*|import\s*\(\s*|import\s*)["']([^"']+)["']/gu, ), ] .map((match) => match[1]) .filter((specifier): specifier is string => typeof specifier === "string", ); } function isWithin(target: string, root: string): boolean { const relative = path.relative(root, target); return ( relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative)) ); } function resolvedImport( importer: string, specifier: string, sourceSet: ReadonlySet, ): string | null { if (!specifier.startsWith(".")) return null; const base = path.resolve(path.dirname(importer), specifier); const candidates = [ base, `${base}.ts`, `${base}.tsx`, `${base}.mts`, `${base}.cts`, path.join(base, "index.ts"), path.join(base, "index.tsx"), ]; return candidates.find((candidate) => sourceSet.has(candidate)) ?? base; } async function runtimeImportGraph(root: string): Promise> { const files = await sourceFiles(root); 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"); imports.set( file, staticImportSpecifiers(source) .map((specifier) => resolvedImport(file, specifier, sourceSet), ) .filter((target): target is string => target !== null), ); } const memo = new Map(); const reachesRuntime = ( file: string, visiting = new Set(), ): boolean => { if (runtimeRoots.some((root) => isWithin(file, root))) 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"); const dependentTests = files.filter( (file) => isWithin(file, testsRoot) && reachesRuntime(file), ); const importingFiles = files.filter( (file) => !runtimeRoots.some((runtimeRoot) => isWithin(file, runtimeRoot), ) && (imports.get(file) ?? []).some((dependency) => runtimeRoots.some((runtimeRoot) => isWithin(dependency, runtimeRoot), ), ), ); return Object.freeze({ dependentTests: Object.freeze(dependentTests), importingFiles: Object.freeze(importingFiles), }); } async function assertNoRuntimeImports(root: string): Promise { const graph = await runtimeImportGraph(root); if (graph.importingFiles.length > 0) { throw new Error( `Removed browser file/storage runtime is still imported by: ${graph.importingFiles .map((file) => path.relative(root, file)) .join(", ")}`, ); } } async function removeRuntimeDependentTests( root: string, ): Promise { const graph = await runtimeImportGraph(root); await Promise.all( graph.dependentTests.map(async (file) => { if (isWithin(file, path.resolve(root, "tests"))) { await rm(file, { force: true }); } }), ); return graph.dependentTests.length; } await rm(fixtureRoot, { recursive: true, force: true }); await mkdir(fixtureRoot, { recursive: true }); for (const target of copyTargets) { await cp(target, path.join(fixtureRoot, target), { recursive: true }); } await symlink( path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir", ); const removedRuntimeTests = await removeRuntimeDependentTests(fixtureRoot); for (const runtimePath of runtimePaths) { await rm(path.join(fixtureRoot, runtimePath), { recursive: true, force: true, }); } const catalogPath = path.join( fixtureRoot, "config/recipes/frontend-capability-recipes.json", ); const catalog = JSON.parse(await readFile(catalogPath, "utf8")) as { recipes: Array>; }; const browserFileRuntimeRecipeIds = new Set([ "offline-indexeddb", "service-worker-pwa", "file-transfer", ]); let removedRuntimeEntries = 0; for (const recipe of catalog.recipes) { if ( typeof recipe.id !== "string" || !browserFileRuntimeRecipeIds.has(recipe.id) || !Object.hasOwn(recipe, "referenceRuntime") ) { continue; } delete recipe.referenceRuntime; removedRuntimeEntries += 1; } if (removedRuntimeEntries !== 3) { throw new Error( `Expected three reference runtime catalog entries, removed ${removedRuntimeEntries}`, ); } await writeFile(catalogPath, `${JSON.stringify(catalog, null, 2)}\n`); const packagePath = path.join(fixtureRoot, "package.json"); const packageDocument = JSON.parse(await readFile(packagePath, "utf8")) as { scripts: Record; }; for (const script of removedScripts) { delete packageDocument.scripts[script]; } await writeFile( packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`, ); await rm(path.join(fixtureRoot, "playwright.capabilities.config.ts"), { force: true, }); await rm( path.join(fixtureRoot, "scripts/check-browser-file-storage-boundaries.ts"), { force: true }, ); await rm( path.join(fixtureRoot, "scripts/verify-browser-capability-evidence.ts"), { force: true }, ); await rm( path.join(fixtureRoot, "scripts/test-browser-file-storage-runtime-removal.ts"), { force: true }, ); // The root snapshot locks the full repository inventory. This removal fixture // validates its smaller registry through check:ci and its regenerated workflow. await rm( path.join(fixtureRoot, "tests/unit/ci-workflow-generation.test.ts"), { force: true }, ); await rm( path.join( fixtureRoot, "tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap", ), { force: true }, ); const gatesPath = path.join(fixtureRoot, "config/ci/gates.json"); const gatesDocument = structuredClone( parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))), ); const removedCommandIds = new Set( gatesDocument.commands .filter(({ script }) => removedScripts.has(script)) .map(({ id }) => id), ); if (removedCommandIds.size !== removedScripts.size) { throw new Error("Browser file/storage CI command removal set is incomplete"); } const removedArtifactIds = new Set( gatesDocument.artifacts .filter(({ path: artifactPath }) => removedEvidencePathFragments.some((fragment) => artifactPath.includes(fragment), ), ) .map(({ id }) => id), ); for (const fragment of removedEvidencePathFragments) { if ( !gatesDocument.artifacts.some(({ path: artifactPath }) => artifactPath.includes(fragment), ) ) { throw new Error(`Browser file/storage CI evidence is missing: ${fragment}`); } } gatesDocument.commands = gatesDocument.commands.filter( ({ id }) => !removedCommandIds.has(id), ); gatesDocument.artifacts = gatesDocument.artifacts.filter( ({ id }) => !removedArtifactIds.has(id), ); for (const gate of gatesDocument.gates) { gate.commandIds = gate.commandIds.filter( (commandId) => !removedCommandIds.has(commandId), ); gate.evidenceArtifactIds = gate.evidenceArtifactIds.filter( (artifactId) => !removedArtifactIds.has(artifactId), ); } const referencedSchemaIds = new Set( gatesDocument.artifacts.map(({ schemaId }) => schemaId), ); gatesDocument.artifactSchemas = gatesDocument.artifactSchemas.filter( ({ id }) => referencedSchemaIds.has(id), ); const validatedGates = parseCiGateContract(gatesDocument); await writeFile( gatesPath, `${JSON.stringify(validatedGates, null, 2)}\n`, ); await generateCiWorkflow({ root: fixtureRoot, contract: validatedGates, check: false, }); await assertNoRuntimeImports(fixtureRoot); const checks: Array = [ ["typecheck", runPnpm("check:types")], ["lint", runPnpm("lint")], ["architecture", runPnpm("check:architecture")], ["test", runPnpm("test:all")], ["build", runPnpm("build")], ["optional-catalog", runPnpm("check:optional-recipes:source")], ["ci-contract", runPnpm("check:ci")], ]; const passed = checks.every(([, result]) => result); await mkdir("artifacts/tests", { recursive: true }); await writeFile( "artifacts/tests/browser-file-storage-runtime-removal.xml", `\n` + `` + checks .map( ([name, result]) => `${result ? "" : ""}`, ) .join("") + `\n`, ); await rm(fixtureRoot, { recursive: true, force: true }); if (!passed) { process.stderr.write( `Browser file/storage runtime removal failed: ${checks .filter(([, result]) => !result) .map(([name]) => name) .join(", ")}\n`, ); process.exit(1); } process.stdout.write( `Browser file/storage runtime removal: PASS (${checks.length} base checks, ${removedRuntimeTests} runtime-dependent tests removed by import graph)\n`, );