diff --git a/packages/schema/CHANGELOG.md b/packages/schema/CHANGELOG.md index 1fb3218b0..aaee03348 100644 --- a/packages/schema/CHANGELOG.md +++ b/packages/schema/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed local `$ref`s in schemas whose root `$id` is a relative path with a directory, such as `schemas/tool.json`: the `$id` was applied twice, so `compile` threw and `unresolvedRefs: "reject"` rejected valid tool arguments. Relative `$id`s are now also applied once with `unevaluatedProperties`/`unevaluatedItems`, when explaining errors under nested resources, and on the first coercion of a schema with `$dynamicRef` ([#11697](https://github.com/earendil-works/pi/issues/11697)) + ## [1.5.0] - 2026-11-02 ### Added diff --git a/packages/schema/src/coerce.ts b/packages/schema/src/coerce.ts index 5dee2487f..f6f554723 100644 --- a/packages/schema/src/coerce.ts +++ b/packages/schema/src/coerce.ts @@ -105,7 +105,7 @@ export class Coercer { } private baseOf(node: unknown, base: string): string { - return isSchemaObject(node) ? (this.c.r.baseOf.get(node) ?? base) : base; + return isSchemaObject(node) ? this.c.r.baseFor(node, base) : base; } // --------------------------------------------------------------------------------------------------------------- diff --git a/packages/schema/src/explain.ts b/packages/schema/src/explain.ts index 3ab363986..5ce216c80 100644 --- a/packages/schema/src/explain.ts +++ b/packages/schema/src/explain.ts @@ -314,14 +314,15 @@ export class Explainer { /** How many of the keys of `v` the branch `s` (through a `$ref`) declares in `properties`. */ private declaredKeys(s: unknown, b: string, v: Record): number { - const node = isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, b).node : s; + const node = + isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, this.c.r.baseFor(s, b)).node : s; const properties = isSchemaObject(node) && isSchemaObject(node.properties) ? node.properties : {}; return Object.keys(v).filter((k) => Object.hasOwn(properties, k)).length; } private tagOf(branches: readonly unknown[], b: string): { key: string; values: unknown[] } | undefined { const resolved = branches.map((s) => - isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, b).node : s, + isSchemaObject(s) && typeof s.$ref === "string" ? this.c.r.resolve(s.$ref, this.c.r.baseFor(s, b)).node : s, ); const first = resolved[0]; if (!isSchemaObject(first) || !isSchemaObject(first.properties)) return undefined; @@ -337,7 +338,8 @@ export class Explainer { } private walkUnevaluated(node: SchemaObject, b: string, v: unknown, path: string, out: ValidationError[]): void { - const { unevaluatedProperties, unevaluatedItems, ...rest } = node; + // `b` already applies the node's `$id`; the copy must not apply it again. + const { unevaluatedProperties, unevaluatedItems, $id: _id, ...rest } = node; const a = newAnnotations(); this.c.annotating(rest, b)(v, a); if (isObject(v) && unevaluatedProperties !== undefined && !a.allProps) { diff --git a/packages/schema/src/resolver.ts b/packages/schema/src/resolver.ts index 5c8b97543..bddd4b94e 100644 --- a/packages/schema/src/resolver.ts +++ b/packages/schema/src/resolver.ts @@ -14,11 +14,12 @@ export const DEFAULT_BASE = "https://json-schema.invalid/root.json"; /** * Resolves references in the root schema and the registered documents. The schema is indexed on the first - * reference, so a schema without references is never walked for identifiers. + * reference or the first `$id` a walker meets, so a schema with neither is never walked for identifiers. */ export class Resolver { readonly draft: "2020-12" | "7"; - readonly rootBase: string; + /** The base URI the root schema is walked from; walkers apply the root's own `$id` through `baseFor`. */ + readonly rootBase = DEFAULT_BASE; /** Schema object to the base URI it was indexed under. */ readonly baseOf = new Map(); /** URI (with a `#name` fragment) of every `$dynamicAnchor`, to its schema. */ @@ -38,7 +39,6 @@ export class Resolver { this.root = root; this.options = options; this.rejectUnresolved = options.unresolvedRefs === "reject"; - this.rootBase = isSchemaObject(root) ? this.baseFor(root, DEFAULT_BASE) : DEFAULT_BASE; } /** @@ -46,10 +46,16 @@ export class Resolver { * the schema is indexed, the indexed base (a resolved reference target already has its own). */ baseFor(node: SchemaObject, base: string): string { - const indexed = this.baseOf.get(node); + let indexed = this.baseOf.get(node); if (indexed !== undefined) return indexed; const id = node.$id; if (typeof id !== "string") return base; + // Index before applying an `$id`: a caller's `base` may already include it, and the index applies it once. + if (!this.indexed) { + this.ensureIndexed(); + indexed = this.baseOf.get(node); + if (indexed !== undefined) return indexed; + } if (this.draft === "7" && (typeof node.$ref === "string" || id.startsWith("#"))) return base; return documentOf(resolveUri(id, base)); } diff --git a/packages/schema/test/coerce.test.ts b/packages/schema/test/coerce.test.ts index 4444f0cc9..3031e6c92 100644 --- a/packages/schema/test/coerce.test.ts +++ b/packages/schema/test/coerce.test.ts @@ -201,6 +201,20 @@ describe("pass 2: TypeBox's conversion rules on JSON Schema", () => { expect(coerce(schema, { item: { id: "7" } })).toEqual({ item: { id: 7 } }); }); + it("follows a relative $ref from the root $id on the first call of a schema with $dynamicRef", () => { + const schema = { + $id: "https://example.com/root.json", + $dynamicAnchor: "node", + type: "object", + properties: { count: { $ref: "defs/n.json" }, children: { type: "array", items: { $dynamicRef: "#node" } } }, + }; + const options = { coerce: true, schemas: { "https://example.com/defs/n.json": { type: "integer" } } } as const; + expect(compile(schema, options).coerce({ count: "2" })).toEqual({ count: 2 }); + const validator = compile(schema, options); + expect(validator.parse({ count: "2" })).toEqual({ ok: true, value: { count: 2 } }); + expect(validator.parse({ count: "2" })).toEqual({ ok: true, value: { count: 2 } }); + }); + it("converts patternProperties and additionalProperties", () => { const schema = { type: "object", diff --git a/packages/schema/test/validator.test.ts b/packages/schema/test/validator.test.ts index c5ebd6edf..841ec0e80 100644 --- a/packages/schema/test/validator.test.ts +++ b/packages/schema/test/validator.test.ts @@ -176,6 +176,73 @@ describe("schemas", () => { ]); }); + // #11697 + it("applies a relative root $id once when resolving local references", () => { + const schema = { + $id: "schemas/tool.json", + type: "object", + properties: { count: { $ref: "#/$defs/count" } }, + required: ["count"], + $defs: { count: { type: "integer" } }, + }; + expect(compile(schema).check({ count: 2 })).toBe(true); + const validator = compile(schema, { unresolvedRefs: "reject" }); + expect(validator.check({ count: 2 })).toBe(true); + expect(validator.errors({ count: "a" })).toEqual([ + { path: "/count", keyword: "type", message: "expected integer, got string" }, + ]); + expect(compile(schema, { coerce: true }).parse({ count: "2" })).toEqual({ ok: true, value: { count: 2 } }); + }); + + it("applies a relative root $id once in schemas with unevaluated keywords", () => { + const props = { + $id: "schemas/tool.json", + type: "object", + properties: { count: { $ref: "#/$defs/count" } }, + $defs: { count: { type: "integer" } }, + unevaluatedProperties: false, + }; + const validator = compile(props, { unresolvedRefs: "reject" }); + expect(validator.check({ count: 2 })).toBe(true); + expect(validator.errors({ count: 2, extra: 1 })).toEqual([ + { path: "/extra", keyword: "unevaluatedProperties", message: "unknown field" }, + ]); + const items = { + $id: "schemas/list.json", + type: "array", + prefixItems: [{ $ref: "#/$defs/count" }], + $defs: { count: { type: "integer" } }, + unevaluatedItems: false, + }; + expect(compile(items, { unresolvedRefs: "reject" }).check([2])).toBe(true); + expect(compile(items).check([2, 3])).toBe(false); + }); + + it("explains errors under nested resources with a relative $id", () => { + const cloned = { + $id: "https://example.com/root.json", + allOf: [{ $ref: "#/$defs/child" }], + $defs: { + child: { + $id: "schemas/tool.json", + $ref: "#/$defs/props", + unevaluatedProperties: false, + $defs: { props: { properties: { count: { type: "integer" } } } }, + }, + }, + }; + expect(compile(cloned).errors({ count: 2, extra: 1 })).toEqual([ + { path: "/extra", keyword: "unevaluatedProperties", message: "unknown field" }, + ]); + const branch = { + $id: "https://example.com/root.json", + anyOf: [{ $id: "a/b.json", $ref: "#/$defs/n", $defs: { n: { type: "integer" } } }, { type: "boolean" }], + }; + expect(compile(branch).errors({})).toEqual([ + { path: "", keyword: "type", message: "expected integer, got object" }, + ]); + }); + it("throws for a malformed schema", () => { expect(() => compile({ properties: { a: 1 } })).toThrow(/must be an object or a boolean/); });