Files
tech-log-frontend/scripts/generate-artifact-schemas.ts
T

132 lines
4.0 KiB
TypeScript

import { mkdir, readFile } from "node:fs/promises";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { z } from "zod";
import {
buildManifestArtifactSchema,
dependencyInventoryArtifactSchema,
jsonSchemaDocumentArtifactSchema,
registrySnapshotArtifactSchema,
supplyChainVerificationArtifactSchema,
} from "./contracts/release-artifacts.ts";
import { writeValidatedJsonArtifact } from "./lib/validated-json-artifact.ts";
const ARTIFACT_SCHEMA_DEFINITIONS = Object.freeze([
{
relativePath: "schemas/artifacts/build-manifest.schema.json",
id: undefined,
schema: buildManifestArtifactSchema,
},
{
relativePath: "schemas/artifacts/dependency-inventory.schema.json",
id: "https://clean-architecture-frontend.local/schemas/dependency-inventory.schema.json",
schema: dependencyInventoryArtifactSchema,
},
{
relativePath: "schemas/artifacts/registry-snapshot.schema.json",
id: undefined,
schema: registrySnapshotArtifactSchema,
},
{
relativePath: "schemas/artifacts/supply-chain-verification.schema.json",
id: "https://clean-architecture-frontend.local/schemas/supply-chain-verification.schema.json",
schema: supplyChainVerificationArtifactSchema,
},
] as const);
type GenerateArtifactSchemasOptions = Readonly<{
root?: string;
check?: boolean;
}>;
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, item]) => [key, canonicalize(item)]),
);
}
return value;
}
function generatedSchema(
definition: (typeof ARTIFACT_SCHEMA_DEFINITIONS)[number],
): Readonly<Record<string, unknown>> {
const schema = z.toJSONSchema(definition.schema, {
target: "draft-2020-12",
});
return canonicalize({
...schema,
...(definition.id === undefined ? {} : { $id: definition.id }),
}) as Readonly<Record<string, unknown>>;
}
export async function generateArtifactSchemas(
options: GenerateArtifactSchemasOptions = {},
): Promise<void> {
const root = path.resolve(options.root ?? process.cwd());
const documents = ARTIFACT_SCHEMA_DEFINITIONS.map((definition) => ({
definition,
document: generatedSchema(definition),
}));
const drift: string[] = [];
for (const { definition, document } of documents) {
const destination = path.join(root, definition.relativePath);
const validatedDocument = jsonSchemaDocumentArtifactSchema.parse(document);
const expected = `${JSON.stringify(validatedDocument, null, 2)}\n`;
if (options.check) {
let actual: string | null = null;
try {
actual = await readFile(destination, "utf8");
} catch {
// A missing or unreadable checked-in schema is drift.
}
if (actual !== expected) drift.push(definition.relativePath);
continue;
}
await mkdir(path.dirname(destination), { recursive: true });
await writeValidatedJsonArtifact({
path: destination,
schema: jsonSchemaDocumentArtifactSchema,
value: document,
});
}
if (drift.length > 0) {
throw new Error(`Artifact JSON Schema drift:\n- ${drift.join("\n- ")}`);
}
}
function argumentValue(name: string): string | undefined {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
const invokedPath = process.argv[1];
if (
invokedPath !== undefined &&
import.meta.url === pathToFileURL(invokedPath).href
) {
try {
await generateArtifactSchemas({
root: argumentValue("--root"),
check: process.argv.includes("--check"),
});
process.stdout.write(
process.argv.includes("--check")
? "Artifact JSON Schemas: PASS\n"
: "Artifact JSON Schemas: GENERATED\n",
);
} catch (error) {
process.stderr.write(
`${error instanceof Error ? error.message : String(error)}\n`,
);
process.exitCode = 1;
}
}