import { createRequire } from "node:module"; import type { ErrorObject, ValidateFunction } from "ajv"; import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js"; import { sanitizeTerminalText } from "../terminal/safe-text.js"; const require = createRequire(import.meta.url); type AjvLike = { addFormat: ( name: string, format: | RegExp | { type?: string; validate: (value: string) => boolean; }, ) => AjvLike; compile: (schema: Record) => ValidateFunction; }; const ajvSingletons = new Map<"default" | "defaults", AjvLike>(); function getAjv(mode: "default" | "defaults"): AjvLike { const cached = ajvSingletons.get(mode); if (cached) { return cached; } const ajvModule = require("ajv") as { default?: new (opts?: object) => AjvLike }; const AjvCtor = typeof ajvModule.default === "function" ? ajvModule.default : (ajvModule as unknown as new (opts?: object) => AjvLike); const instance = new AjvCtor({ allErrors: true, strict: false, removeAdditional: false, ...(mode === "defaults" ? { useDefaults: true } : {}), }); instance.addFormat("uri", { type: "string", validate: (value: string) => { try { // Accept absolute URIs so generated config schemas can keep JSON Schema // `format: "uri"` without noisy AJV warnings during validation/build. new URL(value); return true; } catch { return false; } }, }); ajvSingletons.set(mode, instance); return instance; } type CachedValidator = { validate: ValidateFunction; schema: Record; }; const schemaCache = new Map(); function cloneValidationValue(value: T): T { if (value === undefined || value === null) { return value; } return structuredClone(value); } export type JsonSchemaValidationError = { path: string; message: string; text: string; allowedValues?: string[]; allowedValuesHiddenCount?: number; }; function normalizeAjvPath(instancePath: string | undefined): string { const path = instancePath?.replace(/^\//, "").replace(/\//g, "."); return path && path.length > 0 ? path : ""; } function appendPathSegment(path: string, segment: string): string { const trimmed = segment.trim(); if (!trimmed) { return path; } if (path === "") { return trimmed; } return `${path}.${trimmed}`; } function resolveMissingProperty(error: ErrorObject): string | null { if ( error.keyword !== "required" && error.keyword !== "dependentRequired" && error.keyword !== "dependencies" ) { return null; } const missingProperty = (error.params as { missingProperty?: unknown }).missingProperty; return typeof missingProperty === "string" && missingProperty.trim() ? missingProperty : null; } function resolveAjvErrorPath(error: ErrorObject): string { const basePath = normalizeAjvPath(error.instancePath); const missingProperty = resolveMissingProperty(error); if (!missingProperty) { return basePath; } return appendPathSegment(basePath, missingProperty); } function extractAllowedValues(error: ErrorObject): unknown[] | null { if (error.keyword === "enum") { const allowedValues = (error.params as { allowedValues?: unknown }).allowedValues; return Array.isArray(allowedValues) ? allowedValues : null; } if (error.keyword === "const") { const params = error.params as { allowedValue?: unknown }; if (!Object.prototype.hasOwnProperty.call(params, "allowedValue")) { return null; } return [params.allowedValue]; } return null; } function getAjvAllowedValuesSummary(error: ErrorObject): ReturnType { const allowedValues = extractAllowedValues(error); if (!allowedValues) { return null; } return summarizeAllowedValues(allowedValues); } function formatAjvErrors(errors: ErrorObject[] | null | undefined): JsonSchemaValidationError[] { if (!errors || errors.length === 0) { return [{ path: "", message: "invalid config", text: ": invalid config" }]; } return errors.map((error) => { const path = resolveAjvErrorPath(error); const baseMessage = error.message ?? "invalid"; const allowedValuesSummary = getAjvAllowedValuesSummary(error); const message = allowedValuesSummary ? appendAllowedValuesHint(baseMessage, allowedValuesSummary) : baseMessage; const safePath = sanitizeTerminalText(path); const safeMessage = sanitizeTerminalText(message); return { path, message, text: `${safePath}: ${safeMessage}`, ...(allowedValuesSummary ? { allowedValues: allowedValuesSummary.values, allowedValuesHiddenCount: allowedValuesSummary.hiddenCount, } : {}), }; }); } export function validateJsonSchemaValue(params: { schema: Record; cacheKey: string; value: unknown; applyDefaults?: boolean; }): { ok: true; value: unknown } | { ok: false; errors: JsonSchemaValidationError[] } { const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey; let cached = schemaCache.get(cacheKey); if (!cached || cached.schema !== params.schema) { const validate = getAjv(params.applyDefaults ? "defaults" : "default").compile(params.schema); cached = { validate, schema: params.schema }; schemaCache.set(cacheKey, cached); } const value = params.applyDefaults ? cloneValidationValue(params.value) : params.value; const ok = cached.validate(value); if (ok) { return { ok: true, value }; } return { ok: false, errors: formatAjvErrors(cached.validate.errors) }; }