diff --git a/docs/mcp-contracts.md b/docs/mcp-contracts.md index 8a5cc6cf9f..90ffbc97de 100644 --- a/docs/mcp-contracts.md +++ b/docs/mcp-contracts.md @@ -31,9 +31,11 @@ check the complete catalog after SDK conversion and its generated counterpart. This is REA's local compatibility profile; individual model APIs can impose additional limits. -Output schemas share repeated definitions through local references while -preserving their complete fields and validation rules. Canonical Zod validation -and the input compatibility profile continue to apply. +Input and output schemas share repeated definitions through schema-local +references while preserving their complete fields and validation rules. Input +properties retain their descriptions and literal examples. Root input unions +keep their inline object presentation to avoid redundant branch nesting. +Canonical Zod validation and the input compatibility profile continue to apply. For passive `compare_web_captures` inputs, pass each complete `inspect_web_page` result in `before.inspection` or `after.inspection`, with an diff --git a/src/contracts/inputSchemaPresentation.ts b/src/contracts/inputSchemaPresentation.ts new file mode 100644 index 0000000000..5400687289 --- /dev/null +++ b/src/contracts/inputSchemaPresentation.ts @@ -0,0 +1,163 @@ +const schemaMaps = new Set([ + "$defs", + "definitions", + "properties", + "patternProperties", + "dependentSchemas", + "dependencies", +]); +const schemaArrays = new Set(["allOf", "anyOf", "oneOf", "prefixItems"]); +const schemaChildren = new Set([ + "items", + "additionalItems", + "contains", + "additionalProperties", + "unevaluatedProperties", + "unevaluatedItems", + "propertyNames", + "not", + "if", + "then", + "else", +]); + +const isObject = (value: unknown): value is Readonly> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const resolveReference = ( + root: Readonly>, + reference: string, +): unknown => { + if (!reference.startsWith("#/")) return undefined; + let value: unknown = root; + for (const token of reference.slice(2).split("/")) { + const key = token.replaceAll("~1", "/").replaceAll("~0", "~"); + if (!isObject(value) || !Object.hasOwn(value, key)) return undefined; + value = value[key]; + } + return value; +}; + +const referencedDescription = ( + root: Readonly>, + schema: Readonly>, +): string | undefined => { + let value: unknown = schema; + const active = new Set(); + while (isObject(value)) { + if (typeof value.description === "string") return value.description; + if (typeof value.$ref !== "string" || active.has(value.$ref)) + return undefined; + active.add(value.$ref); + value = resolveReference(root, value.$ref); + } + return undefined; +}; + +const describeSchema = ( + schema: unknown, + root: Readonly>, + describeProperty: (property: string) => string, +): unknown => { + if (!isObject(schema)) return schema; + return Object.fromEntries( + Object.entries(schema).map(([key, value]) => { + if (schemaMaps.has(key) && isObject(value)) + return [ + key, + Object.fromEntries( + Object.entries(value).map(([name, child]) => { + const described = describeSchema(child, root, describeProperty); + return [ + name, + key === "properties" && isObject(described) + ? { + ...described, + description: + referencedDescription(root, described) ?? + describeProperty(name), + } + : described, + ]; + }), + ), + ]; + if (schemaArrays.has(key) && Array.isArray(value)) + return [ + key, + value.map((child) => describeSchema(child, root, describeProperty)), + ]; + if (schemaChildren.has(key)) + return [ + key, + Array.isArray(value) + ? value.map((child) => + describeSchema(child, root, describeProperty), + ) + : describeSchema(value, root, describeProperty), + ]; + // Annotation values and literal const/enum data are not schemas. + return [key, value]; + }), + ); +}; + +const flattenRootUnion = ( + value: Record, + describeProperty: (property: string) => string, +): Record => { + if (!Array.isArray(value.anyOf)) return value; + const branches = value.anyOf; + const objects = branches.filter(isObject); + if (objects.length === 0 || objects.length !== branches.length) return value; + const properties = new Map(); + for (const branch of objects) { + if (!isObject(branch.properties)) return value; + for (const [name, schema] of Object.entries(branch.properties)) { + const previous = properties.get(name); + if (!properties.has(name)) properties.set(name, schema); + else if (JSON.stringify(previous) !== JSON.stringify(schema)) + properties.set(name, { + anyOf: [previous, schema], + description: describeProperty(name), + }); + } + } + // Invocation still uses the exact canonical union, including branch rules. + const { anyOf: _anyOf, required: _required, ...root } = value; + const requiredByBranch = objects.map((branch) => + Array.isArray(branch.required) + ? branch.required.filter( + (item): item is string => typeof item === "string", + ) + : [], + ); + const required = requiredByBranch.reduce((common, current) => + common.filter((name) => current.includes(name)), + ); + const minProperties = Math.max( + typeof root.minProperties === "number" ? root.minProperties : 0, + Math.min(...requiredByBranch.map((names) => names.length)), + ); + return { + ...root, + type: "object", + properties: Object.fromEntries(properties), + ...(minProperties > 0 ? { minProperties } : {}), + ...(required.length > 0 ? { required } : {}), + ...(objects.every((branch) => branch.additionalProperties === false) + ? { additionalProperties: false } + : {}), + }; +}; + +/** Preserve property guidance and literal data while advertising object roots. */ +export const presentInputJsonSchema = ( + root: Record, + describeProperty: (property: string) => string, +): Record => { + const described = describeSchema(root, root, describeProperty); + return isObject(described) + ? flattenRootUnion(described, describeProperty) + : root; +}; diff --git a/src/contracts/toolInputReferences.test.ts b/src/contracts/toolInputReferences.test.ts new file mode 100644 index 0000000000..172daf1936 --- /dev/null +++ b/src/contracts/toolInputReferences.test.ts @@ -0,0 +1,247 @@ +import { Ajv } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { inlineLocalJsonSchemaReferences } from "../../tests/fixtures/localJsonSchemaReferences.js"; +import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; +import { TOOL_CONTRACTS, toolContract } from "./toolContracts.js"; +import { toolInputSchemaWithMetadata } from "./toolSchemaMetadata.js"; + +describe("advertised input references", () => { + it.each(["draft-2020-12", "draft-07"] as const)( + "preserves production native-selector constraints in %s", + (target) => { + const contract = toolContract("observe_native_calls"); + const advertised = toolInputSchemaWithMetadata(contract)[ + "~standard" + ].jsonSchema.input({ target }); + const ajv = + target === "draft-2020-12" + ? new Ajv2020({ strict: false }) + : new Ajv({ strict: false }); + const validate = ajv.compile(advertised); + for (const selector of [ + "setFoo:", + "dataTaskWithRequest:completionHandler:", + "", + "bad selector:", + "bad[selector]:", + "set\0Foo:", + "setFoo:\n", + ]) + for (const extra of [{}, { unexpected: true }]) { + const sample = { + breakpoints: [ + { + kind: "objc-method", + class_name: "NSURLSession", + selector, + ...extra, + }, + ], + }; + expect(validate(sample)).toBe( + contract.inputSchema.safeParse(sample).success, + ); + } + }, + ); + + it.each(["draft-2020-12", "draft-07"] as const)( + "preserves complete input meaning and examples through %s references", + (target) => { + for (const contract of TOOL_CONTRACTS) { + const projection = + toolInputSchemaWithMetadata(contract)["~standard"].jsonSchema.input; + const referenced = projection({ target }); + const inline = projection({ + target, + libraryOptions: { reused: "inline" }, + }); + expect(referenced.type, contract.name).toBe("object"); + expect( + inlineLocalJsonSchemaReferences(referenced), + contract.name, + ).toEqual(inlineLocalJsonSchemaReferences(inline)); + expect(referenced.examples, contract.name).toEqual( + contract.examples.map(({ input }) => input), + ); + expect(projection({ target })).toBe(referenced); + } + }, + ); + + it.each(["draft-2020-12", "draft-07"] as const)( + "retains specialized descriptions, literal metadata and strict validation in %s", + (target) => { + const literal = { + properties: { target: { $ref: "literal caller data" } }, + examples: [{ properties: { path: {} } }], + }; + const range = z + .object({ + offset: z.number().int().nonnegative().max(65535), + length: z.number().int().min(1), + }) + .strict() + .describe("Exact byte range in the selected artifact."); + const canonical = z + .object({ + before: range, + after: range, + metadata: z + .record(z.string(), z.unknown()) + .meta({ default: literal, examples: [literal] }), + constructor: z.string(), + }) + .strict(); + const advertised = toolInputSchemaWithMetadata({ + ...toolContract("open_binary"), + inputSchema: canonical, + })["~standard"].jsonSchema.input({ target }); + expect(inlineLocalJsonSchemaReferences(advertised)).toMatchObject({ + properties: { + before: { description: "Exact byte range in the selected artifact." }, + after: { description: "Exact byte range in the selected artifact." }, + metadata: { default: literal, examples: [literal] }, + constructor: { description: "Value for constructor." }, + }, + }); + const ajv = + target === "draft-2020-12" + ? new Ajv2020({ strict: false }) + : new Ajv({ strict: false }); + const validate = ajv.compile(advertised); + const valid = { + before: { offset: 0, length: 1 }, + after: { offset: 65535, length: 2 }, + metadata: literal, + constructor: "constructor data", + }; + const samples = [ + valid, + ...[ + { offset: -1 }, + { offset: 65536 }, + { offset: 0.5 }, + { length: 0 }, + { unexpected: true }, + ].map((change) => ({ + ...valid, + before: { ...valid.before, ...change }, + })), + { ...valid, after: null }, + { ...valid, unexpected: true }, + ]; + expect(canonical.safeParse(valid).success).toBe(true); + for (const sample of samples) + expect(validate(sample)).toBe(canonical.safeParse(sample).success); + }, + ); +}); + +describe("root input presentation", () => { + it("normalizes repeated string intersections without widening validation", () => { + const root = { + type: "string", + minLength: 1, + description: "Exact selector.", + allOf: [ + { type: "string", pattern: "^[^\\s]+$" }, + { allOf: [{ type: "string", pattern: "^[^\\s]+$" }], minLength: 2 }, + ], + }; + const normalized = inlineLocalJsonSchemaReferences(root); + expect(normalized).toEqual({ + type: "string", + minLength: 2, + pattern: "^[^\\s]+$", + description: "Exact selector.", + }); + const ajv = new Ajv2020({ strict: false }); + const original = ajv.compile(root); + const projected = ajv.compile( + z.record(z.string(), z.unknown()).parse(normalized), + ); + for (const sample of ["", "a", "aa", "a a", "a\n", null, {}]) + expect(projected(sample)).toBe(original(sample)); + }); + + it("preserves prototype-named properties in object-root presentation", () => { + const fields = Object.fromEntries([ + ["__proto__", { type: "string" }], + ["constructor", { type: "number" }], + ]); + const branch = { + type: "object", + properties: fields, + required: ["__proto__"], + additionalProperties: false, + }; + const presented = presentInputJsonSchema( + { anyOf: [branch, branch] }, + (property) => `Parameter ${property}`, + ); + expect(presented).toMatchObject({ + type: "object", + required: ["__proto__"], + minProperties: 1, + additionalProperties: false, + properties: fields, + }); + expect(presented.anyOf).toBeUndefined(); + expect(Object.getPrototypeOf(presented.properties)).toBe(Object.prototype); + expect(Object.hasOwn(presented.properties ?? {}, "__proto__")).toBe(true); + }); + + it("retains reference siblings and does not manufacture root branch constraints", () => { + const branch = { $ref: "#/$defs/object", additionalProperties: false }; + const root = { + $defs: { + object: { type: "object", properties: { value: { type: "string" } } }, + }, + anyOf: [branch], + }; + const presented = presentInputJsonSchema(root, (property) => property); + expect(presented.anyOf).toEqual([branch]); + const ajv = new Ajv2020({ strict: false }); + for (const sample of [{}, { value: "value" }, null]) + expect(ajv.compile(presented)(sample)).toBe(ajv.compile(root)(sample)); + }); + + it("preserves specialized guidance across reference chains without following literal references", () => { + const literal = { properties: { literal: {} }, $ref: "#/$defs/missing" }; + const presented = presentInputJsonSchema( + { + $defs: { + "a~b": { + type: "string", + description: "Literal URL selected by the caller.", + }, + alias: { $ref: "#/$defs/a~0b" }, + }, + type: "object", + properties: { + url: { $ref: "#/$defs/alias" }, + other: { $ref: "#/$defs/alias", description: "Explicit override." }, + }, + default: literal, + const: literal, + examples: [literal], + enum: [literal], + }, + (property) => `Parameter ${property}`, + ); + expect(presented).toMatchObject({ + properties: { + url: { description: "Literal URL selected by the caller." }, + other: { description: "Explicit override." }, + }, + default: literal, + const: literal, + examples: [literal], + enum: [literal], + }); + }); +}); diff --git a/src/contracts/toolSchemaMetadata.ts b/src/contracts/toolSchemaMetadata.ts index 3a845a9ba6..8f5d9b4833 100644 --- a/src/contracts/toolSchemaMetadata.ts +++ b/src/contracts/toolSchemaMetadata.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import type { ToolContract } from "./toolContractTypes.js"; +import { presentInputJsonSchema } from "./inputSchemaPresentation.js"; const PROPERTY_DESCRIPTIONS: Readonly> = { addresses: "Ordered provider-normalized procedure addresses to analyze.", @@ -56,11 +57,25 @@ export const toolInputSchemaWithMetadata = ( withAdvertisedJsonSchema( contract.inputSchema, "input", - (project) => (options) => - flattenRootUnion({ - ...describeProperties(project(options)), + (project) => (options) => { + const shared = project({ + ...options, + libraryOptions: { reused: "ref", ...options.libraryOptions }, + }); + // Root-union flattening compares branch properties before widening them. + // Keep its inline representation: distinct references can name equal + // schemas and otherwise introduce redundant, deeper anyOf properties. + const compatible = Array.isArray(shared.anyOf) + ? project({ + ...options, + libraryOptions: { ...options.libraryOptions, reused: "inline" }, + }) + : shared; + return { + ...presentInputJsonSchema(compatible, fallbackPropertyDescription), examples: contract.examples.map(({ input }) => input), - }), + }; + }, ); /** Share repeated output definitions without changing canonical validation. */ @@ -122,89 +137,10 @@ const memoizeByTarget = ( }; }; -/** Keep MCP tool roots object-shaped for clients that reject root unions. */ -const flattenRootUnion = ( - value: Record, -): Record => { - if (!Array.isArray(value.anyOf)) return value; - const branches = value.anyOf.filter(isObject); - if (branches.length === 0 || branches.length !== value.anyOf.length) - return value; - - const properties: Record = {}; - for (const branch of branches) { - if (!isObject(branch.properties)) return value; - for (const [name, schema] of Object.entries(branch.properties)) { - const previous = properties[name]; - if (previous === undefined) properties[name] = schema; - else if (JSON.stringify(previous) !== JSON.stringify(schema)) - properties[name] = { - anyOf: [previous, schema], - description: fallbackPropertyDescription(name), - }; - } - } - - // The canonical Zod parser still enforces branch validation at invocation. - const { anyOf: _anyOf, required: _required, ...root } = value; - const requiredByBranch = branches.map((branch) => - Array.isArray(branch.required) - ? branch.required.filter( - (item): item is string => typeof item === "string", - ) - : [], - ); - const required = requiredByBranch.reduce((common, current) => - common.filter((name) => current.includes(name)), - ); - const minProperties = Math.max( - typeof root.minProperties === "number" ? root.minProperties : 0, - Math.min(...requiredByBranch.map((names) => names.length)), - ); - return { - ...root, - type: "object", - properties, - ...(minProperties > 0 ? { minProperties } : {}), - ...(required.length > 0 ? { required } : {}), - ...(branches.every((branch) => branch.additionalProperties === false) - ? { additionalProperties: false } - : {}), - }; -}; - -const describeProperties = ( - value: Readonly>, -): Record => - Object.fromEntries( - Object.entries(value).map(([key, child]) => { - if (key !== "properties" || !isObject(child)) - return [key, describeValue(child)]; - return [ - key, - Object.fromEntries( - Object.entries(child).map(([property, propertySchema]) => [ - property, - isObject(propertySchema) && - typeof propertySchema.description !== "string" - ? { - ...describeProperties(propertySchema), - description: fallbackPropertyDescription(property), - } - : describeValue(propertySchema), - ]), - ), - ]; - }), - ); - -const describeValue = (value: unknown): unknown => { - if (Array.isArray(value)) return value.map(describeValue); - return isObject(value) ? describeProperties(value) : value; -}; - const fallbackPropertyDescription = (property: string): string => { - const explicit = PROPERTY_DESCRIPTIONS[property]; + const explicit = Object.hasOwn(PROPERTY_DESCRIPTIONS, property) + ? PROPERTY_DESCRIPTIONS[property] + : undefined; if (explicit !== undefined) return explicit; const words = property.replaceAll("_", " "); if (property.startsWith("max_")) @@ -231,6 +167,3 @@ const fallbackPropertyDescription = (property: string): string => { return `Whether ${words}.`; return `Value for ${words}.`; }; - -const isObject = (value: unknown): value is Readonly> => - typeof value === "object" && value !== null && !Array.isArray(value); diff --git a/tests/boundary/mcp/toolSchemaValidity.test.ts b/tests/boundary/mcp/toolSchemaValidity.test.ts index d2ac26ddfe..0049715067 100644 --- a/tests/boundary/mcp/toolSchemaValidity.test.ts +++ b/tests/boundary/mcp/toolSchemaValidity.test.ts @@ -103,25 +103,44 @@ function expectKnownAuthorityHints(tools: readonly ToolSchemas[]): void { function expectRecursivePropertyDescriptions( schema: unknown, path: string, + root: unknown = schema, + active: ReadonlySet = new Set(), ): void { if (!isRecord(schema)) return; + if (typeof schema.$ref === "string" && !active.has(schema.$ref)) + expectRecursivePropertyDescriptions( + resolveReference(root, schema.$ref), + `${path}.${schema.$ref}`, + root, + new Set([...active, schema.$ref]), + ); if (isRecord(schema.properties)) { for (const [property, child] of Object.entries(schema.properties)) { expect(child, `${path}.${property}`).toMatchObject({ description: expect.any(String), }); - expectRecursivePropertyDescriptions(child, `${path}.${property}`); + expectRecursivePropertyDescriptions( + child, + `${path}.${property}`, + root, + active, + ); } } for (const key of ["items", "additionalProperties"]) if (schema[key] !== undefined) - expectRecursivePropertyDescriptions(schema[key], path); + expectRecursivePropertyDescriptions(schema[key], path, root, active); for (const key of ["allOf", "anyOf", "oneOf", "prefixItems"]) { const children = schema[key]; if (Array.isArray(children)) children.forEach((child: unknown, index: number) => - expectRecursivePropertyDescriptions(child, `${path}.${key}[${index}]`), + expectRecursivePropertyDescriptions( + child, + `${path}.${key}[${index}]`, + root, + active, + ), ); } } diff --git a/tests/fixtures/localJsonSchemaReferences.ts b/tests/fixtures/localJsonSchemaReferences.ts index aa8efdf131..c118623f70 100644 --- a/tests/fixtures/localJsonSchemaReferences.ts +++ b/tests/fixtures/localJsonSchemaReferences.ts @@ -19,6 +19,16 @@ const schemaChildren = new Set([ ]); const lowerBounds = new Set(["minimum", "exclusiveMinimum"]); const upperBounds = new Set(["maximum", "exclusiveMaximum"]); +const annotations = new Set([ + "title", + "description", + "$comment", + "default", + "examples", + "deprecated", + "readOnly", + "writeOnly", +]); const scopedKeywords = new Set([ "properties", "patternProperties", @@ -34,6 +44,102 @@ const scopedKeywords = new Set([ const record = (value: unknown): value is Record => typeof value === "object" && value !== null && !Array.isArray(value); +const compatibleSibling = ( + key: string, + previous: unknown, + value: unknown, +): boolean => + previous === undefined || + JSON.stringify(previous) === JSON.stringify(value) || + annotations.has(key) || + (typeof previous === "number" && + typeof value === "number" && + (lowerBounds.has(key) || upperBounds.has(key))); + +const mergeSiblings = ( + schema: Record, + siblings: Record, +): Record => { + const merged = { ...schema, ...siblings }; + for (const [key, value] of Object.entries(siblings)) { + const previous = schema[key]; + if (!compatibleSibling(key, previous, value)) + throw new Error(`Conflicting schema reference sibling: ${key}`); + if (typeof previous !== "number" || typeof value !== "number") continue; + if (lowerBounds.has(key)) merged[key] = Math.max(previous, value); + else if (upperBounds.has(key)) merged[key] = Math.min(previous, value); + } + return merged; +}; + +const normalizeStringIntersection = ( + schema: Record, +): Record => { + if ( + (schema.type !== undefined && schema.type !== "string") || + !Array.isArray(schema.allOf) + ) + return schema; + let requiresString = schema.type === "string"; + const validBounds = (value: Record): boolean => + ["minLength", "maxLength"].every( + (key) => + !Object.hasOwn(value, key) || + (typeof value[key] === "number" && + Number.isInteger(value[key]) && + value[key] >= 0), + ) && + (!Object.hasOwn(value, "pattern") || typeof value.pattern === "string"); + if (!validBounds(schema)) return schema; + const patterns = new Set(); + let minimum = typeof schema.minLength === "number" ? schema.minLength : 0; + let maximum = + typeof schema.maxLength === "number" ? schema.maxLength : Infinity; + if (typeof schema.pattern === "string") patterns.add(schema.pattern); + const collect = (child: unknown): boolean => { + if (!record(child) || (child.type !== undefined && child.type !== "string")) + return false; + if ( + !validBounds(child) || + (Object.hasOwn(child, "allOf") && !Array.isArray(child.allOf)) + ) + return false; + if (child.type === "string") requiresString = true; + if ( + Object.keys(child).some( + (key) => + !["type", "pattern", "minLength", "maxLength", "allOf"].includes(key), + ) + ) + return false; + if (typeof child.pattern === "string") patterns.add(child.pattern); + if (typeof child.minLength === "number") + minimum = Math.max(minimum, child.minLength); + if (typeof child.maxLength === "number") + maximum = Math.min(maximum, child.maxLength); + return !Array.isArray(child.allOf) || child.allOf.every(collect); + }; + if (!schema.allOf.every(collect) || !requiresString) return schema; + const { + allOf: _allOf, + pattern: _pattern, + minLength: _min, + maxLength: _max, + ...root + } = schema; + const unique = [...patterns]; + return { + ...root, + type: "string", + ...(minimum > 0 ? { minLength: minimum } : {}), + ...(maximum < Infinity ? { maxLength: maximum } : {}), + ...(unique.length === 1 ? { pattern: unique[0] } : {}), + ...(unique.length > 1 + ? { allOf: unique.map((pattern) => ({ type: "string", pattern })) } + : {}), + }; +}; + /** Inline finite local references for representation-independent schema assertions. */ export const inlineLocalJsonSchemaReferences = ( root: Readonly>, @@ -68,27 +174,10 @@ export const inlineLocalJsonSchemaReferences = ( visit(siblings, active), ], }; - const merged = { ...referenced, ...siblings }; - for (const [key, entry] of Object.entries(siblings)) { - const previous = referenced[key]; - if ( - previous === undefined || - JSON.stringify(previous) === JSON.stringify(entry) - ) - continue; - if (typeof previous === "number" && typeof entry === "number") { - if (lowerBounds.has(key)) { - merged[key] = Math.max(previous, entry); - continue; - } - if (upperBounds.has(key)) { - merged[key] = Math.min(previous, entry); - continue; - } - } - throw new Error(`Conflicting schema reference sibling: ${key}`); - } - return visit(merged, new Set([...active, reference])); + return visit( + mergeSiblings(referenced, siblings), + new Set([...active, reference]), + ); } if (Array.isArray(value.allOf) && value.allOf.length === 1) { const child = visit(value.allOf[0], active); @@ -96,25 +185,11 @@ export const inlineLocalJsonSchemaReferences = ( if ( record(child) && !Object.keys(siblings).some((key) => scopedKeywords.has(key)) && - Object.entries(siblings).every( - ([key, entry]) => - child[key] === undefined || - JSON.stringify(child[key]) === JSON.stringify(entry) || - (typeof child[key] === "number" && - typeof entry === "number" && - (lowerBounds.has(key) || upperBounds.has(key))), + Object.entries(siblings).every(([key, entry]) => + compatibleSibling(key, child[key], entry), ) ) { - const merged = { ...child, ...siblings }; - for (const [key, entry] of Object.entries(siblings)) { - const previous = child[key]; - if (typeof previous !== "number" || typeof entry !== "number") - continue; - if (lowerBounds.has(key)) merged[key] = Math.max(previous, entry); - else if (upperBounds.has(key)) - merged[key] = Math.min(previous, entry); - } - return visit(merged, active); + return visit(mergeSiblings(child, siblings), active); } } const normalized = { ...value }; @@ -139,31 +214,33 @@ export const inlineLocalJsonSchemaReferences = ( normalized.exclusiveMaximum <= normalized.maximum ) delete normalized.maximum; - return Object.fromEntries( - Object.entries(normalized) - .filter(([key]) => key !== "$defs" && key !== "definitions") - .map(([key, child]) => { - if (schemaMaps.has(key) && record(child)) - return [ - key, - Object.fromEntries( - Object.entries(child).map(([name, schema]) => [ - name, - visit(schema, active), - ]), - ), - ]; - if (schemaArrays.has(key) && Array.isArray(child)) - return [key, child.map((schema) => visit(schema, active))]; - if (schemaChildren.has(key)) - return [ - key, - Array.isArray(child) - ? child.map((schema) => visit(schema, active)) - : visit(child, active), - ]; - return [key, child]; - }), + return normalizeStringIntersection( + Object.fromEntries( + Object.entries(normalized) + .filter(([key]) => key !== "$defs" && key !== "definitions") + .map(([key, child]) => { + if (schemaMaps.has(key) && record(child)) + return [ + key, + Object.fromEntries( + Object.entries(child).map(([name, schema]) => [ + name, + visit(schema, active), + ]), + ), + ]; + if (schemaArrays.has(key) && Array.isArray(child)) + return [key, child.map((schema) => visit(schema, active))]; + if (schemaChildren.has(key)) + return [ + key, + Array.isArray(child) + ? child.map((schema) => visit(schema, active)) + : visit(child, active), + ]; + return [key, child]; + }), + ), ); }; return visit(root, new Set());