@oh-my-pi/omptype 17.2.6 → 17.2.8

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/src/errors.ts CHANGED
@@ -8,33 +8,120 @@
8
8
  * All human-readable strings are built lazily on property access.
9
9
  */
10
10
 
11
+ /** Context supplied to configurable error formatters. */
12
+ export interface ErrorContext {
13
+ readonly code: string;
14
+ readonly path: readonly PropertyKey[];
15
+ readonly propString: string;
16
+ readonly data: unknown;
17
+ readonly expected: string;
18
+ readonly actual: string;
19
+ readonly problem: string;
20
+ readonly description: string;
21
+ readonly rule?: unknown;
22
+ }
23
+
24
+ /** Per-schema overrides for validation error text. */
25
+ export interface ErrorConfig {
26
+ readonly expected?: string | ((context: ErrorContext) => string);
27
+ readonly actual?: string | ((data: unknown) => string);
28
+ readonly problem?: string | ((context: ErrorContext) => string);
29
+ readonly message?: string | ((context: ErrorContext) => string);
30
+ /** Internal: custom predicate expectations display the offending value rather than its domain. */
31
+ readonly preserveActual?: boolean;
32
+ }
33
+
34
+ function format(
35
+ override: string | ((context: ErrorContext) => string) | undefined,
36
+ context: ErrorContext,
37
+ fallback: string,
38
+ ): string {
39
+ return typeof override === "function" ? override(context) : (override ?? fallback);
40
+ }
41
+
11
42
  /** A single validation failure at one path. */
12
43
  export class OmpError {
44
+ #rawExpected: string;
45
+ #config: ErrorConfig | undefined;
46
+
13
47
  constructor(
14
48
  /** Property path from the root to the failing value (empty at root). */
15
49
  readonly path: PropertyKey[],
16
- /** Human-readable expectation, e.g. `"a string"` or `"at most 3600"`. */
17
- readonly expected: string,
50
+ expected: string,
18
51
  /** The value that failed validation. */
19
52
  readonly data: unknown,
20
- ) {}
53
+ config?: ErrorConfig,
54
+ ) {
55
+ this.#rawExpected = expected;
56
+ this.#config = config;
57
+ }
58
+ /** Prefix this failure when a nested schema delegates validation. */
59
+ prefix(key: PropertyKey): this {
60
+ this.path.unshift(key);
61
+ return this;
62
+ }
63
+
64
+ /** Apply schema-local formatting to this failure. */
65
+ configure(config: ErrorConfig): this {
66
+ this.#config = { ...this.#config, ...config };
67
+ return this;
68
+ }
69
+
70
+ /** Stable category for programmatic error handling. */
71
+ get code(): string {
72
+ return errorCode(this.#rawExpected, this.data);
73
+ }
74
+
75
+ #context(expected: string, actual: string, problem = ""): ErrorContext {
76
+ const { description, rule } = describeExpectation(this.#rawExpected);
77
+ return {
78
+ code: this.code,
79
+ path: this.path,
80
+ propString: formatPath(this.path),
81
+ data: this.data,
82
+ expected,
83
+ actual,
84
+ problem,
85
+ description,
86
+ ...(rule === undefined ? {} : { rule }),
87
+ };
88
+ }
89
+
90
+ /** Human-readable expectation, including a configured override. */
91
+ get expected(): string {
92
+ const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
93
+ const parts = this.#rawExpected.split(" or ");
94
+ const fallback =
95
+ parts.length < 3 ? this.#rawExpected : `${parts.slice(0, -1).join(", ")} or ${parts[parts.length - 1]}`;
96
+ return format(this.#config?.expected, this.#context(fallback, actual), fallback);
97
+ }
21
98
 
22
99
  /** Short description of the received value, e.g. `"a number"` or `"missing"`. */
23
100
  get actual(): string {
24
- return describeValue(this.data);
101
+ const actual = describeValue(this.data, this.#config?.preserveActual ? "predicate" : this.code);
102
+ const override = this.#config?.actual;
103
+ return typeof override === "function" ? override(this.data) : (override ?? actual);
25
104
  }
26
105
 
27
106
  /** Path-less problem statement: `must be <expected> (was <actual>)`. */
28
107
  get problem(): string {
29
- return this.data === MISSING
30
- ? `must be ${this.expected} (was missing)`
31
- : `must be ${this.expected} (was ${this.actual})`;
108
+ const expected = this.expected;
109
+ const actual = this.actual;
110
+ const fallback =
111
+ this.data === MISSING
112
+ ? `must be ${expected} (was missing)`
113
+ : actual === ""
114
+ ? `must be ${expected}`
115
+ : `must be ${expected} (was ${actual})`;
116
+ return format(this.#config?.problem, this.#context(expected, actual, fallback), fallback);
32
117
  }
33
-
34
- /** Full message including the path prefix. */
35
118
  get message(): string {
36
- const at = this.path.length === 0 ? "" : `${this.path.map(String).join(".")} `;
37
- return `${at}${this.problem}`;
119
+ const expected = this.expected;
120
+ const actual = this.actual;
121
+ const problem = this.problem;
122
+ const at = this.path.length === 0 ? "" : `${formatPath(this.path)} `;
123
+ const messageActual = this.#config?.actual === undefined ? describeKind(this.data) : actual;
124
+ return format(this.#config?.message, this.#context(expected, messageActual, problem), `${at}${problem}`);
38
125
  }
39
126
 
40
127
  toString(): string {
@@ -44,8 +131,23 @@ export class OmpError {
44
131
 
45
132
  /** Sentinel for a required key that was absent (distinguishes from `undefined`). */
46
133
  export const MISSING: unique symbol = Symbol("omptype.missing");
134
+ function stringifyValue(data: object): string {
135
+ const seen = new WeakSet<object>();
136
+ return (
137
+ JSON.stringify(data, (_key, value: unknown) => {
138
+ if (typeof value !== "object" || value === null) return value;
139
+ if (seen.has(value)) return "(cycle)";
140
+ seen.add(value);
141
+ return value;
142
+ }) ?? "an object"
143
+ );
144
+ }
47
145
 
48
- function describeValue(data: unknown): string {
146
+ function describeValue(data: unknown, code: string): string {
147
+ if (data === MISSING) return "missing";
148
+ if (code === "undeclared") return "";
149
+ if (code === "referenceSame") return "";
150
+ if (code === "domain") return describeKind(data);
49
151
  if (data === null) return "null";
50
152
  if (Array.isArray(data)) return "an array";
51
153
  switch (typeof data) {
@@ -60,7 +162,7 @@ function describeValue(data: unknown): string {
60
162
  case "undefined":
61
163
  return "undefined";
62
164
  case "object":
63
- return "an object";
165
+ return stringifyValue(data);
64
166
  case "function":
65
167
  return "a function";
66
168
  default:
@@ -68,48 +170,256 @@ function describeValue(data: unknown): string {
68
170
  }
69
171
  }
70
172
 
173
+ function describeKind(data: unknown): string {
174
+ if (data === MISSING) return "missing";
175
+ if (typeof data === "number" && Number.isNaN(data)) return "NaN";
176
+ if (data === null) return "null";
177
+ if (Array.isArray(data)) return "an object";
178
+ switch (typeof data) {
179
+ case "string":
180
+ return "a string";
181
+ case "number":
182
+ return "a number";
183
+ case "bigint":
184
+ return "a bigint";
185
+ case "boolean":
186
+ return "boolean";
187
+ case "undefined":
188
+ return "undefined";
189
+ case "object": {
190
+ const ctor = Object.getPrototypeOf(data)?.constructor;
191
+ return typeof ctor?.name === "string" && ctor.name !== "Object" ? ctor.name : "an object";
192
+ }
193
+ case "function":
194
+ return "a function";
195
+ default:
196
+ return "a symbol";
197
+ }
198
+ }
199
+
200
+ function describeExpectation(expected: string): { description: string; rule?: unknown } {
201
+ const divisor = /divisible by (\d+(?:\.\d+)?)/.exec(expected);
202
+ if (divisor) {
203
+ const rule = Number(divisor[1]);
204
+ return { description: rule === 2 ? "even" : `divisible by ${rule}`, rule };
205
+ }
206
+ return { description: expected.replace(/^(?:an?|the) /, "") };
207
+ }
208
+
209
+ function formatPath(path: readonly PropertyKey[]): string {
210
+ let out = "";
211
+ for (const key of path) {
212
+ if (typeof key === "number") out += `[${key}]`;
213
+ else if (typeof key === "symbol") out += `[${String(key)}]`;
214
+ else out += out.length === 0 ? key : `.${key}`;
215
+ }
216
+ return out;
217
+ }
218
+
219
+ function errorCode(expected: string, data: unknown): string {
220
+ if (data === MISSING) return "required";
221
+ if (expected === "removed") return "undeclared";
222
+ if (expected.includes("serialized to the same value")) return "referenceSame";
223
+ if (expected.includes("divisible by") || expected.includes("integer")) return "divisor";
224
+ if (
225
+ expected === "true" ||
226
+ expected === "false" ||
227
+ expected === "null" ||
228
+ expected === "undefined" ||
229
+ expected === "NaN" ||
230
+ expected === "Infinity" ||
231
+ expected === "-Infinity" ||
232
+ /^-?\d+(?:\.\d+)?n?$/.test(expected) ||
233
+ (expected.includes(" or ") &&
234
+ expected.split(" or ").every(part => /^".*"$|^-?\d+(?:\.\d+)?n?$|^(?:true|false|null|undefined)$/.test(part)))
235
+ ) {
236
+ return "unit";
237
+ }
238
+ if (
239
+ expected.includes("at least") ||
240
+ expected.includes("more than") ||
241
+ expected.includes("timestamp after") ||
242
+ expected === "non-negative" ||
243
+ expected === "positive"
244
+ ) {
245
+ return "min";
246
+ }
247
+ if (
248
+ expected.includes("at most") ||
249
+ expected.includes("less than") ||
250
+ expected.includes("timestamp before") ||
251
+ expected === "non-positive" ||
252
+ expected === "negative"
253
+ ) {
254
+ return "max";
255
+ }
256
+ if (
257
+ expected.includes("matching") ||
258
+ expected.includes("format") ||
259
+ expected.includes("email") ||
260
+ expected.includes("parsable") ||
261
+ expected.includes("only digits")
262
+ ) {
263
+ return "pattern";
264
+ }
265
+ if (expected.includes("predicate") || expected.includes("satisfying") || expected.includes("according to"))
266
+ return "predicate";
267
+ if (expected.startsWith('"') || expected.startsWith("the date ")) return "unit";
268
+ if (expected === "an Error" || expected === "a Date" || expected.startsWith("an instance of ")) return "domain";
269
+ if (
270
+ (expected.includes(" or ") && !expected.includes("IPv")) ||
271
+ expected.endsWith(" instance") ||
272
+ expected.startsWith("a number representing") ||
273
+ [
274
+ "a string",
275
+ "a number",
276
+ "a bigint",
277
+ "a symbol",
278
+ "boolean",
279
+ "an object",
280
+ "an array",
281
+ "a tuple",
282
+ "undefined",
283
+ "null",
284
+ "unknown",
285
+ "never",
286
+ ].includes(expected)
287
+ )
288
+ return "domain";
289
+ return "predicate";
290
+ }
291
+
71
292
  /**
72
- * Aggregate of validation failures; the value returned by a schema call on
73
- * invalid input. Array-like so callers can `.map()` over entries.
293
+ * Validation failure result. The common single-error case remains lazy;
294
+ * traversal only materializes an entry array when a second error is appended.
74
295
  */
75
- export class OmpErrors extends Array<OmpError> {
76
- // `.map()`/`.filter()` on an errors instance should produce plain arrays,
77
- // not attempt `new OmpErrors(n)`.
78
- static override get [Symbol.species](): typeof Array {
79
- return Array;
296
+ type StoredPath = PropertyKey[] | PropertyKey | undefined;
297
+
298
+ export class OmpErrors implements Iterable<OmpError> {
299
+ #path: StoredPath;
300
+ #expected: string;
301
+ #data: unknown;
302
+ #entry: OmpError | undefined;
303
+ #entries: OmpError[] | undefined;
304
+ #config: ErrorConfig | undefined;
305
+ #separator = "\n";
306
+
307
+ constructor(path: StoredPath, expected: string, data: unknown, config?: ErrorConfig) {
308
+ this.#path = path;
309
+ this.#expected = expected;
310
+ this.#data = data;
311
+ this.#config = config;
312
+ }
313
+
314
+ get length(): number {
315
+ return this.#entries?.length ?? 1;
80
316
  }
81
317
 
82
- /** Single-failure constructor used by generated validators. */
83
- static single(path: PropertyKey[], expected: string, data: unknown): OmpErrors {
84
- const out = new OmpErrors();
85
- out.push(new OmpError(path, expected, data));
86
- return out;
318
+ get 0(): OmpError {
319
+ return this.#entries?.[0] ?? this.#getEntry();
87
320
  }
88
321
 
89
- /** Prefix every entry's path with `key` (used when nesting sub-schemas). */
322
+ static single(path: PropertyKey[], expected: string, data: unknown, config?: ErrorConfig): OmpErrors {
323
+ return new OmpErrors(path, expected, data, config);
324
+ }
325
+
326
+ #getEntry(): OmpError {
327
+ if (this.#entry) return this.#entry;
328
+ const path = this.#path === undefined ? [] : Array.isArray(this.#path) ? [...this.#path] : [this.#path];
329
+ const entry = new OmpError(path, this.#expected, this.#data, this.#config);
330
+ this.#entry = entry;
331
+ return entry;
332
+ }
333
+
334
+ /** Append all failures from `other`, preserving traversal order. */
335
+ append(other: OmpErrors): this {
336
+ this.#entries ??= [this.#getEntry()];
337
+ const entries = this.#entries;
338
+ for (const entry of other) entries.push(entry);
339
+ return this;
340
+ }
341
+
342
+ /** Prefix every failure path with `key` when nesting sub-schemas. */
90
343
  prefix(key: PropertyKey): this {
91
- for (let i = 0; i < this.length; i++) {
92
- const e = this[i];
93
- this[i] = new OmpError([key, ...e.path], e.expected, e.data);
344
+ if (this.#entries) {
345
+ for (const entry of this.#entries) entry.prefix(key);
346
+ } else {
347
+ const path = this.#path;
348
+ this.#path = path === undefined ? [key] : Array.isArray(path) ? [key, ...path] : [key, path];
349
+ this.#entry = undefined;
350
+ }
351
+ return this;
352
+ }
353
+
354
+ /** Apply schema-local message formatting without rebuilding failures. */
355
+ configure(config: ErrorConfig): this {
356
+ if (this.#entries) {
357
+ for (const entry of this.#entries) entry.configure(config);
358
+ } else {
359
+ this.#config = { ...this.#config, ...config };
360
+ this.#entry = undefined;
94
361
  }
95
362
  return this;
96
363
  }
97
364
 
98
- /** Human-readable digest of every failure, one per line. Built lazily. */
365
+ get byPath(): Readonly<Record<string, OmpError>> {
366
+ const result: Record<string, OmpError> = {};
367
+ for (const entry of this) result[entry.path.map(String).join(".")] = entry;
368
+ return result;
369
+ }
370
+
371
+ map<result>(fn: (error: OmpError, index: number, errors: OmpErrors) => result): result[] {
372
+ const result: result[] = [];
373
+ let index = 0;
374
+ for (const entry of this) result.push(fn(entry, index++, this));
375
+ return result;
376
+ }
377
+
378
+ filter(fn: (error: OmpError, index: number, errors: OmpErrors) => unknown): OmpError[] {
379
+ const result: OmpError[] = [];
380
+ let index = 0;
381
+ for (const entry of this) {
382
+ if (fn(entry, index++, this)) result.push(entry);
383
+ }
384
+ return result;
385
+ }
386
+
387
+ *[Symbol.iterator](): IterableIterator<OmpError> {
388
+ if (this.#entries) {
389
+ yield* this.#entries;
390
+ } else {
391
+ yield this.#getEntry();
392
+ }
393
+ }
394
+
395
+ /** @internal Render multiple branch failures as alternatives rather than independent failures. */
396
+ asAlternatives(): this {
397
+ this.#separator = " or ";
398
+ return this;
399
+ }
99
400
  get summary(): string {
100
- let out = "";
101
- for (let i = 0; i < this.length; i++) {
102
- if (i > 0) out += "\n";
103
- out += this[i].message;
401
+ const entries = [...this];
402
+ if (
403
+ this.#separator === "\n" &&
404
+ entries.length > 1 &&
405
+ entries.every(
406
+ entry =>
407
+ Object.is(entry.data, entries[0].data) &&
408
+ entry.path.length === entries[0].path.length &&
409
+ entry.path.every((key, index) => key === entries[0].path[index]),
410
+ )
411
+ ) {
412
+ const at = formatPath(entries[0].path);
413
+ const actual = typeof entries[0].data === "string" ? JSON.stringify(entries[0].data) : String(entries[0].data);
414
+ return `${at}${at === "" ? "" : " "}(${actual}) must be...\n${entries.map(entry => ` ◦ ${entry.expected}`).join("\n")}`;
104
415
  }
105
- return out;
416
+ return entries.map(error => error.message).join(this.#separator);
106
417
  }
107
418
 
108
- override toString(): string {
419
+ toString(): string {
109
420
  return this.summary;
110
421
  }
111
422
 
112
- /** Throw a `TraversalError` carrying the summary. */
113
423
  throw(): never {
114
424
  throw new TraversalError(this);
115
425
  }
@@ -0,0 +1,231 @@
1
+ /**
2
+ * JSON Schema → omptype importer.
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`.
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.
13
+ */
14
+ import { OmpTypeError } from "./errors";
15
+ import { type EmbeddableSchema, type IR, IR_BRAND, type PropIR, type TupleItemIR } from "./ir";
16
+ import { keywordIR, patternIR } from "./keywords";
17
+ import { type BaseType, type } from "./type";
18
+
19
+ type JsonSchema = Record<string, unknown>;
20
+
21
+ /** `format` values lowered to built-in keyword validators. */
22
+ const FORMAT_KEYWORDS: Record<string, string> = {
23
+ email: "string.email",
24
+ uuid: "string.uuid",
25
+ "date-time": "string.date.iso",
26
+ date: "string.date.iso",
27
+ ipv4: "string.ip.v4",
28
+ ipv6: "string.ip.v6",
29
+ regex: "string.regex",
30
+ };
31
+
32
+ class Importer {
33
+ readonly #root: JsonSchema;
34
+ readonly #aliases = new Map<string, IR>();
35
+
36
+ constructor(root: JsonSchema) {
37
+ this.#root = root;
38
+ }
39
+
40
+ resolveRef(ref: string): IR {
41
+ const cached = this.#aliases.get(ref);
42
+ if (cached !== undefined) return cached;
43
+
44
+ let target: unknown;
45
+ if (ref === "#") {
46
+ target = this.#root;
47
+ } else {
48
+ const defsMatch = /^#\/(\$defs|definitions)\/(.+)$/.exec(ref);
49
+ if (defsMatch === null) throw new OmpTypeError(`unsupported $ref: ${ref}`);
50
+ const defs = this.#root[defsMatch[1]];
51
+ target = typeof defs === "object" && defs !== null ? (defs as JsonSchema)[defsMatch[2]] : undefined;
52
+ if (target === undefined) throw new OmpTypeError(`unresolved $ref: ${ref}`);
53
+ }
54
+
55
+ // Register the alias before lowering so recursive references resolve
56
+ // to the same node instead of recursing forever.
57
+ let lowered: IR | undefined;
58
+ const alias: IR = {
59
+ k: "alias",
60
+ name: ref,
61
+ resolve: () => {
62
+ lowered ??= this.lower(target);
63
+ return lowered;
64
+ },
65
+ };
66
+ this.#aliases.set(ref, alias);
67
+ return alias;
68
+ }
69
+
70
+ lower(schema: unknown): IR {
71
+ if (schema === true) return { k: "unknown" };
72
+ if (schema === false) return { k: "never" };
73
+ if (typeof schema !== "object" || schema === null) {
74
+ throw new OmpTypeError("JSON Schema nodes must be booleans or objects");
75
+ }
76
+ const node = schema as JsonSchema;
77
+ const desc = typeof node.description === "string" ? node.description : undefined;
78
+ const ir = this.#lowerNode(node);
79
+ return desc === undefined ? ir : { ...ir, desc };
80
+ }
81
+
82
+ #lowerNode(node: JsonSchema): IR {
83
+ if (typeof node.$ref === "string") return this.resolveRef(node.$ref);
84
+
85
+ if (Array.isArray(node.enum)) {
86
+ const members = node.enum.map((v): IR => ({ k: "lit", v }));
87
+ return members.length === 1 ? members[0] : { k: "union", members };
88
+ }
89
+ if ("const" in node) return { k: "lit", v: node.const };
90
+
91
+ const branches = node.anyOf ?? node.oneOf;
92
+ if (Array.isArray(branches)) {
93
+ return { k: "union", members: branches.map(branch => this.lower(branch)) };
94
+ }
95
+ if (Array.isArray(node.allOf)) {
96
+ const members = node.allOf.map(branch => this.lower(branch));
97
+ return members.length === 1 ? members[0] : { k: "intersection", members };
98
+ }
99
+ if (typeof node.not === "object" && node.not !== null && Object.keys(node.not).length === 0) {
100
+ return { k: "never" };
101
+ }
102
+
103
+ if (Array.isArray(node.type)) {
104
+ const members = node.type.map(t => this.#lowerTyped(node, String(t)));
105
+ return members.length === 1 ? members[0] : { k: "union", members };
106
+ }
107
+ if (typeof node.type === "string") return this.#lowerTyped(node, node.type);
108
+
109
+ // No explicit type: infer from structural keywords, else accept anything.
110
+ if (node.properties !== undefined || node.required !== undefined) return this.#lowerObject(node);
111
+ if (node.items !== undefined || node.prefixItems !== undefined) return this.#lowerArray(node);
112
+ return { k: "unknown" };
113
+ }
114
+
115
+ #lowerTyped(node: JsonSchema, kind: string): IR {
116
+ switch (kind) {
117
+ case "null":
118
+ return { k: "null" };
119
+ case "boolean":
120
+ return { k: "boolean" };
121
+ case "string":
122
+ return this.#lowerString(node);
123
+ case "number":
124
+ case "integer": {
125
+ const ir: IR = { k: "number" };
126
+ if (kind === "integer") ir.int = true;
127
+ if (typeof node.minimum === "number") ir.min = node.minimum;
128
+ if (typeof node.maximum === "number") 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") ir.divisor = node.multipleOf;
138
+ return ir;
139
+ }
140
+ case "object":
141
+ return this.#lowerObject(node);
142
+ case "array":
143
+ return this.#lowerArray(node);
144
+ default:
145
+ throw new OmpTypeError(`unsupported JSON Schema type: ${kind}`);
146
+ }
147
+ }
148
+
149
+ #lowerString(node: JsonSchema): IR {
150
+ const base: IR = { k: "string" };
151
+ if (typeof node.minLength === "number") base.min = node.minLength;
152
+ if (typeof node.maxLength === "number") base.max = node.maxLength;
153
+ if (node.format === "uri" || node.format === "url") base.url = true;
154
+
155
+ const members: IR[] = [];
156
+ if (typeof node.format === "string") {
157
+ const keyword = FORMAT_KEYWORDS[node.format];
158
+ if (keyword !== undefined) {
159
+ const formatIR = keywordIR(keyword);
160
+ if (formatIR !== undefined) members.push(formatIR);
161
+ }
162
+ }
163
+ if (typeof node.pattern === "string") members.push(patternIR(new RegExp(node.pattern)));
164
+
165
+ if (members.length === 0) return base;
166
+ if (base.min !== undefined || base.max !== undefined || base.url) members.unshift(base);
167
+ return members.length === 1 ? members[0] : { k: "intersection", members };
168
+ }
169
+
170
+ #lowerObject(node: JsonSchema): IR {
171
+ const required = new Set(Array.isArray(node.required) ? node.required.map(String) : []);
172
+ const props: PropIR[] = [];
173
+ if (typeof node.properties === "object" && node.properties !== null) {
174
+ for (const [key, value] of Object.entries(node.properties)) {
175
+ const prop: PropIR = { key, opt: !required.has(key), val: this.lower(value) };
176
+ if (typeof value === "object" && value !== null && "default" in value) {
177
+ prop.hasDefault = true;
178
+ prop.def = (value as JsonSchema).default;
179
+ prop.opt = true;
180
+ }
181
+ props.push(prop);
182
+ }
183
+ }
184
+ const extra = node.additionalProperties;
185
+ return {
186
+ k: "object",
187
+ props,
188
+ extras: extra === false ? "reject" : "keep",
189
+ ...(typeof extra === "object" && extra !== null ? { index: this.lower(extra) } : {}),
190
+ };
191
+ }
192
+
193
+ #lowerArray(node: JsonSchema): IR {
194
+ if (Array.isArray(node.prefixItems)) {
195
+ const minItems = typeof node.minItems === "number" ? node.minItems : node.prefixItems.length;
196
+ const prefix: TupleItemIR[] = node.prefixItems.map((item, index) => ({
197
+ val: this.lower(item),
198
+ opt: index >= minItems,
199
+ }));
200
+ return {
201
+ k: "tuple",
202
+ prefix,
203
+ postfix: [],
204
+ ...(node.items !== undefined && node.items !== false ? { variadic: this.lower(node.items) } : {}),
205
+ };
206
+ }
207
+ const ir: IR = { k: "array", el: node.items === undefined ? { k: "unknown" } : this.lower(node.items) };
208
+ if (typeof node.minItems === "number") ir.min = node.minItems;
209
+ if (typeof node.maxItems === "number") ir.max = node.maxItems;
210
+ return ir;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * Build a callable omptype schema from a JSON Schema document.
216
+ *
217
+ * # Errors
218
+ * Throws {@link OmpTypeError} on malformed nodes, unresolvable `$ref`s, or
219
+ * types omptype cannot represent.
220
+ */
221
+ export function fromJsonSchema(schema: unknown): BaseType {
222
+ const importer = new Importer(typeof schema === "object" && schema !== null ? (schema as JsonSchema) : {});
223
+ const embedded: EmbeddableSchema = {
224
+ [IR_BRAND]: true,
225
+ ir: importer.lower(schema),
226
+ hasSteps: false,
227
+ hasDefault: false,
228
+ run: value => value,
229
+ };
230
+ return type.raw(embedded);
231
+ }
package/src/index.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * omptype — ArkType-compatible schema validation with a lazy JIT runtime.
3
3
  *
4
- * Drop-in for the arktype surface this repo uses:
5
- * `type()`, `Type`, `type.errors` / `OmpErrors`, `type.enumerated()`,
6
- * `.or/.and/.array/.pipe/.narrow/.describe/.default/.allows/.assert/.toJsonSchema`,
7
- * plus `typeof schema.infer` static inference.
4
+ * ArkType-compatible `type()`/`Type`, keyword modules, recursive scopes,
5
+ * composition and morph APIs, structured errors, input/output inference, and
6
+ * JSON Schema emission.
8
7
  */
9
8
  export * from "./errors";
9
+ export * from "./from-json-schema";
10
10
  export * from "./infer";
11
11
  export * from "./ir";
12
12
  export * from "./json-schema";