fix: harden CI evidence and removal contracts

This commit is contained in:
DongHyeonka
2026-08-02 14:48:04 +09:00
parent 1bb2cc4a20
commit f49d147b01
20 changed files with 1175 additions and 767 deletions
+18 -279
View File
@@ -1,20 +1,23 @@
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";
import {
assertNoRuntimeImports,
prepareRemovalFixture,
pruneRemovalFixtureCiContract,
regenerateRemovalFixtureWorkflow,
removeRuntimeDependentTests,
requireRemovalFixtureEnvironment,
runRemovalFixturePnpm,
} from "./lib/removal-fixture.ts";
const fixtureRoot = path.resolve(".tmp/realtime-runtime-removal");
const pnpmCli = requireEnvironment("npm_execpath");
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
const runtimePaths = [
"src/application/ports/realtime",
"src/application/ports/out/web-push-control.ts",
@@ -38,212 +41,14 @@ const removedEvidencePathFragments = [
"realtime-boundaries",
"realtime-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
);
return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script);
}
async function sourceFiles(directory: string): Promise<string[]> {
const entries = await readdir(directory, { withFileTypes: true });
return (
await Promise.all(
entries.map(async (entry): Promise<string[]> => {
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>,
): 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<Readonly<{
dependentTests: readonly string[];
importingFiles: readonly string[];
}>> {
const files = await sourceFiles(root);
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");
imports.set(
file,
staticImportSpecifiers(source)
.map((specifier) => resolvedImport(file, specifier, sourceSet))
.filter((target): target is string => target !== null),
);
}
const memo = new Map<string, boolean>();
const reachesRuntime = (
file: string,
visiting = new Set<string>(),
): 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");
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),
),
),
),
),
});
}
async function removeRuntimeDependentTests(root: string): Promise<number> {
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;
}
async function assertNoRuntimeImports(root: string): Promise<void> {
const graph = await runtimeImportGraph(root);
if (graph.importingFiles.length > 0) {
throw new Error(
`Removed realtime runtime is still imported by: ${graph.importingFiles
.map((file) => path.relative(root, file))
.join(", ")}`,
);
}
}
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",
);
await prepareRemovalFixture(fixtureRoot);
const removedRuntimeTests =
await removeRuntimeDependentTests(fixtureRoot);
await removeRuntimeDependentTests(fixtureRoot, runtimeSourceRoots);
for (const runtimePath of runtimePaths) {
await rm(path.join(fixtureRoot, runtimePath), {
recursive: true,
@@ -280,17 +85,6 @@ if (!realtimeRecipe || !Object.hasOwn(realtimeRecipe, "referenceRuntime")) {
delete realtimeRecipe.referenceRuntime;
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<string, string>;
};
for (const script of runtimeScripts) {
delete packageDocument.scripts[script];
}
await writeFile(
packagePath,
`${JSON.stringify(packageDocument, null, 2)}\n`,
);
for (const scriptPath of [
"scripts/check-realtime-boundaries.ts",
"scripts/check-realtime-boundary-fixtures.ts",
@@ -313,68 +107,13 @@ await rm(
{ force: true },
);
const gatesPath = path.join(fixtureRoot, "config/ci/gates.json");
const gatesDocument = structuredClone(
parseCiGateContract(JSON.parse(await readFile(gatesPath, "utf8"))),
);
const removedScripts = new Set<string>(runtimeScripts);
const removedCommandIds = new Set(
gatesDocument.commands
.filter(({ script }) => removedScripts.has(script))
.map(({ id }) => id),
);
if (removedCommandIds.size !== runtimeScripts.length) {
throw new Error("Realtime 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(`Realtime 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({
await pruneRemovalFixtureCiContract({
root: fixtureRoot,
contract: validatedGates,
check: false,
removedScripts: new Set<string>(runtimeScripts),
removedEvidencePathFragments,
});
await assertNoRuntimeImports(fixtureRoot);
await regenerateRemovalFixtureWorkflow(fixtureRoot);
await assertNoRuntimeImports(fixtureRoot, runtimeSourceRoots, "realtime");
const checks: Array<readonly [string, boolean]> = [
["typecheck", runPnpm("check:types")],