mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
perf(mcp): reuse input schemas without losing caller guidance (#1162)
This commit is contained in:
1 parent
4fb2f3e6ed
commit
e1afbc1914
6 files changed
+599
-158
No files matched your search
@@ -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
|
||||
|
||||
@@ -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<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const resolveReference = (
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
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<Record<string, unknown>>,
|
||||
schema: Readonly<Record<string, unknown>>,
|
||||
): string | undefined => {
|
||||
let value: unknown = schema;
|
||||
const active = new Set<string>();
|
||||
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<Record<string, unknown>>,
|
||||
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<string, unknown>,
|
||||
describeProperty: (property: string) => string,
|
||||
): Record<string, unknown> => {
|
||||
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<string, unknown>();
|
||||
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<string, unknown>,
|
||||
describeProperty: (property: string) => string,
|
||||
): Record<string, unknown> => {
|
||||
const described = describeSchema(root, root, describeProperty);
|
||||
return isObject(described)
|
||||
? flattenRootUnion(described, describeProperty)
|
||||
: root;
|
||||
};
|
||||
@@ -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],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
import { presentInputJsonSchema } from "./inputSchemaPresentation.js";
|
||||
|
||||
const PROPERTY_DESCRIPTIONS: Readonly<Record<string, string>> = {
|
||||
addresses: "Ordered provider-normalized procedure addresses to analyze.",
|
||||
@@ -56,11 +57,25 @@ export const toolInputSchemaWithMetadata = <Contract extends ToolContract>(
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
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<string, unknown> = {};
|
||||
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<string, unknown>>,
|
||||
): Record<string, unknown> =>
|
||||
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<Record<string, unknown>> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
@@ -103,25 +103,44 @@ function expectKnownAuthorityHints(tools: readonly ToolSchemas[]): void {
|
||||
function expectRecursivePropertyDescriptions(
|
||||
schema: unknown,
|
||||
path: string,
|
||||
root: unknown = schema,
|
||||
active: ReadonlySet<string> = 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+140
-63
@@ -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<string, unknown> =>
|
||||
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<string, unknown>,
|
||||
siblings: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
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<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
if (
|
||||
(schema.type !== undefined && schema.type !== "string") ||
|
||||
!Array.isArray(schema.allOf)
|
||||
)
|
||||
return schema;
|
||||
let requiresString = schema.type === "string";
|
||||
const validBounds = (value: Record<string, unknown>): 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<string>();
|
||||
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<Record<string, unknown>>,
|
||||
@@ -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());
|
||||
|
||||
Reference in new issue
Block a user