@oh-my-pi/omptype 17.2.7 → 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/ir.ts CHANGED
@@ -10,16 +10,30 @@
10
10
  * and the JSON Schema emitter (`json-schema.ts`).
11
11
  */
12
12
 
13
- import { OmpErrors, OmpTypeError } from "./errors";
13
+ import { type ErrorConfig, OmpErrors, OmpTypeError } from "./errors";
14
14
  import { keywordIR, patternIR, templateIR } from "./keywords";
15
15
 
16
16
  /** Brand carried by `Type` instances so the parser can embed them in defs. */
17
17
  export const IR_BRAND: unique symbol = Symbol("omptype.schema");
18
18
 
19
19
  const kMorph: unique symbol = Symbol("omptype.hasMorph");
20
+ const kMorphOwner: unique symbol = Symbol("omptype.hasMorphOwner");
21
+ const kAlias: unique symbol = Symbol("omptype.hasAlias");
22
+ const kAliasOwner: unique symbol = Symbol("omptype.hasAliasOwner");
23
+ const kSimple: unique symbol = Symbol("omptype.simple");
24
+ const kSimpleOwner: unique symbol = Symbol("omptype.simpleOwner");
20
25
 
21
26
  interface IRAnalysis {
22
27
  [kMorph]?: boolean;
28
+ [kMorphOwner]?: object;
29
+ [kAlias]?: boolean;
30
+ [kAliasOwner]?: object;
31
+ [kSimple]?: boolean;
32
+ [kSimpleOwner]?: object;
33
+ /** Node-local metadata used for shallow error formatting. */
34
+ cfg?: ErrorConfig;
35
+ /** True when `desc` was derived from the node itself rather than authored via `.describe()`. */
36
+ descAuto?: boolean;
23
37
  }
24
38
 
25
39
  /**
@@ -32,13 +46,20 @@ export interface EmbeddableSchema {
32
46
  ir: IR;
33
47
  /** True when the schema carries `.pipe()`/`.narrow()` steps. */
34
48
  hasSteps: boolean;
49
+ /** Output IR of the last `.to(target)` step, when statically known. */
50
+ stepOut?: IR;
51
+ /** True when the last pipe step is bare — output shape statically unknown. */
52
+ opaqueOutput?: boolean;
35
53
  /** `.default()` payload; a function is a factory invoked per fill. */
36
54
  defaultValue?: unknown;
37
55
  hasDefault: boolean;
56
+ /** Precomputed output for a non-factory default after validation and morphs. */
57
+ defaultOutput?: unknown;
58
+ hasDefaultOutput?: boolean;
38
59
  /** `.describe()` annotation, emitted into JSON Schema. */
39
60
  description?: string;
40
61
  /** Full validate+morph pipeline (identical to calling the schema). */
41
- run(value: unknown): unknown;
62
+ run(value: unknown, path?: readonly PropertyKey[]): unknown;
42
63
  }
43
64
 
44
65
  /** Policy for undeclared object keys. */
@@ -62,6 +83,8 @@ export interface TupleItemIR {
62
83
  def?: unknown;
63
84
  defFactory?: boolean;
64
85
  hasDefault?: boolean;
86
+ /** True once the default has been validated and static morph output precomputed. */
87
+ defValidated?: boolean;
65
88
  }
66
89
 
67
90
  /** Fixed, optional, variadic, and postfix tuple sequence. */
@@ -100,11 +123,19 @@ export type IR = IRAnalysis &
100
123
  | { k: "intersection"; members: IR[]; desc?: string }
101
124
  | { k: "array"; el: IR; min?: number; max?: number; desc?: string }
102
125
  | TupleIR
103
- | { k: "object"; props: PropIR[]; index?: IR; extras: Extras; desc?: string }
126
+ | {
127
+ k: "object";
128
+ props: PropIR[];
129
+ index?: IR;
130
+ symbolIndex?: IR;
131
+ patternIndexes?: { key: IR; val: IR }[];
132
+ extras: Extras;
133
+ desc?: string;
134
+ }
104
135
  | {
105
136
  k: "refine";
106
137
  base: IR;
107
- pred: (value: unknown) => boolean;
138
+ pred: (value: unknown) => boolean | OmpErrors;
108
139
  expected: string;
109
140
  json?: Record<string, unknown>;
110
141
  desc?: string;
@@ -123,13 +154,15 @@ export type IR = IRAnalysis &
123
154
  );
124
155
 
125
156
  export interface PropIR {
126
- key: string;
157
+ key: PropertyKey;
127
158
  opt: boolean;
128
159
  val: IR;
129
160
  /** Default payload (value, or factory when `defFactory`); missing key is filled. */
130
161
  def?: unknown;
131
162
  defFactory?: boolean;
132
163
  hasDefault?: boolean;
164
+ /** True once the default has been validated and static morph output precomputed. */
165
+ defValidated?: boolean;
133
166
  }
134
167
 
135
168
  /** Definition input accepted by `type()` and object property values. */
@@ -140,11 +173,13 @@ export type Def = string | RegExp | Date | EmbeddableSchema | readonly unknown[]
140
173
  type Tok =
141
174
  | { t: "id"; v: string }
142
175
  | { t: "num"; v: number }
176
+ | { t: "bigint"; v: bigint }
143
177
  | { t: "date"; v: Date }
178
+ | { t: "regex"; v: RegExp }
144
179
  | { t: "str"; v: string }
145
180
  | { t: "op"; v: string };
146
181
 
147
- const SIMPLE_OPS = "|()[]=?%";
182
+ const SIMPLE_OPS = "|&()[]=?%,#";
148
183
 
149
184
  function tokenize(src: string): Tok[] {
150
185
  const toks: Tok[] = [];
@@ -156,28 +191,63 @@ function tokenize(src: string): Tok[] {
156
191
  i++;
157
192
  continue;
158
193
  }
159
- if (c === "'" || c === '"') {
160
- let j = i + 1;
161
- while (j < n && src[j] !== c) j++;
162
- if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`);
163
- toks.push({ t: "str", v: src.slice(i + 1, j) });
164
- i = j + 1;
165
- continue;
166
- }
167
194
  if (c === "d" && (src[i + 1] === "'" || src[i + 1] === '"')) {
168
195
  const quote = src[i + 1];
169
196
  const end = src.indexOf(quote, i + 2);
170
197
  if (end < 0) throw new OmpTypeError(`unterminated date literal in "${src}"`);
171
- const value = new Date(src.slice(i + 2, end));
198
+ const source = src.slice(i + 2, end).trim();
199
+ const value = /^\d+$/.test(source) ? new Date(Number(source)) : new Date(source);
172
200
  if (Number.isNaN(value.valueOf())) throw new OmpTypeError(`invalid date literal in "${src}"`);
173
201
  toks.push({ t: "date", v: value });
174
202
  i = end + 1;
175
203
  continue;
176
204
  }
205
+ if (c === "'" || c === '"') {
206
+ let j = i + 1;
207
+ let value = "";
208
+ for (; j < n && src[j] !== c; j++) {
209
+ if (src[j] === "\\") {
210
+ j++;
211
+ if (j >= n) break;
212
+ }
213
+ value += src[j];
214
+ }
215
+ if (j >= n) throw new OmpTypeError(`unterminated string literal in "${src}"`);
216
+ toks.push({ t: "str", v: value });
217
+ i = j + 1;
218
+ continue;
219
+ }
220
+ if (c === "/") {
221
+ let j = i + 1;
222
+ for (; j < n; j++) {
223
+ if (src[j] === "\\") j++;
224
+ else if (src[j] === "/") break;
225
+ }
226
+ if (j >= n) throw new OmpTypeError(`unterminated regular expression in "${src}"`);
227
+ let end = j + 1;
228
+ while (end < n && /[dgimsuvy]/.test(src[end])) end++;
229
+ const source = src.slice(i + 1, j);
230
+ const flags = src.slice(j + 1, end);
231
+ try {
232
+ toks.push({ t: "regex", v: new RegExp(source, flags) });
233
+ } catch {
234
+ throw new OmpTypeError(`invalid regular expression "${src.slice(i, end)}"`);
235
+ }
236
+ i = end;
237
+ continue;
238
+ }
177
239
  if ((c >= "0" && c <= "9") || (c === "-" && i + 1 < n && src[i + 1] >= "0" && src[i + 1] <= "9")) {
178
240
  let j = i + 1;
179
- while (j < n && ((src[j] >= "0" && src[j] <= "9") || src[j] === "." || src[j] === "e" || src[j] === "+")) j++;
180
- toks.push({ t: "num", v: Number(src.slice(i, j)) });
241
+ while (j < n && /[\w.+-]/.test(src[j])) j++;
242
+ const raw = src.slice(i, j);
243
+ if (/^-?(?:0|[1-9]\d*)n$/.test(raw) && raw !== "-0n") {
244
+ toks.push({ t: "bigint", v: BigInt(raw.slice(0, -1)) });
245
+ } else {
246
+ const valid =
247
+ /^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(raw) && !Object.is(Number(raw), -0) && String(Number(raw)) === raw;
248
+ if (!valid) throw new OmpTypeError(`Malformed number literal '${raw}'`);
249
+ toks.push({ t: "num", v: Number(raw) });
250
+ }
181
251
  i = j;
182
252
  continue;
183
253
  }
@@ -198,6 +268,11 @@ function tokenize(src: string): Tok[] {
198
268
  }
199
269
  continue;
200
270
  }
271
+ if (c === "=" && src[i + 1] === "=") {
272
+ toks.push({ t: "op", v: "==" });
273
+ i += 2;
274
+ continue;
275
+ }
201
276
  if (SIMPLE_OPS.includes(c)) {
202
277
  toks.push({ t: "op", v: c });
203
278
  i++;
@@ -229,8 +304,24 @@ const KEYWORDS: Record<string, () => IR> = {
229
304
  false: () => ({ k: "lit", v: false }),
230
305
  };
231
306
 
232
- /** Resolve a named scope alias to its lazy IR reference. */
233
- export type AliasResolver = (name: string) => IR | undefined;
307
+ /** Resolve named scope aliases and, when present, scoped generic invocations. */
308
+ export interface AliasResolver {
309
+ (name: string): IR | undefined;
310
+ hasGeneric?(name: string): boolean;
311
+ generic?(name: string, arguments_: readonly IR[]): IR | undefined;
312
+ }
313
+
314
+ /**
315
+ * Resolvers that only intercept the `this` self-reference. A parse under such
316
+ * a resolver of a source with no `this` token is identical to a resolver-free
317
+ * parse, so it may read and populate the string-definition cache.
318
+ */
319
+ const THIS_ONLY_RESOLVERS = new WeakSet<AliasResolver>();
320
+
321
+ /** Declare that `resolve` only intercepts `this` (see THIS_ONLY_RESOLVERS). */
322
+ export function markThisOnlyResolver(resolve: AliasResolver): void {
323
+ THIS_ONLY_RESOLVERS.add(resolve);
324
+ }
234
325
 
235
326
  interface ParsedTop {
236
327
  ir: IR;
@@ -278,9 +369,10 @@ class StrParser {
278
369
  let hasDefault = false;
279
370
  if (this.#eatOp("=")) {
280
371
  const t = this.#next();
281
- if (t.t === "num" || t.t === "str") def = t.v;
372
+ if (t.t === "num" || t.t === "bigint" || t.t === "date" || t.t === "str") def = t.v;
282
373
  else if (t.t === "id" && (t.v === "true" || t.v === "false")) def = t.v === "true";
283
374
  else if (t.t === "id" && t.v === "null") def = null;
375
+ else if (t.t === "id" && t.v === "undefined") def = undefined;
284
376
  else throw new OmpTypeError(`unsupported default literal in "${this.#src}"`);
285
377
  hasDefault = true;
286
378
  }
@@ -296,13 +388,36 @@ class StrParser {
296
388
  }
297
389
 
298
390
  parseUnion(): IR {
299
- const first = this.parseBounded();
391
+ const first = this.parseIntersection();
300
392
  if (!this.#eatOp("|")) return first;
301
- const members = [first, this.parseBounded()];
302
- while (this.#eatOp("|")) members.push(this.parseBounded());
393
+ const members = [first, this.parseIntersection()];
394
+ while (this.#eatOp("|")) members.push(this.parseIntersection());
303
395
  return { k: "union", members };
304
396
  }
305
397
 
398
+ parseIntersection(): IR {
399
+ const first = this.parseBounded();
400
+ if (!this.#eatOp("&")) return first;
401
+ const members = [first, this.parseBounded()];
402
+ while (this.#eatOp("&")) members.push(this.parseBounded());
403
+ const literal = members.find((member): member is Extract<IR, { k: "lit" }> => member.k === "lit");
404
+ if (literal && typeof literal.v === "number") {
405
+ for (const member of members) {
406
+ if (
407
+ member.k === "number" &&
408
+ ((member.int && !Number.isInteger(literal.v)) ||
409
+ (member.divisor !== undefined && literal.v % member.divisor !== 0) ||
410
+ (member.min !== undefined && (member.xmin ? literal.v <= member.min : literal.v < member.min)) ||
411
+ (member.max !== undefined && (member.xmax ? literal.v >= member.max : literal.v > member.max)))
412
+ ) {
413
+ throw new OmpTypeError("literal is excluded by intersection");
414
+ }
415
+ }
416
+ return literal;
417
+ }
418
+ return { k: "intersection", members };
419
+ }
420
+
306
421
  /**
307
422
  * `NUM CMP base (CMP NUM)?` or `base (CMP NUM)?`, with `[]*` postfix on the
308
423
  * base AND after a trailing bound — `string>0[]` is an array of bounded
@@ -311,30 +426,38 @@ class StrParser {
311
426
  parseBounded(): IR {
312
427
  const t = this.#peek();
313
428
  const t1 = this.#peek(1);
314
- if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && CMP[t1.v]) {
429
+ if ((t?.t === "num" || t?.t === "date") && t1?.t === "op" && (t1.v === "<" || t1.v === "<=")) {
315
430
  const lo = t.v;
316
431
  this.#pos += 2;
317
432
  let node = this.#eatDivisor(this.parsePostfix());
318
433
  node = applyBound(node, flip(t1.v), lo, this.#src);
319
434
  const t2 = this.#peek();
320
- if (t2?.t === "op" && CMP[t2.v]) {
321
- this.#pos++;
322
- const hi = this.#next();
323
- if (hi.t !== "num" && hi.t !== "date") {
324
- throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`);
325
- }
326
- node = applyBound(node, t2.v, hi.v, this.#src);
435
+ if (!(t2?.t === "op" && CMP[t2.v])) {
436
+ throw new OmpTypeError(`left bound requires a corresponding right bound in "${this.#src}"`);
437
+ }
438
+ if (t2.v === ">" || t2.v === ">=") {
439
+ throw new OmpTypeError(`right bound must use < or <= in "${this.#src}"`);
327
440
  }
441
+ this.#pos++;
442
+ const hi = this.#next();
443
+ if (hi.t !== "num" && hi.t !== "date") {
444
+ throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`);
445
+ }
446
+ node = applyBound(node, t2.v, hi.v, this.#src);
328
447
  return this.#eatArraySuffixes(node);
329
448
  }
330
449
  let node = this.#eatDivisor(this.parsePostfix());
331
450
  const t2 = this.#peek();
332
- if (t2?.t === "op" && CMP[t2.v]) {
451
+ if (t2?.t === "op" && t2.v === "==") {
333
452
  this.#pos++;
334
453
  const limit = this.#next();
335
- if (limit.t !== "num" && limit.t !== "date") {
336
- throw new OmpTypeError(`expected bound after comparator in "${this.#src}"`);
454
+ if (limit.t !== "num" && limit.t !== "bigint" && limit.t !== "date") {
455
+ throw new OmpTypeError(`expected literal after == in "${this.#src}"`);
337
456
  }
457
+ node = applyEquality(node, limit.v, this.#src);
458
+ } else if (t2?.t === "op" && CMP[t2.v] && (this.#peek(1)?.t === "num" || this.#peek(1)?.t === "date")) {
459
+ this.#pos++;
460
+ const limit = this.#next() as Extract<Tok, { t: "num" | "date" }>;
338
461
  node = applyBound(node, t2.v, limit.v, this.#src);
339
462
  node = this.#eatArraySuffixes(node);
340
463
  }
@@ -346,10 +469,11 @@ class StrParser {
346
469
  const divisor = this.#next();
347
470
  if (divisor.t !== "num") throw new OmpTypeError(`expected number after % in "${this.#src}"`);
348
471
  if (node.k !== "number") throw new OmpTypeError(`% requires number in "${this.#src}"`);
349
- if (!Number.isFinite(divisor.v) || divisor.v === 0)
350
- throw new OmpTypeError(`divisor must be non-zero in "${this.#src}"`);
351
- node.divisor = divisor.v;
352
- return node;
472
+ if (!Number.isFinite(divisor.v) || !Number.isInteger(divisor.v) || divisor.v === 0)
473
+ throw new OmpTypeError(`divisor must be a non-zero integer in "${this.#src}"`);
474
+ // Copy-on-write: the primary may be a shared node (string-def cache,
475
+ // generic arguments); stamping it in place would leak into other schemas.
476
+ return { ...node, divisor: Math.abs(divisor.v) };
353
477
  }
354
478
 
355
479
  /** Wrap `node` in array IR for each `[]` pair at the cursor. */
@@ -367,14 +491,38 @@ class StrParser {
367
491
  let node = this.parsePrimary();
368
492
  for (;;) {
369
493
  const t = this.#peek();
370
- if (!(t?.t === "op" && t.v === "[")) break;
371
- this.#pos++;
372
- if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`);
373
- node = { k: "array", el: node };
494
+ if (t?.t === "op" && t.v === "[") {
495
+ this.#pos++;
496
+ if (!this.#eatOp("]")) throw new OmpTypeError(`expected ']' in "${this.#src}"`);
497
+ node = { k: "array", el: node };
498
+ continue;
499
+ }
500
+ if (t?.t === "op" && t.v === "#") {
501
+ this.#pos++;
502
+ const name = this.#next();
503
+ if (name.t !== "id") throw new OmpTypeError(`expected brand name after # in "${this.#src}"`);
504
+ continue;
505
+ }
506
+ break;
374
507
  }
375
508
  return node;
376
509
  }
377
510
 
511
+ #parseGenericArguments(): IR[] {
512
+ this.#eatOp("<");
513
+ const arguments_: IR[] = [];
514
+ if (this.#eatOp(">")) return arguments_;
515
+ for (;;) {
516
+ arguments_.push(this.parseUnion());
517
+ if (this.#eatOp(">")) return arguments_;
518
+ if (!this.#eatOp(",")) throw new OmpTypeError(`expected ',' or '>' in "${this.#src}"`);
519
+ const next = this.#peek();
520
+ if (next?.t === "op" && (next.v === "," || next.v === ">")) {
521
+ throw new OmpTypeError(`generic arguments cannot be empty in "${this.#src}"`);
522
+ }
523
+ }
524
+ }
525
+
378
526
  parsePrimary(): IR {
379
527
  const t = this.#next();
380
528
  if (t.t === "op" && t.v === "(") {
@@ -382,10 +530,43 @@ class StrParser {
382
530
  if (!this.#eatOp(")")) throw new OmpTypeError(`expected ')' in "${this.#src}"`);
383
531
  return inner;
384
532
  }
385
- if (t.t === "str" || t.t === "num" || t.t === "date") return { k: "lit", v: t.v };
533
+ if (t.t === "str" || t.t === "num" || t.t === "bigint" || t.t === "date") return { k: "lit", v: t.v };
534
+ if (t.t === "regex") return patternIR(t.v);
386
535
  if (t.t === "id") {
536
+ if (t.v === "keyof") {
537
+ try {
538
+ return keyOf(this.parsePostfix());
539
+ } catch (error) {
540
+ if (error instanceof OmpTypeError) throw new OmpTypeError("keyof operand must be an object");
541
+ throw error;
542
+ }
543
+ }
544
+ if (t.v === "Array.liftFrom" && this.#peek()?.t === "op" && this.#peek()?.v === "<") {
545
+ this.#pos++;
546
+ const element = this.parsePrimary();
547
+ if (!this.#eatOp(">")) throw new OmpTypeError(`expected '>' in "${this.#src}"`);
548
+ const array: IR = { k: "array", el: element, desc: "an object" };
549
+ return {
550
+ k: "morph",
551
+ input: { k: "union", members: [element, array] },
552
+ fn: value => (Array.isArray(value) ? value : [value]),
553
+ out: array,
554
+ };
555
+ }
556
+ if (t.v === "Record" && this.#peek()?.t === "op" && this.#peek()?.v === "<") {
557
+ const arguments_ = this.#parseGenericArguments();
558
+ if (arguments_.length !== 2) throw new OmpTypeError("Record requires two arguments");
559
+ return { k: "object", props: [], index: arguments_[1], extras: "keep" };
560
+ }
561
+ if (this.#peek()?.t === "op" && this.#peek()?.v === "<" && this.#resolve?.hasGeneric?.(t.v)) {
562
+ const arguments_ = this.#parseGenericArguments();
563
+ const instantiated = this.#resolve.generic?.(t.v, arguments_);
564
+ if (!instantiated) throw new OmpTypeError(`unknown generic "${t.v}" in "${this.#src}"`);
565
+ return instantiated;
566
+ }
567
+ const scoped = this.#resolve?.(t.v);
387
568
  const make = KEYWORDS[t.v];
388
- const keyword = make?.() ?? keywordIR(t.v) ?? this.#resolve?.(t.v);
569
+ const keyword = scoped ?? make?.() ?? keywordIR(t.v);
389
570
  if (!keyword) throw new OmpTypeError(`unknown keyword "${t.v}" in "${this.#src}"`);
390
571
  return keyword;
391
572
  }
@@ -403,7 +584,7 @@ function isWhitespaceAt(src: string, index: number): boolean {
403
584
 
404
585
  /** Fast path for the literal unions pervasive in command schemas. */
405
586
  function parseLiteralUnion(src: string): IR | undefined {
406
- const members: IR[] = [];
587
+ const members: Extract<IR, { k: "lit" }>[] = [];
407
588
  let index = 0;
408
589
  while (index < src.length && isWhitespaceAt(src, index)) index++;
409
590
  for (;;) {
@@ -415,7 +596,19 @@ function parseLiteralUnion(src: string): IR | undefined {
415
596
  index = end + 1;
416
597
  while (index < src.length && isWhitespaceAt(src, index)) index++;
417
598
  if (index === src.length) {
418
- return members.length === 1 ? members[0] : { k: "union", members };
599
+ const ir: IR = members.length === 1 ? members[0] : { k: "union", members };
600
+ let simple = true;
601
+ for (let member = 1; simple && member < members.length; member++) {
602
+ for (let previous = 0; previous < member; previous++) {
603
+ if (members[previous].k === "lit" && members[previous].v === members[member].v) {
604
+ simple = false;
605
+ break;
606
+ }
607
+ }
608
+ }
609
+ ir[kSimple] = simple;
610
+ ir[kSimpleOwner] = ir;
611
+ return ir;
419
612
  }
420
613
  if (src[index] !== "|") return undefined;
421
614
  index++;
@@ -453,8 +646,8 @@ function genericArguments(src: string): { name: string; args: string[] } | undef
453
646
  return { name, args };
454
647
  }
455
648
 
456
- function genericKeys(ir: IR): Set<string> {
457
- const keys = new Set<string>();
649
+ function genericKeys(ir: IR): Set<PropertyKey> {
650
+ const keys = new Set<PropertyKey>();
458
651
  const visit = (node: IR): void => {
459
652
  if (node.k === "lit" && typeof node.v === "string") keys.add(node.v);
460
653
  else if (node.k === "union") for (const member of node.members) visit(member);
@@ -489,6 +682,11 @@ function mergeObjectIR(left: IR, right: IR): IR {
489
682
  k: "object",
490
683
  props,
491
684
  index: right.index ?? left.index,
685
+ symbolIndex: right.symbolIndex ?? left.symbolIndex,
686
+ patternIndexes:
687
+ left.patternIndexes === undefined && right.patternIndexes === undefined
688
+ ? undefined
689
+ : [...(left.patternIndexes ?? []), ...(right.patternIndexes ?? [])],
492
690
  extras: right.extras === "keep" ? left.extras : right.extras,
493
691
  };
494
692
  }
@@ -496,9 +694,26 @@ function mergeObjectIR(left: IR, right: IR): IR {
496
694
  function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined {
497
695
  const generic = genericArguments(src);
498
696
  if (generic === undefined) return undefined;
697
+ if (generic.name === "Array.liftFrom" && generic.args.length === 1) {
698
+ const element = parseDef(generic.args[0], resolve);
699
+ const array: IR = { k: "array", el: element, desc: "an object" };
700
+ return {
701
+ k: "morph",
702
+ input: { k: "union", members: [element, array] },
703
+ fn: value => (Array.isArray(value) ? value : [value]),
704
+ out: array,
705
+ };
706
+ }
499
707
  if (generic.name === "Record" && generic.args.length === 2) {
500
708
  return { k: "object", props: [], index: parseDef(generic.args[1], resolve), extras: "keep" };
501
709
  }
710
+ if ((generic.name === "Extract" || generic.name === "Exclude") && generic.args.length === 2) {
711
+ return distributeFilter(
712
+ parseDef(generic.args[0], resolve),
713
+ parseDef(generic.args[1], resolve),
714
+ generic.name === "Extract",
715
+ );
716
+ }
502
717
  if ((generic.name === "Partial" || generic.name === "Required") && generic.args.length === 1) {
503
718
  const object = resolveStructuralIR(parseDef(generic.args[0], resolve));
504
719
  if (object.k !== "object") throw new OmpTypeError(`${generic.name} requires an object`);
@@ -518,78 +733,88 @@ function parseGeneric(src: string, resolve?: AliasResolver): IR | undefined {
518
733
  return undefined;
519
734
  }
520
735
 
521
- function dateLiteral(source: string): Date {
522
- const value = new Date(source);
523
- if (!Number.isFinite(value.valueOf())) throw new OmpTypeError(`invalid Date literal "${source}"`);
524
- return value;
736
+ /**
737
+ * Subtype comparison lives in `type.ts` (it needs full traversal), so it is
738
+ * installed here at module load for the parser's `Extract`/`Exclude` support.
739
+ */
740
+ let isAssignable: (source: IR, target: IR) => boolean = () => false;
741
+
742
+ /** Install the assignability comparator used by `Extract`/`Exclude`. */
743
+ export function useAssignability(compare: (source: IR, target: IR) => boolean): void {
744
+ isAssignable = compare;
745
+ }
746
+
747
+ /**
748
+ * Distribute `base` over its union members, keeping those assignable to
749
+ * `target` (`keepAssignable`) or those that are not (`Exclude`).
750
+ */
751
+ export function distributeFilter(base: IR, target: IR, keepAssignable: boolean): IR {
752
+ const resolved = base.k === "alias" ? base.resolve() : base;
753
+ const members = resolved.k === "union" ? resolved.members : [resolved];
754
+ const retained = members.filter(member => isAssignable(member, target) === keepAssignable);
755
+ if (retained.length === 0) return { k: "never" };
756
+ return retained.length === 1 ? retained[0] : { k: "union", members: retained };
525
757
  }
526
758
 
527
- function parseDateExpression(src: string): IR | undefined {
528
- const first = src.charCodeAt(0);
529
- if (first !== 68 && first !== 100) return undefined;
530
- const literal = src.match(/^d(['"])(.*)\1$/);
531
- if (literal) return { k: "lit", v: dateLiteral(literal[2]) };
532
- const forward = src.match(/^Date\s*(<=|<|>=|>)\s*d(['"])(.*)\2$/);
533
- const reverse = src.match(/^d(['"])(.*)\1\s*(<=|<|>=|>)\s*Date$/);
534
- if (!forward && !reverse) return undefined;
535
- const bound = dateLiteral(forward ? forward[3] : (reverse?.[2] ?? ""));
536
- const operator = forward
537
- ? forward[1]
538
- : reverse?.[3] === "<="
539
- ? ">="
540
- : reverse?.[3] === "<"
541
- ? ">"
542
- : reverse?.[3] === ">="
543
- ? "<="
544
- : "<";
545
- const timestamp = bound.valueOf();
546
- const relation =
547
- operator === ">="
548
- ? "at or after"
549
- : operator === ">"
550
- ? "later than"
551
- : operator === "<="
552
- ? "at or before"
553
- : "earlier than";
759
+ /** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */
760
+ function parseRegexExec(src: string): IR | undefined {
761
+ if (!src.startsWith("x/")) return undefined;
762
+ const end = src.lastIndexOf("/");
763
+ if (end < 2) throw new OmpTypeError(`unterminated regular expression in "${src}"`);
764
+ let regex: RegExp;
765
+ try {
766
+ regex = new RegExp(src.slice(2, end), src.slice(end + 1));
767
+ } catch {
768
+ throw new OmpTypeError(`invalid regular expression "${src.slice(1)}"`);
769
+ }
554
770
  return {
555
- k: "refine",
556
- base: { k: "instance", ctor: Date, expected: "a Date" },
557
- pred: value => {
558
- if (!(value instanceof Date)) return false;
559
- const actual = value.valueOf();
560
- return operator === ">="
561
- ? actual >= timestamp
562
- : operator === ">"
563
- ? actual > timestamp
564
- : operator === "<="
565
- ? actual <= timestamp
566
- : actual < timestamp;
771
+ k: "morph",
772
+ input: patternIR(regex),
773
+ fn: (value, context) => {
774
+ regex.lastIndex = 0;
775
+ return regex.exec(value as string) ?? context.error(`a string matching ${regex}`);
567
776
  },
568
- expected: `a Date ${relation} ${bound.toISOString()}`,
569
- json: operator === ">=" || operator === ">" ? { minimum: bound.toISOString() } : { maximum: bound.toISOString() },
570
777
  };
571
778
  }
572
779
 
573
- /** Parse recurring global DSL fragments once; scoped aliases bypass the cache. */
574
780
  function parseStringDef(src: string, resolve?: AliasResolver): ParsedTop {
575
- if (resolve === undefined) {
781
+ const cacheable = resolve === undefined || (!src.includes("this") && THIS_ONLY_RESOLVERS.has(resolve));
782
+ if (cacheable) {
576
783
  const cached = stringDefCache.get(src);
577
784
  if (cached) return cached;
578
785
  }
579
- let ir = parseDateExpression(src) ?? parseLiteralUnion(src) ?? parseGeneric(src, resolve);
786
+ const pipeIndex = src.indexOf("|>");
787
+ if (pipeIndex >= 0) {
788
+ const input = src.slice(0, pipeIndex).trim();
789
+ const output = src.slice(pipeIndex + 2).trim();
790
+ if (input.length === 0 || output.length === 0) {
791
+ throw new OmpTypeError(`pipe expression requires operands in "${src}"`);
792
+ }
793
+ const parsedInput = parseStringDef(input, resolve);
794
+ if (parsedInput.hasDefault) {
795
+ throw new OmpTypeError(`unexpected pipe expression after default in "${src}"`);
796
+ }
797
+ const parsed: ParsedTop = {
798
+ ir: {
799
+ k: "morph",
800
+ input: parsedInput.ir,
801
+ fn: value => value,
802
+ out: parseStringDef(output, resolve).ir,
803
+ },
804
+ hasDefault: false,
805
+ optional: false,
806
+ };
807
+ if (cacheable && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed);
808
+ return parsed;
809
+ }
810
+ let ir = parseLiteralUnion(src) ?? parseRegexExec(src) ?? parseGeneric(src, resolve);
580
811
  if (ir === undefined && src.startsWith("`") && src.endsWith("`")) {
581
812
  ir = templateIR(src.slice(1, -1));
582
- } else if (ir === undefined && src.startsWith("/") && src.lastIndexOf("/") > 0) {
583
- const end = src.lastIndexOf("/");
584
- try {
585
- ir = patternIR(new RegExp(src.slice(1, end), src.slice(end + 1)));
586
- } catch {
587
- throw new OmpTypeError(`invalid regular expression "${src}"`);
588
- }
589
813
  }
814
+
590
815
  const parsed: ParsedTop =
591
816
  ir === undefined ? new StrParser(src, resolve).parseTop() : { ir, hasDefault: false, optional: false };
592
- if (resolve === undefined && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed);
817
+ if (cacheable && stringDefCache.size < STRING_DEF_CACHE_MAX) stringDefCache.set(src, parsed);
593
818
  return parsed;
594
819
  }
595
820
 
@@ -606,8 +831,43 @@ function flip(op: string): string {
606
831
  }
607
832
  }
608
833
 
609
- /** Apply `node CMP value` — numeric/string/array ranges or Date bounds. */
834
+ function applyEquality(node: IR, value: number | bigint | Date, src: string): IR {
835
+ if (node.k === "union") {
836
+ return { k: "union", members: node.members.map(member => applyEquality(member, value, src)) };
837
+ }
838
+ if (value instanceof Date) {
839
+ if (!acceptsDate(node)) throw new OmpTypeError(`Date equality requires Date in "${src}"`);
840
+ return { k: "lit", v: value };
841
+ }
842
+ if (node.k === "number" && typeof value === "number") return { k: "lit", v: value };
843
+ if (node.k === "bigint" && typeof value === "bigint") return { k: "lit", v: value };
844
+ if ((node.k === "string" || node.k === "array") && typeof value === "number") {
845
+ if (!Number.isInteger(value) || value < 0) {
846
+ throw new OmpTypeError(`exact length must be a non-negative integer in "${src}"`);
847
+ }
848
+ return { ...node, min: value, max: value };
849
+ }
850
+ throw new OmpTypeError(`equality literal is incompatible with ${node.k} in "${src}"`);
851
+ }
852
+
853
+ /**
854
+ * Apply `node CMP value` — numeric/string/array ranges or Date bounds.
855
+ * Copy-on-write: `node` may be shared (string-def cache, generic arguments,
856
+ * resolved aliases), so bounds land on a fresh node, never in place.
857
+ */
610
858
  function applyBound(node: IR, op: string, value: number | Date, src: string): IR {
859
+ if (node.k === "alias") return applyBound(node.resolve(), op, value, src);
860
+ if (node.k === "refine" && !(value instanceof Date)) {
861
+ return { ...node, base: applyBound(node.base, op, value, src) };
862
+ }
863
+ if (node.k === "union") {
864
+ const kinds = new Set(node.members.map(boundKind));
865
+ if (kinds.size !== 1) throw new OmpTypeError(`cannot apply one bound to multiple bound kinds in "${src}"`);
866
+ return { k: "union", members: node.members.map(member => applyBound(member, op, value, src)) };
867
+ }
868
+ if (!(value instanceof Date) && acceptsDate(node)) {
869
+ return applyBound(node, op, new Date(value), src);
870
+ }
611
871
  if (value instanceof Date) {
612
872
  if (!acceptsDate(node)) throw new OmpTypeError(`date bound requires Date in "${src}"`);
613
873
  const limit = value.valueOf();
@@ -625,42 +885,60 @@ function applyBound(node: IR, op: string, value: number | Date, src: string): IR
625
885
  };
626
886
  }
627
887
  if (node.k === "number") {
888
+ const bounded = { ...node };
628
889
  switch (op) {
629
890
  case ">=":
630
- node.min = value;
631
- node.xmin = false;
891
+ bounded.min = value;
892
+ bounded.xmin = false;
632
893
  break;
633
894
  case ">":
634
- node.min = value;
635
- node.xmin = true;
895
+ bounded.min = value;
896
+ bounded.xmin = true;
636
897
  break;
637
898
  case "<=":
638
- node.max = value;
639
- node.xmax = false;
899
+ bounded.max = value;
900
+ bounded.xmax = false;
640
901
  break;
641
902
  case "<":
642
- node.max = value;
643
- node.xmax = true;
903
+ bounded.max = value;
904
+ bounded.xmax = true;
644
905
  break;
645
906
  }
646
- return node;
907
+ if (
908
+ bounded.min !== undefined &&
909
+ bounded.max !== undefined &&
910
+ (bounded.min > bounded.max || (bounded.min === bounded.max && (bounded.xmin || bounded.xmax)))
911
+ ) {
912
+ throw new OmpTypeError(`numeric range is unsatisfiable in "${src}"`);
913
+ }
914
+ return bounded;
647
915
  }
648
916
  if (node.k === "string" || node.k === "array") {
917
+ if (!Number.isInteger(value) || value < 0) {
918
+ throw new OmpTypeError(`length bound must be a non-negative integer in "${src}"`);
919
+ }
920
+ const bounded = { ...node };
649
921
  switch (op) {
650
922
  case ">=":
651
- node.min = value;
923
+ bounded.min = value;
652
924
  break;
653
925
  case ">":
654
- node.min = value + 1;
926
+ bounded.min = value + 1;
655
927
  break;
656
928
  case "<=":
657
- node.max = value;
929
+ bounded.max = value;
658
930
  break;
659
931
  case "<":
660
- node.max = value - 1;
932
+ bounded.max = value - 1;
661
933
  break;
662
934
  }
663
- return node;
935
+ if (
936
+ (bounded.min !== undefined && bounded.max !== undefined && bounded.min > bounded.max) ||
937
+ (bounded.max ?? 0) < 0
938
+ ) {
939
+ throw new OmpTypeError(`length range is unsatisfiable in "${src}"`);
940
+ }
941
+ return bounded;
664
942
  }
665
943
  throw new OmpTypeError(`cannot bound ${node.k} in "${src}"`);
666
944
  }
@@ -677,12 +955,18 @@ function isEmbedded(def: unknown): def is EmbeddableSchema {
677
955
 
678
956
  /** Embed a schema value: inline pure structure, keep `sub` nodes for stepped schemas. */
679
957
  export function embed(schema: EmbeddableSchema): IR {
680
- if (schema.hasSteps) return { k: "sub", schema, desc: schema.description };
958
+ if (schema.hasSteps) return { k: "sub", schema, desc: schema.description, descAuto: schema.ir.desc === undefined };
681
959
  if (schema.description !== undefined && schema.ir.desc === undefined) {
682
- return { ...schema.ir, desc: schema.description };
960
+ return { ...schema.ir, desc: schema.description, descAuto: true };
683
961
  }
684
962
  return schema.ir;
685
963
  }
964
+ function boundKind(node: IR): "number" | "length" | "date" | undefined {
965
+ if (node.k === "number") return "number";
966
+ if (node.k === "string" || node.k === "array") return "length";
967
+ if (acceptsDate(node)) return "date";
968
+ return undefined;
969
+ }
686
970
 
687
971
  function isCallback(value: unknown): value is (input: unknown, context: MorphContext) => unknown {
688
972
  return typeof value === "function";
@@ -705,52 +989,137 @@ function parseTupleItem(def: unknown, resolve?: AliasResolver): TupleItemIR {
705
989
  hasDefault: true,
706
990
  };
707
991
  }
992
+ if (typeof def === "string") {
993
+ const parsed = parseStringDef(def, resolve);
994
+ return {
995
+ val: parsed.ir,
996
+ opt: parsed.optional || parsed.hasDefault,
997
+ def: parsed.def,
998
+ hasDefault: parsed.hasDefault,
999
+ };
1000
+ }
708
1001
  return { val: parseDef(def, resolve), opt: false };
709
1002
  }
710
1003
 
711
- function parseTuple(def: readonly unknown[], resolve?: AliasResolver): TupleIR {
712
- const prefix: TupleItemIR[] = [];
713
- const postfix: IR[] = [];
714
- let variadic: IR | undefined;
715
- let optionalSeen = false;
1004
+ function cloneTuple(tuple: TupleIR): TupleIR {
1005
+ return {
1006
+ ...tuple,
1007
+ prefix: tuple.prefix.map(item => ({ ...item })),
1008
+ postfix: [...tuple.postfix],
1009
+ };
1010
+ }
1011
+
1012
+ function hasOptionalPrefix(tuple: TupleIR): boolean {
1013
+ return tuple.prefix.some(item => item.opt || item.hasDefault === true);
1014
+ }
1015
+
1016
+ function appendTupleItem(tuple: TupleIR, item: TupleItemIR): void {
1017
+ if (tuple.variadic !== undefined) {
1018
+ if (item.opt || item.hasDefault) {
1019
+ throw new OmpTypeError("An optional element may not follow a variadic element");
1020
+ }
1021
+ if (hasOptionalPrefix(tuple)) {
1022
+ throw new OmpTypeError("A postfix required element cannot follow an optional or defaultable element");
1023
+ }
1024
+ tuple.postfix.push(item.val);
1025
+ return;
1026
+ }
1027
+ if (item.hasDefault && tuple.prefix.some(prefixItem => prefixItem.opt && !prefixItem.hasDefault)) {
1028
+ throw new OmpTypeError("A defaultable element may not follow an optional element without a default");
1029
+ }
1030
+ if (hasOptionalPrefix(tuple) && !item.opt) {
1031
+ throw new OmpTypeError("required tuple elements cannot follow optional elements");
1032
+ }
1033
+ tuple.prefix.push(item);
1034
+ }
1035
+
1036
+ function appendTuple(target: TupleIR, spread: TupleIR): void {
1037
+ if (target.variadic !== undefined && spread.variadic !== undefined) {
1038
+ throw new OmpTypeError("a tuple may have one spread followed by an array definition");
1039
+ }
1040
+ for (const item of spread.prefix) appendTupleItem(target, { ...item });
1041
+ if (spread.variadic !== undefined) {
1042
+ target.variadic = spread.variadic;
1043
+ }
1044
+ for (const item of spread.postfix) appendTupleItem(target, { val: item, opt: false });
1045
+ }
1046
+
1047
+ function spreadAlternatives(spread: IR): TupleIR[] {
1048
+ if (spread.k === "alias") return spreadAlternatives(spread.resolve());
1049
+ if (spread.k === "sub") return spreadAlternatives(spread.schema.ir);
1050
+ if (spread.k === "union") return spread.members.flatMap(spreadAlternatives);
1051
+ if (spread.k === "array") return [{ k: "tuple", prefix: [], variadic: spread.el, postfix: [] }];
1052
+ if (spread.k === "tuple") return [spread];
1053
+ throw new OmpTypeError("tuple spread element must be an array");
1054
+ }
1055
+
1056
+ function parseTuple(def: readonly unknown[], resolve?: AliasResolver): IR {
1057
+ let branches: TupleIR[] = [{ k: "tuple", prefix: [], postfix: [] }];
716
1058
  for (let index = 0; index < def.length; index++) {
717
1059
  if (def[index] === "...") {
718
- if (variadic !== undefined || index + 1 >= def.length) {
1060
+ if (index + 1 >= def.length) {
719
1061
  throw new OmpTypeError("a tuple may have one spread followed by an array definition");
720
1062
  }
721
- const spread = parseDef(def[++index], resolve);
722
- if (spread.k !== "array") throw new OmpTypeError("tuple spread element must be an array");
723
- variadic = spread.el;
724
- continue;
725
- }
726
- if (variadic !== undefined) {
727
- const item = parseTupleItem(def[index], resolve);
728
- if (item.opt || item.hasDefault) {
729
- throw new OmpTypeError("optional tuple elements cannot follow a variadic element");
1063
+ const alternatives = spreadAlternatives(parseDef(def[++index], resolve));
1064
+ const distributed: TupleIR[] = [];
1065
+ for (const branch of branches) {
1066
+ for (const alternative of alternatives) {
1067
+ const next = cloneTuple(branch);
1068
+ appendTuple(next, alternative);
1069
+ distributed.push(next);
1070
+ }
730
1071
  }
731
- postfix.push(item.val);
1072
+ branches = distributed;
732
1073
  continue;
733
1074
  }
734
1075
  const item = parseTupleItem(def[index], resolve);
735
- if (optionalSeen && !item.opt && !item.hasDefault) {
736
- throw new OmpTypeError("required tuple elements cannot follow optional elements");
737
- }
738
- optionalSeen ||= item.opt || item.hasDefault === true;
739
- prefix.push(item);
1076
+ for (const branch of branches) appendTupleItem(branch, { ...item });
740
1077
  }
741
- return { k: "tuple", prefix, variadic, postfix };
1078
+ return branches.length === 1 ? branches[0] : { k: "union", members: branches };
742
1079
  }
743
1080
 
744
1081
  /** Build the runtime schema for an object's or tuple's keys. */
745
1082
  export function keyOf(node: IR): IR {
1083
+ if (node.k === "alias") return keyOf(node.resolve());
1084
+ if (node.k === "sub") return keyOf(node.schema.ir);
1085
+ if (node.k === "refine") return keyOf(node.base);
1086
+ if (node.k === "intersection") {
1087
+ const members = node.members.flatMap(member => {
1088
+ const keys = keyOf(member);
1089
+ return keys.k === "union" ? keys.members : [keys];
1090
+ });
1091
+ return members.length === 1 ? members[0] : { k: "union", members };
1092
+ }
746
1093
  if (node.k === "object") {
747
1094
  const members: IR[] = node.props.map(prop => ({ k: "lit", v: prop.key }));
748
- if (node.index !== undefined) members.push({ k: "string" });
1095
+ if (node.index !== undefined || (node.patternIndexes?.length ?? 0) > 0) members.push({ k: "string" });
1096
+ if (node.symbolIndex !== undefined) members.push({ k: "symbol" });
749
1097
  if (members.length === 0) return { k: "never" };
750
1098
  return members.length === 1 ? members[0] : { k: "union", members };
751
1099
  }
752
1100
  if (node.k === "tuple") return { k: "number", int: true, min: 0 };
753
- throw new OmpTypeError(`keyof requires an object or tuple (was ${node.k})`);
1101
+ if (node.k === "union") {
1102
+ if (node.members.length === 0) return { k: "never" };
1103
+ const literalSets = node.members.map(member => {
1104
+ const keyed = keyOf(member);
1105
+ const literals = keyed.k === "union" ? keyed.members : [keyed];
1106
+ const keys = new Set<PropertyKey>();
1107
+ for (const literal of literals) {
1108
+ if (
1109
+ literal.k === "lit" &&
1110
+ (typeof literal.v === "string" || typeof literal.v === "number" || typeof literal.v === "symbol")
1111
+ ) {
1112
+ keys.add(literal.v);
1113
+ }
1114
+ }
1115
+ return keys;
1116
+ });
1117
+ const common = [...literalSets[0]].filter(key => literalSets.slice(1).every(keys => keys.has(key)));
1118
+ if (common.length === 0) throw new OmpTypeError("keyof operand must be an object");
1119
+ const members = common.map(value => ({ k: "lit", v: value }) satisfies IR);
1120
+ return members.length === 1 ? members[0] : { k: "union", members };
1121
+ }
1122
+ throw new OmpTypeError("keyof operand must be an object");
754
1123
  }
755
1124
 
756
1125
  function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver): IR {
@@ -761,7 +1130,11 @@ function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver):
761
1130
  for (let index = 1; index < def.length; index++) {
762
1131
  const ctor = def[index];
763
1132
  if (!isConstructor(ctor)) throw new OmpTypeError("instanceof operands must be constructors");
764
- members.push({ k: "instance", ctor, expected: `an instance of ${ctor.name || "the constructor"}` });
1133
+ members.push({
1134
+ k: "instance",
1135
+ ctor,
1136
+ expected: ctor === Error ? "an Error" : `an instance of ${ctor.name || "the constructor"}`,
1137
+ });
765
1138
  }
766
1139
  return members.length === 1 ? members[0] : { k: "union", members };
767
1140
  }
@@ -786,30 +1159,59 @@ function parseArrayExpression(def: readonly unknown[], resolve?: AliasResolver):
786
1159
  if (def.length === 3 && def[1] === ":") {
787
1160
  if (!isCallback(def[2])) throw new OmpTypeError("narrow operator requires a predicate");
788
1161
  const predicate = def[2];
1162
+ const name = predicate.name;
1163
+ const expected = name.length === 0 ? "valid according to an anonymous predicate" : `valid according to ${name}`;
789
1164
  return {
790
1165
  k: "refine",
791
1166
  base: parseDef(def[0], resolve),
792
- pred: value =>
793
- predicate(value, {
794
- error: () => OmpErrors.single([], "the predicate", value),
795
- reject: () => OmpErrors.single([], "the predicate", value),
796
- }) === true,
797
- expected: "a value satisfying the predicate",
1167
+ pred: value => {
1168
+ let errors: OmpErrors | undefined;
1169
+ const error = (
1170
+ input:
1171
+ | string
1172
+ | {
1173
+ expected: string;
1174
+ actual?: unknown;
1175
+ path?: readonly PropertyKey[];
1176
+ relativePath?: readonly PropertyKey[];
1177
+ },
1178
+ ): OmpErrors => {
1179
+ const detail = typeof input === "string" ? { expected: input } : input;
1180
+ const next = OmpErrors.single([...(detail.path ?? detail.relativePath ?? [])], detail.expected, value, {
1181
+ preserveActual: true,
1182
+ ...(Object.hasOwn(detail, "actual") ? { actual: String(detail.actual) } : {}),
1183
+ });
1184
+ if (errors) errors.append(next);
1185
+ else errors = next;
1186
+ return next;
1187
+ };
1188
+ const result = predicate(value, { error, reject: error });
1189
+ return errors ?? (result instanceof OmpErrors ? result : result === true);
1190
+ },
1191
+ expected,
798
1192
  };
799
1193
  }
800
1194
  if (def.length >= 3 && def[1] === "@") {
801
1195
  const base = parseDef(def[0], resolve);
802
1196
  const meta = def[2];
803
- if (typeof meta === "string") return { ...base, desc: meta };
804
- if (typeof meta === "object" && meta !== null && "description" in meta && typeof meta.description === "string") {
805
- return { ...base, desc: meta.description };
1197
+ if (typeof meta === "string") return { ...base, desc: meta, cfg: { ...base.cfg, expected: meta } };
1198
+ if (typeof meta === "object" && meta !== null) {
1199
+ const config = meta as ErrorConfig & { description?: string };
1200
+ return {
1201
+ ...base,
1202
+ cfg: {
1203
+ ...(typeof config.description === "string" ? { expected: config.description } : {}),
1204
+ ...config,
1205
+ },
1206
+ ...(typeof config.description === "string" ? { desc: config.description } : {}),
1207
+ };
806
1208
  }
807
1209
  return base;
808
1210
  }
809
1211
  return parseTuple(def, resolve);
810
1212
  }
811
1213
 
812
- function isObjectDefinition(def: unknown): def is Record<string, unknown> {
1214
+ function isObjectDefinition(def: unknown): def is Record<PropertyKey, unknown> {
813
1215
  return (
814
1216
  typeof def === "object" &&
815
1217
  def !== null &&
@@ -819,141 +1221,508 @@ function isObjectDefinition(def: unknown): def is Record<string, unknown> {
819
1221
  );
820
1222
  }
821
1223
 
822
- function parseObjectDefinition(def: Record<string, unknown>, resolve?: AliasResolver): IR {
1224
+ function spreadObjectOf(ir: IR): Extract<IR, { k: "object" }> | undefined {
1225
+ if (ir.k === "alias") return spreadObjectOf(ir.resolve());
1226
+ if (ir.k === "sub") return spreadObjectOf(ir.schema.ir);
1227
+ if (ir.k === "refine") return spreadObjectOf(ir.base);
1228
+ if (ir.k === "anyobject") return { k: "object", props: [], extras: "keep" };
1229
+ if (ir.k === "object") return ir;
1230
+ if (ir.k !== "intersection") return undefined;
1231
+ let result: Extract<IR, { k: "object" }> = { k: "object", props: [], extras: "keep" };
1232
+ for (const member of ir.members) {
1233
+ const object = spreadObjectOf(member);
1234
+ if (object === undefined) return undefined;
1235
+ result = mergeObjectIR(result, object) as Extract<IR, { k: "object" }>;
1236
+ }
1237
+ return result;
1238
+ }
1239
+
1240
+ function indexKeyKind(
1241
+ key: IR,
1242
+ value: IR,
1243
+ props: PropIR[],
1244
+ indexes: {
1245
+ string?: IR;
1246
+ symbol?: IR;
1247
+ patterns: { key: IR; val: IR }[];
1248
+ },
1249
+ ): void {
1250
+ if (key.k === "alias") return indexKeyKind(key.resolve(), value, props, indexes);
1251
+ if (key.k === "union") {
1252
+ for (const member of key.members) indexKeyKind(member, value, props, indexes);
1253
+ return;
1254
+ }
1255
+ if (key.k === "lit" && (typeof key.v === "string" || typeof key.v === "symbol")) {
1256
+ props.push({ key: key.v, opt: false, val: value });
1257
+ return;
1258
+ }
1259
+ if (key.k === "string") {
1260
+ indexes.string = value;
1261
+ return;
1262
+ }
1263
+ if (key.k === "symbol") {
1264
+ indexes.symbol = value;
1265
+ return;
1266
+ }
1267
+ if (key.k === "refine" && key.base.k === "string") {
1268
+ indexes.patterns.push({ key, val: value });
1269
+ return;
1270
+ }
1271
+ throw new OmpTypeError(`indexed key definition must resolve to a string or symbol (was ${expectedOf(key)})`);
1272
+ }
1273
+
1274
+ function addObjectProp(props: PropIR[], spreadKeys: Set<PropertyKey> | undefined, prop: PropIR): void {
1275
+ if (spreadKeys === undefined) {
1276
+ props.push(prop);
1277
+ return;
1278
+ }
1279
+ const previous = props.findIndex(candidate => candidate.key === prop.key);
1280
+ if (previous < 0) {
1281
+ props.push(prop);
1282
+ return;
1283
+ }
1284
+ if (!spreadKeys.delete(prop.key)) throw new OmpTypeError(`duplicate object key ${String(prop.key)}`);
1285
+ props[previous] = prop;
1286
+ }
1287
+
1288
+ function parseObjectDefinition(def: Record<PropertyKey, unknown>, resolve?: AliasResolver): IR {
823
1289
  const props: PropIR[] = [];
824
- let index: IR | undefined;
1290
+ let spreadKeys: Set<PropertyKey> | undefined;
1291
+ let normalizedKey: PropertyKey | undefined;
1292
+ let normalizedKeys: PropertyKey[] | undefined;
1293
+ let indexes:
1294
+ | {
1295
+ string?: IR;
1296
+ symbol?: IR;
1297
+ patterns: { key: IR; val: IR }[];
1298
+ }
1299
+ | undefined;
825
1300
  let extras: Extras = "keep";
826
- for (const rawKey in def) {
827
- const val = def[rawKey];
828
- if (rawKey === "+") {
829
- if (val === "reject" || val === "delete") extras = val;
830
- else if (val === "ignore") extras = "keep";
1301
+ let simple = true;
1302
+ for (const originalKey in def) {
1303
+ if (!Object.hasOwn(def, originalKey)) continue;
1304
+ const val = def[originalKey];
1305
+ if (originalKey === "+") {
1306
+ if (val === "reject" || val === "delete") {
1307
+ extras = val;
1308
+ if (val === "delete") simple = false;
1309
+ } else if (val === "ignore") extras = "keep";
831
1310
  else throw new OmpTypeError(`bad "+" value ${String(val)}`);
832
1311
  continue;
833
1312
  }
834
- if (rawKey === "...") {
835
- const spread = parseDef(val, resolve);
836
- if (spread.k !== "object") throw new OmpTypeError("object spread must resolve to an object");
1313
+ if (originalKey === "...") {
1314
+ const parsed = parseDef(val, resolve);
1315
+ const spread = spreadObjectOf(parsed);
1316
+ if (spread === undefined) {
1317
+ throw new OmpTypeError(`object spread must resolve to an object literal (was ${expectedOf(parsed)})`);
1318
+ }
1319
+ if (simple && !isSimpleIR(spread)) simple = false;
1320
+ spreadKeys ??= new Set();
837
1321
  for (const prop of spread.props) {
838
1322
  const previous = props.findIndex(candidate => candidate.key === prop.key);
839
1323
  if (previous < 0) props.push(prop);
840
1324
  else props[previous] = prop;
1325
+ spreadKeys.add(prop.key);
1326
+ }
1327
+ if (spread.index !== undefined || spread.symbolIndex !== undefined || spread.patternIndexes !== undefined) {
1328
+ const objectIndexes = indexes ?? { patterns: [] };
1329
+ indexes = objectIndexes;
1330
+ objectIndexes.string ??= spread.index;
1331
+ objectIndexes.symbol ??= spread.symbolIndex;
1332
+ if (spread.patternIndexes !== undefined) objectIndexes.patterns.push(...spread.patternIndexes);
841
1333
  }
842
- index ??= spread.index;
843
1334
  if (spread.extras !== "keep") extras = spread.extras;
844
1335
  continue;
845
1336
  }
846
- if (rawKey === "[string]") {
847
- index = parseDef(val, resolve);
1337
+ if (typeof originalKey === "string" && originalKey.startsWith("[") && originalKey.endsWith("]")) {
1338
+ let value: IR;
1339
+ if (typeof val === "string") {
1340
+ const parsed = parseStringDef(val, resolve);
1341
+ if (parsed.hasDefault) throw new OmpTypeError("index signatures cannot specify a default");
1342
+ value = parsed.ir;
1343
+ } else {
1344
+ if (Array.isArray(val) && val.length === 3 && val[1] === "=") {
1345
+ throw new OmpTypeError("index signatures cannot specify a default");
1346
+ }
1347
+ value = parseDef(val, resolve);
1348
+ if (isEmbedded(val) && val.hasDefault) {
1349
+ throw new OmpTypeError("index signatures cannot specify a default");
1350
+ }
1351
+ }
1352
+ const keyDefinition = originalKey.slice(1, -1);
1353
+ const regex = /^\/((?:\\.|[^\\/])*)\/([dgimsuvy]*)$/.exec(keyDefinition);
1354
+ let key: IR;
1355
+ if (regex === null) {
1356
+ key = parseDef(keyDefinition, resolve);
1357
+ } else {
1358
+ try {
1359
+ key = patternIR(new RegExp(regex[1], regex[2]));
1360
+ } catch {
1361
+ throw new OmpTypeError(`invalid index signature pattern ${keyDefinition}`);
1362
+ }
1363
+ }
1364
+ const objectIndexes = indexes ?? { patterns: [] };
1365
+ indexes = objectIndexes;
1366
+ indexKeyKind(key, value, props, objectIndexes);
1367
+ if (simple && (!isSimpleIR(key) || !isSimpleIR(value))) simple = false;
848
1368
  continue;
849
1369
  }
850
- const opt = rawKey.charCodeAt(rawKey.length - 1) === 63;
1370
+ const escapedOptional = typeof originalKey === "string" && originalKey.endsWith("\\?");
1371
+ const escapedMeta =
1372
+ typeof originalKey === "string" &&
1373
+ (originalKey === "\\+" || originalKey === "\\..." || originalKey.startsWith("\\["));
1374
+ const rawKey = escapedOptional
1375
+ ? `${originalKey.slice(0, -2)}?`
1376
+ : escapedMeta
1377
+ ? originalKey.slice(1)
1378
+ : originalKey;
1379
+ const opt = typeof rawKey === "string" && !escapedOptional && !escapedMeta && rawKey.endsWith("?");
851
1380
  const key = opt ? rawKey.slice(0, -1) : rawKey;
1381
+ let prop: PropIR;
852
1382
  if (typeof val === "string") {
853
1383
  const parsed = parseStringDef(val, resolve);
854
- const prop: PropIR = { key, opt: opt || parsed.optional, val: parsed.ir };
1384
+ prop = { key, opt: opt || parsed.optional, val: parsed.ir };
855
1385
  if (parsed.hasDefault) {
856
1386
  prop.def = parsed.def;
857
1387
  prop.hasDefault = true;
858
1388
  }
859
- props.push(prop);
860
1389
  } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") {
861
- props.push({ key, opt: true, val: parseDef(val[0], resolve) });
1390
+ prop = { key, opt: true, val: parseDef(val[0], resolve) };
862
1391
  } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") {
863
- props.push({
1392
+ prop = {
864
1393
  key,
865
1394
  opt,
866
1395
  val: parseDef(val[0], resolve),
867
1396
  def: val[2],
868
1397
  defFactory: typeof val[2] === "function",
869
1398
  hasDefault: true,
870
- });
1399
+ };
871
1400
  } else if (isEmbedded(val)) {
872
- if (val.hasDefault) {
873
- props.push({
874
- key,
875
- opt,
876
- val: embed(val),
877
- def: val.defaultValue,
878
- defFactory: typeof val.defaultValue === "function",
879
- hasDefault: true,
880
- });
881
- } else {
882
- props.push({ key, opt, val: embed(val) });
1401
+ prop = val.hasDefault
1402
+ ? {
1403
+ key,
1404
+ opt,
1405
+ val: embed(val),
1406
+ def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue,
1407
+ defFactory: typeof val.defaultValue === "function",
1408
+ hasDefault: true,
1409
+ defValidated: val.hasDefaultOutput,
1410
+ }
1411
+ : { key, opt, val: embed(val) };
1412
+ } else {
1413
+ prop = {
1414
+ key,
1415
+ opt,
1416
+ val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve),
1417
+ };
1418
+ }
1419
+ if (key !== originalKey) {
1420
+ if (!spreadKeys?.has(key) && props.some(candidate => candidate.key === key)) {
1421
+ throw new OmpTypeError(`duplicate object key ${String(key)}`);
1422
+ }
1423
+ if (normalizedKey === undefined) normalizedKey = key;
1424
+ else {
1425
+ normalizedKeys ??= [normalizedKey];
1426
+ normalizedKeys.push(key);
883
1427
  }
884
- } else if (isObjectDefinition(val)) {
885
- props.push({ key, opt, val: parseObjectDefinition(val, resolve) });
1428
+ } else if (!spreadKeys?.has(key) && (key === normalizedKey || normalizedKeys?.includes(key))) {
1429
+ throw new OmpTypeError(`duplicate object key ${String(key)}`);
1430
+ }
1431
+ if (opt && prop.hasDefault) throw new OmpTypeError(`optional key ${String(key)} cannot specify a default`);
1432
+ if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false;
1433
+ addObjectProp(props, spreadKeys, prop);
1434
+ }
1435
+ for (const key of Object.getOwnPropertySymbols(def)) {
1436
+ if (!Object.prototype.propertyIsEnumerable.call(def, key)) continue;
1437
+ const val = def[key];
1438
+ let prop: PropIR;
1439
+ if (typeof val === "string") {
1440
+ const parsed = parseStringDef(val, resolve);
1441
+ prop = { key, opt: parsed.optional, val: parsed.ir };
1442
+ if (parsed.hasDefault) {
1443
+ prop.def = parsed.def;
1444
+ prop.hasDefault = true;
1445
+ }
1446
+ } else if (Array.isArray(val) && val.length === 2 && val[1] === "?") {
1447
+ prop = { key, opt: true, val: parseDef(val[0], resolve) };
1448
+ } else if (Array.isArray(val) && val.length === 3 && val[1] === "=") {
1449
+ prop = {
1450
+ key,
1451
+ opt: false,
1452
+ val: parseDef(val[0], resolve),
1453
+ def: val[2],
1454
+ defFactory: typeof val[2] === "function",
1455
+ hasDefault: true,
1456
+ };
1457
+ } else if (isEmbedded(val)) {
1458
+ prop = val.hasDefault
1459
+ ? {
1460
+ key,
1461
+ opt: false,
1462
+ val: embed(val),
1463
+ def: val.hasDefaultOutput ? val.defaultOutput : val.defaultValue,
1464
+ defFactory: typeof val.defaultValue === "function",
1465
+ hasDefault: true,
1466
+ defValidated: val.hasDefaultOutput,
1467
+ }
1468
+ : { key, opt: false, val: embed(val) };
886
1469
  } else {
887
- props.push({ key, opt, val: parseDef(val, resolve) });
1470
+ prop = {
1471
+ key,
1472
+ opt: false,
1473
+ val: isObjectDefinition(val) ? parseObjectDefinition(val, resolve) : parseDef(val, resolve),
1474
+ };
888
1475
  }
1476
+ if (simple && (prop.hasDefault || !isSimpleIR(prop.val))) simple = false;
1477
+ addObjectProp(props, spreadKeys, prop);
889
1478
  }
890
- return { k: "object", props, index, extras };
1479
+ const object: IR = {
1480
+ k: "object",
1481
+ props,
1482
+ index: indexes?.string,
1483
+ symbolIndex: indexes?.symbol,
1484
+ patternIndexes: indexes === undefined || indexes.patterns.length === 0 ? undefined : indexes.patterns,
1485
+ extras,
1486
+ };
1487
+ object[kSimple] = simple;
1488
+ object[kSimpleOwner] = object;
1489
+ return object;
891
1490
  }
892
1491
 
893
1492
  /** Parse a definition, optionally resolving names from an enclosing scope. */
894
1493
  export function parseDef(def: unknown, resolve?: AliasResolver): IR {
895
1494
  if (typeof def === "string") {
896
1495
  const parsed = parseStringDef(def, resolve);
1496
+ if (parsed.hasDefault) {
1497
+ throw new OmpTypeError("A default may only be specified for an object property or tuple element");
1498
+ }
897
1499
  if (parsed.optional) {
898
1500
  throw new OmpTypeError(`optional "?" marker is only valid on object property values`);
899
1501
  }
900
1502
  return parsed.ir;
901
1503
  }
902
- if (Array.isArray(def)) return parseArrayExpression(def, resolve);
1504
+ if (Array.isArray(def)) {
1505
+ if (def.length === 3 && def[1] === "=") {
1506
+ throw new OmpTypeError("A default may only be specified for an object property or tuple element");
1507
+ }
1508
+ return parseArrayExpression(def, resolve);
1509
+ }
903
1510
  if (def instanceof RegExp) return patternIR(def);
904
1511
  if (def instanceof Date) return { k: "lit", v: def };
905
1512
  if (isEmbedded(def)) return embed(def);
1513
+ if (typeof def === "function") {
1514
+ const resolved = Reflect.apply(def, undefined, []);
1515
+ if (!isEmbedded(resolved)) {
1516
+ throw new OmpTypeError(`thunk must return a Type (was ${typeof resolved})`);
1517
+ }
1518
+ return embed(resolved);
1519
+ }
906
1520
  if (isObjectDefinition(def)) return parseObjectDefinition(def, resolve);
907
- throw new OmpTypeError(`unsupported definition ${String(def)}`);
1521
+ throw new OmpTypeError(`unsupported definition ${String(def)} (was ${typeof def})`);
1522
+ }
1523
+
1524
+ /** Whether `ir` needs no construction-time normalization or morph analysis. */
1525
+ export function isSimpleIR(ir: IR): boolean {
1526
+ const cached = ir[kSimpleOwner] === ir ? ir[kSimple] : undefined;
1527
+ if (cached !== undefined) return cached;
1528
+ const simple = scanSimpleIR(ir);
1529
+ ir[kSimple] = simple;
1530
+ ir[kSimpleOwner] = ir;
1531
+ return simple;
1532
+ }
1533
+
1534
+ function scanSimpleIR(ir: IR): boolean {
1535
+ switch (ir.k) {
1536
+ case "intersection":
1537
+ case "morph":
1538
+ case "sub":
1539
+ case "alias":
1540
+ return false;
1541
+ case "refine":
1542
+ return scanSimpleIR(ir.base);
1543
+ case "union":
1544
+ if (ir.members.length < 2) return false;
1545
+ if (
1546
+ ir.members.length === 2 &&
1547
+ ir.members.every(member => member.k === "lit" && typeof member.v === "boolean")
1548
+ ) {
1549
+ return false;
1550
+ }
1551
+ for (let index = 0; index < ir.members.length; index++) {
1552
+ const member = ir.members[index];
1553
+ if (
1554
+ member.k !== "lit" ||
1555
+ (member.v !== null && (typeof member.v === "object" || typeof member.v === "function"))
1556
+ ) {
1557
+ return false;
1558
+ }
1559
+ for (let previous = 0; previous < index; previous++) {
1560
+ const candidate = ir.members[previous];
1561
+ if (candidate.k === "lit" && candidate.v === member.v) return false;
1562
+ }
1563
+ }
1564
+ return true;
1565
+ case "array":
1566
+ return scanSimpleIR(ir.el);
1567
+ case "tuple":
1568
+ for (const item of ir.prefix) {
1569
+ if (item.hasDefault || !scanSimpleIR(item.val)) return false;
1570
+ }
1571
+ if (ir.variadic !== undefined && !scanSimpleIR(ir.variadic)) return false;
1572
+ for (const item of ir.postfix) if (!scanSimpleIR(item)) return false;
1573
+ return true;
1574
+ case "object":
1575
+ if (ir.extras === "delete") return false;
1576
+ for (const prop of ir.props) {
1577
+ if (prop.hasDefault || !scanSimpleIR(prop.val)) return false;
1578
+ }
1579
+ if (ir.index !== undefined && !scanSimpleIR(ir.index)) return false;
1580
+ if (ir.symbolIndex !== undefined && !scanSimpleIR(ir.symbolIndex)) return false;
1581
+ if (ir.patternIndexes !== undefined) {
1582
+ for (const pattern of ir.patternIndexes) {
1583
+ if (!scanSimpleIR(pattern.key) || !scanSimpleIR(pattern.val)) return false;
1584
+ }
1585
+ }
1586
+ return true;
1587
+ default:
1588
+ return true;
1589
+ }
908
1590
  }
909
1591
 
910
1592
  /** True when validating `ir` can produce an output different from its input. */
911
1593
  export function hasMorph(ir: IR): boolean {
912
- const cached = ir[kMorph];
1594
+ const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined;
1595
+ if (cached !== undefined) return cached;
1596
+ const result = scanMorph(ir);
1597
+ ir[kMorph] = result;
1598
+ ir[kMorphOwner] = ir;
1599
+ return result;
1600
+ }
1601
+
1602
+ function scanMorph(ir: IR, activeAliases?: Set<IR>): boolean {
1603
+ const cached = ir[kMorphOwner] === ir ? ir[kMorph] : undefined;
913
1604
  if (cached !== undefined) return cached;
914
1605
 
915
1606
  let result = false;
916
1607
  switch (ir.k) {
917
1608
  case "sub":
918
- result = true;
919
- break;
920
1609
  case "morph":
921
- case "alias":
922
1610
  result = true;
923
1611
  break;
1612
+ case "alias": {
1613
+ if (activeAliases?.has(ir)) return false;
1614
+ const aliases = activeAliases ?? new Set<IR>();
1615
+ aliases.add(ir);
1616
+ result = scanMorph(ir.resolve(), aliases);
1617
+ aliases.delete(ir);
1618
+ return result;
1619
+ }
924
1620
  case "object":
925
1621
  result = ir.extras === "delete";
926
- for (let i = 0; !result && i < ir.props.length; i++) {
927
- const prop = ir.props[i];
928
- result = prop.hasDefault === true || hasMorph(prop.val);
1622
+ for (let index = 0; !result && index < ir.props.length; index++) {
1623
+ const prop = ir.props[index];
1624
+ result = prop.hasDefault === true || scanMorph(prop.val, activeAliases);
1625
+ }
1626
+ if (!result && ir.index !== undefined) result = scanMorph(ir.index, activeAliases);
1627
+ if (!result && ir.symbolIndex !== undefined) result = scanMorph(ir.symbolIndex, activeAliases);
1628
+ if (!result && ir.patternIndexes !== undefined) {
1629
+ for (const pattern of ir.patternIndexes) {
1630
+ if (scanMorph(pattern.val, activeAliases)) {
1631
+ result = true;
1632
+ break;
1633
+ }
1634
+ }
929
1635
  }
930
- if (!result && ir.index !== undefined) result = hasMorph(ir.index);
931
1636
  break;
932
1637
  case "array":
933
- result = hasMorph(ir.el);
1638
+ result = scanMorph(ir.el, activeAliases);
934
1639
  break;
935
1640
  case "union":
936
- result = ir.members.some(hasMorph);
937
- break;
938
1641
  case "intersection":
939
- result = ir.members.some(hasMorph);
1642
+ for (const member of ir.members) {
1643
+ if (scanMorph(member, activeAliases)) {
1644
+ result = true;
1645
+ break;
1646
+ }
1647
+ }
940
1648
  break;
941
1649
  case "refine":
942
- result = hasMorph(ir.base);
1650
+ result = scanMorph(ir.base, activeAliases);
943
1651
  break;
944
1652
  case "tuple":
945
- result =
946
- ir.prefix.some(item => item.hasDefault === true || hasMorph(item.val)) ||
947
- (ir.variadic !== undefined && hasMorph(ir.variadic)) ||
948
- ir.postfix.some(hasMorph);
1653
+ for (const item of ir.prefix) {
1654
+ if (item.hasDefault === true || scanMorph(item.val, activeAliases)) {
1655
+ result = true;
1656
+ break;
1657
+ }
1658
+ }
1659
+ if (!result && ir.variadic !== undefined) result = scanMorph(ir.variadic, activeAliases);
1660
+ if (!result) {
1661
+ for (const item of ir.postfix) {
1662
+ if (scanMorph(item, activeAliases)) {
1663
+ result = true;
1664
+ break;
1665
+ }
1666
+ }
1667
+ }
949
1668
  break;
950
1669
  }
951
- ir[kMorph] = result;
952
1670
  return result;
953
1671
  }
954
1672
 
1673
+ /**
1674
+ * True when a traversal of `ir` can revisit nodes through recursive aliases,
1675
+ * requiring cycle guards in the interpreter. Embedded sub-schemas run their
1676
+ * own guarded traversal and are intentionally not inspected.
1677
+ */
1678
+ export function hasAlias(ir: IR): boolean {
1679
+ const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined;
1680
+ if (cached !== undefined) return cached;
1681
+ const result = scanAlias(ir);
1682
+ ir[kAlias] = result;
1683
+ ir[kAliasOwner] = ir;
1684
+ return result;
1685
+ }
1686
+
1687
+ function scanAlias(ir: IR): boolean {
1688
+ const cached = ir[kAliasOwner] === ir ? ir[kAlias] : undefined;
1689
+ if (cached !== undefined) return cached;
1690
+ switch (ir.k) {
1691
+ case "alias":
1692
+ return true;
1693
+ case "object":
1694
+ for (const prop of ir.props) if (scanAlias(prop.val)) return true;
1695
+ if (ir.index !== undefined && scanAlias(ir.index)) return true;
1696
+ if (ir.symbolIndex !== undefined && scanAlias(ir.symbolIndex)) return true;
1697
+ if (ir.patternIndexes !== undefined) {
1698
+ for (const pattern of ir.patternIndexes) {
1699
+ if (scanAlias(pattern.key) || scanAlias(pattern.val)) return true;
1700
+ }
1701
+ }
1702
+ return false;
1703
+ case "array":
1704
+ return scanAlias(ir.el);
1705
+ case "tuple":
1706
+ for (const item of ir.prefix) if (scanAlias(item.val)) return true;
1707
+ if (ir.variadic !== undefined && scanAlias(ir.variadic)) return true;
1708
+ for (const item of ir.postfix) if (scanAlias(item)) return true;
1709
+ return false;
1710
+ case "union":
1711
+ case "intersection":
1712
+ for (const member of ir.members) if (scanAlias(member)) return true;
1713
+ return false;
1714
+ case "refine":
1715
+ return scanAlias(ir.base);
1716
+ case "morph":
1717
+ return scanAlias(ir.input) || (ir.out !== undefined && scanAlias(ir.out));
1718
+ default:
1719
+ return false;
1720
+ }
1721
+ }
1722
+
955
1723
  /** Human-readable expectation for error messages, e.g. `"a string"`. */
956
1724
  export function expectedOf(ir: IR): string {
1725
+ if (ir.desc !== undefined) return ir.desc;
957
1726
  switch (ir.k) {
958
1727
  case "unknown":
959
1728
  return "unknown";
@@ -1020,8 +1789,8 @@ export function expectedOf(ir: IR): string {
1020
1789
  case "morph":
1021
1790
  return expectedOf(ir.input);
1022
1791
  case "alias":
1023
- return ir.name;
1792
+ return ir.name === "this" ? expectedOf(ir.resolve()) : ir.name;
1024
1793
  case "sub":
1025
- return expectedOf(ir.schema.ir);
1794
+ return ir.desc ?? ir.schema.description ?? expectedOf(ir.schema.ir);
1026
1795
  }
1027
1796
  }