mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
@earendil-works/pi-schema compiles JSON Schema (drafts 2020-12 and 7) once into plain closures: no eval or new Function, so it runs under a strict CSP. It passes 2,222 of the 2,223 core tests of the official JSON-Schema-Test-Suite, checks Chord-style argument schemas within 1.2x of TypeBox's JIT, and keeps 3,200 compiled schemas in 7.9 MB (TypeBox: 54.7 MB). - compile(schema, options) returns a Validator: check() is the boolean check (a type guard for builder schemas), errors() reports each failure with a JSON Pointer path, missing and unknown fields at the field. - Builders (str, int, obj, opt, union, ...) return plain JSON Schema typed with the values it accepts (Static<typeof schema>). - Formats are an optional entry point (./formats), adapted from TypeBox (MIT). - Tests run the vendored suite (drafts 2020-12 and 7, with optional and format tests); npm run bench compares conformance, speed, setup cost, memory and bundle size with TypeBox, @cfworker/json-schema and omptype.
261 lines
7.7 KiB
TypeScript
261 lines
7.7 KiB
TypeScript
// Speed cases: realistic schemas with a valid and an invalid value each. Argument checks shaped like Chord's
|
|
// contracts (closed objects, tagged unions), pi's built-in tool schemas, and MCP tool schemas with $ref and anyOf.
|
|
|
|
import type { JsonSchema } from "../../src/index.ts";
|
|
|
|
/** A schema, the draft it is written in, and one valid and one invalid value. */
|
|
export interface Case {
|
|
readonly name: string;
|
|
readonly schema: JsonSchema;
|
|
readonly valid: unknown;
|
|
readonly invalid: unknown;
|
|
}
|
|
|
|
type Props = Record<string, object>;
|
|
const closed = (properties: Props, optional: string[] = []) => ({
|
|
type: "object",
|
|
properties,
|
|
required: Object.keys(properties).filter((k) => !optional.includes(k)),
|
|
additionalProperties: false,
|
|
});
|
|
const str = (o: object = {}) => ({ type: "string", ...o });
|
|
const int = (o: object = {}) => ({ type: "integer", ...o });
|
|
const num = (o: object = {}) => ({ type: "number", ...o });
|
|
const lit = (v: string) => ({ const: v });
|
|
const arr = (items: object, o: object = {}) => ({ type: "array", items, ...o });
|
|
const uuid = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$";
|
|
|
|
const block = {
|
|
anyOf: [
|
|
closed({ kind: lit("text"), text: str() }),
|
|
closed({ kind: lit("image"), src: str(), width: int({ minimum: 0 }), height: int({ minimum: 0 }), alt: str() }, [
|
|
"alt",
|
|
]),
|
|
closed({ kind: lit("code"), lang: { anyOf: [lit("ts"), lit("js"), lit("py")] }, source: str() }),
|
|
],
|
|
};
|
|
|
|
const document = closed(
|
|
{
|
|
id: str({ pattern: uuid }),
|
|
title: str({ minLength: 1, maxLength: 200 }),
|
|
tags: arr(str(), { maxItems: 16 }),
|
|
author: closed({ name: str(), email: str() }, ["email"]),
|
|
blocks: arr(block),
|
|
score: num({ minimum: 0, maximum: 1 }),
|
|
draft: { type: "boolean" },
|
|
},
|
|
["score", "draft"],
|
|
);
|
|
|
|
const documentValue = {
|
|
id: "01890a5d-ac96-774b-bcce-b302099a8057",
|
|
title: "Quarterly notes",
|
|
tags: ["a", "b", "c"],
|
|
author: { name: "Mario", email: "m@example.com" },
|
|
blocks: [
|
|
{ kind: "text", text: "Hello" },
|
|
{ kind: "image", src: "a.png", width: 640, height: 480 },
|
|
{ kind: "code", lang: "ts", source: "const x = 1;" },
|
|
{ kind: "text", text: "World" },
|
|
{ kind: "image", src: "b.png", width: 10, height: 20, alt: "b" },
|
|
],
|
|
score: 0.5,
|
|
};
|
|
|
|
const id = int({ minimum: 0 });
|
|
const message = {
|
|
anyOf: [
|
|
closed({
|
|
kind: lit("hello"),
|
|
protocol: int({ minimum: 0 }),
|
|
incarnation: str({ pattern: uuid }),
|
|
calls: int({ minimum: 1 }),
|
|
}),
|
|
closed(
|
|
{
|
|
kind: lit("hold"),
|
|
hold: id,
|
|
type: str(),
|
|
version: int({ minimum: 1 }),
|
|
fingerprint: str({ pattern: "^[0-9a-f]{16}$" }),
|
|
params: {},
|
|
address: closed({ kind: str(), params: {} }),
|
|
chain: arr(str({ pattern: uuid }), { maxItems: 64 }),
|
|
},
|
|
["chain"],
|
|
),
|
|
closed({ kind: lit("release"), hold: id }),
|
|
closed({ kind: lit("call"), call: id, hold: id, method: id, args: {} }, ["args"]),
|
|
closed({ kind: lit("abort"), call: id }),
|
|
],
|
|
};
|
|
|
|
const tool = (properties: Props, required: string[]) => ({ type: "object", properties, required });
|
|
|
|
const mcpIssue = {
|
|
$schema: "http://json-schema.org/draft-07/schema#",
|
|
type: "object",
|
|
properties: {
|
|
owner: str({ minLength: 1, pattern: "^[A-Za-z0-9-]+$" }),
|
|
repo: str({ minLength: 1, pattern: "^[A-Za-z0-9._-]+$" }),
|
|
title: str({ minLength: 1 }),
|
|
body: str(),
|
|
labels: arr(str()),
|
|
assignees: arr(str()),
|
|
milestone: { anyOf: [{ type: "number" }, { type: "null" }] },
|
|
state: { type: "string", enum: ["open", "closed"] },
|
|
},
|
|
required: ["owner", "repo", "title"],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const mcpEdit = {
|
|
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
type: "object",
|
|
$defs: {
|
|
Range: closed({ start: int({ minimum: 1 }), end: int({ minimum: 1 }) }),
|
|
Replace: closed({ oldText: str(), newText: str() }),
|
|
LineEdit: closed({ range: { $ref: "#/$defs/Range" }, text: str() }),
|
|
},
|
|
properties: {
|
|
path: str({ pattern: "^(/|\\./|[A-Za-z]:\\\\)?[^\\u0000]+$" }),
|
|
edits: arr({ anyOf: [{ $ref: "#/$defs/Replace" }, { $ref: "#/$defs/LineEdit" }] }, { minItems: 1 }),
|
|
dryRun: { type: "boolean" },
|
|
},
|
|
required: ["path", "edits"],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const mcpPage = {
|
|
type: "object",
|
|
$defs: {
|
|
RichText: closed(
|
|
{
|
|
type: { enum: ["text", "mention"] },
|
|
content: str({ maxLength: 2000 }),
|
|
annotations: closed({ bold: { type: "boolean" }, italic: { type: "boolean" }, color: str() }, [
|
|
"bold",
|
|
"italic",
|
|
"color",
|
|
]),
|
|
},
|
|
["annotations"],
|
|
),
|
|
Block: {
|
|
type: "object",
|
|
properties: {
|
|
type: { enum: ["paragraph", "heading_1", "heading_2", "bulleted_list_item", "to_do"] },
|
|
rich_text: arr({ $ref: "#/$defs/RichText" }),
|
|
checked: { type: "boolean" },
|
|
children: arr({ $ref: "#/$defs/Block" }),
|
|
},
|
|
required: ["type", "rich_text"],
|
|
additionalProperties: false,
|
|
},
|
|
},
|
|
properties: {
|
|
parent: { oneOf: [closed({ page_id: str({ pattern: uuid }) }), closed({ database_id: str({ pattern: uuid }) })] },
|
|
title: str({ minLength: 1 }),
|
|
due: str({ pattern: "^\\d{4}-\\d{2}-\\d{2}$" }),
|
|
children: arr({ $ref: "#/$defs/Block" }, { maxItems: 100 }),
|
|
},
|
|
required: ["parent", "title"],
|
|
additionalProperties: false,
|
|
};
|
|
|
|
const rt = (content: string) => ({
|
|
type: "text",
|
|
content,
|
|
annotations: { bold: false, italic: false, color: "default" },
|
|
});
|
|
const pageValue = {
|
|
parent: { page_id: "01890a5d-ac96-774b-bcce-b302099a8057" },
|
|
title: "Plan",
|
|
due: "2026-11-01",
|
|
children: [
|
|
{ type: "heading_1", rich_text: [rt("Plan")] },
|
|
{
|
|
type: "bulleted_list_item",
|
|
rich_text: [rt("one")],
|
|
children: [{ type: "to_do", rich_text: [rt("sub")], checked: true }],
|
|
},
|
|
{ type: "paragraph", rich_text: [rt("a"), rt("b"), rt("c")] },
|
|
],
|
|
};
|
|
const pageInvalid = structuredClone(pageValue);
|
|
pageInvalid.children[1].children = [{ type: "to_do", rich_text: [rt("sub")], checked: "yes" as unknown as boolean }];
|
|
|
|
/** The speed cases. */
|
|
export const cases: readonly Case[] = [
|
|
{ name: "small closed object", schema: closed({ text: str() }), valid: { text: "hello" }, invalid: { text: 1 } },
|
|
{
|
|
name: "nested object with tagged unions",
|
|
schema: document,
|
|
valid: documentValue,
|
|
invalid: {
|
|
...documentValue,
|
|
blocks: [...documentValue.blocks.slice(0, 4), { kind: "image", src: "b.png", width: -1, height: 20 }],
|
|
},
|
|
},
|
|
{
|
|
name: "message union of 5",
|
|
schema: message,
|
|
valid: { kind: "call", call: 7, hold: 3, method: 1, args: { a: 1 } },
|
|
invalid: { kind: "call", call: 7, hold: 3, method: 1, extra: true },
|
|
},
|
|
{
|
|
name: "tool: read",
|
|
schema: tool({ path: str({ description: "Path to the file" }), offset: num(), limit: num() }, ["path"]),
|
|
valid: { path: "src/a.ts", offset: 10, limit: 50 },
|
|
invalid: { offset: 1 },
|
|
},
|
|
{
|
|
name: "tool: edit",
|
|
schema: tool({ path: str(), edits: arr(tool({ oldText: str(), newText: str() }, ["oldText", "newText"])) }, [
|
|
"path",
|
|
"edits",
|
|
]),
|
|
valid: {
|
|
path: "a.ts",
|
|
edits: [
|
|
{ oldText: "a", newText: "b" },
|
|
{ oldText: "c", newText: "d" },
|
|
],
|
|
},
|
|
invalid: { path: "a.ts", edits: [{ oldText: "a" }] },
|
|
},
|
|
{
|
|
name: "tool: bash",
|
|
schema: tool({ command: str(), timeout: num() }, ["command"]),
|
|
valid: { command: "ls -la", timeout: 30 },
|
|
invalid: { command: 5 },
|
|
},
|
|
{
|
|
name: "MCP create_issue (pattern, anyOf, enum)",
|
|
schema: mcpIssue,
|
|
valid: {
|
|
owner: "earendil-works",
|
|
repo: "pi",
|
|
title: "Bug",
|
|
body: "...",
|
|
labels: ["bug"],
|
|
milestone: null,
|
|
state: "open",
|
|
},
|
|
invalid: { owner: "earendil works", repo: "pi", title: "Bug" },
|
|
},
|
|
{
|
|
name: "MCP edit_file ($ref, anyOf)",
|
|
schema: mcpEdit,
|
|
valid: {
|
|
path: "./src/a.ts",
|
|
edits: [
|
|
{ oldText: "a", newText: "b" },
|
|
{ range: { start: 1, end: 3 }, text: "x" },
|
|
],
|
|
},
|
|
invalid: { path: "./src/a.ts", edits: [{ range: { start: 0, end: 3 }, text: "x" }] },
|
|
},
|
|
{ name: "MCP create_page (recursive $ref, oneOf)", schema: mcpPage, valid: pageValue, invalid: pageInvalid },
|
|
];
|