fix(contracts): resolve local schema references within their resource
This commit is contained in:
1 parent
3b2d2863da
commit
46cc5ce056
8 files changed
+903
-135
No files matched your search
@@ -1,3 +1,4 @@
|
||||
import { Ajv } from "ajv";
|
||||
import { Ajv2020 } from "ajv/dist/2020.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
@@ -352,3 +353,210 @@ describe("compact input schema budget and reduction", () => {
|
||||
expect(serializedBytes(schema)).toBeLessThanOrEqual(13312);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["draft-2020-12", "https://example.test/nested"],
|
||||
["draft-2020-12", "nested.json"],
|
||||
["draft-07", "https://example.test/nested"],
|
||||
["draft-07", "nested.json"],
|
||||
] as const)(
|
||||
"keeps embedded resource references scoped when inlining twice in %s with %s",
|
||||
(dialect, id) => {
|
||||
const source = {
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://example.test/root.json",
|
||||
type: "object",
|
||||
definitions: {
|
||||
target: { type: "string" },
|
||||
nested: {
|
||||
$id: id,
|
||||
type: "object",
|
||||
definitions: { target: { type: "number" } },
|
||||
properties: { value: { $ref: "#/definitions/target" } },
|
||||
required: ["value"],
|
||||
},
|
||||
},
|
||||
properties: {
|
||||
before: { $ref: "#/definitions/nested" },
|
||||
after: { $ref: "#/definitions/nested" },
|
||||
},
|
||||
required: ["before", "after"],
|
||||
};
|
||||
const compact = compactAdvertisedInputSchema(source, 13312);
|
||||
expect(compact.presentation).toBe("inline");
|
||||
expect(JSON.stringify(compact.schema)).not.toContain('"$ref":');
|
||||
const createAjv = () =>
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
const canonical = createAjv().compile(source);
|
||||
const advertised = createAjv().compile(compact.schema);
|
||||
for (const value of [7, "seven", null]) {
|
||||
const input = { before: { value }, after: { value } };
|
||||
expect(advertised(input)).toBe(canonical(input));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("decodes URI fragments and JSON Pointer escapes through schema arrays", () => {
|
||||
const source = {
|
||||
type: "object",
|
||||
definitions: { "a/b~": { prefixItems: [{ type: "number" }] } },
|
||||
properties: { value: { $ref: "#/definitions/a%7E1b%7E0/prefixItems/0" } },
|
||||
};
|
||||
const result = compactAdvertisedInputSchema(source, 13312);
|
||||
expect(result.presentation).toBe("inline");
|
||||
const canonical = new Ajv2020({ strict: false }).compile(source);
|
||||
const advertised = new Ajv2020({ strict: false }).compile(result.schema);
|
||||
for (const value of [7, "seven", null])
|
||||
expect(advertised({ value })).toBe(canonical({ value }));
|
||||
});
|
||||
|
||||
it.each(["$dynamicRef", "$recursiveRef"])(
|
||||
"does not strip resource identities from a partially expanded %s closure",
|
||||
(keyword) => {
|
||||
const result = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
definitions: {
|
||||
nested: {
|
||||
$id: "https://example.test/nested",
|
||||
type: "object",
|
||||
properties: { next: { [keyword]: "#" } },
|
||||
},
|
||||
},
|
||||
properties: { child: { $ref: "#/definitions/nested" } },
|
||||
},
|
||||
13312,
|
||||
);
|
||||
expect(result.presentation).toBe("reduced");
|
||||
expect(JSON.stringify(result.schema)).not.toContain('"$ref":');
|
||||
expect(result.schema.description).toContain("canonical");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["", "#legacy", "https://example.test/root.json"])(
|
||||
"does not invent a pointer resource for an aliased or ambiguous nested ID %j",
|
||||
(id) => {
|
||||
const result = compactAdvertisedInputSchema(
|
||||
{
|
||||
$id: "https://example.test/root.json",
|
||||
type: "object",
|
||||
definitions: {
|
||||
target: { type: "string" },
|
||||
nested: {
|
||||
$id: id,
|
||||
type: "object",
|
||||
definitions: { target: { type: "number" } },
|
||||
properties: { value: { $ref: "#/definitions/target" } },
|
||||
},
|
||||
},
|
||||
properties: { child: { $ref: "#/definitions/nested" } },
|
||||
},
|
||||
13312,
|
||||
);
|
||||
expect(result.presentation).toBe("reduced");
|
||||
expect(result.schema.properties).toEqual({ child: {} });
|
||||
},
|
||||
);
|
||||
|
||||
it("uses explicit reduction for Draft 7 reference siblings with conflicting client semantics", () => {
|
||||
const result = compactAdvertisedInputSchema(
|
||||
{
|
||||
$schema: "http://json-schema.org/draft-07/schema#",
|
||||
type: "object",
|
||||
definitions: { value: { type: "number" } },
|
||||
properties: { value: { $ref: "#/definitions/value", maximum: 5 } },
|
||||
},
|
||||
13312,
|
||||
);
|
||||
expect(result.presentation).toBe("reduced");
|
||||
expect(result.schema.properties).toEqual({ value: { type: "number" } });
|
||||
});
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"removes embedded dialect declarations only after static expansion in %s",
|
||||
(dialect) => {
|
||||
const $schema =
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema";
|
||||
const shared = { $ref: "#/definitions/value" };
|
||||
const source = {
|
||||
$schema,
|
||||
$id: "https://example.test/root",
|
||||
type: "object",
|
||||
properties: Object.fromEntries(
|
||||
["string", "number"].map((type) => [
|
||||
type,
|
||||
{
|
||||
$schema,
|
||||
$id: `${type}.json#`,
|
||||
type: "object",
|
||||
definitions: { value: { type } },
|
||||
properties: { value: shared },
|
||||
required: ["value"],
|
||||
},
|
||||
]),
|
||||
),
|
||||
required: ["string", "number"],
|
||||
};
|
||||
const result = compactAdvertisedInputSchema(source, 13312);
|
||||
expect(result.presentation).toBe("inline");
|
||||
expect(result.schema.$schema).toBe($schema);
|
||||
expect(JSON.stringify(result.schema).match(/"\$schema"/gu)).toHaveLength(1);
|
||||
const createAjv = () =>
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
const canonical = createAjv().compile(source);
|
||||
const advertised = createAjv().compile(result.schema);
|
||||
for (const value of [
|
||||
{ string: { value: "a" }, number: { value: 7 } },
|
||||
{ string: { value: 7 }, number: { value: "a" } },
|
||||
])
|
||||
expect(advertised(value)).toBe(canonical(value));
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["child.json", "https://example.test/child.json"])(
|
||||
"does not expand ambiguous sibling resources with ID %s",
|
||||
($id) => {
|
||||
const result = compactAdvertisedInputSchema(
|
||||
{
|
||||
type: "object",
|
||||
properties: Object.fromEntries(
|
||||
["string", "number"].map((type) => [
|
||||
type,
|
||||
{
|
||||
$id,
|
||||
type: "object",
|
||||
definitions: { value: { type } },
|
||||
properties: { value: { $ref: "#/definitions/value" } },
|
||||
},
|
||||
]),
|
||||
),
|
||||
},
|
||||
13312,
|
||||
);
|
||||
expect(result.presentation).toBe("reduced");
|
||||
expect(result.schema.properties).toEqual({ string: {}, number: {} });
|
||||
},
|
||||
);
|
||||
|
||||
it("includes restored root identity in the compact byte budget", () => {
|
||||
const result = compactAdvertisedInputSchema(
|
||||
{
|
||||
$id: `https://example.test/${"a".repeat(1024)}`,
|
||||
type: "object",
|
||||
properties: { value: { type: "number" } },
|
||||
},
|
||||
512,
|
||||
);
|
||||
expect(result.presentation).toBe("reduced");
|
||||
expect(serializedBytes(result.schema)).toBeLessThanOrEqual(512);
|
||||
expect(result.schema.properties).toEqual({ value: { type: "number" } });
|
||||
});
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
JsonSchemaReferences,
|
||||
type JsonSchemaScope,
|
||||
} from "./jsonSchemaReferences.js";
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
@@ -124,66 +128,104 @@ const stripChildAnnotations = (child: unknown): unknown => {
|
||||
return kept;
|
||||
};
|
||||
|
||||
const resolveLocalReference = (
|
||||
root: Record<string, unknown>,
|
||||
reference: string,
|
||||
): unknown => {
|
||||
if (reference === "#") return root;
|
||||
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 (!isSchemaObject(value) || !Object.hasOwn(value, key)) return undefined;
|
||||
value = value[key];
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
/** Stop serialization at the budget before allocating an expanded schema. */
|
||||
const inlineWithinBudget = (
|
||||
root: Record<string, unknown>,
|
||||
budgetBytes: number,
|
||||
): Record<string, unknown> | undefined => {
|
||||
const references = new JsonSchemaReferences(root);
|
||||
const identity = Object.fromEntries(
|
||||
["$id", "$anchor", "$schema"].flatMap((key) =>
|
||||
Object.hasOwn(root, key) ? [[key, root[key]]] : [],
|
||||
),
|
||||
);
|
||||
const identityBytes =
|
||||
Object.keys(identity).length === 0 ? 0 : serializedBytes(identity) - 2;
|
||||
if (identityBytes > budgetBytes) return undefined;
|
||||
let resolved = true;
|
||||
function* serialize(
|
||||
value: unknown,
|
||||
position: "schema" | "map" | "data",
|
||||
active: ReadonlySet<string>,
|
||||
scope: JsonSchemaScope | undefined,
|
||||
active: ReadonlyMap<JsonSchemaScope, ReadonlySet<string>>,
|
||||
): Generator<string> {
|
||||
if (Array.isArray(value)) {
|
||||
yield "[";
|
||||
for (const [index, child] of value.entries()) {
|
||||
if (index > 0) yield ",";
|
||||
yield* serialize(child, position, active);
|
||||
yield* serialize(child, position, scope, active);
|
||||
}
|
||||
yield "]";
|
||||
} else if (isSchemaObject(value)) {
|
||||
const currentScope =
|
||||
position === "schema" ? references.enter(value, scope) : scope;
|
||||
if (
|
||||
position === "schema" &&
|
||||
(currentScope === undefined ||
|
||||
currentScope.dialect !== references.rootScope.dialect ||
|
||||
[
|
||||
"$dynamicRef",
|
||||
"$recursiveRef",
|
||||
"$dynamicAnchor",
|
||||
"$recursiveAnchor",
|
||||
].some((key) => Object.hasOwn(value, key)))
|
||||
) {
|
||||
resolved = false;
|
||||
return;
|
||||
}
|
||||
if (position === "schema" && Object.keys(value).length === 0) {
|
||||
yield JSON.stringify(jsonValueSchema());
|
||||
return;
|
||||
}
|
||||
if (position === "schema" && typeof value.$ref === "string") {
|
||||
const target = resolveLocalReference(root, value.$ref);
|
||||
if (target === undefined || active.has(value.$ref)) {
|
||||
const target = references.resolve(currentScope, value.$ref);
|
||||
if (
|
||||
target.kind !== "target" ||
|
||||
currentScope === undefined ||
|
||||
active.get(currentScope)?.has(value.$ref)
|
||||
) {
|
||||
resolved = false;
|
||||
return;
|
||||
}
|
||||
const { $ref, ...siblings } = value;
|
||||
// Reference siblings are independent constraints in draft 2020-12.
|
||||
// Merging their keys into the target would overwrite validation rules.
|
||||
yield* serialize(
|
||||
Object.keys(siblings).length === 0
|
||||
? target
|
||||
: { allOf: [target, siblings] },
|
||||
"schema",
|
||||
new Set([...active, $ref]),
|
||||
const {
|
||||
$ref,
|
||||
$id: _id,
|
||||
$anchor: _anchor,
|
||||
$schema: _schema,
|
||||
...siblings
|
||||
} = value;
|
||||
const nextActive = new Map(active);
|
||||
nextActive.set(
|
||||
currentScope,
|
||||
new Set([...(active.get(currentScope) ?? []), $ref]),
|
||||
);
|
||||
// Draft 7 ignores siblings, while clients such as Ajv also apply them.
|
||||
// Do not label either interpretation an equivalent inline advertisement.
|
||||
if (
|
||||
currentScope.dialect === "draft-07" &&
|
||||
Object.keys(siblings).length > 0
|
||||
) {
|
||||
resolved = false;
|
||||
return;
|
||||
}
|
||||
const expanded =
|
||||
Object.keys(siblings).length === 0
|
||||
? target.value
|
||||
: { allOf: [target.value, siblings] };
|
||||
yield* serialize(expanded, "schema", currentScope, nextActive);
|
||||
return;
|
||||
}
|
||||
yield "{";
|
||||
let first = true;
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (position === "schema" && definitionKeys.has(key)) continue;
|
||||
if (
|
||||
position === "schema" &&
|
||||
(definitionKeys.has(key) ||
|
||||
key === "$id" ||
|
||||
key === "$anchor" ||
|
||||
key === "$schema")
|
||||
)
|
||||
continue;
|
||||
if (!first) yield ",";
|
||||
first = false;
|
||||
yield JSON.stringify(key) + ":";
|
||||
@@ -196,7 +238,7 @@ const inlineWithinBudget = (
|
||||
(schemaArrays.has(key) || schemaChildren.has(key))
|
||||
? "schema"
|
||||
: "data";
|
||||
yield* serialize(child, childPosition, active);
|
||||
yield* serialize(child, childPosition, currentScope, active);
|
||||
}
|
||||
yield "}";
|
||||
} else {
|
||||
@@ -204,31 +246,47 @@ const inlineWithinBudget = (
|
||||
}
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let bytes = 0;
|
||||
for (const chunk of serialize(root, "schema", new Set())) {
|
||||
let bytes = identityBytes;
|
||||
for (const chunk of serialize(
|
||||
root,
|
||||
"schema",
|
||||
references.rootScope,
|
||||
new Map(),
|
||||
)) {
|
||||
bytes += Buffer.byteLength(chunk, "utf8");
|
||||
if (bytes > budgetBytes || !resolved) return undefined;
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return resolved
|
||||
? (JSON.parse(chunks.join("")) as Record<string, unknown>)
|
||||
: undefined;
|
||||
if (!resolved) return undefined;
|
||||
const expanded: unknown = JSON.parse(chunks.join(""));
|
||||
if (!isSchemaObject(expanded)) return undefined;
|
||||
// Every emitted dependency is now static and reference-free. Nested resource
|
||||
// identifiers/anchors can leave without duplicating identities at call sites.
|
||||
const schema = { ...identity, ...expanded };
|
||||
return serializedBytes(schema) <= budgetBytes ? schema : undefined;
|
||||
};
|
||||
|
||||
/** Preserve every possible type, including referenced and nullable unions. */
|
||||
const detectedTypes = (
|
||||
schema: unknown,
|
||||
root: Record<string, unknown>,
|
||||
active: ReadonlySet<string> = new Set(),
|
||||
references: JsonSchemaReferences,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
active: ReadonlySet<object> = new Set(),
|
||||
): readonly string[] | undefined => {
|
||||
if (!isSchemaObject(schema)) return undefined;
|
||||
const scope = references.enter(schema, inherited);
|
||||
if (scope === undefined) return undefined;
|
||||
if (typeof schema.$ref === "string") {
|
||||
if (active.has(schema.$ref)) return undefined;
|
||||
return detectedTypes(
|
||||
resolveLocalReference(root, schema.$ref),
|
||||
root,
|
||||
new Set([...active, schema.$ref]),
|
||||
);
|
||||
if (active.has(schema)) return undefined;
|
||||
const target = references.resolve(scope, schema.$ref);
|
||||
return target.kind === "target"
|
||||
? detectedTypes(
|
||||
target.value,
|
||||
references,
|
||||
scope,
|
||||
new Set([...active, schema]),
|
||||
)
|
||||
: undefined;
|
||||
}
|
||||
if (typeof schema.type === "string") return [schema.type];
|
||||
if (
|
||||
@@ -243,7 +301,7 @@ const detectedTypes = (
|
||||
: [];
|
||||
if (alternatives.length === 0) return undefined;
|
||||
const types = alternatives.map((branch) =>
|
||||
detectedTypes(branch, root, active),
|
||||
detectedTypes(branch, references, scope, active),
|
||||
);
|
||||
if (types.some((type) => type === undefined)) return undefined;
|
||||
return [...new Set(types.flatMap((type) => type ?? []))];
|
||||
@@ -281,9 +339,10 @@ const reduceToShallowProperties = (
|
||||
]
|
||||
.filter((text) => text !== undefined)
|
||||
.join(" ");
|
||||
const references = new JsonSchemaReferences(schema);
|
||||
const shallow = Object.fromEntries(
|
||||
Object.entries(properties).map(([name, value]) => {
|
||||
const types = detectedTypes(value, schema);
|
||||
const types = detectedTypes(value, references, references.rootScope);
|
||||
const type = types?.length === 1 ? types[0] : types;
|
||||
const description =
|
||||
isSchemaObject(value) && typeof value.description === "string"
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
JsonSchemaReferences,
|
||||
type JsonSchemaScope,
|
||||
} from "./jsonSchemaReferences.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
import {
|
||||
@@ -9,43 +13,38 @@ import {
|
||||
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 === "#") return root;
|
||||
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>>,
|
||||
references: JsonSchemaReferences,
|
||||
schema: Readonly<Record<string, unknown>>,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
): string | undefined => {
|
||||
let value: unknown = schema;
|
||||
const active = new Set<string>();
|
||||
let scope = inherited;
|
||||
const active = new Set<object>();
|
||||
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);
|
||||
scope = references.enter(value, scope);
|
||||
if (
|
||||
typeof value.description === "string" &&
|
||||
(typeof value.$ref !== "string" || scope?.dialect !== "draft-07")
|
||||
)
|
||||
return value.description;
|
||||
if (typeof value.$ref !== "string" || active.has(value)) return undefined;
|
||||
active.add(value);
|
||||
const target = references.resolve(scope, value.$ref);
|
||||
if (target.kind !== "target") return undefined;
|
||||
value = target.value;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const describeSchema = (
|
||||
schema: unknown,
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
references: JsonSchemaReferences,
|
||||
inherited: JsonSchemaScope | undefined,
|
||||
describeProperty: (property: string) => string | undefined,
|
||||
): unknown => {
|
||||
if (!isObject(schema)) return schema;
|
||||
const scope = references.enter(schema, inherited);
|
||||
return Object.fromEntries(
|
||||
Object.entries(schema).map(([key, value]) => {
|
||||
if (schemaMaps.has(key) && isObject(value))
|
||||
@@ -53,11 +52,20 @@ const describeSchema = (
|
||||
key,
|
||||
Object.fromEntries(
|
||||
Object.entries(value).map(([name, child]) => {
|
||||
const described = describeSchema(child, root, describeProperty);
|
||||
if (key !== "properties" || !isObject(described))
|
||||
const described = describeSchema(
|
||||
child,
|
||||
references,
|
||||
scope,
|
||||
describeProperty,
|
||||
);
|
||||
if (
|
||||
key !== "properties" ||
|
||||
!isObject(described) ||
|
||||
!isObject(child)
|
||||
)
|
||||
return [name, described];
|
||||
const description =
|
||||
referencedDescription(root, described) ??
|
||||
referencedDescription(references, child, scope) ??
|
||||
describeProperty(name);
|
||||
return [
|
||||
name,
|
||||
@@ -71,16 +79,18 @@ const describeSchema = (
|
||||
if (schemaArrays.has(key) && Array.isArray(value))
|
||||
return [
|
||||
key,
|
||||
value.map((child) => describeSchema(child, root, describeProperty)),
|
||||
value.map((child) =>
|
||||
describeSchema(child, references, scope, describeProperty),
|
||||
),
|
||||
];
|
||||
if (schemaChildren.has(key))
|
||||
return [
|
||||
key,
|
||||
Array.isArray(value)
|
||||
? value.map((child) =>
|
||||
describeSchema(child, root, describeProperty),
|
||||
describeSchema(child, references, scope, describeProperty),
|
||||
)
|
||||
: describeSchema(value, root, describeProperty),
|
||||
: describeSchema(value, references, scope, describeProperty),
|
||||
];
|
||||
// Annotation values and literal const/enum data are not schemas.
|
||||
return [key, value];
|
||||
@@ -89,18 +99,29 @@ const describeSchema = (
|
||||
};
|
||||
|
||||
const objectBranch = (
|
||||
root: Readonly<Record<string, unknown>>,
|
||||
references: JsonSchemaReferences,
|
||||
branch: unknown,
|
||||
): Readonly<Record<string, unknown>> | undefined => {
|
||||
// Reference siblings evaluate separately from the target in 2020-12, so a
|
||||
// merged object could accept inputs the branch rejects.
|
||||
const resolved =
|
||||
isObject(branch) && typeof branch.$ref === "string"
|
||||
? Object.keys(branch).length === 1
|
||||
? resolveReference(root, branch.$ref)
|
||||
: undefined
|
||||
: branch;
|
||||
let resolved = branch;
|
||||
if (isObject(branch) && typeof branch.$ref === "string") {
|
||||
if (Object.keys(branch).length !== 1) return undefined;
|
||||
const target = references.resolve(references.rootScope, branch.$ref);
|
||||
if (target.kind !== "target") return undefined;
|
||||
resolved = target.value;
|
||||
}
|
||||
return isObject(resolved) &&
|
||||
references.enter(resolved, references.rootScope) === references.rootScope &&
|
||||
![
|
||||
"$id",
|
||||
"$schema",
|
||||
"$anchor",
|
||||
"$dynamicAnchor",
|
||||
"$recursiveAnchor",
|
||||
"$dynamicRef",
|
||||
"$recursiveRef",
|
||||
].some((key) => Object.hasOwn(resolved, key)) &&
|
||||
resolved.type === "object" &&
|
||||
isObject(resolved.properties)
|
||||
? resolved
|
||||
@@ -324,6 +345,7 @@ const normalizeAdvertisedSchema = (value: unknown): SchemaNormalization => {
|
||||
// union of the groups. The canonical Zod schema still enforces the exact group.
|
||||
const flattenRootObjectUnion = (
|
||||
root: Record<string, unknown>,
|
||||
references: JsonSchemaReferences,
|
||||
): Record<string, unknown> => {
|
||||
const { anyOf, oneOf, ...rest } = root;
|
||||
const alternatives = Array.isArray(anyOf) ? anyOf : oneOf;
|
||||
@@ -333,7 +355,21 @@ const flattenRootObjectUnion = (
|
||||
root.properties !== undefined
|
||||
)
|
||||
return root;
|
||||
const branches = alternatives.map((branch) => objectBranch(root, branch));
|
||||
// Lifting removes union locations and branch identities. References into those
|
||||
// locations, named anchors or unresolved bases cannot survive that rewrite.
|
||||
for (const { scope, target } of references.references())
|
||||
if (
|
||||
(target.kind === "unresolved" && target.reason === "dynamic") ||
|
||||
(scope === references.rootScope &&
|
||||
(target.kind !== "target" ||
|
||||
target.path.length === 0 ||
|
||||
target.path[0] === "anyOf" ||
|
||||
target.path[0] === "oneOf"))
|
||||
)
|
||||
return root;
|
||||
const branches = alternatives.map((branch) =>
|
||||
objectBranch(references, branch),
|
||||
);
|
||||
if (!branches.every((branch) => branch !== undefined)) return root;
|
||||
const variants = new Map<string, unknown[]>();
|
||||
for (const branch of branches)
|
||||
@@ -385,15 +421,17 @@ const presentDiscriminatedRoot = (
|
||||
root: Record<string, unknown>,
|
||||
): Record<string, unknown> => {
|
||||
const { "x-rea-root-discriminator": discriminator, ...canonical } = root;
|
||||
const flattened = flattenRootObjectUnion(canonical);
|
||||
if (typeof discriminator !== "string") return flattened;
|
||||
const references = new JsonSchemaReferences(canonical);
|
||||
const flattened = flattenRootObjectUnion(canonical, references);
|
||||
if (typeof discriminator !== "string" || flattened === canonical)
|
||||
return flattened;
|
||||
const alternatives = canonical.anyOf ?? canonical.oneOf;
|
||||
if (!Array.isArray(alternatives) || alternatives.length !== 2)
|
||||
throw new TypeError(
|
||||
"A presented root discriminator requires two object alternatives",
|
||||
);
|
||||
const branches = alternatives.map((alternative) => {
|
||||
const branch = objectBranch(canonical, alternative);
|
||||
const branch = objectBranch(references, alternative);
|
||||
if (branch === undefined || branch.additionalProperties !== false)
|
||||
throw new TypeError(
|
||||
"A presented root discriminator requires strict objects",
|
||||
@@ -487,7 +525,13 @@ export const presentInputJsonSchema = (
|
||||
describeProperty: (property: string) => string | undefined,
|
||||
): Record<string, unknown> => {
|
||||
const flattened = presentDiscriminatedRoot(root);
|
||||
const described = describeSchema(flattened, flattened, describeProperty);
|
||||
const references = new JsonSchemaReferences(flattened);
|
||||
const described = describeSchema(
|
||||
flattened,
|
||||
references,
|
||||
references.rootScope,
|
||||
describeProperty,
|
||||
);
|
||||
if (!isObject(described))
|
||||
return normalizeAdvertisedSchema(flattened).schema as Record<
|
||||
string,
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
schemaArrays,
|
||||
schemaChildren,
|
||||
schemaMaps,
|
||||
} from "./jsonSchemaKeywords.js";
|
||||
|
||||
type SchemaObject = Readonly<Record<string, unknown>>;
|
||||
type Dialect = "draft-07" | "draft-2020-12";
|
||||
|
||||
/** A fragment's resource and URI base are distinct facts. */
|
||||
export interface JsonSchemaScope {
|
||||
readonly root: SchemaObject;
|
||||
readonly baseUri: string | undefined;
|
||||
readonly dialect: Dialect | undefined;
|
||||
readonly parent?: JsonSchemaScope;
|
||||
}
|
||||
|
||||
type LocalReference =
|
||||
| {
|
||||
readonly kind: "target";
|
||||
readonly value: unknown;
|
||||
readonly path: readonly string[];
|
||||
}
|
||||
| { readonly kind: "anchor" }
|
||||
| {
|
||||
readonly kind: "unresolved";
|
||||
readonly reason:
|
||||
| "scope"
|
||||
| "external"
|
||||
| "fragment"
|
||||
| "pointer"
|
||||
| "resource-boundary"
|
||||
| "dynamic";
|
||||
};
|
||||
|
||||
const isObject = (value: unknown): value is SchemaObject =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const dialectOf = (
|
||||
schema: SchemaObject,
|
||||
inherited: Dialect | undefined,
|
||||
): Dialect | undefined =>
|
||||
schema.$schema === undefined
|
||||
? inherited
|
||||
: schema.$schema === "http://json-schema.org/draft-07/schema#" ||
|
||||
schema.$schema === "http://json-schema.org/draft-07/schema"
|
||||
? "draft-07"
|
||||
: schema.$schema === "https://json-schema.org/draft/2020-12/schema"
|
||||
? "draft-2020-12"
|
||||
: undefined;
|
||||
|
||||
/** Per-projection scope ownership; no external resource loading or dynamic-reference evaluation. */
|
||||
export class JsonSchemaReferences {
|
||||
readonly rootScope: JsonSchemaScope;
|
||||
readonly #scopes = new WeakMap<
|
||||
object,
|
||||
WeakMap<JsonSchemaScope, JsonSchemaScope | undefined>
|
||||
>();
|
||||
readonly #referenceSites: {
|
||||
scope: JsonSchemaScope;
|
||||
reference: string;
|
||||
keyword: string;
|
||||
}[] = [];
|
||||
readonly #identities = new Map<string, JsonSchemaScope>();
|
||||
readonly #relativeIdentities = new WeakMap<
|
||||
JsonSchemaScope,
|
||||
Map<string, JsonSchemaScope>
|
||||
>();
|
||||
readonly #ambiguous = new WeakSet<JsonSchemaScope>();
|
||||
|
||||
constructor(root: SchemaObject) {
|
||||
let baseUri: string | undefined;
|
||||
if (typeof root.$id === "string" && URL.canParse(root.$id)) {
|
||||
const url = new URL(root.$id);
|
||||
url.hash = "";
|
||||
baseUri = url.href;
|
||||
}
|
||||
this.rootScope = {
|
||||
root,
|
||||
baseUri,
|
||||
dialect: dialectOf(root, "draft-2020-12"),
|
||||
};
|
||||
if (baseUri !== undefined) this.#identities.set(baseUri, this.rootScope);
|
||||
// Index resources before consumers resolve anything, so later duplicate IDs
|
||||
// cannot invalidate facts that have already escaped from the owner.
|
||||
const pending = [{ value: root as unknown, scope: this.rootScope }];
|
||||
const visited = new WeakMap<JsonSchemaScope, WeakSet<object>>();
|
||||
while (pending.length > 0) {
|
||||
const { value, scope: parent } = pending.pop()!;
|
||||
if (!isObject(value)) continue;
|
||||
const scope = this.#scope(value, parent);
|
||||
if (scope === undefined) continue;
|
||||
let seen = visited.get(scope);
|
||||
if (seen === undefined) {
|
||||
seen = new WeakSet();
|
||||
visited.set(scope, seen);
|
||||
}
|
||||
if (seen.has(value)) continue;
|
||||
seen.add(value);
|
||||
for (const keyword of ["$ref", "$dynamicRef", "$recursiveRef"]) {
|
||||
const reference = value[keyword];
|
||||
if (typeof reference !== "string") continue;
|
||||
this.#referenceSites.push({ scope, reference, keyword });
|
||||
if (keyword !== "$ref") continue;
|
||||
const target = this.resolve(scope, reference);
|
||||
if (target.kind === "target")
|
||||
pending.push({ value: target.value, scope });
|
||||
}
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (schemaMaps.has(key) && isObject(child)) {
|
||||
for (const item of Object.values(child))
|
||||
pending.push({ value: item, scope });
|
||||
} else if (
|
||||
(schemaArrays.has(key) || schemaChildren.has(key)) &&
|
||||
Array.isArray(child)
|
||||
) {
|
||||
for (const item of child) pending.push({ value: item, scope });
|
||||
} else if (schemaChildren.has(key))
|
||||
pending.push({ value: child, scope });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enter(
|
||||
schema: SchemaObject,
|
||||
parent: JsonSchemaScope | undefined,
|
||||
): JsonSchemaScope | undefined {
|
||||
if (parent === undefined) return undefined;
|
||||
const scope = this.#scope(schema, parent);
|
||||
for (let current = scope; current !== undefined; current = current.parent)
|
||||
if (this.#ambiguous.has(current)) return undefined;
|
||||
return scope?.dialect === undefined ? undefined : scope;
|
||||
}
|
||||
|
||||
#scope(
|
||||
schema: SchemaObject,
|
||||
parent: JsonSchemaScope,
|
||||
): JsonSchemaScope | undefined {
|
||||
if (schema === parent.root) return parent;
|
||||
if (schema.$id === undefined) return parent;
|
||||
let cached = this.#scopes.get(schema);
|
||||
if (cached?.has(parent)) return cached.get(parent);
|
||||
if (cached === undefined) {
|
||||
cached = new WeakMap();
|
||||
this.#scopes.set(schema, cached);
|
||||
}
|
||||
let scope: JsonSchemaScope | undefined;
|
||||
if (
|
||||
typeof schema.$id === "string" &&
|
||||
schema.$id !== "" &&
|
||||
(!schema.$id.includes("#") ||
|
||||
(schema.$id !== "#" &&
|
||||
schema.$id.indexOf("#") === schema.$id.length - 1))
|
||||
) {
|
||||
let baseUri: string | undefined;
|
||||
try {
|
||||
if (parent.baseUri !== undefined || URL.canParse(schema.$id)) {
|
||||
const url = new URL(schema.$id, parent.baseUri);
|
||||
url.hash = "";
|
||||
baseUri = url.href;
|
||||
}
|
||||
scope = {
|
||||
root: schema,
|
||||
baseUri,
|
||||
dialect: dialectOf(schema, parent.dialect),
|
||||
parent,
|
||||
};
|
||||
if (baseUri !== undefined) {
|
||||
const existing = this.#identities.get(baseUri);
|
||||
if (existing !== undefined && existing.root !== schema) {
|
||||
this.#ambiguous.add(existing);
|
||||
this.#ambiguous.add(scope);
|
||||
} else if (existing !== undefined) scope = existing;
|
||||
else this.#identities.set(baseUri, scope);
|
||||
} else {
|
||||
let identities = this.#relativeIdentities.get(parent);
|
||||
if (identities === undefined) {
|
||||
identities = new Map();
|
||||
this.#relativeIdentities.set(parent, identities);
|
||||
}
|
||||
const identity = schema.$id.replace(/#$/u, "");
|
||||
const existing = identities.get(identity);
|
||||
if (existing !== undefined && existing.root !== schema) {
|
||||
this.#ambiguous.add(existing);
|
||||
this.#ambiguous.add(scope);
|
||||
} else if (existing !== undefined) scope = existing;
|
||||
else identities.set(identity, scope);
|
||||
}
|
||||
} catch {
|
||||
// An opaque or unknown URI base must not be replaced with a guessed scope.
|
||||
}
|
||||
}
|
||||
// Empty/fragment IDs can alias a resource or be legacy anchors. Keep them
|
||||
// unresolved rather than resetting the pointer root to that subschema.
|
||||
cached.set(parent, scope);
|
||||
return scope;
|
||||
}
|
||||
|
||||
*references(): IterableIterator<{
|
||||
readonly scope: JsonSchemaScope;
|
||||
readonly target: LocalReference;
|
||||
}> {
|
||||
for (const { scope, reference, keyword } of this.#referenceSites)
|
||||
yield {
|
||||
scope,
|
||||
target:
|
||||
keyword === "$ref"
|
||||
? this.resolve(scope, reference)
|
||||
: { kind: "unresolved", reason: "dynamic" },
|
||||
};
|
||||
}
|
||||
|
||||
resolve(
|
||||
scope: JsonSchemaScope | undefined,
|
||||
reference: string,
|
||||
): LocalReference {
|
||||
if (scope === undefined || this.enter(scope.root, scope) === undefined)
|
||||
return { kind: "unresolved", reason: "scope" };
|
||||
if (!reference.startsWith("#"))
|
||||
return { kind: "unresolved", reason: "external" };
|
||||
let fragment: string;
|
||||
try {
|
||||
fragment = decodeURIComponent(reference.slice(1));
|
||||
} catch {
|
||||
return { kind: "unresolved", reason: "fragment" };
|
||||
}
|
||||
if (fragment === "") return { kind: "target", value: scope.root, path: [] };
|
||||
if (!fragment.startsWith("/")) return { kind: "anchor" };
|
||||
let value: unknown = scope.root;
|
||||
const path: string[] = [];
|
||||
for (const token of fragment.slice(1).split("/")) {
|
||||
if (/~(?:[^01]|$)/u.test(token))
|
||||
return { kind: "unresolved", reason: "pointer" };
|
||||
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
!Object.hasOwn(value, key) ||
|
||||
(Array.isArray(value) && !/^(?:0|[1-9][0-9]*)$/u.test(key))
|
||||
)
|
||||
return { kind: "unresolved", reason: "pointer" };
|
||||
if (value !== scope.root && isObject(value) && "$id" in value)
|
||||
return { kind: "unresolved", reason: "resource-boundary" };
|
||||
path.push(key);
|
||||
value = Reflect.get(value, key);
|
||||
}
|
||||
return { kind: "target", value, path };
|
||||
}
|
||||
}
|
||||
@@ -608,3 +608,132 @@ describe("advertised Boolean schema composition", () => {
|
||||
expect(presented.examples).toEqual([literal]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"describes references from their containing resource in %s",
|
||||
(dialect) => {
|
||||
const presented = presentInputJsonSchema(
|
||||
{
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://example.test/root.json",
|
||||
type: "object",
|
||||
definitions: { value: { type: "string", description: "Outer text." } },
|
||||
properties: {
|
||||
child: {
|
||||
$id: "child.json",
|
||||
type: "object",
|
||||
definitions: {
|
||||
value: { type: "number", description: "Nested number." },
|
||||
},
|
||||
properties: { value: { $ref: "#/definitions/value" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
() => undefined,
|
||||
);
|
||||
expect(presented).toMatchObject({
|
||||
properties: {
|
||||
child: { properties: { value: { description: "Nested number." } } },
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["draft-2020-12", "draft-07"] as const)(
|
||||
"keeps root union resource identities when lifting would change relative refs in %s",
|
||||
(dialect) => {
|
||||
const source = {
|
||||
$schema:
|
||||
dialect === "draft-07"
|
||||
? "http://json-schema.org/draft-07/schema#"
|
||||
: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://example.test/root.json",
|
||||
anyOf: ["first", "second"].map((kind) => ({
|
||||
$id: `https://example.test/${kind}/schema.json`,
|
||||
type: "object",
|
||||
properties: { kind: { const: kind }, value: { $ref: "child.json" } },
|
||||
required: ["kind", "value"],
|
||||
additionalProperties: false,
|
||||
})),
|
||||
};
|
||||
const presented = presentInputJsonSchema(source, () => undefined);
|
||||
const createAjv = () => {
|
||||
const ajv =
|
||||
dialect === "draft-07"
|
||||
? new Ajv({ strict: false })
|
||||
: new Ajv2020({ strict: false });
|
||||
ajv.addSchema({
|
||||
$id: "https://example.test/first/child.json",
|
||||
type: "string",
|
||||
});
|
||||
ajv.addSchema({
|
||||
$id: "https://example.test/second/child.json",
|
||||
type: "number",
|
||||
});
|
||||
return ajv;
|
||||
};
|
||||
const canonical = createAjv().compile(source);
|
||||
const advertised = createAjv().compile(presented);
|
||||
for (const kind of ["first", "second"])
|
||||
for (const value of [7, "seven", null])
|
||||
expect(advertised({ kind, value })).toBe(canonical({ kind, value }));
|
||||
},
|
||||
);
|
||||
|
||||
it("does not lift properties that still refer to deleted root-union locations", () => {
|
||||
const source = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { value: { $ref: "#/anyOf/1/properties/value" } },
|
||||
required: ["value"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { value: { type: "number" } },
|
||||
required: ["value"],
|
||||
},
|
||||
],
|
||||
};
|
||||
const presented = presentInputJsonSchema(source, () => undefined);
|
||||
const canonical = new Ajv2020({ strict: false }).compile(source);
|
||||
const advertised = new Ajv2020({ strict: false }).compile(presented);
|
||||
for (const value of [7, "seven", null])
|
||||
expect(advertised({ value })).toBe(canonical({ value }));
|
||||
});
|
||||
|
||||
it.each(["$ref", "$dynamicRef"])(
|
||||
"preserves the root union used by recursive %s children",
|
||||
(keyword) => {
|
||||
const source = {
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { end: { const: "done" } },
|
||||
required: ["end"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { next: { [keyword]: "#" } },
|
||||
required: ["next"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
],
|
||||
};
|
||||
const presented = presentInputJsonSchema(source, () => undefined);
|
||||
expect(presented.anyOf).toBeDefined();
|
||||
const canonical = new Ajv2020({ strict: false }).compile(source);
|
||||
const advertised = new Ajv2020({ strict: false }).compile(presented);
|
||||
for (const value of [
|
||||
{ end: "done" },
|
||||
{ next: { end: "done" } },
|
||||
{ next: {} },
|
||||
{ next: { end: "invalid" } },
|
||||
])
|
||||
expect(advertised(value)).toBe(canonical(value));
|
||||
},
|
||||
);
|
||||
@@ -327,3 +327,33 @@ it.each(["relative/", "https://example.test/nested.json"])(
|
||||
expect(projected).not.toHaveProperty("$id");
|
||||
},
|
||||
);
|
||||
|
||||
it("does not synthesize an output base for duplicate embedded resource identifiers", () => {
|
||||
const canonical = z.looseObject({}).meta({
|
||||
type: "object",
|
||||
properties: {
|
||||
before: { $id: "https://example.test/repeated", type: "string" },
|
||||
after: { $id: "https://example.test/repeated", type: "number" },
|
||||
},
|
||||
});
|
||||
const projected = toolOutputSchemaWithMetadata({
|
||||
...toolContract("binary_session"),
|
||||
outputSchema: canonical,
|
||||
})["~standard"].jsonSchema.output({ target: "draft-2020-12" });
|
||||
expect(projected).not.toHaveProperty("$id");
|
||||
});
|
||||
|
||||
it.each(["$dynamicRef", "$recursiveRef"])(
|
||||
"preserves the output base for %s",
|
||||
(keyword) => {
|
||||
const canonical = z.looseObject({}).meta({
|
||||
type: "object",
|
||||
properties: { next: { [keyword]: "#" } },
|
||||
});
|
||||
const projected = toolOutputSchemaWithMetadata({
|
||||
...toolContract("binary_session"),
|
||||
outputSchema: canonical,
|
||||
})["~standard"].jsonSchema.output({ target: "draft-2020-12" });
|
||||
expect(projected).not.toHaveProperty("$id");
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
JsonSchemaReferences,
|
||||
type JsonSchemaScope,
|
||||
} from "./jsonSchemaReferences.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { ToolContract } from "./toolContractTypes.js";
|
||||
@@ -164,10 +168,14 @@ const hasRelativeSchemaReference = (
|
||||
): boolean => {
|
||||
// Local pointers can make even annotation values executable schemas. Keep
|
||||
// their resource root and traversal role separate from ordinary instance data.
|
||||
const pending: { value: unknown; root: object; position: SchemaPosition }[] =
|
||||
[{ value: schema, root: schema, position: "schema" }];
|
||||
const references = new JsonSchemaReferences(schema);
|
||||
const pending: {
|
||||
value: unknown;
|
||||
scope: JsonSchemaScope | undefined;
|
||||
position: SchemaPosition;
|
||||
}[] = [{ value: schema, scope: references.rootScope, position: "schema" }];
|
||||
const visited = new WeakMap<
|
||||
object,
|
||||
JsonSchemaScope,
|
||||
Record<SchemaPosition, WeakSet<object>>
|
||||
>();
|
||||
while (pending.length > 0) {
|
||||
@@ -175,26 +183,24 @@ const hasRelativeSchemaReference = (
|
||||
const current = entry.value;
|
||||
if (current === null || typeof current !== "object") continue;
|
||||
const { position } = entry;
|
||||
const root =
|
||||
position !== "map" &&
|
||||
isSchemaRecord(current) &&
|
||||
typeof current.$id === "string" &&
|
||||
URL.canParse(current.$id)
|
||||
? current
|
||||
: entry.root;
|
||||
let positions = visited.get(root);
|
||||
const scope =
|
||||
position !== "map" && isSchemaRecord(current)
|
||||
? references.enter(current, entry.scope)
|
||||
: entry.scope;
|
||||
if (scope === undefined) return true;
|
||||
let positions = visited.get(scope);
|
||||
if (positions === undefined) {
|
||||
positions = {
|
||||
schema: new WeakSet(),
|
||||
map: new WeakSet(),
|
||||
unknown: new WeakSet(),
|
||||
};
|
||||
visited.set(root, positions);
|
||||
visited.set(scope, positions);
|
||||
}
|
||||
if (positions[position].has(current)) continue;
|
||||
positions[position].add(current);
|
||||
if (Array.isArray(current)) {
|
||||
for (const value of current) pending.push({ value, root, position });
|
||||
for (const value of current) pending.push({ value, scope, position });
|
||||
continue;
|
||||
}
|
||||
for (const [key, value] of Object.entries(current)) {
|
||||
@@ -203,14 +209,15 @@ const hasRelativeSchemaReference = (
|
||||
["$id", "$ref", "$dynamicRef", "$recursiveRef"].includes(key) &&
|
||||
typeof value === "string"
|
||||
) {
|
||||
if (key === "$dynamicRef" || key === "$recursiveRef") return true;
|
||||
if (!value.startsWith("#") && !URL.canParse(value)) return true;
|
||||
if (key !== "$id" && value.startsWith("#")) {
|
||||
const target = localSchemaReference(root, value);
|
||||
const target = references.resolve(scope, value);
|
||||
// Unknown anchors are already examined at their schema positions.
|
||||
// Unresolvable pointers retain the existing reference base.
|
||||
if (target === undefined) return true;
|
||||
if (target.kind === "unresolved") return true;
|
||||
if (target.kind === "target")
|
||||
pending.push({ value: target.value, root, position: "schema" });
|
||||
pending.push({ value: target.value, scope, position: "schema" });
|
||||
}
|
||||
}
|
||||
if (position === "schema" && schemaLiteralValues.has(key)) continue;
|
||||
@@ -224,44 +231,12 @@ const hasRelativeSchemaReference = (
|
||||
? "schema"
|
||||
: "unknown";
|
||||
if (value !== null && typeof value === "object")
|
||||
pending.push({ value, root, position: childPosition });
|
||||
pending.push({ value, scope, position: childPosition });
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/** Decode URI-fragment JSON Pointers without interpreting literal data as schemas. */
|
||||
const localSchemaReference = (
|
||||
root: object,
|
||||
reference: string,
|
||||
): { kind: "target"; value: unknown } | { kind: "anchor" } | undefined => {
|
||||
let fragment: string;
|
||||
try {
|
||||
fragment = decodeURIComponent(reference.slice(1));
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (fragment === "") return { kind: "target", value: root };
|
||||
if (!fragment.startsWith("/")) return { kind: "anchor" };
|
||||
let value: unknown = root;
|
||||
for (const token of fragment.slice(1).split("/")) {
|
||||
if (/~(?:[^01]|$)/u.test(token)) return undefined;
|
||||
const key = token.replaceAll("~1", "/").replaceAll("~0", "~");
|
||||
if (
|
||||
value === null ||
|
||||
typeof value !== "object" ||
|
||||
!Object.hasOwn(value, key)
|
||||
)
|
||||
return undefined;
|
||||
// A pointer can cross a resource boundary even inside literal data.
|
||||
// Preserve the original projection rather than guess the reference scope.
|
||||
if (value !== root && isSchemaRecord(value) && "$id" in value)
|
||||
return undefined;
|
||||
value = Reflect.get(value, key);
|
||||
}
|
||||
return { kind: "target", value };
|
||||
};
|
||||
|
||||
const withAdvertisedJsonSchema = <Schema extends z.ZodType>(
|
||||
canonical: Schema,
|
||||
io: "input" | "output",
|
||||
|
||||
@@ -459,3 +459,77 @@ describe("MCP compact input schema canonical enforcement", () => {
|
||||
expect(ajv.validateSchema(advertised.inputSchema)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["full", "compact"] as const)(
|
||||
"preserves nested resource validation through the %s SDK catalog and canonical handler",
|
||||
async (profile) => {
|
||||
const source = {
|
||||
$schema: "https://json-schema.org/draft/2020-12/schema",
|
||||
$id: "https://example.test/root.json",
|
||||
type: "object",
|
||||
definitions: {
|
||||
value: { type: "string" },
|
||||
child: {
|
||||
$id: "child.json",
|
||||
type: "object",
|
||||
definitions: { value: { type: "number" } },
|
||||
properties: { value: { $ref: "#/definitions/value" } },
|
||||
required: ["value"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
properties: { child: { $ref: "#/definitions/child" } },
|
||||
required: ["child"],
|
||||
additionalProperties: false,
|
||||
};
|
||||
const canonical = new Ajv2020({ strict: false }).compile(source);
|
||||
const registration = toolRegistrationOptions(
|
||||
toolContract("binary_session"),
|
||||
);
|
||||
const inputSchema = {
|
||||
"~standard": {
|
||||
...registration.inputSchema["~standard"],
|
||||
vendor: "rea-resource-fixture",
|
||||
jsonSchema: {
|
||||
...registration.inputSchema["~standard"].jsonSchema,
|
||||
input: () => source,
|
||||
},
|
||||
validate: (value: unknown) =>
|
||||
canonical(value)
|
||||
? { value }
|
||||
: { issues: [{ message: "Invalid resource value" }] },
|
||||
},
|
||||
};
|
||||
const server = profile === "compact" ? compactServer() : fullServer();
|
||||
let calls = 0;
|
||||
server.registerTool("resource_fixture", { inputSchema }, async () => {
|
||||
calls++;
|
||||
return { content: [{ type: "text", text: "accepted" }] };
|
||||
});
|
||||
const client = new Client({ name: "resource-scope", version: "1" });
|
||||
const [ct, st] = InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(st);
|
||||
await client.connect(ct);
|
||||
const definition = (await client.listTools()).tools.find(
|
||||
({ name }) => name === "resource_fixture",
|
||||
);
|
||||
if (definition === undefined) throw new Error("Missing resource fixture");
|
||||
const advertised = new Ajv2020({ strict: false }).compile(
|
||||
definition.inputSchema,
|
||||
);
|
||||
for (const value of [7, "seven"]) {
|
||||
const args = { child: { value } };
|
||||
expect(advertised(args)).toBe(canonical(args));
|
||||
const result = await client.callTool({
|
||||
name: "resource_fixture",
|
||||
arguments: args,
|
||||
});
|
||||
expect(result.isError === true).toBe(!canonical(args));
|
||||
}
|
||||
expect(calls).toBe(1);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user