fix(contracts): resolve local schema references within their resource

This commit is contained in:
morluto committed 2026-10-22 17:53:35 +08:00
1 parent 3b2d2863da
commit 46cc5ce056
8 files changed
+903 -135

No files matched your search

+208
View File
@@ -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" } });
});
+104 -45
View File
@@ -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"
+85 -41
View File
@@ -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,
+249
View File
@@ -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 };
}
}
+129
View File
@@ -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");
},
);
+24 -49
View File
@@ -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()]);
}
},
);