fix: harden CI evidence and removal contracts
This commit is contained in:
@@ -1,22 +1,25 @@
|
||||
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/browser-file-storage-runtime-removal",
|
||||
);
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
|
||||
const runtimePaths = [
|
||||
"src/application/ports/browser-file-storage",
|
||||
"src/application/ports/browser-transfer",
|
||||
@@ -42,216 +45,14 @@ 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
|
||||
);
|
||||
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");
|
||||
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<void> {
|
||||
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<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;
|
||||
}
|
||||
|
||||
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,
|
||||
@@ -290,17 +91,6 @@ if (removedRuntimeEntries !== 3) {
|
||||
}
|
||||
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 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,
|
||||
});
|
||||
@@ -330,67 +120,17 @@ await rm(
|
||||
{ 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({
|
||||
await pruneRemovalFixtureCiContract({
|
||||
root: fixtureRoot,
|
||||
contract: validatedGates,
|
||||
check: false,
|
||||
removedScripts,
|
||||
removedEvidencePathFragments,
|
||||
});
|
||||
await assertNoRuntimeImports(fixtureRoot);
|
||||
await regenerateRemovalFixtureWorkflow(fixtureRoot);
|
||||
await assertNoRuntimeImports(
|
||||
fixtureRoot,
|
||||
runtimeSourceRoots,
|
||||
"browser file/storage",
|
||||
);
|
||||
|
||||
const checks: Array<readonly [string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
|
||||
Reference in New Issue
Block a user