From fa041090f7b2bcfc807de1d57efd39a233513add Mon Sep 17 00:00:00 2001 From: starptech Date: Sat, 18 Jul 2026 17:08:52 +0200 Subject: [PATCH] fix(provider --- packages/opencode/src/provider/mfjs.ts | 374 ++++++++++++++++--- packages/opencode/test/provider/mfjs.test.ts | 200 ++++++++++ 2 files changed, 521 insertions(+), 53 deletions(-) diff --git a/packages/opencode/src/provider/mfjs.ts b/packages/opencode/src/provider/mfjs.ts index 8a72724a03..8fb4e0ebf6 100644 --- a/packages/opencode/src/provider/mfjs.ts +++ b/packages/opencode/src/provider/mfjs.ts @@ -5,34 +5,81 @@ import type { JSONSchema7 } from "@ai-sdk/provider" // MFJS specification and reference validator: https://github.com/MoonshotAI/walle type JsonRecord = Record +type Context = { + root: JsonRecord + definitions: JsonRecord + properties: number +} +type Position = { + depth: number + definition: boolean + root: boolean +} const TYPES = new Set(["string", "number", "boolean", "integer", "object", "array", "null"]) const RESERVED_PROPERTIES = new Set(["$defs", "$ref", "anyOf", "required", "additionalProperties"]) const MAX_ANY_OF = 500 const MAX_ENUM = 1000 +const MAX_DEPTH = 30 +const MAX_PROPERTIES = 3000 +const MAX_SCHEMA_SIZE = 120_000 // Moonshot accepts MFJS, a strict and intentionally smaller JSON Schema dialect. // Lower broader schemas here so one incompatible tool cannot reject the whole request. export function sanitize(value: unknown): JSONSchema7 { const root = isPlainObject(value) ? value : {} - const result = sanitizeNode(root, root, true) - if (result.type === "object") return result as JSONSchema7 - if (typeof result.$ref === "string" && refIsObject(result.$ref, root)) return result as JSONSchema7 - if (Array.isArray(result.anyOf)) { - const branches = result.anyOf.filter(isPlainObject) - if (branches.length > 0 && branches.every((branch) => branch.type === "object")) { - return withMetadata(mergeObjectUnion(branches), result) as JSONSchema7 - } - } - return withMetadata({ type: "object", properties: {} }, result) as JSONSchema7 + const definitions = collectDefinitions(root) + const context = { root, definitions: referencedDefinitions(root, definitions), properties: 0 } + const lowered = sanitizeNode(root, context, { depth: 0, definition: false, root: true }) + const rooted = forceObjectRoot(lowered, context) + const referenced = pruneInvalidRefs(rooted, rooted) + const terminating = ensureTermination(forceObjectRoot(referenced, context)) + const depthBounded = + schemaDepth(terminating, terminating, new Set()) <= MAX_DEPTH ? terminating : emptyRoot(terminating) + return fitSchemaSize(forceObjectRoot(depthBounded, context)) as JSONSchema7 } -function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRecord { - if (!isPlainObject(value)) return { type: isRoot ? "object" : "string" } +function forceObjectRoot(result: JsonRecord, context: Context) { + if (result.type === "object") return result + if (typeof result.$ref === "string" && refIsObject(result.$ref, context)) return result + if (Array.isArray(result.anyOf)) { + const branches = result.anyOf.filter(isPlainObject).map((branch) => { + if (branch.type === "object" || typeof branch.$ref !== "string" || !refIsObject(branch.$ref, context)) { + return branch + } + const target = resolveRef(branch.$ref, context) + return isPlainObject(target) + ? sanitizeNode(target, context, { depth: 0, definition: false, root: false }) + : branch + }) + if (branches.length > 0 && branches.every((branch) => branch.type === "object")) { + return withMetadata(mergeObjectUnion(branches), result) + } + } + return withMetadata({ type: "object", properties: {} }, result) +} - const source = lowerAllOf(value, root) +function sanitizeNode(value: unknown, context: Context, position: Position): JsonRecord { + if (position.depth >= MAX_DEPTH) return {} + if (value === true || value === false) return position.root ? { type: "object", properties: {} } : {} + if (!isPlainObject(value)) return position.root ? { type: "object", properties: {} } : {} + if (Object.keys(value).length === 0) return position.root ? { type: "object", properties: {} } : {} + + const source = lowerAllOf(value, context) const ref = rewriteRef(source.$ref) - if (ref && refExists(ref, root)) return withRootMetadata({ $ref: ref }, source, root, isRoot) + if (ref && refExists(ref, context)) { + const siblings = Object.fromEntries( + Object.entries(source).filter(([key]) => !["$ref", "description", "title", "default"].includes(key)), + ) + if (Object.keys(siblings).length === 0) return withRootMetadata({ $ref: ref }, source, context, position) + if (Object.keys(siblings).length === 1 && siblings.nullable === true) { + return withRootMetadata({ anyOf: [{ $ref: ref }, { type: "null" }] }, source, context, position) + } + const target = resolveRef(ref, context) + if (isPlainObject(target)) { + return sanitizeNode({ ...target, ...siblings }, context, position) + } + } const typeList = Array.isArray(source.type) ? source.type.filter((item): item is string => typeof item === "string" && TYPES.has(item)) @@ -64,7 +111,7 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (source.nullable === true && !branches.some((branch) => branch.type === "null")) branches.push({ type: "null" }) const sanitized = branches - .map((branch) => sanitizeNode(branch, root)) + .map((branch) => sanitizeNode(branch, context, child(position))) .flatMap((branch) => (Object.keys(branch).length === 1 && Array.isArray(branch.anyOf) ? branch.anyOf : [branch])) const unique = [...new Map(sanitized.map((branch) => [JSON.stringify(branch), branch])).values()] const result = @@ -72,8 +119,8 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec ? (unique[0] ?? {}) : unique.length > 1 ? { anyOf: unique.slice(0, MAX_ANY_OF) } - : sanitizeNode(unionBase, root) - return withRootMetadata(result, source, root, isRoot) + : sanitizeNode(unionBase, context, position) + return withRootMetadata(result, source, context, position) } const explicitType = @@ -89,23 +136,31 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec const base = Object.fromEntries( Object.entries(source).filter(([key]) => !["type", "enum", "const", "$defs", "definitions", "$id"].includes(key)), ) - const branches = enumGroups.map((group) => sanitizeNode({ ...base, type: group.type, enum: group.values }, root)) + const branches = enumGroups.map((group) => + sanitizeNode({ ...base, type: group.type, enum: group.values }, context, child(position)), + ) const result = branches.length === 1 ? (branches[0] ?? {}) : { anyOf: branches } - return withRootMetadata(result, source, root, isRoot) + return withRootMetadata(result, source, context, position) } const type = explicitType ?? inferType(source, enumGroups) const result: JsonRecord = { type } if (typeof source.description === "string") result.description = source.description if (typeof source.title === "string") result.title = source.title - if ("default" in source) result.default = source.default + if ("default" in source && !position.definition) result.default = source.default if (type === "object") { if (isPlainObject(source.properties)) { + const remaining = Math.max(0, MAX_PROPERTIES - context.properties) + const entries = Object.entries(source.properties) + .filter( + ([name]) => + name.length > 0 && !RESERVED_PROPERTIES.has(name) && (!position.definition || !name.includes("/")), + ) + .slice(0, remaining) + context.properties += entries.length result.properties = Object.fromEntries( - Object.entries(source.properties) - .filter(([name]) => !RESERVED_PROPERTIES.has(name) && !name.includes("/")) - .map(([name, schema]) => [name, sanitizeNode(schema, root)]), + entries.map(([name, schema]) => [name, sanitizeNode(schema, context, child(position))]), ) } const properties = isPlainObject(result.properties) ? result.properties : undefined @@ -122,7 +177,9 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (typeof source.additionalProperties === "boolean") result.additionalProperties = source.additionalProperties if (isPlainObject(source.additionalProperties)) { result.additionalProperties = - Object.keys(source.additionalProperties).length === 0 ? {} : sanitizeNode(source.additionalProperties, root) + Object.keys(source.additionalProperties).length === 0 + ? {} + : sanitizeNode(source.additionalProperties, context, child(position)) } } @@ -135,7 +192,9 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec ? source.prefixItems.filter(isPlainObject) : [] const sanitized = [ - ...new Map(items.map((item) => sanitizeNode(item, root)).map((item) => [JSON.stringify(item), item])).values(), + ...new Map( + items.map((item) => sanitizeNode(item, context, child(position))).map((item) => [JSON.stringify(item), item]), + ).values(), ] if (sanitized.length === 1) result.items = sanitized[0] if (sanitized.length > 1) result.items = { anyOf: sanitized.slice(0, MAX_ANY_OF) } @@ -144,18 +203,20 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (type === "string") copyRange(result, source, "minLength", "maxLength", true, true) if (type === "number" || type === "integer") { - const minimum = - typeof source.minimum === "number" - ? source.minimum - : type === "integer" && typeof source.exclusiveMinimum === "number" - ? Math.floor(source.exclusiveMinimum) + 1 - : undefined - const maximum = - typeof source.maximum === "number" - ? source.maximum - : type === "integer" && typeof source.exclusiveMaximum === "number" - ? Math.ceil(source.exclusiveMaximum) - 1 - : undefined + const minimums = [ + ...(typeof source.minimum === "number" ? [source.minimum] : []), + ...(type === "integer" && typeof source.exclusiveMinimum === "number" + ? [Math.floor(source.exclusiveMinimum) + 1] + : []), + ] + const maximums = [ + ...(typeof source.maximum === "number" ? [source.maximum] : []), + ...(type === "integer" && typeof source.exclusiveMaximum === "number" + ? [Math.ceil(source.exclusiveMaximum) - 1] + : []), + ] + const minimum = minimums.length > 0 ? Math.max(...minimums) : undefined + const maximum = maximums.length > 0 ? Math.min(...maximums) : undefined copyRange(result, { minimum, maximum }, "minimum", "maximum", type === "integer", false) } @@ -166,17 +227,17 @@ function sanitizeNode(value: unknown, root: JsonRecord, isRoot = false): JsonRec if (values.length > 0) result.enum = values } - return withRootMetadata(result, source, root, isRoot) + return withRootMetadata(result, source, context, position) } -function withRootMetadata(result: JsonRecord, source: JsonRecord, root: JsonRecord, isRoot: boolean) { - if (!isRoot) return result +function withRootMetadata(result: JsonRecord, source: JsonRecord, context: Context, position: Position) { + if (!position.root) return result if (typeof source.$id === "string") result.$id = source.$id const sanitized = Object.fromEntries( - Object.entries(definitions(root)) + Object.entries(context.definitions) .filter(([name, schema]) => name.length > 0 && !name.includes("/") && isPlainObject(schema)) - .map(([name, schema]) => [name, sanitizeNode(schema, root)]), + .map(([name, schema]) => [name, sanitizeNode(schema, context, { depth: 0, definition: true, root: false })]), ) if (Object.keys(sanitized).length > 0) result.$defs = sanitized return result @@ -188,6 +249,188 @@ function withMetadata(result: JsonRecord, source: JsonRecord) { return result } +function child(position: Position): Position { + return { depth: position.depth + 1, definition: position.definition, root: false } +} + +function pruneInvalidRefs(schema: JsonRecord, root: JsonRecord): JsonRecord { + if (typeof schema.$ref === "string" && !resolveOutputRef(schema.$ref, root)) return {} + + const result = { ...schema } + if (isPlainObject(schema.properties)) { + result.properties = Object.fromEntries( + Object.entries(schema.properties).map(([name, property]) => [ + name, + isPlainObject(property) ? pruneInvalidRefs(property, root) : {}, + ]), + ) + } + if (isPlainObject(schema.items)) result.items = pruneInvalidRefs(schema.items, root) + if (isPlainObject(schema.additionalProperties)) { + result.additionalProperties = pruneInvalidRefs(schema.additionalProperties, root) + } + if (Array.isArray(schema.anyOf)) { + const branches = schema.anyOf.filter(isPlainObject).map((branch) => pruneInvalidRefs(branch, root)) + if (branches.length === 1) return withMetadata(branches[0] ?? {}, result) + result.anyOf = branches + } + if (isPlainObject(schema.$defs)) { + result.$defs = Object.fromEntries( + Object.entries(schema.$defs).map(([name, definition]) => [ + name, + isPlainObject(definition) ? pruneInvalidRefs(definition, root) : {}, + ]), + ) + } + return result +} + +function resolveOutputRef(ref: string, root: JsonRecord): JsonRecord | undefined { + if (ref === "#") return root + if (!ref.startsWith("#/$defs/") || ref === "#/$defs/") return + const parts = ref + .slice("#/$defs/".length) + .split("/") + .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) + return parts.reduce((current, part, index) => { + const value = index === 0 ? (isPlainObject(root.$defs) ? root.$defs[part] : undefined) : current?.[part] + return isPlainObject(value) ? value : undefined + }, undefined) +} + +function ensureTermination(schema: JsonRecord) { + if (terminates(schema, schema, new Set())) return schema + if (schema.type === "object") { + const { required: _, ...result } = schema + return result + } + if (schema.type === "array") { + const { items: _, ...result } = schema + return result + } + return emptyRoot(schema) +} + +function emptyRoot(source: JsonRecord) { + return { + type: "object", + properties: {}, + ...(typeof source.$id === "string" ? { $id: source.$id } : {}), + } +} + +function terminates(schema: JsonRecord, root: JsonRecord, refs: Set): boolean { + const types = schemaTypes(schema.type) + if (types.some((type) => type !== "object" && type !== "array")) return true + + if (types.includes("array")) { + if (!isPlainObject(schema.items) || Object.keys(schema.items).length === 0) return true + if (terminates(schema.items, root, new Set(refs))) return true + } + + if (types.includes("object")) { + const required = Array.isArray(schema.required) + ? schema.required.filter((item): item is string => typeof item === "string") + : [] + const properties = isPlainObject(schema.properties) ? schema.properties : undefined + if (required.length === 0 || !properties || Object.keys(properties).length === 0) return true + if ( + required.some((name) => { + const property = properties[name] + return isPlainObject(property) && terminates(property, root, new Set(refs)) + }) + ) { + return true + } + } + + if (Array.isArray(schema.anyOf)) { + if (schema.anyOf.some((branch) => isPlainObject(branch) && terminates(branch, root, new Set(refs)))) return true + } + + if (typeof schema.$ref === "string") { + if (refs.has(schema.$ref)) return false + const target = resolveOutputRef(schema.$ref, root) + if (!target) return false + const next = new Set(refs) + next.add(schema.$ref) + return terminates(target, root, next) + } + return Object.keys(schema).length === 0 +} + +function fitSchemaSize(schema: JsonRecord) { + if (schemaSize(schema) <= MAX_SCHEMA_SIZE) return schema + const compact = stripAnnotations(schema) + if (schemaSize(compact) <= MAX_SCHEMA_SIZE) return compact + return { type: "object", properties: {} } +} + +function schemaDepth(schema: JsonRecord, root: JsonRecord, refs: Set): number { + const properties = isPlainObject(schema.properties) + ? Math.max( + 0, + ...Object.values(schema.properties).map((item) => + isPlainObject(item) ? 1 + schemaDepth(item, root, refs) : 0, + ), + ) + : 0 + const items = isPlainObject(schema.items) ? schemaDepth(schema.items, root, refs) : 0 + const additionalProperties = isPlainObject(schema.additionalProperties) + ? schemaDepth(schema.additionalProperties, root, refs) + : 0 + const anyOf = Array.isArray(schema.anyOf) + ? Math.max(0, ...schema.anyOf.map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0))) + : 0 + const definitions = isPlainObject(schema.$defs) + ? Math.max( + 0, + ...Object.values(schema.$defs).map((item) => (isPlainObject(item) ? schemaDepth(item, root, refs) : 0)), + ) + : 0 + const ref = (() => { + if (typeof schema.$ref !== "string" || refs.has(schema.$ref)) return 0 + const target = resolveOutputRef(schema.$ref, root) + if (!target) return 0 + const next = new Set(refs) + next.add(schema.$ref) + return schemaDepth(target, root, next) + })() + return Math.max(properties, items, additionalProperties, anyOf, definitions, ref) +} + +function stripAnnotations(value: JsonRecord): JsonRecord { + return Object.fromEntries( + Object.entries(value).flatMap(([key, item]) => { + if (["description", "title", "default"].includes(key)) return [] + if ((key === "properties" || key === "$defs") && isPlainObject(item)) { + return [ + [ + key, + Object.fromEntries( + Object.entries(item).map(([name, schema]) => [ + name, + isPlainObject(schema) ? stripAnnotations(schema) : schema, + ]), + ), + ], + ] + } + if (Array.isArray(item)) { + return [[key, item.map((entry) => (isPlainObject(entry) ? stripAnnotations(entry) : entry))]] + } + return [[key, isPlainObject(item) ? stripAnnotations(item) : item]] + }), + ) +} + +function schemaSize(schema: JsonRecord) { + const json = JSON.stringify(schema).replace(/[<>&\u2028\u2029]/g, (char) => { + return `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}` + }) + return new TextEncoder().encode(json).byteLength +} + function mergeObjectUnion(branches: JsonRecord[]) { const propertyNames = new Set( branches.flatMap((branch) => (isPlainObject(branch.properties) ? Object.keys(branch.properties) : [])), @@ -225,12 +468,12 @@ function mergeObjectUnion(branches: JsonRecord[]) { } } -function lowerAllOf(source: JsonRecord, root: JsonRecord) { +function lowerAllOf(source: JsonRecord, context: Context) { if (!Array.isArray(source.allOf)) return source const base = Object.fromEntries(Object.entries(source).filter(([key]) => key !== "allOf")) return source.allOf.filter(isPlainObject).reduce((result, branch) => { const ref = rewriteRef(branch.$ref) - const target = ref ? resolveRef(ref, root) : undefined + const target = ref ? resolveRef(ref, context) : undefined const next = isPlainObject(target) ? { ...target, ...branch, $ref: undefined } : branch return intersect(result, next) ?? merge(result, next) }, base) @@ -333,38 +576,63 @@ function rewriteRef(value: unknown) { if (ref === "#" || ref.startsWith("#/$defs/")) return ref } -function refExists(ref: string, root: JsonRecord) { +function refExists(ref: string, context: Context) { if (ref === "#") return true - return isPlainObject(resolveRef(ref, root)) + return ref !== "#/$defs/" && isPlainObject(resolveRef(ref, context)) } -function refIsObject(ref: string, root: JsonRecord) { - const target = resolveRef(ref, root) +function refIsObject(ref: string, context: Context) { + const target = resolveRef(ref, context) if (!isPlainObject(target)) return false - const lowered = lowerAllOf(target, root) + const lowered = lowerAllOf(target, context) return lowered.type === "object" || isPlainObject(lowered.properties) } -function resolveRef(ref: string, root: JsonRecord): unknown { - if (ref === "#") return root +function resolveRef(ref: string, context: Context): unknown { + if (ref === "#") return context.root return ref .slice("#/$defs/".length) .split("/") .map((part) => part.replaceAll("~1", "/").replaceAll("~0", "~")) .reduce((current, part, index) => { - if (index === 0) return definitions(root)[part] + if (index === 0) return context.definitions[part] if (!isPlainObject(current)) return return current[part] }, undefined) } -function definitions(root: JsonRecord) { +function collectDefinitions(root: JsonRecord) { return { ...(isPlainObject(root.definitions) ? root.definitions : {}), ...(isPlainObject(root.$defs) ? root.$defs : {}), } } +function referencedDefinitions(root: JsonRecord, definitions: JsonRecord) { + const names = new Set() + const visit = (value: unknown) => { + if (Array.isArray(value)) { + value.forEach(visit) + return + } + if (!isPlainObject(value)) return + + const ref = rewriteRef(value.$ref) + if (ref?.startsWith("#/$defs/")) { + const name = ref.slice("#/$defs/".length).split("/", 1)[0]?.replaceAll("~1", "/").replaceAll("~0", "~") + if (name && !names.has(name) && isPlainObject(definitions[name])) { + names.add(name) + visit(definitions[name]) + } + } + Object.entries(value).forEach(([key, item]) => { + if (key !== "$defs" && key !== "definitions") visit(item) + }) + } + visit(root) + return Object.fromEntries([...names].map((name) => [name, definitions[name]])) +} + function schemaTypes(value: unknown) { if (typeof value === "string" && TYPES.has(value)) return [value] if (!Array.isArray(value)) return [] diff --git a/packages/opencode/test/provider/mfjs.test.ts b/packages/opencode/test/provider/mfjs.test.ts index bee3a9f4d2..e30af14d27 100644 --- a/packages/opencode/test/provider/mfjs.test.ts +++ b/packages/opencode/test/provider/mfjs.test.ts @@ -1,6 +1,11 @@ import { describe, expect, test } from "bun:test" import { MFJS } from "@/provider/mfjs" +function asObject(value: unknown) { + if (typeof value === "object" && value !== null && !Array.isArray(value)) return value as Record + throw new Error("expected object") +} + describe("MFJS.sanitize", () => { test("removes siblings from references while preserving definitions", () => { expect( @@ -260,6 +265,21 @@ describe("MFJS.sanitize", () => { }) }) + test("omits unreferenced definitions", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { type: "string" } }, + $defs: { + Unused: { type: "object", properties: { ignored: { type: "string" } } }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "string" } }, + }) + }) + test("drops references to missing definitions", () => { expect( MFJS.sanitize({ @@ -294,6 +314,186 @@ describe("MFJS.sanitize", () => { }) }) + test("preserves unconstrained child schemas", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + empty: {}, + truthy: true, + falsy: false, + }, + }), + ).toEqual({ + type: "object", + properties: { + empty: {}, + truthy: {}, + falsy: {}, + }, + }) + }) + + test("removes empty property names and references to filtered definitions", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + "": { type: "string" }, + value: { $ref: "#/$defs/Bad~1Name" }, + }, + required: ["", "value"], + $defs: { + "Bad/Name": { type: "object" }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: {} }, + required: ["value"], + }) + }) + + test("makes required recursive roots terminable", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + required: ["node"], + $defs: { + Node: { + type: "object", + properties: { next: { $ref: "#/$defs/Node" } }, + required: ["next"], + }, + }, + }), + ).toEqual({ + type: "object", + properties: { node: { $ref: "#/$defs/Node" } }, + $defs: { + Node: { + type: "object", + properties: { next: { $ref: "#/$defs/Node" } }, + required: ["next"], + }, + }, + }) + }) + + test("preserves nullable reference semantics", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { $ref: "#/$defs/Value", nullable: true }, + }, + $defs: { + Value: { type: "object", properties: { label: { type: "string" } } }, + }, + }), + ).toEqual({ + type: "object", + properties: { + value: { anyOf: [{ $ref: "#/$defs/Value" }, { type: "null" }] }, + }, + $defs: { + Value: { type: "object", properties: { label: { type: "string" } } }, + }, + }) + }) + + test("merges referenced object branches at the root", () => { + expect( + MFJS.sanitize({ + anyOf: [{ $ref: "#/$defs/Left" }, { $ref: "#/$defs/Right" }], + $defs: { + Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, + Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + left: { type: "string" }, + right: { type: "number" }, + }, + $defs: { + Left: { type: "object", properties: { left: { type: "string" } }, required: ["left"] }, + Right: { type: "object", properties: { right: { type: "number" } }, required: ["right"] }, + }, + }) + }) + + test("combines inclusive and exclusive integer bounds", () => { + expect( + MFJS.sanitize({ + type: "object", + properties: { + value: { + type: "integer", + minimum: 0, + exclusiveMinimum: 5, + maximum: 10, + exclusiveMaximum: 8, + }, + }, + }), + ).toEqual({ + type: "object", + properties: { value: { type: "integer", minimum: 6, maximum: 7 } }, + }) + }) + + test("enforces Walle property and size limits", () => { + const properties = Object.fromEntries( + Array.from({ length: 3001 }, (_, index) => [`property_${index}`, { type: "string" }]), + ) + const limited = asObject(MFJS.sanitize({ type: "object", properties })) + + expect(Object.keys(asObject(limited.properties))).toHaveLength(3000) + expect( + MFJS.sanitize({ + type: "object", + description: "<".repeat(20_000), + properties: { + description: { type: "string" }, + default: { type: "string" }, + }, + }), + ).toEqual({ + type: "object", + properties: { + description: { type: "string" }, + default: { type: "string" }, + }, + }) + }) + + test("enforces Walle depth limits", () => { + const deep = Array.from({ length: 35 }).reduce>( + (schema) => ({ type: "object", properties: { next: schema }, required: ["next"] }), + { type: "string" }, + ) + + expect(JSON.stringify(MFJS.sanitize(deep)).match(/properties/g)?.length).toBe(30) + }) + + test("enforces depth limits across references", () => { + const definition = Array.from({ length: 30 }).reduce>( + (schema) => ({ type: "object", properties: { next: schema } }), + { type: "string" }, + ) + + expect( + MFJS.sanitize({ + type: "object", + properties: { value: { $ref: "#/$defs/Value" } }, + $defs: { Value: definition }, + }), + ).toEqual({ type: "object", properties: {} }) + }) + test("is idempotent", () => { const once = MFJS.sanitize({ type: "object",