@oh-my-pi/omptype 18.15.0 → 18.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,24 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.17.0] - 2026-11-06
6
+
7
+ ### Breaking Changes
8
+
9
+ - `fromJsonSchema` returns an opaque whole-document validator (no `.pick()`/`.props`); `format` is annotation-only unless checked via `formats`; bad `$ref` throws `invalid $ref`/`unresolved $ref`.
10
+
11
+ ### Added
12
+
13
+ - Added `fromJsonSchema(schema, { draft, schemas, formats })`, validating JSON Schema draft 2020-12 and draft 7, including dynamic/remote refs and unevaluated keywords.
14
+
15
+ ### Changed
16
+
17
+ - Faster `fromJsonSchema` validation, including `$ref`-heavy schemas.
18
+
19
+ ### Fixed
20
+
21
+ - `fromJsonSchema` falls back to interpreted validation instead of throwing `EvalError` where generated code is forbidden.
22
+
5
23
  ## [18.13.3] - 2026-10-28
6
24
 
7
25
  ### Changed
package/README.md CHANGED
@@ -87,6 +87,39 @@ const user = ZodUser.parse({ name: "Ada" });
87
87
  `@oh-my-pi/omptype/ark` provides the repository's ArkType compatibility facade
88
88
  and re-exports the same `type` and `scope` implementations.
89
89
 
90
+ ## JSON Schema import
91
+
92
+ `fromJsonSchema` turns any JSON Schema document into an omptype schema that
93
+ validates exactly as drafts 2020-12 and 7 specify, including `$dynamicRef`,
94
+ `unevaluatedProperties`/`unevaluatedItems`, `$id`/`$anchor` resolution, and
95
+ meta-schema vocabularies:
96
+
97
+ ```ts
98
+ import { fromJsonSchema } from "@oh-my-pi/omptype";
99
+
100
+ const read = fromJsonSchema(mcpTool.inputSchema, {
101
+ draft: "2020-12", // semantics where a resource declares no `$schema` ("7" for draft 7)
102
+ schemas: { "https://example.com/defs.json": defs }, // documents `$ref`s may name
103
+ formats: { email: value => value.includes("@") }, // `format` only asserts with a check
104
+ });
105
+ read.allows(args); // boolean
106
+ read(args); // args with absent `default`s filled in, or OmpErrors with paths
107
+ ```
108
+
109
+ The first calls are interpreted from a compiled plan; hot schemas switch to
110
+ generated code (tagged unions become a `switch`, simple patterns char-code
111
+ checks). It passes all 1,298 draft 2020-12 and 925 draft 7 core tests of the
112
+ JSON-Schema-Test-Suite. Hot checks from the
113
+ [pi-schema benchmark](https://github.com/earendil-works/pi/tree/main/packages/schema)
114
+ (Node 26, Apple M4 Max, ns per valid check):
115
+
116
+ | Case | omptype | ata | Ajv | TypeBox | pi-schema |
117
+ | -------------------------------- | ------: | --: | --: | ------: | --------: |
118
+ | nested object with tagged unions | **85** | 131 | 337 | 341 | 305 |
119
+ | union of 5 messages | **11** | 24 | 48 | 75 | 60 |
120
+ | MCP tool schema with `$ref` | **35** | 40 | 85 | 66 | 113 |
121
+ | recursive `$ref` schema | **158** | 196 | 246 | 776 | 570 |
122
+
90
123
  ## Performance
91
124
 
92
125
  Run the benchmark from the repository root:
@@ -240,6 +240,10 @@ class Builder {
240
240
  }
241
241
  case "sub":
242
242
  return `!(${this.ref(node.schema.run)}(${v}) instanceof AE)`;
243
+ case "refine":
244
+ if (node.check !== undefined)
245
+ return `(${this.predicate(node.base, v)})&&${this.ref(node.check)}(${v})`;
246
+ return `!(${this.ref(boundWalk(node))}(${v}) instanceof AE)`;
243
247
  default:
244
248
  return `!(${this.ref(boundWalk(node))}(${v}) instanceof AE)`;
245
249
  }
@@ -895,6 +899,22 @@ function boundWalk(node) {
895
899
  function resolvedRoot(ir) {
896
900
  return ir.k === "alias" ? ir.resolve() : ir;
897
901
  }
902
+ /** Set once the runtime refuses code generation from strings (strict CSP); the interpreter serves from then on. */
903
+ let jitBlocked = false;
904
+ /** Build with `build`, or `undefined` when code generation from strings is forbidden. */
905
+ function tryBuild(build) {
906
+ if (jitBlocked)
907
+ return undefined;
908
+ try {
909
+ return build();
910
+ }
911
+ catch (error) {
912
+ if (!(error instanceof EvalError))
913
+ throw error;
914
+ jitBlocked = true;
915
+ return undefined;
916
+ }
917
+ }
898
918
  const compiledCache = new WeakMap();
899
919
  const allowsCache = new WeakMap();
900
920
  /** Compile `ir` into a specialized validator. */
@@ -908,8 +928,9 @@ export function compile(ir) {
908
928
  // fresh one forever. The wrapper resolves to the built validator by call
909
929
  // time; the interpreter is a safety net that never triggers post-build.
910
930
  const built = {};
911
- compiledCache.set(root, value => (built.value === undefined ? walk(root, value) : built.value(value)));
912
- const compiled = new Builder().build(root);
931
+ const deferred = (value) => (built.value === undefined ? walk(root, value) : built.value(value));
932
+ compiledCache.set(root, deferred);
933
+ const compiled = tryBuild(() => new Builder().build(root)) ?? ((value) => walk(root, value));
913
934
  built.value = compiled;
914
935
  compiledCache.set(root, compiled);
915
936
  return compiled;
@@ -923,7 +944,8 @@ export function compileAllows(ir) {
923
944
  if (validator === undefined) {
924
945
  const built = {};
925
946
  allowsCache.set(root, ((value) => built.value === undefined ? !(walk(root, value) instanceof OmpErrors) : built.value(value)));
926
- const compiled = new Builder().buildAllows(root);
947
+ const compiled = tryBuild(() => new Builder().buildAllows(root)) ??
948
+ ((value) => !(walk(root, value) instanceof OmpErrors));
927
949
  built.value = compiled;
928
950
  allowsCache.set(root, compiled);
929
951
  return compiled;
@@ -1,235 +1,80 @@
1
1
  /**
2
2
  * JSON Schema → omptype importer.
3
3
  *
4
- * Rebuilds a callable schema from JSON Schema documents — the inverse of
5
- * `Type.toJsonSchema()` for the dialect omptype (and its Zod/TypeBox
6
- * adapters) emits: draft-07 and draft-2020-12 structural keywords, string
7
- * formats, `$defs`/`definitions` references (including recursion), enums,
8
- * and composition via `anyOf`/`oneOf`/`allOf`.
4
+ * Validates exactly as JSON Schema drafts 2020-12 and 7 specify: every
5
+ * keyword including `$dynamicRef`, `unevaluatedProperties`/`unevaluatedItems`,
6
+ * `$id`/`$anchor` resolution, remote references, and vocabularies declared by
7
+ * meta-schemas. Validation runs on an eval-free closure engine and moves to
8
+ * generated code once a schema is hot (see `jsonschema/`).
9
9
  *
10
- * Unknown or non-structural keywords are ignored, matching lenient importer
11
- * behavior: the result validates every constraint the schema can express in
12
- * omptype IR and passes the rest through.
10
+ * The result is a regular omptype schema: callable, composable, emitting the
11
+ * source document from `toJsonSchema()`, and filling absent properties from
12
+ * their `default` when called (never in `allows`).
13
+ *
14
+ * # Example
15
+ * ```ts
16
+ * const read = fromJsonSchema({
17
+ * type: "object",
18
+ * properties: { path: { type: "string" }, offset: { type: "integer", minimum: 0 } },
19
+ * required: ["path"],
20
+ * });
21
+ * read.allows({ path: "a.ts" }); // true
22
+ * read({ offset: -1 }); // OmpErrors: path missing, offset below 0
23
+ * ```
13
24
  */
14
- import { OmpTypeError } from "./errors.js";
15
- import { parseLocalRefPointer } from "./local-ref.js";
25
+ import { OmpErrors } from "./errors.js";
16
26
  import { IR_BRAND } from "./ir.js";
17
- import { keywordIR, patternIR } from "./keywords.js";
27
+ import { Validator } from "./jsonschema/validator.js";
18
28
  import { type } from "./type.js";
19
- /** `format` values lowered to built-in keyword validators. */
20
- const FORMAT_KEYWORDS = {
21
- email: "string.email",
22
- uuid: "string.uuid",
23
- "date-time": "string.date.iso",
24
- date: "string.date.iso",
25
- ipv4: "string.ip.v4",
26
- ipv6: "string.ip.v6",
27
- regex: "string.regex",
28
- };
29
- class Importer {
30
- #root;
31
- #aliases = new Map();
32
- constructor(root) {
33
- this.#root = root;
34
- }
35
- resolveRef(ref) {
36
- const cached = this.#aliases.get(ref);
37
- if (cached !== undefined)
38
- return cached;
39
- let target;
40
- if (ref === "#") {
41
- target = this.#root;
42
- }
43
- else {
44
- const pointer = parseLocalRefPointer(ref);
45
- if (pointer === undefined || pointer.length !== 2 || (pointer[0] !== "$defs" && pointer[0] !== "definitions"))
46
- throw new OmpTypeError(`unsupported $ref: ${ref}`);
47
- const defs = this.#root[pointer[0]];
48
- target = typeof defs === "object" && defs !== null ? defs[pointer[1]] : undefined;
49
- if (target === undefined)
50
- throw new OmpTypeError(`unresolved $ref: ${ref}`);
51
- }
52
- // Register the alias before lowering so recursive references resolve
53
- // to the same node instead of recursing forever.
54
- let lowered;
55
- const alias = {
56
- k: "alias",
57
- name: ref,
58
- resolve: () => {
59
- lowered ??= this.lower(target);
60
- return lowered;
61
- },
62
- };
63
- this.#aliases.set(ref, alias);
64
- return alias;
65
- }
66
- lower(schema) {
67
- if (schema === true)
68
- return { k: "unknown" };
69
- if (schema === false)
70
- return { k: "never" };
71
- if (typeof schema !== "object" || schema === null) {
72
- throw new OmpTypeError("JSON Schema nodes must be booleans or objects");
73
- }
74
- const node = schema;
75
- const desc = typeof node.description === "string" ? node.description : undefined;
76
- const ir = this.#lowerNode(node);
77
- return desc === undefined ? ir : { ...ir, desc };
78
- }
79
- #lowerNode(node) {
80
- if (typeof node.$ref === "string")
81
- return this.resolveRef(node.$ref);
82
- if (Array.isArray(node.enum)) {
83
- const members = node.enum.map((v) => ({ k: "lit", v }));
84
- return members.length === 1 ? members[0] : { k: "union", members };
85
- }
86
- if ("const" in node)
87
- return { k: "lit", v: node.const };
88
- const branches = node.anyOf ?? node.oneOf;
89
- if (Array.isArray(branches)) {
90
- return { k: "union", members: branches.map(branch => this.lower(branch)) };
91
- }
92
- if (Array.isArray(node.allOf)) {
93
- const members = node.allOf.map(branch => this.lower(branch));
94
- return members.length === 1 ? members[0] : { k: "intersection", members };
95
- }
96
- if (typeof node.not === "object" && node.not !== null && Object.keys(node.not).length === 0) {
97
- return { k: "never" };
98
- }
99
- if (Array.isArray(node.type)) {
100
- const members = node.type.map(t => this.#lowerTyped(node, String(t)));
101
- return members.length === 1 ? members[0] : { k: "union", members };
102
- }
103
- if (typeof node.type === "string")
104
- return this.#lowerTyped(node, node.type);
105
- // No explicit type: infer from structural keywords, else accept anything.
106
- if (node.properties !== undefined || node.required !== undefined)
107
- return this.#lowerObject(node);
108
- if (node.items !== undefined || node.prefixItems !== undefined)
109
- return this.#lowerArray(node);
110
- return { k: "unknown" };
111
- }
112
- #lowerTyped(node, kind) {
113
- switch (kind) {
114
- case "null":
115
- return { k: "null" };
116
- case "boolean":
117
- return { k: "boolean" };
118
- case "string":
119
- return this.#lowerString(node);
120
- case "number":
121
- case "integer": {
122
- const ir = { k: "number" };
123
- if (kind === "integer")
124
- ir.int = true;
125
- if (typeof node.minimum === "number")
126
- ir.min = node.minimum;
127
- if (typeof node.maximum === "number")
128
- ir.max = node.maximum;
129
- if (typeof node.exclusiveMinimum === "number") {
130
- ir.min = node.exclusiveMinimum;
131
- ir.xmin = true;
132
- }
133
- if (typeof node.exclusiveMaximum === "number") {
134
- ir.max = node.exclusiveMaximum;
135
- ir.xmax = true;
136
- }
137
- if (typeof node.multipleOf === "number")
138
- ir.divisor = node.multipleOf;
139
- return ir;
140
- }
141
- case "object":
142
- return this.#lowerObject(node);
143
- case "array":
144
- return this.#lowerArray(node);
145
- default:
146
- throw new OmpTypeError(`unsupported JSON Schema type: ${kind}`);
147
- }
148
- }
149
- #lowerString(node) {
150
- const base = { k: "string" };
151
- if (typeof node.minLength === "number")
152
- base.min = node.minLength;
153
- if (typeof node.maxLength === "number")
154
- base.max = node.maxLength;
155
- if (node.format === "uri" || node.format === "url")
156
- base.url = true;
157
- const members = [];
158
- if (typeof node.format === "string") {
159
- const keyword = FORMAT_KEYWORDS[node.format];
160
- if (keyword !== undefined) {
161
- const formatIR = keywordIR(keyword);
162
- if (formatIR !== undefined)
163
- members.push(formatIR);
164
- }
165
- }
166
- if (typeof node.pattern === "string")
167
- members.push(patternIR(new RegExp(node.pattern)));
168
- if (members.length === 0)
169
- return base;
170
- if (base.min !== undefined || base.max !== undefined || base.url)
171
- members.unshift(base);
172
- return members.length === 1 ? members[0] : { k: "intersection", members };
173
- }
174
- #lowerObject(node) {
175
- const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []);
176
- const props = [];
177
- if (typeof node.properties === "object" && node.properties !== null) {
178
- for (const [key, value] of Object.entries(node.properties)) {
179
- const prop = { key, opt: !required.has(key), val: this.lower(value) };
180
- if (typeof value === "object" && value !== null && "default" in value) {
181
- prop.hasDefault = true;
182
- prop.def = value.default;
183
- prop.opt = true;
184
- }
185
- props.push(prop);
186
- }
187
- }
188
- const extra = node.additionalProperties;
189
- return {
190
- k: "object",
191
- props,
192
- extras: extra === false ? "reject" : "keep",
193
- ...(typeof extra === "object" && extra !== null ? { index: this.lower(extra) } : {}),
194
- };
195
- }
196
- #lowerArray(node) {
197
- if (Array.isArray(node.prefixItems)) {
198
- const minItems = typeof node.minItems === "number" ? node.minItems : node.prefixItems.length;
199
- const prefix = node.prefixItems.map((item, index) => ({
200
- val: this.lower(item),
201
- opt: index >= minItems,
202
- }));
203
- return {
204
- k: "tuple",
205
- prefix,
206
- postfix: [],
207
- ...(node.items !== undefined && node.items !== false ? { variadic: this.lower(node.items) } : {}),
208
- };
209
- }
210
- const ir = { k: "array", el: node.items === undefined ? { k: "unknown" } : this.lower(node.items) };
211
- if (typeof node.minItems === "number")
212
- ir.min = node.minItems;
213
- if (typeof node.maxItems === "number")
214
- ir.max = node.maxItems;
215
- return ir;
216
- }
217
- }
218
29
  /**
219
30
  * Build a callable omptype schema from a JSON Schema document.
220
31
  *
221
32
  * # Errors
222
- * Throws {@link OmpTypeError} on malformed nodes, unresolvable `$ref`s, or
223
- * types omptype cannot represent.
33
+ * Throws `OmpTypeError` when a `$ref` is malformed or names a document or
34
+ * location that neither `schema` nor `options.schemas` contains, when a
35
+ * `pattern` is not a regular expression, or when a schema node is neither a
36
+ * boolean nor an object.
224
37
  */
225
- export function fromJsonSchema(schema) {
226
- const importer = new Importer(typeof schema === "object" && schema !== null ? schema : {});
38
+ export function fromJsonSchema(schema, options) {
39
+ // Runs after 64 checks, long after `imported` below exists.
40
+ const validator = new Validator(schema, options, compiled => {
41
+ imported.allows = compiled;
42
+ });
43
+ const check = validator.check;
44
+ const node = typeof schema === "object" && schema !== null ? schema : undefined;
45
+ const refine = {
46
+ k: "refine",
47
+ base: { k: "unknown" },
48
+ check,
49
+ pred: value => check(value) || toErrors(validator.explain(value), value),
50
+ expected: "a value matching the JSON Schema",
51
+ json: node ?? (schema === false ? { not: {} } : {}),
52
+ };
53
+ if (typeof node?.description === "string")
54
+ refine.desc = node.description;
55
+ const ir = validator.hasDefaults
56
+ ? { k: "morph", input: refine, fn: value => validator.fillDefaults(value) }
57
+ : refine;
227
58
  const embedded = {
228
59
  [IR_BRAND]: true,
229
- ir: importer.lower(schema),
60
+ ir,
230
61
  hasSteps: false,
231
62
  hasDefault: false,
232
63
  run: value => value,
233
64
  };
234
- return type.raw(embedded);
65
+ const imported = type.raw(embedded);
66
+ // The engine's check is the whole boolean path; skip the generic dispatcher.
67
+ // Once generated code exists, `onCompile` above rebinds `allows` to it.
68
+ Object.defineProperty(imported, "allows", { value: check, writable: true, configurable: true });
69
+ return imported;
70
+ }
71
+ function toErrors(issues, value) {
72
+ if (issues.length === 0)
73
+ return new OmpErrors([], "a value matching the JSON Schema", value);
74
+ const errors = new OmpErrors(issues[0].path, issues[0].expected, issues[0].data);
75
+ for (let index = 1; index < issues.length; index++) {
76
+ const issue = issues[index];
77
+ errors.append(new OmpErrors(issue.path, issue.expected, issue.data));
78
+ }
79
+ return errors;
235
80
  }
package/dist/js/interp.js CHANGED
@@ -202,6 +202,8 @@ function checkNode(ir, v) {
202
202
  case "refine":
203
203
  if (!checks(ir.base, v))
204
204
  return false;
205
+ if (ir.check !== undefined)
206
+ return ir.check(v);
205
207
  try {
206
208
  return ir.pred(v) === true;
207
209
  }