Files
clean-architecture-frontend…/scripts/lib/removal-fixture.ts
T
DongHyeonkaandClaude Opus 5 250531aa43 fix: stop test fixtures from deleting the repository's dependencies
Four fixtures linked the installed dependencies into a throwaway root with a
single directory symlink at <fixture>/node_modules, then ran pnpm inside that
root. pnpm does not recognise the modules directory it finds there and purges
it; with CI=true it does so without a prompt. The purge followed the symlink and
deleted the repository's own node_modules mid-run, so a test suite uninstalled
the workspace it was running in. That is what produced the cascading,
file-unrelated failures a full test:unit run reported, and it happened twice
while running the suites for the adapter re-review.

scripts/lib/fixture-node-modules.ts replaces all four sites: node_modules is a
real directory whose entries are individual symlinks, so a recursive delete
unlinks the fixture's own links instead of walking through one link into the
shared tree. Resolution is unchanged.

tests/unit/fixture-node-modules.test.ts performs the exact recursive delete pnpm
performs and asserts the source tree survives, and check:adapter-inventory now
fails on any reintroduction of the directory-symlink form — verified by putting
the old line back and watching the gate reject it.

A full tests/unit + tests/integration run now leaves the dependencies intact.
removal-fixture, supply-chain and security-followup-archive, the three suites
that had to be excluded before, pass in that run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-14 16:08:32 +09:00

240 lines
9.6 KiB
TypeScript

import { spawnSync } from "node:child_process";
import {
cp,
mkdir,
readFile,
readdir,
rm,
writeFile,
} from "node:fs/promises";
import path from "node:path";
import {
loadCiGateContract,
parseCiGateContract,
} from "../contracts/ci-gates.ts";
import { linkFixtureNodeModules } from "./fixture-node-modules.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<void> {
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 linkFixtureNodeModules(root);
}
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<string[]> {
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<Readonly<{ dependentTests: readonly string[]; importingFiles: readonly string[] }>> {
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<string, readonly string[]>();
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<string, boolean>();
const reachesRuntime = (file: string, visiting = new Set<string>()): 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<number> {
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;
}
export async function assertNoRuntimeImports(
root: string,
runtimeSourceRoots: readonly string[],
capability: string,
): Promise<void> {
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<string, string>,
orchestrationScript: string,
removedScripts: ReadonlySet<string>,
): 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<void> {
const contract = await loadCiGateContract(root, { mode: "removal-fixture" });
await generateCiWorkflow({ root, contract, check: false });
}
export async function pruneRemovalFixtureCiContract(options: Readonly<{
root: string;
removedScripts: ReadonlySet<string>;
removedEvidencePathFragments: readonly string[];
}>): Promise<void> {
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<string, string>;
};
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, { mode: "removal-fixture" });
await Promise.all([
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
]);
}