feat: add artifact schema registry
This commit is contained in:
388
packages/core/src/artifact-schema.ts
Normal file
388
packages/core/src/artifact-schema.ts
Normal file
@@ -0,0 +1,388 @@
|
||||
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
||||
import { dirname, join, relative, resolve, sep } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
Ajv2020,
|
||||
type ErrorObject,
|
||||
type FormatDefinition,
|
||||
type ValidateFunction,
|
||||
} from "ajv/dist/2020.js";
|
||||
|
||||
import { DevflowError } from "./errors.js";
|
||||
import type { JsonObject, JsonValue } from "./persona.js";
|
||||
|
||||
export type JsonSchema = JsonObject;
|
||||
|
||||
export interface ValidationError {
|
||||
instancePath: string;
|
||||
schemaPath: string;
|
||||
keyword: string;
|
||||
message?: string;
|
||||
params: JsonObject;
|
||||
}
|
||||
|
||||
export interface ArtifactSchemaOptions {
|
||||
root?: string;
|
||||
}
|
||||
|
||||
interface CompiledArtifactSchema {
|
||||
id: string;
|
||||
schema: JsonSchema;
|
||||
validate: ValidateFunction;
|
||||
path: string;
|
||||
}
|
||||
|
||||
const schemaIdPattern = /^[a-z][a-z0-9_-]*\/[a-z][a-z0-9_-]*@[1-9]\d*$/;
|
||||
const schemaRootSegments = ["docs", "schemas", "artifacts"] as const;
|
||||
const registries = new Map<string, Map<string, CompiledArtifactSchema>>();
|
||||
|
||||
export function loadSchema(id: string, options: ArtifactSchemaOptions = {}): JsonSchema {
|
||||
assertSchemaId(id);
|
||||
const schema = registryFor(options).get(id);
|
||||
if (!schema) {
|
||||
throw artifactSchemaUnknown(id);
|
||||
}
|
||||
|
||||
return schema.schema;
|
||||
}
|
||||
|
||||
export function validateArtifact(
|
||||
id: string,
|
||||
data: unknown,
|
||||
options: ArtifactSchemaOptions = {},
|
||||
): { ok: true } | { ok: false; errors: ValidationError[] } {
|
||||
assertSchemaId(id);
|
||||
const schema = registryFor(options).get(id);
|
||||
if (!schema) {
|
||||
throw artifactSchemaUnknown(id);
|
||||
}
|
||||
|
||||
if (schema.validate(data)) {
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
errors: (schema.validate.errors ?? []).map(toValidationError),
|
||||
};
|
||||
}
|
||||
|
||||
export function clearArtifactSchemaCacheForTests(): void {
|
||||
registries.clear();
|
||||
}
|
||||
|
||||
function registryFor(options: ArtifactSchemaOptions): Map<string, CompiledArtifactSchema> {
|
||||
const root = resolveRegistryRoot(options.root ?? findDefaultSchemaRoot());
|
||||
const cached = registries.get(root);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const registry = loadRegistry(root);
|
||||
registries.set(root, registry);
|
||||
return registry;
|
||||
}
|
||||
|
||||
function resolveRegistryRoot(root: string): string {
|
||||
try {
|
||||
return realpathSync(resolve(root));
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(root, error);
|
||||
}
|
||||
}
|
||||
|
||||
function findDefaultSchemaRoot(): string {
|
||||
const moduleDirectory = currentModuleDirectory();
|
||||
if (moduleDirectory === undefined) {
|
||||
throw artifactSchemaLoadFailed(
|
||||
"default",
|
||||
new Error("Could not resolve current module directory for artifact schemas"),
|
||||
);
|
||||
}
|
||||
|
||||
const packageRoot = findCorePackageRoot(moduleDirectory);
|
||||
if (packageRoot === undefined) {
|
||||
throw artifactSchemaLoadFailed(
|
||||
"default",
|
||||
new Error("Could not find @devflow/core package root for artifact schemas"),
|
||||
);
|
||||
}
|
||||
|
||||
return resolve(packageRoot, "../..", ...schemaRootSegments);
|
||||
}
|
||||
|
||||
function currentModuleDirectory(): string | undefined {
|
||||
const stack = new Error().stack?.split("\n").slice(1) ?? [];
|
||||
|
||||
for (const line of stack) {
|
||||
const match = line.match(/\(?((?:file:\/\/)?\/[^):]+\.(?:cjs|mjs|js|ts)):\d+:\d+\)?/);
|
||||
if (!match?.[1]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = match[1].startsWith("file://") ? fileURLToPath(match[1]) : match[1];
|
||||
return dirname(path);
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function findCorePackageRoot(startDirectory: string): string | undefined {
|
||||
let current = resolve(startDirectory);
|
||||
|
||||
while (true) {
|
||||
if (isCorePackageRoot(current)) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const parent = dirname(current);
|
||||
if (parent === current) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function isCorePackageRoot(directory: string): boolean {
|
||||
const packageJsonPath = join(directory, "package.json");
|
||||
if (!existsSync(packageJsonPath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as { name?: unknown };
|
||||
return packageJson.name === "@devflow/core";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function addArtifactFormats(ajv: Ajv2020): void {
|
||||
ajv.addFormat("uuid", uuidFormat);
|
||||
ajv.addFormat("utc-date-time", utcDateTimeFormat);
|
||||
}
|
||||
|
||||
const uuidFormat: FormatDefinition<string> = {
|
||||
type: "string",
|
||||
validate: (value: string) =>
|
||||
/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(value),
|
||||
};
|
||||
|
||||
const utcDateTimeFormat: FormatDefinition<string> = {
|
||||
type: "string",
|
||||
validate: isUtcDateTime,
|
||||
};
|
||||
|
||||
function isUtcDateTime(value: string): boolean {
|
||||
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?Z$/);
|
||||
if (!match) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const [, yearText, monthText, dayText, hourText, minuteText, secondText, millisecondText] = match;
|
||||
const year = Number(yearText);
|
||||
const month = Number(monthText);
|
||||
const day = Number(dayText);
|
||||
const hour = Number(hourText);
|
||||
const minute = Number(minuteText);
|
||||
const second = Number(secondText);
|
||||
const millisecond = millisecondText === undefined ? 0 : Number(millisecondText);
|
||||
const date = new Date(Date.UTC(year, month - 1, day, hour, minute, second, millisecond));
|
||||
|
||||
return (
|
||||
date.getUTCFullYear() === year &&
|
||||
date.getUTCMonth() === month - 1 &&
|
||||
date.getUTCDate() === day &&
|
||||
date.getUTCHours() === hour &&
|
||||
date.getUTCMinutes() === minute &&
|
||||
date.getUTCSeconds() === second &&
|
||||
date.getUTCMilliseconds() === millisecond
|
||||
);
|
||||
}
|
||||
|
||||
function loadRegistry(root: string): Map<string, CompiledArtifactSchema> {
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true });
|
||||
addArtifactFormats(ajv);
|
||||
const schemas = new Map<string, JsonSchemaFile>();
|
||||
|
||||
for (const file of readSchemaFiles(root, root)) {
|
||||
if (schemas.has(file.id)) {
|
||||
throw artifactSchemaLoadFailed(file.id, new Error(`Duplicate artifact schema id ${file.id}`));
|
||||
}
|
||||
|
||||
schemas.set(file.id, file);
|
||||
}
|
||||
|
||||
const registry = new Map<string, CompiledArtifactSchema>();
|
||||
for (const file of schemas.values()) {
|
||||
try {
|
||||
registry.set(file.id, {
|
||||
id: file.id,
|
||||
schema: deepFreeze(file.schema),
|
||||
validate: ajv.compile(file.schema),
|
||||
path: file.path,
|
||||
});
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(file.id, error);
|
||||
}
|
||||
}
|
||||
|
||||
return registry;
|
||||
}
|
||||
|
||||
interface JsonSchemaFile {
|
||||
id: string;
|
||||
schema: JsonSchema;
|
||||
path: string;
|
||||
}
|
||||
|
||||
function readSchemaFiles(root: string, directory: string): JsonSchemaFile[] {
|
||||
const files: JsonSchemaFile[] = [];
|
||||
let entryNames: string[];
|
||||
try {
|
||||
entryNames = readdirSync(directory).sort();
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(directory, error);
|
||||
}
|
||||
|
||||
for (const entryName of entryNames) {
|
||||
const path = join(directory, entryName);
|
||||
let stat: ReturnType<typeof lstatSync>;
|
||||
try {
|
||||
stat = lstatSync(path);
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(path, error);
|
||||
}
|
||||
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw artifactSchemaLoadFailed(
|
||||
entryName,
|
||||
new Error("Artifact schema path must not be a symlink"),
|
||||
);
|
||||
}
|
||||
|
||||
if (stat.isDirectory()) {
|
||||
files.push(...readSchemaFiles(root, path));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!entryName.endsWith(".json")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const canonicalPath = resolveSchemaFilePath(path);
|
||||
const id = schemaIdFromPath(root, canonicalPath);
|
||||
const parsed = parseSchemaFile(id, canonicalPath);
|
||||
if (!isJsonObject(parsed)) {
|
||||
throw artifactSchemaLoadFailed(id, new Error("Artifact schema must be a JSON object"));
|
||||
}
|
||||
|
||||
if (parsed.$id !== id) {
|
||||
throw artifactSchemaLoadFailed(id, new Error(`Artifact schema $id must equal ${id}`));
|
||||
}
|
||||
|
||||
files.push({ id, schema: parsed, path: canonicalPath });
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function schemaIdFromPath(root: string, path: string) {
|
||||
const relativePath = relative(root, path).split(sep).join("/");
|
||||
const id = relativePath.replace(/\.json$/, "");
|
||||
if (!schemaIdPattern.test(id)) {
|
||||
throw artifactSchemaLoadFailed(id, new Error(`Invalid artifact schema path ${relativePath}`));
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
function assertSchemaId(id: string) {
|
||||
if (!schemaIdPattern.test(id)) {
|
||||
throw artifactSchemaUnknown(id);
|
||||
}
|
||||
}
|
||||
|
||||
function toValidationError(error: ErrorObject): ValidationError {
|
||||
return {
|
||||
instancePath: error.instancePath,
|
||||
schemaPath: error.schemaPath,
|
||||
keyword: error.keyword,
|
||||
...(error.message === undefined ? {} : { message: error.message }),
|
||||
params: toJsonObject(error.params),
|
||||
};
|
||||
}
|
||||
|
||||
function toJsonObject(value: Record<string, unknown>): JsonObject {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).map(([key, childValue]) => [key, toJsonValue(childValue)]),
|
||||
);
|
||||
}
|
||||
|
||||
function toJsonValue(value: unknown): JsonValue {
|
||||
if (value === null || typeof value === "string" || typeof value === "boolean") {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? value : String(value);
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(toJsonValue);
|
||||
}
|
||||
|
||||
if (isJsonObject(value)) {
|
||||
return toJsonObject(value);
|
||||
}
|
||||
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is JsonObject {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function resolveSchemaFilePath(path: string): string {
|
||||
try {
|
||||
return realpathSync(path);
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(path, error);
|
||||
}
|
||||
}
|
||||
|
||||
function parseSchemaFile(id: string, path: string): unknown {
|
||||
try {
|
||||
return JSON.parse(readFileSync(path, "utf8")) as unknown;
|
||||
} catch (error) {
|
||||
throw artifactSchemaLoadFailed(id, error);
|
||||
}
|
||||
}
|
||||
|
||||
function deepFreeze<T extends JsonValue>(value: T): T {
|
||||
if (value !== null && typeof value === "object") {
|
||||
Object.freeze(value);
|
||||
for (const child of Object.values(value)) {
|
||||
deepFreeze(child);
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function artifactSchemaUnknown(id: string) {
|
||||
return new DevflowError(`artifact_schema_unknown:${id}`, {
|
||||
class: "fatal",
|
||||
code: "artifact_schema_unknown",
|
||||
recoveryHint: `Add docs/schemas/artifacts/${id}.json or update the template schema id.`,
|
||||
});
|
||||
}
|
||||
|
||||
function artifactSchemaLoadFailed(id: string, cause: unknown) {
|
||||
return new DevflowError(`artifact_schema_load_failed:${id}`, {
|
||||
class: "fatal",
|
||||
code: "artifact_schema_load_failed",
|
||||
cause,
|
||||
recoveryHint: "Fix the artifact schema JSON document.",
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user