fix: harden CI evidence and removal contracts
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
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`),
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user