@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/interp.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Tree-walking validator used for a schema's first few calls (before the JIT
3
- * compiler kicks in) and as the fallback for IR shapes the compiler declines.
2
+ * Tree-walking validator used for a schema's first few calls and as the
3
+ * targeted fallback for recursive or predicate-only JIT subtrees.
4
4
  *
5
5
  * Semantics must stay in lockstep with `compile.ts`:
6
6
  * - success returns the output value; the input is returned as-is unless the
@@ -9,23 +9,65 @@
9
9
  * - failure returns an `OmpErrors` with a single fast-fail entry
10
10
  */
11
11
  import { MISSING, OmpErrors } from "./errors";
12
- import { expectedOf, hasMorph, type IR } from "./ir";
12
+ import { expectedOf, hasAlias, hasMorph, type IR } from "./ir";
13
13
 
14
14
  const own = Object.prototype.hasOwnProperty;
15
+ /** Return an independent runtime value for a prevalidated static default. */
16
+ export function materializeDefault(payload: unknown): unknown {
17
+ if (payload === null || typeof payload !== "object") return payload;
18
+ if (payload instanceof Date) return new Date(payload);
19
+ return structuredClone(payload);
20
+ }
21
+ let activeVisits: WeakMap<object, Set<IR>> | undefined;
22
+ let activeChecks: WeakMap<object, Set<IR>> | undefined;
15
23
 
16
- /** Validate `value` against `ir`; returns output value or `OmpErrors`. */
17
- export function walk(ir: IR, value: unknown): unknown {
18
- const path: PropertyKey[] = [];
19
- const out = visit(ir, value, path);
20
- return out;
24
+ /**
25
+ * Validate `value` against `ir`; returns output value or `OmpErrors`.
26
+ * `path` seeds the traversal location so nested step callbacks observe
27
+ * absolute ctx.path values when a compiled parent delegates a subtree;
28
+ * resulting error paths are then already absolute.
29
+ */
30
+ export function walk(ir: IR, value: unknown, path: PropertyKey[] = []): unknown {
31
+ const previousVisits = activeVisits;
32
+ const previousChecks = activeChecks;
33
+ // Created lazily by visit/checks only when a recursive-alias node is reached.
34
+ activeVisits = undefined;
35
+ activeChecks = undefined;
36
+ try {
37
+ return visit(ir, value, path);
38
+ } finally {
39
+ activeVisits = previousVisits;
40
+ activeChecks = previousChecks;
41
+ }
21
42
  }
22
43
 
23
44
  function fail(path: PropertyKey[], expected: string, data: unknown): OmpErrors {
24
- return OmpErrors.single([...path], expected, data);
45
+ const storedPath = path.length === 0 ? undefined : path.length === 1 ? path[0] : [...path];
46
+ return new OmpErrors(storedPath, expected, data);
25
47
  }
26
48
 
27
49
  /** Pure predicate used for union-member scanning (no morphs, no errors). */
28
50
  function checks(ir: IR, v: unknown): boolean {
51
+ // Cycle guards are only needed when the subtree can revisit nodes through
52
+ // recursive aliases; plain schemas skip the WeakMap bookkeeping entirely.
53
+ if (typeof v !== "object" || v === null || !hasAlias(ir)) return checkNode(ir, v);
54
+ activeChecks ??= new WeakMap();
55
+ const visits = activeChecks;
56
+ let visited = visits.get(v);
57
+ if (visited?.has(ir)) return true;
58
+ if (visited === undefined) {
59
+ visited = new Set();
60
+ visits.set(v, visited);
61
+ }
62
+ visited.add(ir);
63
+ try {
64
+ return checkNode(ir, v);
65
+ } finally {
66
+ visited.delete(ir);
67
+ }
68
+ }
69
+
70
+ function checkNode(ir: IR, v: unknown): boolean {
29
71
  switch (ir.k) {
30
72
  case "unknown":
31
73
  return true;
@@ -37,6 +79,10 @@ function checks(ir: IR, v: unknown): boolean {
37
79
  return typeof v === "boolean";
38
80
  case "bigint":
39
81
  return typeof v === "bigint";
82
+ case "symbol":
83
+ return typeof v === "symbol";
84
+ case "never":
85
+ return false;
40
86
  case "anyobject":
41
87
  return typeof v === "object" && v !== null;
42
88
  case "string":
@@ -47,26 +93,54 @@ function checks(ir: IR, v: unknown): boolean {
47
93
  (!ir.url || URL.canParse(v))
48
94
  );
49
95
  case "number":
96
+ if (typeof v !== "number") return false;
50
97
  return (
51
- typeof v === "number" &&
52
- (!ir.int || Number.isInteger(v)) &&
98
+ (ir.int ? Number.isInteger(v) : Number.isFinite(v)) &&
99
+ (ir.divisor === undefined || v % ir.divisor === 0) &&
53
100
  (ir.min === undefined || (ir.xmin ? v > ir.min : v >= ir.min)) &&
54
101
  (ir.max === undefined || (ir.xmax ? v < ir.max : v <= ir.max))
55
102
  );
56
103
  case "lit":
57
- return v === ir.v;
104
+ return ir.v instanceof Date ? v instanceof Date && v.valueOf() === ir.v.valueOf() : v === ir.v;
58
105
  case "union":
59
106
  return ir.members.some(m => checks(m, v));
107
+ case "intersection":
108
+ return ir.members.every(member => checks(member, v));
60
109
  case "array": {
61
110
  if (!Array.isArray(v)) return false;
62
111
  if (ir.min !== undefined && v.length < ir.min) return false;
63
112
  if (ir.max !== undefined && v.length > ir.max) return false;
64
- for (const el of v) if (!checks(ir.el, el)) return false;
113
+ for (const element of v) if (!checks(ir.el, element)) return false;
114
+ return true;
115
+ }
116
+ case "tuple": {
117
+ if (!Array.isArray(v)) return false;
118
+ let required = ir.postfix.length;
119
+ for (const item of ir.prefix) if (!item.opt && !item.hasDefault) required++;
120
+ if (v.length < required) return false;
121
+ if (ir.variadic === undefined && v.length > ir.prefix.length + ir.postfix.length) return false;
122
+ const postfixStart = v.length - ir.postfix.length;
123
+ const prefixCount = Math.min(ir.prefix.length, postfixStart);
124
+ for (let index = 0; index < prefixCount; index++) {
125
+ if (!checks(ir.prefix[index].val, v[index])) return false;
126
+ }
127
+ for (let index = prefixCount; index < ir.prefix.length; index++) {
128
+ const item = ir.prefix[index];
129
+ if (!item.opt && !item.hasDefault) return false;
130
+ }
131
+ if (ir.variadic !== undefined) {
132
+ for (let index = prefixCount; index < postfixStart; index++) {
133
+ if (!checks(ir.variadic, v[index])) return false;
134
+ }
135
+ }
136
+ for (let index = 0; index < ir.postfix.length; index++) {
137
+ if (!checks(ir.postfix[index], v[postfixStart + index])) return false;
138
+ }
65
139
  return true;
66
140
  }
67
141
  case "object": {
68
- if (typeof v !== "object" || v === null || Array.isArray(v)) return false;
69
- const rec = v as Record<string, unknown>;
142
+ if (typeof v !== "object" || v === null) return false;
143
+ const rec = v as Record<PropertyKey, unknown>;
70
144
  for (const p of ir.props) {
71
145
  const present = p.key in rec;
72
146
  if (!present) {
@@ -75,34 +149,117 @@ function checks(ir: IR, v: unknown): boolean {
75
149
  }
76
150
  if (!checks(p.val, rec[p.key])) return false;
77
151
  }
78
- if (ir.index) {
79
- for (const key in rec) {
80
- if (own.call(rec, key) && !checks(ir.index, rec[key])) return false;
81
- }
82
- } else if (ir.extras === "reject") {
83
- for (const key in rec) {
84
- if (!own.call(rec, key)) continue;
85
- let declared = false;
86
- for (const p of ir.props) {
87
- if (p.key === key) {
88
- declared = true;
89
- break;
90
- }
152
+ for (const key in rec) {
153
+ if (!own.call(rec, key)) continue;
154
+ if (ir.index !== undefined && !checks(ir.index, rec[key])) return false;
155
+ let patternMatched = false;
156
+ if (ir.patternIndexes !== undefined) {
157
+ for (const pattern of ir.patternIndexes) {
158
+ if (!checks(pattern.key, key)) continue;
159
+ patternMatched = true;
160
+ if (!checks(pattern.val, rec[key])) return false;
91
161
  }
92
- if (!declared) return false;
162
+ }
163
+ if (
164
+ ir.extras === "reject" &&
165
+ ir.index === undefined &&
166
+ !patternMatched &&
167
+ !ir.props.some(prop => prop.key === key)
168
+ ) {
169
+ return false;
170
+ }
171
+ }
172
+ for (const key of Object.getOwnPropertySymbols(rec)) {
173
+ if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue;
174
+ if (ir.symbolIndex !== undefined && !checks(ir.symbolIndex, rec[key])) return false;
175
+ if (ir.extras === "reject" && ir.symbolIndex === undefined && !ir.props.some(prop => prop.key === key)) {
176
+ return false;
93
177
  }
94
178
  }
95
179
  return true;
96
180
  }
181
+ case "instance":
182
+ return v instanceof ir.ctor;
183
+ case "refine":
184
+ if (!checks(ir.base, v)) return false;
185
+ try {
186
+ return ir.pred(v) === true;
187
+ } catch {
188
+ return false;
189
+ }
190
+ case "alias":
191
+ return checks(ir.resolve(), v);
192
+ case "morph":
193
+ return checks(ir.input, v);
97
194
  case "sub":
98
195
  return !(ir.schema.run(v) instanceof OmpErrors);
99
196
  }
100
197
  }
101
198
 
102
199
  function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
200
+ if (typeof v !== "object" || v === null || !hasAlias(ir)) return visitFinish(ir, v, path);
201
+ activeVisits ??= new WeakMap();
202
+ const visits = activeVisits;
203
+ let visited = visits.get(v);
204
+ if (visited?.has(ir)) return v;
205
+ if (visited === undefined) {
206
+ visited = new Set();
207
+ visits.set(v, visited);
208
+ }
209
+ visited.add(ir);
210
+ try {
211
+ return visitFinish(ir, v, path);
212
+ } finally {
213
+ visited.delete(ir);
214
+ }
215
+ }
216
+
217
+ /** Run the node visitor, then apply node-local error configuration. */
218
+ function visitFinish(ir: IR, v: unknown, path: PropertyKey[]): unknown {
219
+ const out = visitNode(ir, v, path);
220
+ if (!(out instanceof OmpErrors) || ir.cfg === undefined) return out;
221
+ for (const error of out) {
222
+ if (error.path.length !== path.length) return out;
223
+ }
224
+ return out.configure(ir.cfg);
225
+ }
226
+
227
+ function visitNode(ir: IR, v: unknown, path: PropertyKey[]): unknown {
103
228
  switch (ir.k) {
229
+ case "alias":
230
+ return visit(ir.resolve(), v, path);
231
+ case "refine": {
232
+ const base = visit(ir.base, v, path);
233
+ if (base instanceof OmpErrors) return base;
234
+ try {
235
+ const result = ir.pred(base);
236
+ if (result instanceof OmpErrors) return path.length === 0 ? result : prefixAll(result, path);
237
+ return result ? base : fail(path, ir.expected, base);
238
+ } catch {
239
+ return fail(path, ir.expected, base);
240
+ }
241
+ }
242
+ case "morph": {
243
+ const input = visit(ir.input, v, path);
244
+ if (input instanceof OmpErrors) return input;
245
+ const context = {
246
+ error: (expected: string, data: unknown = input) => fail(path, expected, data),
247
+ reject: (problem: string, data: unknown = input) => fail(path, problem, data),
248
+ };
249
+ const output = ir.fn(input, context);
250
+ if (output instanceof OmpErrors) return output;
251
+ return ir.out === undefined ? output : visit(ir.out, output, path);
252
+ }
253
+ case "intersection": {
254
+ let output = v;
255
+ for (const member of ir.members) {
256
+ output = visit(member, output, path);
257
+ if (output instanceof OmpErrors) return output;
258
+ }
259
+ return output;
260
+ }
104
261
  case "sub": {
105
- const out = ir.schema.run(v);
262
+ const out = ir.schema.run(v, path);
106
263
  if (out instanceof OmpErrors) {
107
264
  return path.length === 0 ? out : prefixAll(out, path);
108
265
  }
@@ -116,46 +273,131 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
116
273
  return v;
117
274
  }
118
275
  }
119
- for (const m of ir.members) {
120
- if (m.k === "sub" || hasMorph(m)) {
121
- const out = visit(m, v, path);
276
+ let targeted: OmpErrors | undefined;
277
+ let targetCount = 0;
278
+ for (const member of ir.members) {
279
+ if (member.k === "sub" || hasMorph(member)) {
280
+ const out = visit(member, v, path);
122
281
  if (!(out instanceof OmpErrors)) return out;
282
+ if (kindMatches(unwrapBase(member), v)) {
283
+ targetCount++;
284
+ targeted ??= out;
285
+ }
123
286
  }
124
287
  }
125
- return unionFail(ir, v, path);
288
+ if (targetCount === 1 && targeted !== undefined) return targeted;
289
+ return ir.members.some(canRefineUnionFailure) ? unionFail(ir, v, path) : fail(path, expectedOf(ir), v);
126
290
  }
127
291
  case "array": {
128
292
  if (!Array.isArray(v)) return fail(path, "an array", v);
129
- if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v);
130
- if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v);
131
- if (!hasMorph(ir.el)) {
132
- for (let i = 0; i < v.length; i++) {
133
- if (!checks(ir.el, v[i])) {
134
- path.push(i);
135
- const err = visit(ir.el, v[i], path);
293
+ if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v.length);
294
+ if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length);
295
+ const morph = hasMorph(ir.el);
296
+ const out = morph ? new Array<unknown>(v.length) : v;
297
+ let errors: OmpErrors | undefined;
298
+ for (let index = 0; index < v.length; index++) {
299
+ path.push(index);
300
+ const element = visit(ir.el, v[index], path);
301
+ path.pop();
302
+ if (element instanceof OmpErrors) {
303
+ if (errors) errors.append(element);
304
+ else errors = element;
305
+ } else if (morph) {
306
+ out[index] = element;
307
+ }
308
+ }
309
+ return errors ?? out;
310
+ }
311
+ case "tuple": {
312
+ if (!Array.isArray(v)) return fail(path, "an array", v);
313
+ let required = ir.postfix.length;
314
+ for (const item of ir.prefix) if (!item.opt && !item.hasDefault) required++;
315
+ if (v.length < required) return fail(path, `an array of at least length ${required}`, v);
316
+ const maximum = ir.prefix.length + ir.postfix.length;
317
+ if (ir.variadic === undefined && v.length > maximum) {
318
+ return fail(path, `an array of at most length ${maximum}`, v);
319
+ }
320
+ const postfixStart = v.length - ir.postfix.length;
321
+ const prefixCount = Math.min(ir.prefix.length, postfixStart);
322
+ const morph = hasMorph(ir);
323
+ const output = morph ? [...v] : v;
324
+ let errors: OmpErrors | undefined;
325
+ for (let index = 0; index < prefixCount; index++) {
326
+ path.push(index);
327
+ const item = visit(ir.prefix[index].val, v[index], path);
328
+ path.pop();
329
+ if (item instanceof OmpErrors) {
330
+ if (errors) errors.append(item);
331
+ else errors = item;
332
+ } else if (morph) {
333
+ output[index] = item;
334
+ }
335
+ }
336
+ for (let index = prefixCount; index < ir.prefix.length; index++) {
337
+ const item = ir.prefix[index];
338
+ if (item.hasDefault && morph) {
339
+ const payload = item.def;
340
+ if (item.defFactory && typeof payload === "function") {
341
+ path.push(index);
342
+ const resolved = visit(item.val, payload(), path);
136
343
  path.pop();
137
- return err instanceof OmpErrors ? err : fail([...path, i], expectedOf(ir.el), v[i]);
344
+ if (resolved instanceof OmpErrors) {
345
+ if (errors) errors.append(resolved);
346
+ else errors = resolved;
347
+ } else {
348
+ output[index] = resolved;
349
+ }
350
+ } else {
351
+ output[index] = materializeDefault(payload);
352
+ }
353
+ } else if (!item.opt) {
354
+ path.push(index);
355
+ const error = fail(path, expectedOf(item.val), MISSING);
356
+ path.pop();
357
+ if (errors) errors.append(error);
358
+ else errors = error;
359
+ }
360
+ }
361
+ if (ir.variadic !== undefined) {
362
+ for (let index = prefixCount; index < postfixStart; index++) {
363
+ path.push(index);
364
+ const item = visit(ir.variadic, v[index], path);
365
+ path.pop();
366
+ if (item instanceof OmpErrors) {
367
+ if (errors) errors.append(item);
368
+ else errors = item;
369
+ } else if (morph) {
370
+ output[index] = item;
138
371
  }
139
372
  }
140
- return v;
141
373
  }
142
- const out = new Array<unknown>(v.length);
143
- for (let i = 0; i < v.length; i++) {
144
- path.push(i);
145
- const el = visit(ir.el, v[i], path);
374
+ for (let index = 0; index < ir.postfix.length; index++) {
375
+ const inputIndex = postfixStart + index;
376
+ path.push(inputIndex);
377
+ const item = visit(ir.postfix[index], v[inputIndex], path);
146
378
  path.pop();
147
- if (el instanceof OmpErrors) return el;
148
- out[i] = el;
379
+ if (item instanceof OmpErrors) {
380
+ if (errors) errors.append(item);
381
+ else errors = item;
382
+ } else if (morph) {
383
+ output[inputIndex] = item;
384
+ }
149
385
  }
150
- return out;
386
+ return errors ?? output;
151
387
  }
152
388
  case "object": {
153
- if (typeof v !== "object" || v === null || Array.isArray(v)) return fail(path, "an object", v);
154
- const rec = v as Record<string, unknown>;
389
+ if (typeof v !== "object" || v === null) return fail(path, "an object", v);
390
+ const rec = v as Record<PropertyKey, unknown>;
155
391
  const morph = hasMorph(ir);
156
- let out: Record<string, unknown> | undefined;
392
+ let out: Record<PropertyKey, unknown> | undefined;
393
+ let errors: OmpErrors | undefined;
157
394
  if (morph) {
158
- if (ir.extras === "delete" && !ir.index) {
395
+ if (
396
+ ir.extras === "delete" &&
397
+ ir.index === undefined &&
398
+ ir.symbolIndex === undefined &&
399
+ ir.patternIndexes === undefined
400
+ ) {
159
401
  out = {};
160
402
  } else {
161
403
  out = { ...rec };
@@ -164,51 +406,155 @@ function visit(ir: IR, v: unknown, path: PropertyKey[]): unknown {
164
406
  for (const p of ir.props) {
165
407
  if (!(p.key in rec)) {
166
408
  if (p.hasDefault && out) {
167
- // defFactory guarantees a callable default payload
168
409
  const payload = p.def;
169
- out[p.key] = p.defFactory && typeof payload === "function" ? payload() : payload;
410
+ if (p.defFactory && typeof payload === "function") {
411
+ path.push(p.key);
412
+ const resolved = visit(p.val, payload(), path);
413
+ path.pop();
414
+ if (resolved instanceof OmpErrors) {
415
+ if (errors) errors.append(resolved);
416
+ else errors = resolved;
417
+ } else {
418
+ out[p.key] = resolved;
419
+ }
420
+ } else {
421
+ out[p.key] = materializeDefault(payload);
422
+ }
170
423
  continue;
171
424
  }
172
425
  if (p.opt || p.hasDefault) continue;
173
426
  path.push(p.key);
174
- const err = fail(path, expectedOf(p.val), MISSING);
427
+ const error = fail(path, expectedOf(p.val), MISSING);
175
428
  path.pop();
176
- return err;
429
+ if (errors) errors.append(error);
430
+ else errors = error;
431
+ continue;
177
432
  }
178
433
  path.push(p.key);
179
- const res = visit(p.val, rec[p.key], path);
434
+ const result = visit(p.val, rec[p.key], path);
180
435
  path.pop();
181
- if (res instanceof OmpErrors) return res;
182
- if (out) out[p.key] = res;
436
+ if (result instanceof OmpErrors) {
437
+ if (errors) errors.append(result);
438
+ else errors = result;
439
+ } else if (out) {
440
+ out[p.key] = result;
441
+ }
183
442
  }
184
- if (ir.index) {
185
- for (const key in rec) {
186
- if (!own.call(rec, key)) continue;
443
+ for (const key in rec) {
444
+ if (!own.call(rec, key)) continue;
445
+ let indexed = false;
446
+ if (ir.index !== undefined) {
447
+ indexed = true;
187
448
  path.push(key);
188
- const res = visit(ir.index, rec[key], path);
449
+ const result = visit(ir.index, rec[key], path);
189
450
  path.pop();
190
- if (res instanceof OmpErrors) return res;
191
- if (out) out[key] = res;
192
- }
193
- } else if (ir.extras === "reject") {
194
- for (const key in rec) {
195
- if (!own.call(rec, key)) continue;
196
- let declared = false;
197
- for (const p of ir.props) {
198
- if (p.key === key) {
199
- declared = true;
200
- break;
201
- }
451
+ if (result instanceof OmpErrors) {
452
+ if (errors) errors.append(result);
453
+ else errors = result;
454
+ } else if (out) {
455
+ out[key] = result;
202
456
  }
203
- if (!declared) {
457
+ }
458
+ if (ir.patternIndexes !== undefined) {
459
+ for (const pattern of ir.patternIndexes) {
460
+ if (!checks(pattern.key, key)) continue;
461
+ indexed = true;
204
462
  path.push(key);
205
- const err = fail(path, "removed (undeclared key)", rec[key]);
463
+ const result = visit(pattern.val, rec[key], path);
206
464
  path.pop();
207
- return err;
465
+ if (result instanceof OmpErrors) {
466
+ if (errors) errors.append(result);
467
+ else errors = result;
468
+ } else if (out) {
469
+ out[key] = result;
470
+ }
208
471
  }
209
472
  }
473
+ if (ir.extras === "reject" && !indexed && !ir.props.some(prop => prop.key === key)) {
474
+ path.push(key);
475
+ const error = fail(path, "removed", rec[key]);
476
+ path.pop();
477
+ if (errors) errors.append(error);
478
+ else errors = error;
479
+ }
210
480
  }
211
- return out ?? v;
481
+ for (const key of Object.getOwnPropertySymbols(rec)) {
482
+ if (!Object.prototype.propertyIsEnumerable.call(rec, key)) continue;
483
+ if (ir.symbolIndex !== undefined) {
484
+ path.push(key);
485
+ const result = visit(ir.symbolIndex, rec[key], path);
486
+ path.pop();
487
+ if (result instanceof OmpErrors) {
488
+ if (errors) errors.append(result);
489
+ else errors = result;
490
+ } else if (out) {
491
+ out[key] = result;
492
+ }
493
+ } else if (ir.extras === "reject" && !ir.props.some(prop => prop.key === key)) {
494
+ path.push(key);
495
+ const error = fail(path, "removed", rec[key]);
496
+ path.pop();
497
+ if (errors) errors.append(error);
498
+ else errors = error;
499
+ }
500
+ }
501
+ return errors ?? out ?? v;
502
+ }
503
+ case "string": {
504
+ if (typeof v !== "string") return fail(path, "a string", v);
505
+ if (ir.min !== undefined && v.length < ir.min) return fail(path, `at least length ${ir.min}`, v.length);
506
+ if (ir.max !== undefined && v.length > ir.max) return fail(path, `at most length ${ir.max}`, v.length);
507
+ if (ir.url && !URL.canParse(v)) return fail(path, "a URL string", v);
508
+ return v;
509
+ }
510
+ case "number": {
511
+ if (typeof v !== "number" || !Number.isFinite(v)) return fail(path, ir.int ? "an integer" : "a number", v);
512
+ let errors: OmpErrors | undefined;
513
+ const add = (expected: string): void => {
514
+ const error = fail(path, expected, v);
515
+ if (errors) errors.append(error);
516
+ else errors = error;
517
+ };
518
+ if (ir.int && !Number.isInteger(v)) add("an integer");
519
+ if (ir.divisor !== undefined && v % ir.divisor !== 0) add(`a number divisible by ${ir.divisor}`);
520
+ if (ir.min !== undefined && (ir.xmin ? v <= ir.min : v < ir.min)) {
521
+ add(
522
+ ir.min === 0
523
+ ? ir.xmin
524
+ ? "positive"
525
+ : "non-negative"
526
+ : `a number ${ir.xmin ? "more than" : "at least"} ${ir.min}`,
527
+ );
528
+ }
529
+ if (ir.max !== undefined && (ir.xmax ? v >= ir.max : v > ir.max)) {
530
+ add(
531
+ ir.max === 0
532
+ ? ir.xmax
533
+ ? "negative"
534
+ : "non-positive"
535
+ : `a number ${ir.xmax ? "less than" : "at most"} ${ir.max}`,
536
+ );
537
+ }
538
+ return errors ?? v;
539
+ }
540
+ case "lit": {
541
+ if (checks(ir, v)) return v;
542
+ if ((typeof ir.v === "object" && ir.v !== null) || typeof ir.v === "function") {
543
+ let expected = "the specified reference";
544
+ try {
545
+ const serialized = JSON.stringify(ir.v);
546
+ if (serialized !== undefined) {
547
+ expected = `reference equal to ${serialized}`;
548
+ if (typeof v === "object" && v !== null && JSON.stringify(v) === serialized) {
549
+ expected += " (serialized to the same value)";
550
+ }
551
+ }
552
+ } catch {
553
+ // Cyclic values still get a useful reference-identity expectation.
554
+ }
555
+ return fail(path, expected, v);
556
+ }
557
+ return fail(path, expectedOf(ir), v);
212
558
  }
213
559
  default:
214
560
  return checks(ir, v) ? v : fail(path, expectedOf(ir), v);
@@ -220,6 +566,14 @@ function prefixAll(errs: OmpErrors, path: PropertyKey[]): OmpErrors {
220
566
  return errs;
221
567
  }
222
568
 
569
+ /** True when a union failure can be replaced with a more specific nested error. */
570
+ export function canRefineUnionFailure(member: IR): boolean {
571
+ const base = unwrapBase(member);
572
+ if (base.k === "array" || base.k === "object") return true;
573
+ if (base.k === "string") return base.min !== undefined || base.max !== undefined || base.url === true;
574
+ return base.k === "number" && (base.int === true || base.min !== undefined || base.max !== undefined);
575
+ }
576
+
223
577
  /**
224
578
  * Detailed failure for a union: descend into the member the value was clearly
225
579
  * aimed at — unique runtime-kind match, else an object member whose literal
@@ -227,41 +581,170 @@ function prefixAll(errs: OmpErrors, path: PropertyKey[]): OmpErrors {
227
581
  * for a precise nested error (paths, narrow messages) instead of the coarse
228
582
  * "A or B" expectation.
229
583
  */
230
- export function unionFail(ir: IR & { k: "union" }, v: unknown, path: PropertyKey[]): OmpErrors {
584
+ export function unionFail(ir: IR & { k: "union" }, v: unknown, path: PropertyKey[], expected?: string): OmpErrors {
231
585
  let best: IR | undefined;
232
- for (const m of ir.members) {
233
- const base = m.k === "sub" ? m.schema.ir : m;
586
+ for (const member of ir.members) {
587
+ const base = unwrapBase(member);
234
588
  if (!kindMatches(base, v)) continue;
235
589
  if (best !== undefined) {
236
590
  best = undefined;
237
591
  break;
238
592
  }
239
- best = m;
593
+ best = member;
594
+ }
595
+ if (best === undefined) {
596
+ const discriminated = discriminateFailure(ir.members, v, path);
597
+ if (discriminated !== undefined) return discriminated;
240
598
  }
241
- if (best === undefined) best = discriminate(ir.members, v);
242
- if (best) {
599
+ if (best !== undefined) {
243
600
  const out = visit(best, v, path);
244
601
  if (out instanceof OmpErrors) return out;
245
602
  }
246
- return fail(path, expectedOf(ir), v);
603
+ if (ir.members.every(member => unwrapBase(member).k === "object")) {
604
+ const branches = ir.members.flatMap(member => {
605
+ const result = visit(member, v, path);
606
+ return result instanceof OmpErrors ? [[...result]] : [];
607
+ });
608
+ if (branches.length !== 0) {
609
+ const common = branches[0].filter(
610
+ (entry, index, first) =>
611
+ first.findIndex(candidate => pathsEqual(candidate.path, entry.path)) === index &&
612
+ branches.every(branch => branch.some(candidate => pathsEqual(candidate.path, entry.path))),
613
+ );
614
+ const alternatives: OmpErrors[] = [];
615
+ if (common.length !== 0) {
616
+ for (const entry of common) {
617
+ const expectations = new Set<string>();
618
+ for (const branch of branches) {
619
+ for (const candidate of branch) {
620
+ if (pathsEqual(candidate.path, entry.path)) {
621
+ expectations.add(candidate.expected.endsWith(" instance") ? "an object" : candidate.expected);
622
+ }
623
+ }
624
+ }
625
+ alternatives.push(
626
+ new OmpErrors(entry.path, [...expectations].join(" or "), entry.data, { preserveActual: true }),
627
+ );
628
+ }
629
+ } else {
630
+ for (const branch of branches) {
631
+ for (const entry of branch) {
632
+ alternatives.push(
633
+ new OmpErrors(
634
+ entry.path,
635
+ entry.expected.endsWith(" instance") ? "an object" : entry.expected,
636
+ entry.data,
637
+ { preserveActual: true },
638
+ ),
639
+ );
640
+ }
641
+ }
642
+ }
643
+ const combined = alternatives[0];
644
+ for (let index = 1; index < alternatives.length; index++) combined.append(alternatives[index]);
645
+ return alternatives.length === 1 ? combined : combined.asAlternatives();
646
+ }
647
+ }
648
+ return fail(path, expected ?? expectedOf(ir), v);
247
649
  }
248
650
 
249
- /** Pick the sole object member whose literal-typed property matches the value's. */
250
- function discriminate(members: IR[], v: unknown): IR | undefined {
251
- if (typeof v !== "object" || v === null || Array.isArray(v)) return undefined;
252
- const rec = v as Record<string, unknown>;
253
- let match: IR | undefined;
254
- for (const m of members) {
255
- const base = m.k === "sub" ? m.schema.ir : m;
256
- if (base.k !== "object") continue;
257
- for (const p of base.props) {
258
- if (p.val.k !== "lit" || rec[p.key] !== p.val.v) continue;
259
- if (match !== undefined) return undefined; // ambiguous
260
- match = m;
261
- break;
651
+ interface LiteralDiscriminant {
652
+ path: PropertyKey[];
653
+ value: unknown;
654
+ }
655
+
656
+ function unwrapBase(member: IR, seen = new Set<IR>()): IR {
657
+ if (seen.has(member)) return member;
658
+ seen.add(member);
659
+ if (member.k === "sub") return unwrapBase(member.schema.ir, seen);
660
+ if (member.k === "alias") return unwrapBase(member.resolve(), seen);
661
+ if (member.k === "refine") return unwrapBase(member.base, seen);
662
+ return member;
663
+ }
664
+
665
+ function collectDiscriminants(member: IR, prefix: PropertyKey[] = [], seen = new Set<IR>()): LiteralDiscriminant[] {
666
+ if (seen.has(member)) return [];
667
+ seen.add(member);
668
+ if (member.k === "alias") return collectDiscriminants(member.resolve(), prefix, seen);
669
+ if (member.k === "sub") return collectDiscriminants(member.schema.ir, prefix, seen);
670
+ if (member.k === "refine") return collectDiscriminants(member.base, prefix, seen);
671
+ if (member.k !== "object") return [];
672
+ const result: LiteralDiscriminant[] = [];
673
+ for (const property of member.props) {
674
+ const propertyPath = [...prefix, property.key];
675
+ const value = unwrapBase(property.val);
676
+ if (value.k === "lit") result.push({ path: propertyPath, value: value.v });
677
+ else result.push(...collectDiscriminants(property.val, propertyPath, new Set(seen)));
678
+ }
679
+ return result;
680
+ }
681
+
682
+ function pathsEqual(left: readonly PropertyKey[], right: readonly PropertyKey[]): boolean {
683
+ return left.length === right.length && left.every((key, index) => key === right[index]);
684
+ }
685
+
686
+ function valueAtPath(value: unknown, path: readonly PropertyKey[]): { present: boolean; value?: unknown } {
687
+ let cursor = value;
688
+ for (const key of path) {
689
+ if ((typeof cursor !== "object" && typeof cursor !== "function") || cursor === null || !(key in cursor)) {
690
+ return { present: false };
691
+ }
692
+ cursor = (cursor as Record<PropertyKey, unknown>)[key];
693
+ }
694
+ return { present: true, value: cursor };
695
+ }
696
+
697
+ function discriminateFailure(members: IR[], value: unknown, path: PropertyKey[]): OmpErrors | undefined {
698
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
699
+ const byMember = members.map(member => collectDiscriminants(member));
700
+ const candidates: { path: PropertyKey[]; distinct: number; declared: number }[] = [];
701
+ for (const discriminants of byMember) {
702
+ for (const discriminant of discriminants) {
703
+ if (candidates.some(candidate => pathsEqual(candidate.path, discriminant.path))) continue;
704
+ const values: unknown[] = [];
705
+ let declared = 0;
706
+ for (const branch of byMember) {
707
+ const match = branch.find(candidate => pathsEqual(candidate.path, discriminant.path));
708
+ if (match === undefined) continue;
709
+ declared++;
710
+ if (!values.some(candidate => Object.is(candidate, match.value))) values.push(match.value);
711
+ }
712
+ if (values.length > 1) candidates.push({ path: discriminant.path, distinct: values.length, declared });
713
+ }
714
+ }
715
+ candidates.sort((left, right) => right.distinct - left.distinct || right.declared - left.declared);
716
+ for (const candidate of candidates) {
717
+ const actual = valueAtPath(value, candidate.path);
718
+ const exact: IR[] = [];
719
+ const defaults: IR[] = [];
720
+ const expectedMembers: IR[] = [];
721
+ for (let index = 0; index < members.length; index++) {
722
+ const discriminant = byMember[index].find(item => pathsEqual(item.path, candidate.path));
723
+ if (discriminant === undefined) {
724
+ defaults.push(members[index]);
725
+ continue;
726
+ }
727
+ expectedMembers.push({ k: "lit", v: discriminant.value });
728
+ if (actual.present && Object.is(actual.value, discriminant.value)) exact.push(members[index]);
729
+ }
730
+ if (!actual.present) {
731
+ if (defaults.length !== 0 && defaults.length < members.length) {
732
+ return discriminateFailure(defaults, value, path);
733
+ }
734
+ return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), undefined);
735
+ }
736
+ if (exact.length === 0) {
737
+ if (defaults.length !== 0) return discriminateFailure(defaults, value, path);
738
+ return fail([...path, ...candidate.path], expectedOf({ k: "union", members: expectedMembers }), actual.value);
739
+ }
740
+ if (exact.length === 1) {
741
+ const result = visit(exact[0], value, path);
742
+ return result instanceof OmpErrors ? result : undefined;
262
743
  }
744
+ const nested = discriminateFailure(exact, value, path);
745
+ if (nested !== undefined) return nested;
263
746
  }
264
- return match;
747
+ return undefined;
265
748
  }
266
749
 
267
750
  /** True when a value's runtime shape could only be aimed at this member. */