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>(); 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 { 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 = { 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 = { 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 { const ajv = new Ajv2020({ allErrors: true, strict: true }); addArtifactFormats(ajv); const schemas = new Map(); 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(); 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; 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): 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(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.", }); }