234 lines
9.3 KiB
TypeScript
234 lines
9.3 KiB
TypeScript
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<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 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<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));
|
|
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);
|
|
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);
|
|
await Promise.all([
|
|
writeFile(packagePath, `${JSON.stringify(packageDocument, null, 2)}\n`),
|
|
writeFile(gatesPath, `${JSON.stringify(validated, null, 2)}\n`),
|
|
]);
|
|
}
|