import { Compile, type Validator as TypeBoxValidator } from "typebox/compile"; import { Format } from "typebox/format"; import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { appendAllowedValuesHint, summarizeAllowedValues } from "../config/allowed-values.js"; import { applyJsonSchemaDefaults, findJsonSchemaShapeError, normalizeJsonSchemaForTypeBox, } from "../shared/json-schema-defaults.js"; import type { JsonSchemaObject } from "../shared/json-schema.types.js"; import { PluginLruCache } from "./plugin-cache-primitives.js"; type TypeBoxValidationError = { keyword?: string; instancePath?: string; schemaPath?: string; params?: Record; message?: string; }; type CachedValidator = { hasDefaults: boolean; validate: TypeBoxValidator; schema: JsonSchemaValue; schemaFingerprint: string; }; export type JsonSchemaValue = JsonSchemaObject | boolean; const schemaCache = new PluginLruCache(512); const annotationOnlyFormats = [ "date-time", "date", "duration", "email", "hostname", "idn-email", "idn-hostname", "ipv4", "ipv6", "iri-reference", "iri", "json-pointer-uri-fragment", "json-pointer", "regex", "relative-json-pointer", "time", "uri-reference", "uri-template", "url", "uuid", ] as const; function fingerprintSchema(schema: JsonSchemaValue): string { return JSON.stringify(schema); } function schemaHasDefaults(schema: unknown): boolean { if (!schema || typeof schema !== "object") { return false; } if (Array.isArray(schema)) { return schema.some((item) => schemaHasDefaults(item)); } const record = schema as Record; if (Object.hasOwn(record, "default")) { return true; } return Object.values(record).some((value) => schemaHasDefaults(value)); } function cloneValidationValue(value: T): T { if (value === undefined || value === null) { return value; } return structuredClone(value); } function compileSchema(schema: JsonSchemaValue): TypeBoxValidator { return Compile(normalizeJsonSchemaForTypeBox(schema) as never); } function relaxConditionalRequiredKeywords( schema: JsonSchemaValue, insideConditionalBranch = false, ): JsonSchemaValue { if (Array.isArray(schema)) { return schema.map((entry) => relaxConditionalRequiredKeywords(entry as JsonSchemaValue, insideConditionalBranch), ) as never; } if (!schema || typeof schema !== "object") { return schema; } return Object.fromEntries( Object.entries(schema) .filter(([key]) => !(insideConditionalBranch && key === "required")) .map(([key, value]) => [ key, typeof value === "boolean" || (value && typeof value === "object") ? relaxConditionalRequiredKeywords( value as JsonSchemaValue, insideConditionalBranch || key === "then" || key === "else", ) : value, ]), ) as JsonSchemaValue; } function withPluginFormatSemantics(callback: () => T): T { const previousFormats = Format.Entries(); // TypeBox format checks are global; snapshot/restore keeps plugin schema semantics local. Format.Set("uri", (value) => URL.canParse(value)); for (const format of annotationOnlyFormats) { Format.Set(format, () => true); } try { return callback(); } finally { Format.Clear(); for (const [format, check] of previousFormats) { Format.Set(format, check); } } } function checkSchema(validate: TypeBoxValidator, value: unknown): TypeBoxValidationError[] | null { return withPluginFormatSemantics(() => { if (validate.Check(value)) { return null; } return [...validate.Errors(value)] as TypeBoxValidationError[]; }); } function applyDefaultsWithPluginFormatSemantics(schema: JsonSchemaValue, value: unknown): unknown { return withPluginFormatSemantics(() => applyJsonSchemaDefaults(schema, value)); } function isDefaultActivatedConditionalFailure(params: { schema: JsonSchemaValue; originalValue: unknown; defaultedValue: unknown; }): boolean { const relaxedConditionalValidator = compileSchema( relaxConditionalRequiredKeywords(params.schema), ); if (checkSchema(relaxedConditionalValidator, params.defaultedValue)) { return false; } const originalValidator = compileSchema(params.schema); return checkSchema(originalValidator, params.originalValue) === null; } export type JsonSchemaValidationError = { path: string; message: string; text: string; additionalProperty?: string; allowedValues?: string[]; allowedValuesHiddenCount?: number; }; function normalizeErrorPath(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 firstStringParam(value: unknown): string | null { if (typeof value === "string" && value.trim()) { return value; } if (Array.isArray(value)) { const first = value.find( (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, ); return first ?? null; } return null; } function resolveMissingProperty(error: TypeBoxValidationError): string | null { if ( error.keyword !== "required" && error.keyword !== "dependentRequired" && error.keyword !== "dependencies" ) { return null; } return ( firstStringParam(error.params?.missingProperty) ?? firstStringParam(error.params?.requiredProperties) ?? firstStringParam(error.params?.dependencies) ); } function resolveValidationErrorPath(error: TypeBoxValidationError): string { const basePath = normalizeErrorPath(error.instancePath); const missingProperty = resolveMissingProperty(error); if (!missingProperty) { return basePath; } return appendPathSegment(basePath, missingProperty); } function extractAllowedValues(error: TypeBoxValidationError): unknown[] | null { if (error.keyword === "enum") { const allowedValues = error.params?.allowedValues; return Array.isArray(allowedValues) ? allowedValues : null; } if (error.keyword === "const") { const params = error.params; if (!params || !Object.hasOwn(params, "allowedValue")) { return null; } return [params.allowedValue]; } return null; } function getAllowedValuesSummary( error: TypeBoxValidationError, ): ReturnType { const allowedValues = extractAllowedValues(error); if (!allowedValues) { return null; } return summarizeAllowedValues(allowedValues); } function resolveAdditionalProperty(error: TypeBoxValidationError): string | undefined { if (error.keyword !== "additionalProperties") { return undefined; } return firstStringParam(error.params?.additionalProperty) ?? undefined; } function resolveAdditionalProperties(error: TypeBoxValidationError): string[] { if (error.keyword !== "additionalProperties") { return []; } const additionalProperties = error.params?.additionalProperties; if (Array.isArray(additionalProperties)) { return additionalProperties.filter((entry): entry is string => typeof entry === "string"); } const additionalProperty = error.params?.additionalProperty; return typeof additionalProperty === "string" ? [additionalProperty] : []; } function formatRequiredMessage(error: TypeBoxValidationError): string | null { const missingProperty = resolveMissingProperty(error); if (!missingProperty) { return null; } return `must have required property '${missingProperty}'`; } function formatAdditionalPropertiesMessage(error: TypeBoxValidationError): string | null { const additionalProperties = resolveAdditionalProperties(error); if (additionalProperties.length === 0) { return null; } const quoted = additionalProperties.map((entry) => `"${entry}"`).join(", "); return `must not have additional properties: ${quoted}`; } function formatValidationErrorMessage(error: TypeBoxValidationError): string { return ( formatRequiredMessage(error) ?? formatAdditionalPropertiesMessage(error) ?? error.message ?? "invalid" ); } function formatValidationErrors( errors: TypeBoxValidationError[] | null | undefined, ): JsonSchemaValidationError[] { if (!errors || errors.length === 0) { return [{ path: "", message: "invalid config", text: ": invalid config" }]; } return errors.map((error) => { const path = resolveValidationErrorPath(error); const baseMessage = formatValidationErrorMessage(error); const allowedValuesSummary = getAllowedValuesSummary(error); const additionalProperty = resolveAdditionalProperty(error); const message = allowedValuesSummary ? appendAllowedValuesHint(baseMessage, allowedValuesSummary) : baseMessage; const safePath = sanitizeTerminalText(path); const safeMessage = sanitizeTerminalText(message); return { path, message, text: `${safePath}: ${safeMessage}`, ...(additionalProperty ? { additionalProperty } : {}), ...(allowedValuesSummary ? { allowedValues: allowedValuesSummary.values, allowedValuesHiddenCount: allowedValuesSummary.hiddenCount, } : {}), }; }); } export function validateJsonSchemaValue(params: { schema: JsonSchemaValue; cacheKey: string; value: unknown; applyDefaults?: boolean; cache?: boolean; }): { ok: true; value: unknown } | { ok: false; errors: JsonSchemaValidationError[] } { const schemaError = findJsonSchemaShapeError(params.schema); if (schemaError) { throw new Error(sanitizeTerminalText(`invalid schema: ${schemaError}`)); } const useCache = params.cache !== false; if (!useCache) { const validate = compileSchema(params.schema); const value = params.applyDefaults && schemaHasDefaults(params.schema) ? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value)) : params.value; const errors = checkSchema(validate, value); if (!errors) { return { ok: true, value }; } if ( params.applyDefaults && value !== params.value && isDefaultActivatedConditionalFailure({ schema: params.schema, originalValue: params.value, defaultedValue: value, }) ) { return { ok: true, value }; } return { ok: false, errors: formatValidationErrors(errors) }; } const cacheKey = params.applyDefaults ? `${params.cacheKey}::defaults` : params.cacheKey; let cached = schemaCache.get(cacheKey); const schemaFingerprint = !cached || cached.schema !== params.schema ? fingerprintSchema(params.schema) : undefined; if ( !cached || (cached.schema !== params.schema && cached.schemaFingerprint !== schemaFingerprint) ) { const validate = compileSchema(params.schema); cached = { hasDefaults: params.applyDefaults ? schemaHasDefaults(params.schema) : false, validate, schema: params.schema, schemaFingerprint: schemaFingerprint ?? fingerprintSchema(params.schema), }; schemaCache.set(cacheKey, cached); } else if (cached.schema !== params.schema) { cached.schema = params.schema; } const value = params.applyDefaults && cached.hasDefaults ? applyDefaultsWithPluginFormatSemantics(params.schema, cloneValidationValue(params.value)) : params.value; const errors = checkSchema(cached.validate, value); if (!errors) { return { ok: true, value }; } if ( params.applyDefaults && value !== params.value && isDefaultActivatedConditionalFailure({ schema: params.schema, originalValue: params.value, defaultedValue: value, }) ) { return { ok: true, value }; } return { ok: false, errors: formatValidationErrors(errors) }; }