Files
tech-log-frontend/scripts/check-optional-recipes.ts
T

272 lines
8.5 KiB
TypeScript

import { mkdir, readFile, stat } from "node:fs/promises";
import { optionalRecipesArtifactSchema } from "./contracts/release-artifacts.ts";
import {
measureOptionalRecipeBundle,
type OptionalRecipeBundleMeasurement,
} from "./lib/optional-recipe-bundle.ts";
import {
scanOptionalRecipeSources,
scanProductionBundle,
validateRecipeCatalog,
} from "./lib/optional-recipes.ts";
import { assertMatchesJsonSchema } from "./lib/json-schema.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
type GateViolation = Readonly<{
ruleId: string;
path: string;
detail?: string;
}>;
type ReferenceRuntimeBundleInput = Readonly<{
recipeId: string;
sourceRoots: readonly string[];
bundleBudgetGzipBytes: number;
}>;
const argument = (name: string, fallback: string): string => {
const index = process.argv.indexOf(name);
return index === -1 ? fallback : (process.argv[index + 1] ?? fallback);
};
const catalogPath = argument(
"--catalog",
"config/recipes/frontend-capability-recipes.json",
);
const sourceRoot = argument("--source-root", "src");
const distRoot = argument("--dist-root", "dist");
const artifactPath = argument(
"--artifact",
"artifacts/quality/optional-recipes.json",
);
const requireDist = process.argv.includes("--require-dist");
const catalog = JSON.parse(
await readFile(catalogPath, "utf8"),
) as Record<string, unknown>;
const recipes = Array.isArray(catalog.recipes) ? catalog.recipes : [];
const packageDocument = JSON.parse(
await readFile("package.json", "utf8"),
) as Record<string, unknown>;
const catalogSchemaViolations: string[] = [];
try {
assertMatchesJsonSchema(
JSON.parse(
await readFile(
"schemas/config/frontend-capability-recipes.schema.json",
"utf8",
),
),
catalog,
"optional recipe catalog",
);
} catch {
catalogSchemaViolations.push("CATALOG_JSON_SCHEMA_INVALID");
}
const catalogViolations = [
...catalogSchemaViolations,
...validateRecipeCatalog(catalog, packageDocument),
];
const runtimeSourceViolations = (
await Promise.all(
recipes.flatMap((recipe: unknown) => {
if (!recipe || typeof recipe !== "object") return [];
const row = recipe as Record<string, unknown>;
const runtime = row.referenceRuntime;
if (!runtime || typeof runtime !== "object") return [];
const sourceRoots = (runtime as Record<string, unknown>).sourceRoots;
if (!Array.isArray(sourceRoots)) return [];
return sourceRoots
.filter(
(sourceRoot): sourceRoot is string =>
typeof sourceRoot === "string",
)
.map(async (sourceRoot) => ({
sourceRoot,
exists: await stat(sourceRoot)
.then(() => true)
.catch(() => false),
}));
}),
)
).filter(({ exists }) => !exists);
const {
inputs: referenceRuntimeBundleInputs,
violations: referenceRuntimeBundleConfigurationViolations,
} = referenceRuntimeBundleInputsFrom(recipes);
const referenceRuntimeBundles: OptionalRecipeBundleMeasurement[] = [];
const referenceRuntimeBundleMeasurementViolations: GateViolation[] = [];
for (const bundleInput of referenceRuntimeBundleInputs) {
try {
const measurement = await measureOptionalRecipeBundle(bundleInput);
referenceRuntimeBundles.push(measurement);
if (!measurement.passed) {
referenceRuntimeBundleMeasurementViolations.push({
ruleId: "REFERENCE_RUNTIME_BUNDLE_BUDGET_EXCEEDED",
path: measurement.recipeId,
detail:
`${measurement.gzipBytes} > ` +
`${measurement.bundleBudgetGzipBytes} gzip bytes`,
});
}
} catch (error) {
referenceRuntimeBundleMeasurementViolations.push({
ruleId: "REFERENCE_RUNTIME_BUNDLE_MEASUREMENT_FAILED",
path: bundleInput.recipeId,
detail: safeErrorSummary(error),
});
}
}
referenceRuntimeBundles.sort((left, right) =>
compareText(left.recipeId, right.recipeId),
);
const sourceViolations = await scanOptionalRecipeSources(sourceRoot);
const bundlePresent = await stat(`${distRoot}/.vite/manifest.json`)
.then(() => true)
.catch(() => false);
const bundleViolations = await scanProductionBundle(distRoot);
const violations: GateViolation[] = [
...catalogViolations.map((ruleId) => ({ ruleId, path: catalogPath })),
...runtimeSourceViolations.map(({ sourceRoot }) => ({
ruleId: "REFERENCE_RUNTIME_SOURCE_MISSING",
path: sourceRoot,
})),
...sourceViolations,
...bundleViolations.map((path) => ({
ruleId: "UNSELECTED_RECIPE_IN_PRODUCTION_BUNDLE",
path,
})),
...(requireDist && !bundlePresent
? [{ ruleId: "PRODUCTION_BUNDLE_MISSING", path: distRoot }]
: []),
...referenceRuntimeBundleConfigurationViolations,
...referenceRuntimeBundleMeasurementViolations,
];
const report = optionalRecipesArtifactSchema.parse({
schemaVersion: 1,
decisionId: "VD-10",
selectedCapabilities: [],
referenceRuntimes: recipes
.filter(
(recipe: unknown): recipe is Record<string, unknown> =>
Boolean(
recipe &&
typeof recipe === "object" &&
(recipe as Record<string, unknown>).referenceRuntime,
),
)
.map((recipe: Record<string, unknown>) => ({
id: recipe.id,
referenceRuntime: recipe.referenceRuntime,
})),
recipeCount: recipes.length,
productionRuntimeDependencies:
catalog.productionRuntimeDependencies ?? null,
referenceRuntimeBundleBudgets: referenceRuntimeBundles,
bundleStatus: bundlePresent
? bundleViolations.length === 0
? "PASS"
: "FAIL"
: "NOT_BUILT",
violations,
passed: violations.length === 0,
});
await mkdir("artifacts/quality", { recursive: true });
await writeValidatedJsonArtifact({
path: artifactPath,
schema: optionalRecipesArtifactSchema,
value: report,
});
if (violations.length > 0) {
process.stderr.write(
`Optional recipe contract failed:\n${violations
.map(
(violation) =>
`${violation.ruleId}: ${violation.path}` +
(violation.detail ? ` (${violation.detail})` : ""),
)
.join("\n")}\n`,
);
process.exit(1);
}
const referenceRuntimeBudgetStatus =
referenceRuntimeBundles.length > 0
? `, budgets=${referenceRuntimeBundles
.map(
(measurement) =>
`${measurement.recipeId}:${measurement.gzipBytes}/` +
measurement.bundleBudgetGzipBytes,
)
.join(",")} gzip bytes`
: "";
process.stdout.write(
`Optional recipes: PASS (${report.recipeCount} optional capabilities, ${report.referenceRuntimes.length} uncomposed reference runtimes, bundle=${report.bundleStatus}${referenceRuntimeBudgetStatus})\n`,
);
function referenceRuntimeBundleInputsFrom(
recipeRows: readonly unknown[],
): Readonly<{
inputs: readonly ReferenceRuntimeBundleInput[];
violations: readonly GateViolation[];
}> {
const inputs: ReferenceRuntimeBundleInput[] = [];
const violations: GateViolation[] = [];
for (const recipe of recipeRows) {
if (!recipe || typeof recipe !== "object") continue;
const row = recipe as Record<string, unknown>;
if (row.referenceRuntime === undefined) continue;
const recipeId =
typeof row.id === "string" ? row.id : "unknown-reference-runtime";
const runtime = row.referenceRuntime;
const sourceRoots =
runtime && typeof runtime === "object"
? (runtime as Record<string, unknown>).sourceRoots
: null;
if (
!Array.isArray(sourceRoots) ||
!sourceRoots.every(
(sourceRoot): sourceRoot is string =>
typeof sourceRoot === "string",
) ||
!Number.isSafeInteger(row.bundleBudgetGzipBytes) ||
(row.bundleBudgetGzipBytes as number) < 1
) {
violations.push({
ruleId: "REFERENCE_RUNTIME_BUNDLE_CONFIGURATION_INVALID",
path: recipeId,
});
continue;
}
inputs.push(
Object.freeze({
recipeId,
sourceRoots: Object.freeze([...sourceRoots]),
bundleBudgetGzipBytes: row.bundleBudgetGzipBytes as number,
}),
);
}
return Object.freeze({
inputs: Object.freeze(
inputs.sort((left, right) =>
compareText(left.recipeId, right.recipeId),
),
),
violations: Object.freeze(violations),
});
}
function safeErrorSummary(error: unknown): string {
const message =
error instanceof Error ? error.message : "unknown measurement failure";
return message
.split(/\r?\n/, 1)[0]
?.replaceAll(process.cwd(), ".")
.slice(0, 512) ?? "unknown measurement failure";
}
function compareText(left: string, right: string): number {
return left < right ? -1 : left > right ? 1 : 0;
}