pi-mono/packages/schema/test/validator.test.ts
robopipi[bot] 2bd2c2d0a6
fix(schema): apply a relative root $id only once when resolving references (#11726)
A schema whose root `$id` was a relative path with a directory part,
such as `schemas/tool.json`, could not resolve its local `$ref`s. The
`Resolver` constructor applied the root `$id` to `DEFAULT_BASE` once to
compute `rootBase`. The compiler, the error explainer and the coercer
start from `rootBase` and call `baseFor(root, rootBase)`, which applied
the `$id` a second time. Local references therefore resolved against
`https://json-schema.invalid/schemas/schemas/tool.json`.

The effect was that `compile(schema)` threw `Unresolved $ref
#/$defs/count`. With `unresolvedRefs: "reject"`, which is how tool
arguments are validated (pi-ai's `validateToolArguments`, used by the
agent loop and durable, and durable's `structuredOutput` check), valid
arguments were rejected and the tool never ran. Absolute `$id`s and bare
names like `tool.json` were not affected, because resolving them twice
gives the same URI.

The fix:
- `rootBase` is now `DEFAULT_BASE`; walkers apply the root's own `$id`
through `baseFor`.
- `baseFor` indexes the schema before applying an `$id` and then returns
the indexed base, so a caller whose base already includes the `$id`
cannot apply it again. Without this,
`unevaluatedProperties`/`unevaluatedItems` still failed: `build()`
passes the already-based node to `annotating()`, which applied the `$id`
once more.
- The coercer resolves bases through `baseFor` instead of reading
`baseOf` directly. Otherwise the `rootBase` change broke the first
`parse`/`coerce` of a schema with `$dynamicRef`: the restarted dynamic
compiler is not indexed yet, so a relative `$ref` resolved against
`DEFAULT_BASE` and conversion was silently skipped.
- The explainer drops `$id` from the copy it makes for unevaluated
keywords, and resolves `anyOf`/`oneOf` branch `$ref`s against the
branch's own base (both threw in `errors()` on main for nested resources
with a relative `$id`).

Testing:
- Regression tests in `validator.test.ts` (relative root `$id` with
local `$ref`, with `unevaluatedProperties`/`unevaluatedItems`, error
reporting under nested relative resources) and `coerce.test.ts` (first
coercion of a `$dynamicRef` schema with a relative external `$ref`).
They fail without the change and pass with it.
- Full pi-schema suite: 8712 passed.
- Independently reviewed against edge cases: draft 7 (inert `$id` beside
`$ref`, `#anchor` ids), fragment ids, nested relative chains, anchors,
registered schemas referring back to the root, fresh
`parse`/`coerce`/`errors` after a dynamic restart.

Fixes #11697

Requested by @badlogic

---------

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: Petr Houška <houskape@gmail.com>
2026-11-03 23:14:11 +01:00

390 lines
15 KiB
TypeScript

// compile() and Validator: checks, errors, values, options and schemas from other sources.
import Type from "typebox";
import { describe, expect, it } from "vitest";
import { formats } from "../src/formats/index.ts";
import { compile } from "../src/index.ts";
const read = {
type: "object",
properties: { path: { type: "string" }, offset: { type: "integer", minimum: 1 } },
required: ["path"],
additionalProperties: false,
};
const block = {
anyOf: [
{
type: "object",
properties: { kind: { const: "text" }, text: { type: "string" } },
required: ["kind", "text"],
additionalProperties: false,
},
{
type: "object",
properties: { kind: { const: "image" }, width: { type: "integer", minimum: 0 } },
required: ["kind", "width"],
additionalProperties: false,
},
],
};
describe("errors", () => {
it("reports a missing field and an unknown field at the field", () => {
expect(compile(read).errors({ offset: 0, extra: true })).toEqual([
{ path: "/path", keyword: "required", message: "missing" },
{ path: "/offset", keyword: "minimum", message: "expected >= 1" },
{ path: "/extra", keyword: "additionalProperties", message: "unknown field" },
]);
});
it("is empty for a valid value", () => {
expect(compile(read).errors({ path: "a.ts" })).toEqual([]);
});
it("reports the branch a tagged union's tag selects", () => {
const validator = compile({ type: "array", items: block });
expect(
validator.errors([
{ kind: "text", text: "a" },
{ kind: "image", width: -1 },
]),
).toEqual([{ path: "/1/width", keyword: "minimum", message: "expected >= 0" }]);
expect(validator.errors([{ kind: "video" }])).toEqual([
{ path: "/0/kind", keyword: "anyOf", message: 'expected one of ["text","image"]' },
]);
});
it("escapes ~ and / in paths as JSON Pointer does", () => {
const validator = compile({ type: "object", properties: { "a/b~c": { type: "string" } } });
expect(validator.errors({ "a/b~c": 1 })).toEqual([
{ path: "/a~1b~0c", keyword: "type", message: "expected string, got integer" },
]);
});
it("follows $ref into the referenced schema", () => {
const validator = compile({
$defs: { point: { type: "object", properties: { x: { type: "number" } }, required: ["x"] } },
type: "array",
items: { $ref: "#/$defs/point" },
});
expect(validator.errors([{ x: 1 }, {}])).toEqual([{ path: "/1/x", keyword: "required", message: "missing" }]);
});
});
describe("values", () => {
it("treats a property set to undefined as absent", () => {
const validator = compile(read);
expect(validator.check({ path: "a.ts", offset: undefined })).toBe(true);
expect(validator.check({ path: undefined })).toBe(false);
});
it("checks bytes as Uint8Array with length bounds", () => {
const validator = compile({ type: "object", properties: { data: { type: "bytes", maxLength: 4 } } });
expect(validator.check({ data: new Uint8Array(4) })).toBe(true);
expect(validator.check({ data: new Uint8Array(5) })).toBe(false);
expect(validator.check({ data: [1, 2] })).toBe(false);
expect(validator.errors({ data: new Uint8Array(5) })).toEqual([
{ path: "/data", keyword: "maxLength", message: "expected at most 4 bytes" },
]);
});
it("does not treat bytes as an object", () => {
expect(compile({ type: "object" }).check(new Uint8Array(1))).toBe(false);
});
it("checks properties named like Object.prototype members as own properties", () => {
const validator = compile({
type: "object",
properties: { constructor: { type: "string" }, toString: { type: "number" } },
required: ["constructor"],
additionalProperties: false,
});
expect(validator.check({})).toBe(false);
expect(validator.check({ constructor: "a" })).toBe(true);
expect(validator.check({ constructor: "a", toString: "b" })).toBe(false);
});
it("does not accept every value for a type named like an Object.prototype member", () => {
expect(compile({ type: "constructor" }).check(1)).toBe(false);
});
it("checks a detached check function", () => {
const { check } = compile(read);
expect([{ path: "a" }, { path: 1 }].filter(check)).toEqual([{ path: "a" }]);
});
});
describe("options", () => {
it("treats format as an annotation unless formats are given", () => {
const schema = { type: "string", format: "email" };
expect(compile(schema).check("not an email")).toBe(true);
expect(compile(schema, { formats }).check("not an email")).toBe(false);
expect(compile(schema, { formats }).check("a@example.com")).toBe(true);
});
it("resolves $ref to registered documents", () => {
const point = { type: "object", properties: { x: { type: "number" } }, required: ["x"] };
const validator = compile(
{ $ref: "https://example.com/point.json" },
{ schemas: { "https://example.com/point.json": point } },
);
expect(validator.check({ x: 1 })).toBe(true);
expect(validator.check({})).toBe(false);
});
it("throws for a $ref that names nothing", () => {
expect(() => compile({ $ref: "https://example.com/missing.json" })).toThrow(/Unresolved \$ref/);
expect(() => compile({ $ref: "#/$defs/missing" })).toThrow(/Unresolved \$ref/);
});
it("applies draft 7 semantics: $ref overrides its siblings", () => {
const schema = { definitions: { s: { type: "string" } }, $ref: "#/definitions/s", maxLength: 1 };
expect(compile(schema, { draft: "7" }).check("ab")).toBe(true);
expect(compile(schema).check("ab")).toBe(false);
});
});
describe("schemas", () => {
it("compiles TypeBox schemas as they are", () => {
const validator = compile(Type.Object({ name: Type.String(), tags: Type.Array(Type.String()) }));
expect(validator.check({ name: "a", tags: [] })).toBe(true);
expect(validator.errors({ name: "a", tags: [1] })).toEqual([
{ path: "/tags/0", keyword: "type", message: "expected string, got integer" },
]);
});
it("compiles deep-frozen schemas and never modifies a schema", () => {
const schema = deepFreeze(structuredClone({ ...block, $defs: { s: { type: "string", pattern: "^a" } } }));
const before = JSON.stringify(schema);
const validator = compile(schema);
expect(validator.check({ kind: "text", text: "x" })).toBe(true);
expect(validator.errors({ kind: "image", width: -1 })).toHaveLength(1);
expect(JSON.stringify(schema)).toBe(before);
});
it("compiles a recursive schema", () => {
const tree = {
type: "object",
properties: { value: { type: "number" }, children: { type: "array", items: { $ref: "#" } } },
required: ["value"],
};
const validator = compile(tree);
expect(validator.check({ value: 1, children: [{ value: 2, children: [{ value: 3 }] }] })).toBe(true);
expect(validator.errors({ value: 1, children: [{ value: 2, children: [{}] }] })).toEqual([
{ path: "/children/0/children/0/value", keyword: "required", message: "missing" },
]);
});
// #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/);
});
});
function deepFreeze<T>(value: T): T {
if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
Object.freeze(value);
for (const child of Object.values(value)) deepFreeze(child);
}
return value;
}
describe("unresolved references", () => {
const dangling = { type: "object", properties: { t: { $ref: "#/definitions/Missing" } } };
const external = { type: "object", properties: { t: { $ref: "https://example.com/schemas/t.json" } } };
it("throw when the schema compiles, by default", () => {
expect(() => compile(dangling)).toThrow("Unresolved $ref #/definitions/Missing");
expect(() => compile(external)).toThrow("Unresolved $ref https://example.com/schemas/t.json");
});
it("reject only values at their place with unresolvedRefs: reject", () => {
for (const schema of [dangling, external]) {
const validator = compile(schema, { unresolvedRefs: "reject" });
expect(validator.check({})).toBe(true);
expect(validator.check({ t: 1 })).toBe(false);
}
expect(compile(dangling, { unresolvedRefs: "reject" }).errors({ t: 1 })).toEqual([
{ path: "/t", keyword: "$ref", message: "unresolved reference #/definitions/Missing" },
]);
});
it("leave values at their place uncoerced", () => {
const validator = compile(dangling, { unresolvedRefs: "reject", coerce: true });
expect(validator.parse({ t: "1" })).toEqual({
ok: false,
errors: [{ path: "/t", keyword: "$ref", message: "unresolved reference #/definitions/Missing" }],
});
});
it("reject a TypeBox Ref without its definition", () => {
const validator = compile(Type.Object({ a: Type.Ref("A") }), { unresolvedRefs: "reject" });
expect(validator.errors({ a: { x: "1" } })).toEqual([
{ path: "/a", keyword: "$ref", message: "unresolved reference A" },
]);
});
});
describe("string lengths", () => {
it("count an unpaired high surrogate as one code point and a pair as one", () => {
expect(compile({ type: "string", minLength: 2 }).check("\ud800A")).toBe(true);
expect(compile({ type: "string", maxLength: 1 }).check("\ud800A")).toBe(false);
expect(compile({ type: "string", maxLength: 1 }).check("\ud800")).toBe(true);
expect(compile({ type: "string", maxLength: 1 }).check("😀")).toBe(true);
expect(compile({ type: "string", minLength: 2 }).check("😀")).toBe(false);
expect(compile({ type: "string", minLength: 2 }).check("\udc00\ud800")).toBe(true);
});
});
describe("error messages", () => {
it("list at most 20 enum values", () => {
const values = Array.from({ length: 5000 }, (_, i) => `v${i}`);
const validator = compile({ type: "object", properties: { e: { type: "string", enum: values } } });
expect(validator.check({ e: "v4999" })).toBe(true);
const [error] = validator.errors({ e: "v5000" });
expect(error.message).toBe(
`expected one of ${JSON.stringify(values.slice(0, 20)).slice(0, -1)}, ... and 4980 more]`,
);
expect(compile({ enum: ["a", "b"] }).errors("c")).toEqual([
{ path: "", keyword: "enum", message: 'expected one of ["a","b"]' },
]);
});
it("accept a 10,000-value enum", () => {
const values = Array.from({ length: 10_000 }, (_, i) => `member${i}`);
expect(compile({ enum: values }).check("member9999")).toBe(true);
});
it("describe contains bounds in words", () => {
const at = (schema: object) => compile({ type: "array", ...schema }).errors(["a"])[0]?.message;
expect(at({ contains: { type: "number" } })).toBe("expected at least 1 matching item");
expect(at({ contains: { type: "number" }, minContains: 2 })).toBe("expected at least 2 matching items");
expect(at({ contains: { type: "string" }, maxContains: 0, minContains: 0 })).toBe(
"expected at most 0 matching items",
);
expect(at({ contains: { type: "number" }, minContains: 1, maxContains: 3 })).toBe(
"expected between 1 and 3 matching items",
);
});
it("blame a oneOf failure on the branch that declares most of the object's properties", () => {
const filter = {
type: "object",
properties: { where: { $ref: "#/$defs/F" } },
$defs: {
F: {
oneOf: [
{
type: "object",
properties: { field: { type: "string" }, eq: { type: "string" } },
required: ["field", "eq"],
additionalProperties: false,
},
{
type: "object",
properties: { and: { type: "array", items: { $ref: "#/$defs/F" } } },
required: ["and"],
additionalProperties: false,
},
],
},
},
};
expect(compile(filter).errors({ where: { and: [{ field: "a", eq: 1, x: 2 }] } })).toEqual([
{ path: "/where/and/0/eq", keyword: "type", message: "expected string, got integer" },
{ path: "/where/and/0/x", keyword: "additionalProperties", message: "unknown field" },
]);
});
it("explain one object reached through the same reference at two paths at each path", () => {
const schema = {
type: "object",
properties: { a: { $ref: "#/$defs/P" }, b: { $ref: "#/$defs/P" } },
$defs: { P: { type: "object", properties: { n: { type: "integer" } } } },
};
const shared = { n: "x" };
expect(compile(schema).errors({ a: shared, b: shared })).toEqual([
{ path: "/a/n", keyword: "type", message: "expected integer, got string" },
{ path: "/b/n", keyword: "type", message: "expected integer, got string" },
]);
});
});
describe("TypeBox's extra formats", () => {
it("check url and json-pointer-uri-fragment", () => {
const url = compile({ type: "string", format: "url" }, { formats });
expect(url.check("https://example.com/a?b=1")).toBe(true);
expect(url.check("not a url")).toBe(false);
const fragment = compile({ type: "string", format: "json-pointer-uri-fragment" }, { formats });
expect(fragment.check("#/a/b~1c")).toBe(true);
expect(fragment.check("#/a b")).toBe(false);
expect(fragment.check("/a")).toBe(false);
});
});