@oh-my-pi/omptype 17.2.6

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/ir.ts ADDED
@@ -0,0 +1,480 @@
1
+ /**
2
+ * Schema IR and the ArkType-compatible definition parser.
3
+ *
4
+ * `parseDef` turns the definition subset this repo uses — string DSL
5
+ * (primitives, literals, unions, arrays, bounds, `number.integer`,
6
+ * `string.url`, inline `= literal` defaults), object literals (optional `?`
7
+ * keys, `"+"` undeclared-key policy, `"[string]"` index signatures), tuple
8
+ * `[def, "[]"]` arrays, and embedded `Type` instances — into a small IR tree
9
+ * consumed by the interpreter (`interp.ts`), the JIT compiler (`compile.ts`),
10
+ * and the JSON Schema emitter (`json-schema.ts`).
11
+ */
12
+
13
+ import { OmpTypeError } from "./errors";
14
+
15
+ /** Brand carried by `Type` instances so the parser can embed them in defs. */
16
+ export const IR_BRAND: unique symbol = Symbol("omptype.schema");
17
+
18
+ /**
19
+ * The parser-facing surface of an embedded `Type` instance.
20
+ * `type.ts` implements this on every schema it creates.
21
+ */
22
+ export interface EmbeddableSchema {
23
+ [IR_BRAND]: true;
24
+ /** Structural IR of the schema (base type when runtime steps exist). */
25
+ ir: IR;
26
+ /** True when the schema carries `.pipe()`/`.narrow()` steps. */
27
+ hasSteps: boolean;
28
+ /** `.default()` payload; a function is a factory invoked per fill. */
29
+ defaultValue?: unknown;
30
+ hasDefault: boolean;
31
+ /** `.describe()` annotation, emitted into JSON Schema. */
32
+ description?: string;
33
+ /** Full validate+morph pipeline (identical to calling the schema). */
34
+ run(value: unknown): unknown;
35
+ }
36
+
37
+ /** Policy for undeclared object keys. */
38
+ export type Extras = "keep" | "reject" | "delete";
39
+
40
+ export type IR =
41
+ | { k: "unknown"; desc?: string }
42
+ | { k: "null"; desc?: string }
43
+ | { k: "undefined"; desc?: string }
44
+ | { k: "boolean"; desc?: string }
45
+ | { k: "bigint"; desc?: string }
46
+ /** Any non-null object (the bare `object` keyword). */
47
+ | { k: "anyobject"; desc?: string }
48
+ | { k: "string"; min?: number; max?: number; url?: boolean; desc?: string }
49
+ | { k: "number"; min?: number; max?: number; xmin?: boolean; xmax?: boolean; int?: boolean; desc?: string }
50
+ | { k: "lit"; v: unknown; desc?: string }
51
+ | { k: "union"; members: IR[]; desc?: string }
52
+ | { k: "array"; el: IR; min?: number; max?: number; desc?: string }
53
+ | { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string }
54
+ /** Embedded schema with runtime steps; validated by calling `run`. */
55
+ | { k: "sub"; schema: EmbeddableSchema; desc?: string };
56
+
57
+ export interface PropIR {
58
+ key: string;
59
+ opt: boolean;
60
+ val: IR;
61
+ /** Default payload (value, or factory when `defFactory`); missing key is filled. */
62
+ def?: unknown;
63
+ defFactory?: boolean;
64
+ hasDefault?: boolean;
65
+ }
66
+
67
+ /** Definition input accepted by `type()` and object property values. */
68
+ export type Def = string | EmbeddableSchema | readonly Def[] | { readonly [k: string]: Def };
69
+
70
+ // ── tokenizer ────────────────────────────────────────────────────────────────
71
+
72
+ type Tok = { t: "id"; v: string } | { t: "num"; v: number } | { t: "str"; v: string } | { t: "op"; v: string };
73
+
74
+ const SIMPLE_OPS = "|()[]=?";
75
+
76
+ function tokenize(src: string): Tok[] {
77
+ const toks: Tok[] = [];
78
+ let i = 0;
79
+ const n = src.length;
80
+ while (i < n) {
81
+ const c = src[i];
82
+ if (c === " " || c === "\t" || c === "\n" || c === "\r") {
83
+ i++;
84
+ continue;
85
+ }
86
+ if (c === "'" || c === '"') {
87
+ let j = i + 1;
88
+ while (j < n && src[j] !== c) j++;
89
+ if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`);
90
+ toks.push({ t: "str", v: src.slice(i + 1, j) });
91
+ i = j + 1;
92
+ continue;
93
+ }
94
+ if ((c >= "0" && c <= "9") || (c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) {
95
+ let j = i + 1;
96
+ while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === "." || src[j] === "e" || src[j] === "+")) j++;
97
+ toks.push({ t: "num", v: Number(src.slice(i, j)) });
98
+ i = j;
99
+ continue;
100
+ }
101
+ if (/[a-zA-Z_$]/.test(c)) {
102
+ let j = i + 1;
103
+ while (j < n && /[\w.$]/.test(src[j])) j++;
104
+ toks.push({ t: "id", v: src.slice(i, j) });
105
+ i = j;
106
+ continue;
107
+ }
108
+ if (c === "<" || c === ">") {
109
+ if (src[i + 1] === "=") {
110
+ toks.push({ t: "op", v: `${c}=` });
111
+ i += 2;
112
+ } else {
113
+ toks.push({ t: "op", v: c });
114
+ i++;
115
+ }
116
+ continue;
117
+ }
118
+ if (SIMPLE_OPS.includes(c)) {
119
+ toks.push({ t: "op", v: c });
120
+ i++;
121
+ continue;
122
+ }
123
+ throw new OmpTypeError(`unexpected character '${c}' in "${src}"`);
124
+ }
125
+ return toks;
126
+ }
127
+
128
+ // ── string-definition parser ─────────────────────────────────────────────────
129
+
130
+ const CMP: Record<string, true> = { "<": true, "<=": true, ">": true, ">=": true };
131
+
132
+ const KEYWORDS: Record<string, () => IR> = {
133
+ string: () => ({ k: "string" }),
134
+ "string.url": () => ({ k: "string", url: true }),
135
+ number: () => ({ k: "number" }),
136
+ "number.integer": () => ({ k: "number", int: true }),
137
+ boolean: () => ({ k: "boolean" }),
138
+ bigint: () => ({ k: "bigint" }),
139
+ null: () => ({ k: "null" }),
140
+ undefined: () => ({ k: "undefined" }),
141
+ unknown: () => ({ k: "unknown" }),
142
+ object: () => ({ k: "anyobject" }),
143
+ true: () => ({ k: "lit", v: true }),
144
+ false: () => ({ k: "lit", v: false }),
145
+ };
146
+
147
+ interface ParsedTop {
148
+ ir: IR;
149
+ def?: unknown;
150
+ hasDefault: boolean;
151
+ /** Trailing `?` marker — only legal on object property values. */
152
+ optional: boolean;
153
+ }
154
+
155
+ class StrParser {
156
+ #toks: Tok[];
157
+ #pos = 0;
158
+ #src: string;
159
+
160
+ constructor(src: string) {
161
+ this.#src = src;
162
+ this.#toks = tokenize(src);
163
+ }
164
+
165
+ #peek(offset = 0): Tok | undefined {
166
+ return this.#toks[this.#pos + offset];
167
+ }
168
+
169
+ #next(): Tok {
170
+ const t = this.#toks[this.#pos++];
171
+ if (!t) throw new OmpTypeError(`unexpected end of definition "${this.#src}"`);
172
+ return t;
173
+ }
174
+
175
+ #eatOp(v: string): boolean {
176
+ const t = this.#peek();
177
+ if (t?.t === "op" && t.v === v) {
178
+ this.#pos++;
179
+ return true;
180
+ }
181
+ return false;
182
+ }
183
+
184
+ /** Full definition with optional trailing `= literal` default and/or `?` optional marker. */
185
+ parseTop(): ParsedTop {
186
+ const ir = this.parseUnion();
187
+ let def: unknown;
188
+ let hasDefault = false;
189
+ if (this.#eatOp("=")) {
190
+ const t = this.#next();
191
+ if (t.t === "num" || t.t === "str") def = t.v;
192
+ else if (t.t === "id" && (t.v === "true" || t.v === "false")) def = t.v === "true";
193
+ else if (t.t === "id" && t.v === "null") def = null;
194
+ else throw new OmpTypeError(`unsupported default literal in "${this.#src}"`);
195
+ hasDefault = true;
196
+ }
197
+ const optional = this.#eatOp("?");
198
+ this.#expectEnd();
199
+ return { ir, def, hasDefault, optional };
200
+ }
201
+
202
+ #expectEnd(): void {
203
+ if (this.#pos < this.#toks.length) {
204
+ throw new OmpTypeError(`trailing tokens in definition "${this.#src}"`);
205
+ }
206
+ }
207
+
208
+ parseUnion(): IR {
209
+ const first = this.parseBounded();
210
+ if (!this.#eatOp("|")) return first;
211
+ const members = [first, this.parseBounded()];
212
+ while (this.#eatOp("|")) members.push(this.parseBounded());
213
+ return { k: "union", members };
214
+ }
215
+
216
+ /**
217
+ * `NUM CMP base (CMP NUM)?` or `base (CMP NUM)?`, with `[]*` postfix on the
218
+ * base AND after a trailing bound — `string>0[]` is an array of bounded
219
+ * strings, matching ArkType precedence (bounds bind tighter than `[]`).
220
+ */
221
+ parseBounded(): IR {
222
+ const t = this.#peek();
223
+ const t1 = this.#peek(1);
224
+ if (t?.t === "num" && t1?.t === "op" && CMP[t1.v]) {
225
+ // leading bound: `1 <= string <= 10`, `0 < number`
226
+ const lo = t.v;
227
+ this.#pos += 2;
228
+ let node = this.parsePostfix();
229
+ node = applyBound(node, flip(t1.v), lo, this.#src);
230
+ const t2 = this.#peek();
231
+ if (t2?.t === "op" && CMP[t2.v]) {
232
+ this.#pos++;
233
+ const hi = this.#next();
234
+ if (hi.t !== "num") throw new OmpTypeError(`expected number after comparator in "${this.#src}"`);
235
+ node = applyBound(node, t2.v, hi.v, this.#src);
236
+ }
237
+ return this.#eatArraySuffixes(node);
238
+ }
239
+ let node = this.parsePostfix();
240
+ const t2 = this.#peek();
241
+ if (t2?.t === "op" && CMP[t2.v]) {
242
+ this.#pos++;
243
+ const num = this.#next();
244
+ if (num.t !== "num") throw new OmpTypeError(`expected number after comparator in "${this.#src}"`);
245
+ node = applyBound(node, t2.v, num.v, this.#src);
246
+ node = this.#eatArraySuffixes(node);
247
+ }
248
+ return node;
249
+ }
250
+
251
+ /** Wrap `node` in array IR for each `[]` pair at the cursor. */
252
+ #eatArraySuffixes(node: IR): IR {
253
+ for (;;) {
254
+ const t = this.#peek();
255
+ if (!(t?.t === "op" && t.v === "[")) return node;
256
+ this.#pos++;
257
+ if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`);
258
+ node = { k: "array", el: node };
259
+ }
260
+ }
261
+
262
+ parsePostfix(): IR {
263
+ let node = this.parsePrimary();
264
+ for (;;) {
265
+ const t = this.#peek();
266
+ if (!(t?.t === "op" && t.v === "[")) break;
267
+ this.#pos++;
268
+ if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`);
269
+ node = { k: "array", el: node };
270
+ }
271
+ return node;
272
+ }
273
+
274
+ parsePrimary(): IR {
275
+ const t = this.#next();
276
+ if (t.t === "op" && t.v === "(") {
277
+ const inner = this.parseUnion();
278
+ if (!this.#eatOp(")")) throw new OmpTypeError(`expected ')' in "${this.#src}"`);
279
+ return inner;
280
+ }
281
+ if (t.t === "str") return { k: "lit", v: t.v };
282
+ if (t.t === "num") return { k: "lit", v: t.v };
283
+ if (t.t === "id") {
284
+ const make = KEYWORDS[t.v];
285
+ if (!make) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`);
286
+ return make();
287
+ }
288
+ throw new OmpTypeError(`unexpected token in "${this.#src}"`);
289
+ }
290
+ }
291
+
292
+ function flip(op: string): string {
293
+ switch (op) {
294
+ case "<":
295
+ return ">";
296
+ case "<=":
297
+ return ">=";
298
+ case ">":
299
+ return "<";
300
+ default:
301
+ return "<=";
302
+ }
303
+ }
304
+
305
+ /** Apply `node CMP value` — numeric range, string length, or array length. */
306
+ function applyBound(node: IR, op: string, value: number, src: string): IR {
307
+ if (node.k === "number") {
308
+ switch (op) {
309
+ case ">=":
310
+ node.min = value;
311
+ node.xmin = false;
312
+ break;
313
+ case ">":
314
+ node.min = value;
315
+ node.xmin = true;
316
+ break;
317
+ case "<=":
318
+ node.max = value;
319
+ node.xmax = false;
320
+ break;
321
+ case "<":
322
+ node.max = value;
323
+ node.xmax = true;
324
+ break;
325
+ }
326
+ return node;
327
+ }
328
+ if (node.k === "string" || node.k === "array") {
329
+ switch (op) {
330
+ case ">=":
331
+ node.min = value;
332
+ break;
333
+ case ">":
334
+ node.min = value + 1;
335
+ break;
336
+ case "<=":
337
+ node.max = value;
338
+ break;
339
+ case "<":
340
+ node.max = value - 1;
341
+ break;
342
+ }
343
+ return node;
344
+ }
345
+ throw new OmpTypeError(`cannot bound ${node.k} in "${src}"`);
346
+ }
347
+
348
+ // ── definition parser ────────────────────────────────────────────────────────
349
+
350
+ function isEmbedded(def: unknown): def is EmbeddableSchema {
351
+ return (typeof def === "function" || (typeof def === "object" && def !== null)) && IR_BRAND in (def as object);
352
+ }
353
+
354
+ /** Embed a schema value: inline pure structure, keep `sub` nodes for stepped schemas. */
355
+ export function embed(schema: EmbeddableSchema): IR {
356
+ if (schema.hasSteps) return { k: "sub", schema, desc: schema.description };
357
+ if (schema.description !== undefined && schema.ir.desc === undefined) {
358
+ return { ...schema.ir, desc: schema.description };
359
+ }
360
+ return schema.ir;
361
+ }
362
+
363
+ /** Parse a definition (string DSL, object literal, tuple, or embedded schema) into IR. */
364
+ export function parseDef(def: Def): IR {
365
+ if (typeof def === "string") {
366
+ const parsed = new StrParser(def).parseTop();
367
+ if (parsed.optional) throw new OmpTypeError(`optional "?" marker is only valid on object property values`);
368
+ return parsed.ir;
369
+ }
370
+ if (isEmbedded(def)) return embed(def);
371
+ if (Array.isArray(def)) {
372
+ if (def.length === 2 && def[1] === "[]") return { k: "array", el: parseDef(def[0] as Def) };
373
+ throw new OmpTypeError(`unsupported tuple definition (only [def, "[]"])`);
374
+ }
375
+ if (typeof def === "object" && def !== null) {
376
+ const props: PropIR[] = [];
377
+ let index: IR | undefined;
378
+ let extras: Extras = "keep";
379
+ const rec = def as { readonly [k: string]: Def };
380
+ for (const rawKey in rec) {
381
+ const val = rec[rawKey];
382
+ if (rawKey === "+") {
383
+ if (val === "reject" || val === "delete") extras = val;
384
+ else if (val === "ignore") extras = "keep";
385
+ else throw new OmpTypeError(`bad "+" value ${String(val)}`);
386
+ continue;
387
+ }
388
+ if (rawKey === "[string]") {
389
+ index = parseDef(val);
390
+ continue;
391
+ }
392
+ const opt = rawKey.endsWith("?");
393
+ const key = opt ? rawKey.slice(0, -1) : rawKey;
394
+ if (typeof val === "string") {
395
+ const parsed = new StrParser(val).parseTop();
396
+ const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir };
397
+ if (parsed.hasDefault) {
398
+ prop.def = parsed.def;
399
+ prop.hasDefault = true;
400
+ }
401
+ props.push(prop);
402
+ } else if (isEmbedded(val) && val.hasDefault) {
403
+ props.push({
404
+ key,
405
+ opt,
406
+ val: embed(val),
407
+ def: val.defaultValue,
408
+ defFactory: typeof val.defaultValue === "function",
409
+ hasDefault: true,
410
+ });
411
+ } else {
412
+ props.push({ key, opt, val: parseDef(val) });
413
+ }
414
+ }
415
+ return { k: "object", props, index, extras };
416
+ }
417
+ throw new OmpTypeError(`unsupported definition ${String(def)}`);
418
+ }
419
+
420
+ /** True when validating `ir` can produce an output different from its input. */
421
+ export function hasMorph(ir: IR): boolean {
422
+ switch (ir.k) {
423
+ case "sub":
424
+ return true;
425
+ case "object":
426
+ if (ir.extras === "delete") return true;
427
+ for (const p of ir.props) {
428
+ if (p.hasDefault || hasMorph(p.val)) return true;
429
+ }
430
+ return ir.index !== undefined && hasMorph(ir.index);
431
+ case "array":
432
+ return hasMorph(ir.el);
433
+ case "union":
434
+ return ir.members.some(hasMorph);
435
+ default:
436
+ return false;
437
+ }
438
+ }
439
+
440
+ /** Human-readable expectation for error messages, e.g. `"a string"`. */
441
+ export function expectedOf(ir: IR): string {
442
+ switch (ir.k) {
443
+ case "unknown":
444
+ return "unknown";
445
+ case "null":
446
+ return "null";
447
+ case "undefined":
448
+ return "undefined";
449
+ case "boolean":
450
+ return "boolean";
451
+ case "bigint":
452
+ return "a bigint";
453
+ case "anyobject":
454
+ return "an object";
455
+ case "string": {
456
+ let out = ir.url ? "a URL string" : "a string";
457
+ if (ir.min !== undefined && ir.max !== undefined) out += ` (length ${ir.min} to ${ir.max})`;
458
+ else if (ir.min !== undefined) out += ` (length at least ${ir.min})`;
459
+ else if (ir.max !== undefined) out += ` (length at most ${ir.max})`;
460
+ return out;
461
+ }
462
+ case "number": {
463
+ let out = ir.int ? "an integer" : "a number";
464
+ if (ir.min !== undefined) out += ` ${ir.xmin ? "more than" : "at least"} ${ir.min}`;
465
+ if (ir.max !== undefined)
466
+ out += `${ir.min !== undefined ? " and" : ""} ${ir.xmax ? "less than" : "at most"} ${ir.max}`;
467
+ return out;
468
+ }
469
+ case "lit":
470
+ return typeof ir.v === "string" ? JSON.stringify(ir.v) : String(ir.v);
471
+ case "union":
472
+ return [...new Set(ir.members.map(expectedOf))].join(" or ");
473
+ case "array":
474
+ return "an array";
475
+ case "object":
476
+ return "an object";
477
+ case "sub":
478
+ return expectedOf(ir.schema.ir);
479
+ }
480
+ }
@@ -0,0 +1,172 @@
1
+ import type { IR, PropIR } from "./ir";
2
+
3
+ export interface JsonSchemaOptions {
4
+ description?: string;
5
+ }
6
+
7
+ type JsonSchema = Record<string, unknown>;
8
+
9
+ /** Emit the draft-2020-12 JSON Schema represented by an IR tree. */
10
+ export function irToJsonSchema(ir: IR, options?: JsonSchemaOptions): JsonSchema {
11
+ const schema = emit(ir);
12
+ if (options?.description !== undefined) schema.description = options.description;
13
+ return schema;
14
+ }
15
+
16
+ function emit(ir: IR): JsonSchema {
17
+ let schema: JsonSchema;
18
+ switch (ir.k) {
19
+ case "unknown":
20
+ case "undefined":
21
+ schema = {};
22
+ break;
23
+ case "null":
24
+ schema = { type: "null" };
25
+ break;
26
+ case "boolean":
27
+ schema = { type: "boolean" };
28
+ break;
29
+ case "bigint":
30
+ schema = { type: "integer" };
31
+ break;
32
+ case "anyobject":
33
+ schema = { type: "object" };
34
+ break;
35
+ case "string":
36
+ schema = emitString(ir);
37
+ break;
38
+ case "number":
39
+ schema = emitNumber(ir);
40
+ break;
41
+ case "lit":
42
+ schema = emitLiteral(ir.v);
43
+ break;
44
+ case "union":
45
+ schema = emitUnion(ir.members);
46
+ break;
47
+ case "array":
48
+ schema = { type: "array", items: emit(ir.el) };
49
+ if (ir.min !== undefined) schema.minItems = ir.min;
50
+ if (ir.max !== undefined) schema.maxItems = ir.max;
51
+ break;
52
+ case "object":
53
+ schema = emitObject(ir.props, ir.index, ir.extras);
54
+ break;
55
+ case "sub":
56
+ schema = emit(ir.schema.ir);
57
+ if (ir.schema.description !== undefined) schema.description = ir.schema.description;
58
+ break;
59
+ }
60
+ if (ir.desc !== undefined) schema.description = ir.desc;
61
+ return schema;
62
+ }
63
+
64
+ function emitString(ir: Extract<IR, { k: "string" }>): JsonSchema {
65
+ const schema: JsonSchema = { type: "string" };
66
+ if (ir.min !== undefined) schema.minLength = ir.min;
67
+ if (ir.max !== undefined) schema.maxLength = ir.max;
68
+ if (ir.url) schema.format = "uri";
69
+ return schema;
70
+ }
71
+
72
+ function emitNumber(ir: Extract<IR, { k: "number" }>): JsonSchema {
73
+ const schema: JsonSchema = { type: ir.int ? "integer" : "number" };
74
+ if (ir.min !== undefined) schema[ir.xmin ? "exclusiveMinimum" : "minimum"] = ir.min;
75
+ if (ir.max !== undefined) schema[ir.xmax ? "exclusiveMaximum" : "maximum"] = ir.max;
76
+ return schema;
77
+ }
78
+
79
+ function emitLiteral(value: unknown): JsonSchema {
80
+ if (isJsonValue(value)) return { const: value };
81
+ switch (typeof value) {
82
+ case "string":
83
+ return { type: "string" };
84
+ case "number":
85
+ return { type: "number" };
86
+ case "boolean":
87
+ return { type: "boolean" };
88
+ case "bigint":
89
+ return { type: "integer" };
90
+ case "object":
91
+ return { type: "object" };
92
+ default:
93
+ return {};
94
+ }
95
+ }
96
+
97
+ function emitUnion(members: IR[]): JsonSchema {
98
+ const defined = members.filter(member => member.k !== "undefined");
99
+ if (defined.length === 0) return {};
100
+ if (defined.length === 1) return emit(defined[0]);
101
+ if (defined.every(member => member.k === "lit" && isJsonValue(member.v))) {
102
+ const values = defined.map(member => (member as Extract<IR, { k: "lit" }>).v);
103
+ const schema: JsonSchema = { enum: values };
104
+ const scalarType = homogeneousScalarType(values);
105
+ if (scalarType !== undefined) schema.type = scalarType;
106
+ return schema;
107
+ }
108
+ return { anyOf: defined.map(emit) };
109
+ }
110
+
111
+ function homogeneousScalarType(values: unknown[]): string | undefined {
112
+ const first = jsonScalarType(values[0]);
113
+ if (first === undefined) return undefined;
114
+ for (let i = 1; i < values.length; i++) {
115
+ if (jsonScalarType(values[i]) !== first) return undefined;
116
+ }
117
+ return first;
118
+ }
119
+
120
+ function jsonScalarType(value: unknown): string | undefined {
121
+ if (value === null) return "null";
122
+ switch (typeof value) {
123
+ case "string":
124
+ case "number":
125
+ case "boolean":
126
+ return typeof value;
127
+ default:
128
+ return undefined;
129
+ }
130
+ }
131
+
132
+ function emitObject(props: PropIR[], index: IR | undefined, extras: "keep" | "reject" | "delete"): JsonSchema {
133
+ const properties: Record<string, unknown> = {};
134
+ const required: string[] = [];
135
+ // ArkType emits required properties first (each group in declaration
136
+ // order); downstream wire consumers rely on that stable ordering.
137
+ const ordered = [...props.filter(p => !p.opt && !p.hasDefault), ...props.filter(p => p.opt || p.hasDefault)];
138
+ for (const prop of ordered) {
139
+ const propertySchema = emit(prop.val);
140
+ if (prop.hasDefault) {
141
+ propertySchema.default = prop.defFactory ? (prop.def as () => unknown)() : prop.def;
142
+ }
143
+ properties[prop.key] = propertySchema;
144
+ if (!prop.opt && !prop.hasDefault) required.push(prop.key);
145
+ }
146
+ const schema: JsonSchema = { type: "object", properties };
147
+ if (required.length > 0) schema.required = required;
148
+ if (index !== undefined) schema.additionalProperties = emit(index);
149
+ else if (extras === "reject") schema.additionalProperties = false;
150
+ return schema;
151
+ }
152
+
153
+ function isJsonValue(value: unknown, seen = new Set<object>()): boolean {
154
+ if (value === null || typeof value === "string" || typeof value === "boolean") return true;
155
+ if (typeof value === "number") return Number.isFinite(value);
156
+ if (typeof value !== "object" || seen.has(value)) return false;
157
+ seen.add(value);
158
+ if (Array.isArray(value)) {
159
+ for (const item of value) {
160
+ if (!isJsonValue(item, seen)) return false;
161
+ }
162
+ seen.delete(value);
163
+ return true;
164
+ }
165
+ const prototype = Object.getPrototypeOf(value);
166
+ if (prototype !== Object.prototype && prototype !== null) return false;
167
+ for (const key in value) {
168
+ if (Object.hasOwn(value, key) && !isJsonValue((value as Record<string, unknown>)[key], seen)) return false;
169
+ }
170
+ seen.delete(value);
171
+ return true;
172
+ }