fix: harden CI evidence and removal contracts
This commit is contained in:
@@ -17,7 +17,10 @@ import {
|
||||
import { validatePackageScriptGraph } from "./lib/package-script-graph.ts";
|
||||
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
|
||||
|
||||
const contract = await loadCiGateContract(process.cwd());
|
||||
const removalFixtureMode = process.argv.includes("--reduced-removal-fixture");
|
||||
const contract = await loadCiGateContract(process.cwd(), {
|
||||
mode: removalFixtureMode ? "removal-fixture" : "canonical",
|
||||
});
|
||||
const index = indexCiGateContract(contract);
|
||||
const [packageDocument, nodeVersion] = await Promise.all([
|
||||
readFile("package.json", "utf8").then((value) => JSON.parse(value) as { scripts?: Record<string, string> }),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { createHash } from "node:crypto";
|
||||
import path from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
@@ -246,9 +247,18 @@ const artifactSchemaSchema = z.discriminatedUnion("kind", [
|
||||
.strict(),
|
||||
]);
|
||||
|
||||
const artifactSchema = z
|
||||
.object({ id, path: repositoryPath, schemaId: id })
|
||||
.strict();
|
||||
const artifactBaseShape = { id, path: repositoryPath, schemaId: id } as const;
|
||||
const artifactSchema = z.discriminatedUnion("production", [
|
||||
z.object({ ...artifactBaseShape, production: z.literal("source-controlled") }).strict(),
|
||||
z
|
||||
.object({
|
||||
...artifactBaseShape,
|
||||
production: z.literal("command-generated"),
|
||||
producerCommandIds: z.array(id).min(1).max(32),
|
||||
})
|
||||
.strict(),
|
||||
z.object({ ...artifactBaseShape, production: z.literal("runner-generated") }).strict(),
|
||||
]);
|
||||
|
||||
const gateSchema = z
|
||||
.object({
|
||||
@@ -405,6 +415,35 @@ export type CiGateContractIndex = Readonly<{
|
||||
jobs: ReadonlyMap<string, CiWorkflowJob>;
|
||||
retentionClasses: ReadonlyMap<string, CiGateContract["retention"]["classes"][number]>;
|
||||
}>;
|
||||
export type LoadCiGateContractOptions = Readonly<{
|
||||
mode?: "canonical" | "removal-fixture";
|
||||
}>;
|
||||
|
||||
const CANONICAL_GATE_SHAPE_SHA256 =
|
||||
"a4a963d0b9deffb7a0a3d755bbbcb979d72610eb74751c3a2e5eca55251e12d4";
|
||||
|
||||
function canonicalGateShapeSha256(gates: CiGateContract["gates"]): string {
|
||||
const normalized = gates.map(
|
||||
({
|
||||
id,
|
||||
name,
|
||||
commandIds,
|
||||
logArtifactId,
|
||||
evidenceArtifactIds,
|
||||
retentionClassId,
|
||||
requiresEnvironment,
|
||||
}) => ({
|
||||
id,
|
||||
name,
|
||||
commandIds,
|
||||
logArtifactId,
|
||||
evidenceArtifactIds,
|
||||
retentionClassId,
|
||||
requiresEnvironment: requiresEnvironment ?? [],
|
||||
}),
|
||||
);
|
||||
return createHash("sha256").update(JSON.stringify(normalized)).digest("hex");
|
||||
}
|
||||
|
||||
export function parseCiGateContract(value: unknown): CiGateContract {
|
||||
const result = ciGateContractSchema.safeParse(value);
|
||||
@@ -417,12 +456,22 @@ export function parseCiGateContract(value: unknown): CiGateContract {
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function loadCiGateContract(root = process.cwd()): Promise<CiGateContract> {
|
||||
export async function loadCiGateContract(
|
||||
root = process.cwd(),
|
||||
options: LoadCiGateContractOptions = {},
|
||||
): Promise<CiGateContract> {
|
||||
const [rawContract, rawPackage] = await Promise.all([
|
||||
readFile(path.join(root, "config/ci/gates.json"), "utf8"),
|
||||
readFile(path.join(root, "package.json"), "utf8"),
|
||||
]);
|
||||
const contract = parseCiGateContract(JSON.parse(rawContract));
|
||||
const mode = options.mode ?? "canonical";
|
||||
if (
|
||||
mode === "canonical" &&
|
||||
canonicalGateShapeSha256(contract.gates) !== CANONICAL_GATE_SHAPE_SHA256
|
||||
) {
|
||||
throw new TypeError("CI gate contract canonical gate semantic shape drift");
|
||||
}
|
||||
const packageDocument = z
|
||||
.object({ scripts: z.record(z.string(), z.string()).default({}) })
|
||||
.passthrough()
|
||||
@@ -434,10 +483,23 @@ export async function loadCiGateContract(root = process.cwd()): Promise<CiGateCo
|
||||
if (missing.length > 0) {
|
||||
throw new TypeError(`CI gate contract missing package scripts: ${missing.join(", ")}`);
|
||||
}
|
||||
const expectedCheckCi = "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow";
|
||||
const expectedCheckCi = mode === "canonical"
|
||||
? "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts && corepack pnpm check:ci-workflow"
|
||||
: "corepack pnpm check:artifact-schemas && node scripts/check-ci-contract.ts --reduced-removal-fixture && corepack pnpm check:ci-workflow";
|
||||
if (packageDocument.scripts["check:ci"] !== expectedCheckCi) {
|
||||
throw new TypeError("check:ci must use the exact canonical non-recursive orchestration");
|
||||
}
|
||||
const canonicalCheckCiDependencies = {
|
||||
"check:artifact-schemas": "node scripts/generate-artifact-schemas.ts --check",
|
||||
"check:ci-workflow": mode === "canonical"
|
||||
? "node scripts/generate-ci-workflow.ts --check"
|
||||
: "node scripts/generate-ci-workflow.ts --check --reduced-removal-fixture",
|
||||
} as const;
|
||||
for (const [script, expected] of Object.entries(canonicalCheckCiDependencies)) {
|
||||
if (packageDocument.scripts[script] !== expected) {
|
||||
throw new TypeError(`canonical check:ci dependency drift: ${script}`);
|
||||
}
|
||||
}
|
||||
const graphFailures = validatePackageScriptGraph(packageDocument.scripts, "check:ci");
|
||||
if (graphFailures.length > 0) {
|
||||
throw new TypeError(`CI package script graph invalid:\n${graphFailures.join("\n")}`);
|
||||
@@ -510,6 +572,16 @@ function validateContractSemantics(
|
||||
if (!schemaIds.has(artifact.schemaId)) {
|
||||
issue(`unknown artifact schema ${artifact.schemaId} for ${artifact.id}`);
|
||||
}
|
||||
if (artifact.production === "command-generated") {
|
||||
if (new Set(artifact.producerCommandIds).size !== artifact.producerCommandIds.length) {
|
||||
issue(`duplicate producer command reference for artifact: ${artifact.id}`);
|
||||
}
|
||||
for (const producerCommandId of artifact.producerCommandIds) {
|
||||
if (!commandIds.has(producerCommandId)) {
|
||||
issue(`unknown producer command ${producerCommandId} for ${artifact.id}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const gate of contract.gates) {
|
||||
if (new Set(gate.commandIds).size !== gate.commandIds.length) {
|
||||
@@ -524,6 +596,21 @@ function validateContractSemantics(
|
||||
for (const artifactId of [gate.logArtifactId, ...gate.evidenceArtifactIds]) {
|
||||
if (!artifactIds.has(artifactId)) issue(`unknown artifact ${artifactId} for ${gate.id}`);
|
||||
}
|
||||
const logArtifact = contract.artifacts.find(({ id }) => id === gate.logArtifactId);
|
||||
if (logArtifact && logArtifact.production !== "runner-generated") {
|
||||
issue(`gate log must be runner-generated: ${gate.id}`);
|
||||
}
|
||||
for (const artifactId of gate.evidenceArtifactIds) {
|
||||
const artifact = contract.artifacts.find(({ id }) => id === artifactId);
|
||||
if (
|
||||
artifact?.production === "command-generated" &&
|
||||
!artifact.producerCommandIds.some((producerCommandId) =>
|
||||
gate.commandIds.includes(producerCommandId)
|
||||
)
|
||||
) {
|
||||
issue(`gate lacks a bound producer command for ${artifact.id}: ${gate.id}`);
|
||||
}
|
||||
}
|
||||
if (!retentionIds.has(gate.retentionClassId)) {
|
||||
issue(`unknown retention class ${gate.retentionClassId} for ${gate.id}`);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export type GenerateCiWorkflowOptions = Readonly<{
|
||||
root: string;
|
||||
contract?: CiGateContract;
|
||||
check: boolean;
|
||||
contractMode?: "canonical" | "removal-fixture";
|
||||
}>;
|
||||
|
||||
export type GenerateCiWorkflowResult = Readonly<{
|
||||
@@ -176,7 +177,7 @@ function renderStep(
|
||||
" - name: Frozen install",
|
||||
" run: |",
|
||||
" corepack enable",
|
||||
" corepack pnpm install --frozen-lockfile",
|
||||
" corepack pnpm install --frozen-lockfile --ignore-scripts",
|
||||
];
|
||||
case "browser-install":
|
||||
return [
|
||||
@@ -335,7 +336,9 @@ export function createCiWorkflowGenerator(
|
||||
const createNonce = dependencies.createNonce ?? randomUUID;
|
||||
return async function generate(options: GenerateCiWorkflowOptions): Promise<GenerateCiWorkflowResult> {
|
||||
const root = path.resolve(options.root);
|
||||
const contract = options.contract ?? (await loadCiGateContract(root));
|
||||
const contract = options.contract ?? (await loadCiGateContract(root, {
|
||||
mode: options.contractMode ?? "canonical",
|
||||
}));
|
||||
const target = path.resolve(root, contract.providerAdapter);
|
||||
if (path.relative(root, target).startsWith("..") || path.relative(root, target) === "") {
|
||||
throw new TypeError(`workflow target escapes repository root: ${contract.providerAdapter}`);
|
||||
@@ -449,8 +452,11 @@ function hasErrorCode(error: unknown, code: string): boolean {
|
||||
const isCli = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
if (isCli) {
|
||||
const check = process.argv.includes("--check");
|
||||
const contractMode = process.argv.includes("--reduced-removal-fixture")
|
||||
? "removal-fixture" as const
|
||||
: "canonical" as const;
|
||||
try {
|
||||
const result = await generateCiWorkflow({ root: process.cwd(), check });
|
||||
const result = await generateCiWorkflow({ root: process.cwd(), check, contractMode });
|
||||
if (!result.matches) {
|
||||
process.stderr.write(
|
||||
`CI workflow drift: ${result.target} differs at byte ${result.firstDifferenceByte ?? 0}, line ${result.firstDifferenceLine ?? 1}\n`,
|
||||
|
||||
@@ -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`),
|
||||
]);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { lstat } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import {
|
||||
ciCheckoutIdentityFailures,
|
||||
@@ -37,6 +39,28 @@ let passed = true;
|
||||
const DEFAULT_STEP_TIMEOUT_MS = 30 * 60 * 1_000;
|
||||
const MAX_STEP_OUTPUT_BYTES = 16 * 1024 * 1_024;
|
||||
const LOG_DIAGNOSTIC_RESERVE_BYTES = 4_096;
|
||||
const freshlyProducedArtifactIds = new Set<string>();
|
||||
const commandGeneratedEvidence = gate.evidenceArtifactIds
|
||||
.map((artifactId) => contractIndex.artifacts.get(artifactId))
|
||||
.filter((artifact) => artifact?.production === "command-generated");
|
||||
|
||||
async function observeArtifactGeneration(relativePath: string): Promise<string> {
|
||||
try {
|
||||
const metadata = await lstat(path.join(process.cwd(), relativePath), {
|
||||
bigint: true,
|
||||
});
|
||||
return [
|
||||
metadata.dev,
|
||||
metadata.ino,
|
||||
metadata.size,
|
||||
metadata.mtimeNs,
|
||||
metadata.ctimeNs,
|
||||
].join(":");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") return "missing";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const appendOutput = (...values: readonly string[]): boolean => {
|
||||
for (const value of values.filter(Boolean)) {
|
||||
const addedBytes = Buffer.byteLength(value, "utf8") + 1;
|
||||
@@ -97,6 +121,17 @@ if (passed) {
|
||||
for (const commandId of gate.commandIds) {
|
||||
const step = contractIndex.commands.get(commandId);
|
||||
if (!step) throw new TypeError(`CI gate command disappeared after validation: ${commandId}`);
|
||||
const producedArtifacts = commandGeneratedEvidence.filter((artifact) =>
|
||||
artifact.producerCommandIds.includes(commandId)
|
||||
);
|
||||
const generationBefore = new Map(
|
||||
await Promise.all(
|
||||
producedArtifacts.map(async (artifact) => [
|
||||
artifact.id,
|
||||
await observeArtifactGeneration(artifact.path),
|
||||
] as const),
|
||||
),
|
||||
);
|
||||
const commandLine = `$ corepack pnpm ${step.script} ${(step.args ?? []).join(" ")}`.trim();
|
||||
if (!appendOutput(commandLine) || logSchema.maxBytes - outputBytes <= LOG_DIAGNOSTIC_RESERVE_BYTES) {
|
||||
appendOutput("gate aggregate output budget exhausted before command execution");
|
||||
@@ -159,6 +194,21 @@ if (passed) {
|
||||
passed = false;
|
||||
break;
|
||||
}
|
||||
for (const artifact of producedArtifacts) {
|
||||
const generationAfter = await observeArtifactGeneration(artifact.path);
|
||||
if (generationAfter !== generationBefore.get(artifact.id)) {
|
||||
freshlyProducedArtifactIds.add(artifact.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (passed) {
|
||||
for (const artifact of commandGeneratedEvidence) {
|
||||
if (!freshlyProducedArtifactIds.has(artifact.id)) {
|
||||
appendOutput(`command-generated evidence was not freshly produced: ${artifact.path}`);
|
||||
passed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -1,17 +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 {
|
||||
filesBelow,
|
||||
prepareRemovalFixture,
|
||||
pruneRemovalFixtureCiContract,
|
||||
pruneScriptOrchestration,
|
||||
regenerateRemovalFixtureWorkflow,
|
||||
requireRemovalFixtureEnvironment,
|
||||
runRemovalFixturePnpm,
|
||||
} from "./lib/removal-fixture.ts";
|
||||
|
||||
const fixtureRoot = path.resolve(".tmp/optional-recipe-removal");
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
|
||||
const copyTargets = [
|
||||
"src",
|
||||
"tests",
|
||||
@@ -44,51 +50,55 @@ const copyTargets = [
|
||||
".nvmrc",
|
||||
];
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
throw new Error(`${name} is required to run 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 filesBelow(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(target) : [target];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
||||
for (const rootOnlyTest of [
|
||||
"tests/unit/ci-workflow-generation.test.ts",
|
||||
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
||||
]) {
|
||||
await rm(path.join(fixtureRoot, rootOnlyTest), { force: true });
|
||||
}
|
||||
|
||||
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 rm(path.join(fixtureRoot, "recipes"), { recursive: true, force: true });
|
||||
await rm(path.join(fixtureRoot, "tests/recipes"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
|
||||
const removedCiScripts = new Set([
|
||||
"check:types:recipes",
|
||||
"test:recipes",
|
||||
"check:optional-recipes",
|
||||
"check:optional-recipes:source",
|
||||
"check:optional-recipe-fixtures",
|
||||
"test:optional-recipe-removal",
|
||||
]);
|
||||
const fixturePackagePath = path.join(fixtureRoot, "package.json");
|
||||
const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
pruneScriptOrchestration(fixturePackage.scripts, "check:types", removedCiScripts);
|
||||
pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts);
|
||||
await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`);
|
||||
await pruneRemovalFixtureCiContract({
|
||||
root: fixtureRoot,
|
||||
removedScripts: removedCiScripts,
|
||||
removedEvidencePathFragments: [
|
||||
"optional-recipes",
|
||||
"optional-recipe-fixtures",
|
||||
"optional-recipe-removal",
|
||||
],
|
||||
});
|
||||
await regenerateRemovalFixtureWorkflow(fixtureRoot);
|
||||
|
||||
const checks: Array<[string, boolean]> = [
|
||||
["typecheck", runPnpm("check:types")],
|
||||
["architecture", runPnpm("check:architecture")],
|
||||
["test", runPnpm("test:all")],
|
||||
["build", runPnpm("build")],
|
||||
["ci-contract", runPnpm("check:ci")],
|
||||
];
|
||||
const residue: string[] = [];
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, "dist"))) {
|
||||
|
||||
@@ -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")],
|
||||
|
||||
@@ -1,25 +1,30 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import {
|
||||
access,
|
||||
cp,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
readdir,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
|
||||
import { isProductionModulePath } from "./lib/risk-coverage.ts";
|
||||
import {
|
||||
filesBelow,
|
||||
prepareRemovalFixture,
|
||||
pruneRemovalFixtureCiContract,
|
||||
pruneScriptOrchestration,
|
||||
regenerateRemovalFixtureWorkflow,
|
||||
requireRemovalFixtureEnvironment,
|
||||
runRemovalFixturePnpm,
|
||||
} from "./lib/removal-fixture.ts";
|
||||
|
||||
const fixtureParent = path.resolve(".tmp");
|
||||
await mkdir(fixtureParent, { recursive: true });
|
||||
const fixtureRoot = await mkdtemp(
|
||||
path.join(fixtureParent, "reference-feature-removal-"),
|
||||
);
|
||||
const pnpmCli = requireEnvironment("npm_execpath");
|
||||
const pnpmCli = requireRemovalFixtureEnvironment("npm_execpath");
|
||||
const featureSource = "src/features/reference-feature";
|
||||
const featureTests = "tests/features/reference-feature";
|
||||
const commonTestPaths = [
|
||||
@@ -162,36 +167,20 @@ type GovernanceRegistry = Record<string, unknown> & {
|
||||
};
|
||||
type RemovalGovernance = { registries: GovernanceRegistry[] };
|
||||
|
||||
function requireEnvironment(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) throw new Error(`${name} is required for sample removal`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function filesBelow(directory: string): Promise<string[]> {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
const groups = await Promise.all(
|
||||
entries.map((entry) => {
|
||||
const target = path.join(directory, entry.name);
|
||||
return entry.isDirectory() ? filesBelow(target) : [target];
|
||||
}),
|
||||
);
|
||||
return groups.flat();
|
||||
}
|
||||
|
||||
function runPnpm(script: string, extra: string[] = []): boolean {
|
||||
const result = spawnSync(process.execPath, [pnpmCli, script, ...extra], {
|
||||
cwd: fixtureRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
return result.status === 0;
|
||||
return runRemovalFixturePnpm(fixtureRoot, pnpmCli, script, extra);
|
||||
}
|
||||
|
||||
try {
|
||||
for (const target of copyTargets) {
|
||||
await cp(target, path.join(fixtureRoot, target), { recursive: true });
|
||||
await prepareRemovalFixture(fixtureRoot, copyTargets);
|
||||
for (const excludedFixtureTest of [
|
||||
"tests/unit/ci-workflow-generation.test.ts",
|
||||
"tests/unit/__snapshots__/ci-workflow-generation.test.ts.snap",
|
||||
"tests/unit/removal-fixture.test.ts",
|
||||
"tests/unit/http-scenario-evidence.test.ts",
|
||||
]) {
|
||||
await rm(path.join(fixtureRoot, excludedFixtureTest), { force: true });
|
||||
}
|
||||
await symlink(path.resolve("node_modules"), path.join(fixtureRoot, "node_modules"), "dir");
|
||||
|
||||
const coveragePolicyFile = path.join(
|
||||
fixtureRoot,
|
||||
@@ -329,6 +318,33 @@ try {
|
||||
`${JSON.stringify(removalGovernance, null, 2)}\n`,
|
||||
);
|
||||
|
||||
const removedCiScripts = new Set([
|
||||
"check:types:fixture:feature-input",
|
||||
"check:types:fixture:reference-operation",
|
||||
"test:http-scenario-evidence",
|
||||
"test:reference-feature",
|
||||
]);
|
||||
const fixturePackagePath = path.join(fixtureRoot, "package.json");
|
||||
const fixturePackage = JSON.parse(await readFile(fixturePackagePath, "utf8")) as {
|
||||
scripts: Record<string, string>;
|
||||
};
|
||||
pruneScriptOrchestration(fixturePackage.scripts, "test:all", removedCiScripts);
|
||||
fixturePackage.scripts["test:coverage"] = fixturePackage.scripts["test:coverage"]
|
||||
.replace(" tests/features/reference-feature", "");
|
||||
delete fixturePackage.scripts["check:http-scenario-evidence"];
|
||||
delete fixturePackage.scripts["check:http-scenario-evidence:fixture"];
|
||||
await writeFile(fixturePackagePath, `${JSON.stringify(fixturePackage, null, 2)}\n`);
|
||||
await pruneRemovalFixtureCiContract({
|
||||
root: fixtureRoot,
|
||||
removedScripts: removedCiScripts,
|
||||
removedEvidencePathFragments: [
|
||||
"reference-feature.xml",
|
||||
"http-scenario-executions",
|
||||
"http-scenario-evidence",
|
||||
],
|
||||
});
|
||||
await regenerateRemovalFixtureWorkflow(fixtureRoot);
|
||||
|
||||
const residue: string[] = [];
|
||||
for (const root of ["src", "tests"]) {
|
||||
for (const file of await filesBelow(path.join(fixtureRoot, root))) {
|
||||
@@ -366,6 +382,7 @@ try {
|
||||
["unit-integration", runPnpm("test:all")],
|
||||
["coverage", runPnpm("test:coverage")],
|
||||
["test-evidence-source", runPnpm("check:test-evidence:source")],
|
||||
["ci-contract", runPnpm("check:ci")],
|
||||
[
|
||||
"home-smoke",
|
||||
runPnpm("exec", [
|
||||
|
||||
Reference in New Issue
Block a user