Files
oh-my-pi/packages/ai/src/utils/schema/meta-validator.ts
T
can1357 82c24a3f74 fix(ai/schema): enforce conditional keywords, fix $ref recursion, preserve Zod root extras
- JSON Schema validator now enforces propertyNames, patternProperties, dependentRequired, dependencies, if/then/else, contains, and prefixItems instead of silently accepting values that violate them; unevaluated* still permissive but warns once.
- Recursive $ref no longer short-circuits to true on revisit: cycle detection keys on (ref, value-identity) with a primitive depth cap, so nested sub-schema violations are caught.
- Meta-validator now structurally validates if/then/else/dependencies sub-schemas and accepts draft-07 dependencies as either schemas or string-array dependent keys.
- Wire-schema null normalization no longer strips null-valued unknown root fields before preserveUnknownRootFields snapshots them, so task.simple and similar callers still see disallowed null arguments.
2026-05-15 17:47:20 +02:00

172 lines
6.0 KiB
TypeScript

import { areJsonValuesEqual } from "./equality";
/**
* Hand-rolled JSON Schema meta-validator.
*
* Replaces the old AJV meta-schema check in request hot paths with a small
* structural validator for the JSON Schema subset this repo emits and forwards.
* Unknown keywords are accepted for forward compatibility; known keywords are
* checked so malformed provider payloads still fall back instead of being sent.
*/
type Json = unknown;
function isPlainObject(value: Json): value is Record<string, Json> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
const TYPE_NAMES = new Set<string>(["string", "number", "integer", "boolean", "object", "array", "null"]);
function isNonNegativeInteger(value: Json): value is number {
return typeof value === "number" && Number.isInteger(value) && value >= 0;
}
function hasUniqueJsonValues(values: readonly unknown[]): boolean {
for (let i = 0; i < values.length; i += 1) {
for (let j = i + 1; j < values.length; j += 1) {
if (areJsonValuesEqual(values[i], values[j])) return false;
}
}
return true;
}
function checkTypeKeyword(value: Json): boolean {
if (typeof value === "string") return TYPE_NAMES.has(value);
if (!Array.isArray(value) || value.length === 0) return false;
const seen = new Set<string>();
for (const entry of value) {
if (typeof entry !== "string" || !TYPE_NAMES.has(entry) || seen.has(entry)) return false;
seen.add(entry);
}
return true;
}
function checkSchemaArray(value: Json, seen: WeakSet<object>): boolean {
return Array.isArray(value) && value.every(entry => checkNode(entry, seen));
}
function checkSchemaMap(value: Json, seen: WeakSet<object>): boolean {
if (!isPlainObject(value)) return false;
return Object.values(value).every(sub => checkNode(sub, seen));
}
/** Validate a single sub-schema node. */
function checkNode(node: Json, seen: WeakSet<object>): boolean {
// Boolean schemas (`true` / `false`) are valid JSON Schema.
if (node === true || node === false) return true;
if (!isPlainObject(node)) return false;
if (seen.has(node)) return true;
seen.add(node);
if ("type" in node && !checkTypeKeyword(node.type)) return false;
for (const key of ["anyOf", "oneOf", "allOf"] as const) {
if (key in node && !checkSchemaArray(node[key], seen)) return false;
}
if ("not" in node && !checkNode(node.not, seen)) return false;
for (const key of ["if", "then", "else"] as const) {
if (key in node && !checkNode(node[key], seen)) return false;
}
for (const key of ["properties", "patternProperties", "$defs", "definitions"] as const) {
if (key in node && !checkSchemaMap(node[key], seen)) return false;
}
if ("propertyNames" in node && !checkNode(node.propertyNames, seen)) return false;
if ("contains" in node && !checkNode(node.contains, seen)) return false;
if ("required" in node) {
const value = node.required;
if (!Array.isArray(value)) return false;
const seenRequired = new Set<string>();
for (const entry of value) {
if (typeof entry !== "string" || seenRequired.has(entry)) return false;
seenRequired.add(entry);
}
}
if ("items" in node) {
const items = node.items;
if (Array.isArray(items)) {
for (const entry of items) {
if (!checkNode(entry, seen)) return false;
}
} else if (!checkNode(items, seen)) return false;
}
if ("prefixItems" in node && !checkSchemaArray(node.prefixItems, seen)) return false;
for (const key of [
"additionalProperties",
"additionalItems",
"unevaluatedProperties",
"unevaluatedItems",
] as const) {
if (!(key in node)) continue;
const value = node[key];
if (typeof value !== "boolean" && !checkNode(value, seen)) return false;
}
if ("dependentSchemas" in node && !checkSchemaMap(node.dependentSchemas, seen)) return false;
if ("dependentRequired" in node) {
const value = node.dependentRequired;
if (!isPlainObject(value)) return false;
for (const entry of Object.values(value)) {
if (!Array.isArray(entry) || !entry.every(item => typeof item === "string")) return false;
}
}
// Draft-07 `dependencies`: each entry is either a schema or a string[].
if ("dependencies" in node) {
const value = node.dependencies;
if (!isPlainObject(value)) return false;
for (const entry of Object.values(value)) {
if (Array.isArray(entry)) {
if (!entry.every(item => typeof item === "string")) return false;
} else if (!checkNode(entry, seen)) return false;
}
}
if ("enum" in node) {
if (!Array.isArray(node.enum) || node.enum.length === 0 || !hasUniqueJsonValues(node.enum)) return false;
}
for (const key of ["minimum", "maximum", "multipleOf"] as const) {
if (key in node && typeof node[key] !== "number") return false;
}
if (node.multipleOf !== undefined && typeof node.multipleOf === "number" && node.multipleOf <= 0) return false;
for (const key of ["exclusiveMinimum", "exclusiveMaximum"] as const) {
if (key in node && typeof node[key] !== "number" && typeof node[key] !== "boolean") return false;
}
for (const key of ["minLength", "maxLength", "minItems", "maxItems", "minProperties", "maxProperties"] as const) {
if (key in node && !isNonNegativeInteger(node[key])) return false;
}
for (const key of ["minContains", "maxContains"] as const) {
if (key in node && !isNonNegativeInteger(node[key])) return false;
}
if ("uniqueItems" in node && typeof node.uniqueItems !== "boolean") return false;
if ("pattern" in node) {
if (typeof node.pattern !== "string") return false;
try {
new RegExp(node.pattern);
} catch {
return false;
}
}
if ("format" in node && typeof node.format !== "string") return false;
if ("nullable" in node && typeof node.nullable !== "boolean") return false;
if ("readOnly" in node && typeof node.readOnly !== "boolean") return false;
if ("writeOnly" in node && typeof node.writeOnly !== "boolean") return false;
if ("deprecated" in node && typeof node.deprecated !== "boolean") return false;
return true;
}
/** Validate that `schema` is structurally a valid JSON Schema (subset). */
export function isValidJsonSchema(schema: unknown): boolean {
try {
return checkNode(schema, new WeakSet<object>());
} catch {
return false;
}
}