@zmdb/validator 1.0.0-beta.1

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.
@@ -0,0 +1,694 @@
1
+ // The runtime half of the validator: one walk over `TypeIR`.
2
+ //
3
+ // This is what runs before the build has transformed anything — `vitest`, `tsx`, a REPL
4
+ // — and what a call site falls back to when the emitter refused its type. So it is not a
5
+ // toy: REQ-AV-4 requires it to accept and reject **exactly** what the emitted code
6
+ // accepts and rejects, and to report the same issue at the same path. A fallback that
7
+ // disagrees with the compiled form is worse than no fallback, because the disagreement
8
+ // only shows up in production.
9
+ //
10
+ // Two things make that achievable rather than aspirational:
11
+ //
12
+ // 1. **One vocabulary.** Both walks read `TypeIR`, and it is the only shape either of
13
+ // them accepts. This file used to walk its own `TypeDescriptor` — a hand-written
14
+ // mirror of a type, in a form nothing checked against the type it claimed to describe
15
+ // — which is why the two paths had drifted into three divergences by the time anyone
16
+ // measured: the emitted object check accepted an array, the emitted number check
17
+ // accepted `NaN`, and the runtime pattern check threw above 10 000 characters. The
18
+ // descriptor and the `toIR` bridge that normalised it are both gone.
19
+ // 2. **One set of decisions.** Every `expected` string, and the question of whether a
20
+ // union has a discriminant, comes from `@zmdb/schema/ir` — imported by the
21
+ // compiler emitter too. Those are the parts that would otherwise drift.
22
+ //
23
+ // The differential suite (`differential.spec.ts`) feeds both paths the same corpora and
24
+ // asserts the two answers are identical, so the claim above is measured.
25
+ import { discriminantOf, expectedForConstraint, expectedForDiscriminant, expectedOf, hasExcessCheck, messageFor, } from '@zmdb/schema/ir';
26
+ import { failWith } from '../errors.js';
27
+ import {} from '../index.js';
28
+ import { getCachedRegExp } from '../regex-complexity.js';
29
+ export { AssertError, failWith } from '../errors.js';
30
+ /** True for a non-null, non-array object — proves a keyed read is safe. */
31
+ function isRecord(value) {
32
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
33
+ }
34
+ const REF_TABLES = new WeakMap();
35
+ const NO_REFS = new Map();
36
+ function childDepth(depth) {
37
+ return depth === undefined ? undefined : Math.max(0, depth - 1);
38
+ }
39
+ function collectRefs(node, into) {
40
+ switch (node.kind) {
41
+ case 'object':
42
+ if (node.name !== undefined) {
43
+ if (into.has(node.name))
44
+ return;
45
+ into.set(node.name, node);
46
+ }
47
+ for (const property of node.properties)
48
+ collectRefs(property.type, into);
49
+ return;
50
+ case 'array':
51
+ collectRefs(node.element, into);
52
+ return;
53
+ case 'tuple':
54
+ for (const element of node.elements)
55
+ collectRefs(element, into);
56
+ return;
57
+ case 'union':
58
+ for (const member of node.members)
59
+ collectRefs(member, into);
60
+ return;
61
+ default:
62
+ return;
63
+ }
64
+ }
65
+ function refsOf(root) {
66
+ const cached = REF_TABLES.get(root);
67
+ if (cached)
68
+ return cached;
69
+ const table = new Map();
70
+ collectRefs(root, table);
71
+ const result = table.size === 0 ? NO_REFS : table;
72
+ REF_TABLES.set(root, result);
73
+ return result;
74
+ }
75
+ // ---------------------------------------------------------------------------
76
+ // check
77
+ // ---------------------------------------------------------------------------
78
+ /**
79
+ * The shape half of a scalar test, without its bounds. Mirrors `scalarBase` in the
80
+ * emitter line for line; both reject `NaN` and an invalid `Date`.
81
+ */
82
+ function scalarMatches(scalar, value) {
83
+ switch (scalar) {
84
+ case 'string':
85
+ return typeof value === 'string';
86
+ case 'number':
87
+ return typeof value === 'number' && !Number.isNaN(value);
88
+ case 'integer':
89
+ return Number.isInteger(value);
90
+ case 'bigint':
91
+ return typeof value === 'bigint';
92
+ case 'boolean':
93
+ return typeof value === 'boolean';
94
+ case 'date':
95
+ return value instanceof Date && !Number.isNaN(value.getTime());
96
+ }
97
+ }
98
+ /**
99
+ * Bounds, in the order the emitter emits them.
100
+ *
101
+ * A pattern is tested with a plain cached `RegExp` and **no input-length cap**. The old
102
+ * `safeTestPattern` threw above 10 000 characters, which the emitted form — a literal
103
+ * `/re/.test(v)` — has no way to reproduce, so the cap was a divergence disguised as a
104
+ * safety feature. It also guarded the wrong boundary: a pattern comes from the author's
105
+ * own `Pattern<…>` tag and is complexity-checked at build time, so the untrusted side is
106
+ * the input, and refusing to answer about a long input is not a safe answer.
107
+ *
108
+ * boundary: every cast here is a comparison against a value whose kind the scalar check has
109
+ * already established — a constraint only exists on a node that carries one — and each is
110
+ * written as the negation of the passing comparison rather than as the failing one. That is
111
+ * what makes the casts sound *and* unnecessary to defend: `!(x >= min)` is `true` for a
112
+ * value that is not a number at all, because the comparison is `false`, so a wrong kind
113
+ * reaching here fails the constraint instead of passing it. Writing `x < min` instead would
114
+ * be the same expression with the opposite answer for `NaN`.
115
+ */
116
+ function constraintsMatch(constraints, value) {
117
+ if (!constraints)
118
+ return true;
119
+ if (constraints.minimum !== undefined && !(value >= constraints.minimum))
120
+ return false;
121
+ if (constraints.maximum !== undefined && !(value <= constraints.maximum))
122
+ return false;
123
+ const length = value.length;
124
+ if (constraints.minLength !== undefined && !(length >= constraints.minLength))
125
+ return false;
126
+ if (constraints.maxLength !== undefined && !(length <= constraints.maxLength))
127
+ return false;
128
+ if (constraints.pattern !== undefined && !getCachedRegExp(constraints.pattern).test(value))
129
+ return false;
130
+ return true;
131
+ }
132
+ function matches(value, node, refs, depth) {
133
+ switch (node.kind) {
134
+ case 'unknown':
135
+ return true;
136
+ case 'null':
137
+ return value === null;
138
+ case 'undefined':
139
+ return value === undefined;
140
+ case 'literal':
141
+ return value === node.value;
142
+ case 'scalar':
143
+ return scalarMatches(node.scalar, value) && constraintsMatch(node.constraints, value);
144
+ case 'array': {
145
+ if (!Array.isArray(value))
146
+ return false;
147
+ if (!constraintsMatch(node.constraints, value))
148
+ return false;
149
+ if (depth !== undefined && depth <= 1)
150
+ return true;
151
+ const nestedDepth = childDepth(depth);
152
+ for (const item of value)
153
+ if (!matches(item, node.element, refs, nestedDepth))
154
+ return false;
155
+ return true;
156
+ }
157
+ case 'tuple': {
158
+ if (!Array.isArray(value) || value.length !== node.elements.length)
159
+ return false;
160
+ if (depth !== undefined && depth <= 1)
161
+ return true;
162
+ const nestedDepth = childDepth(depth);
163
+ for (const [index, element] of node.elements.entries()) {
164
+ if (!matches(value[index], element, refs, nestedDepth))
165
+ return false;
166
+ }
167
+ return true;
168
+ }
169
+ case 'object':
170
+ return objectMatches(value, node, refs, undefined, depth);
171
+ case 'union':
172
+ return unionMatches(value, node, refs, depth);
173
+ case 'ref': {
174
+ const target = refs.get(node.name);
175
+ return target ? objectMatches(value, target, refs, undefined, depth) : false;
176
+ }
177
+ case 'unsupported':
178
+ // The emitter refuses to compile one of these, so the only way to be here is a
179
+ // reflection refused it. Nothing satisfies a type we cannot describe.
180
+ return false;
181
+ }
182
+ }
183
+ /** `skip` is a discriminant the union has already established. */
184
+ function objectMatches(value, node, refs, skip, depth) {
185
+ if (!isRecord(value))
186
+ return false;
187
+ if (depth === 0)
188
+ return true;
189
+ const nestedDepth = childDepth(depth);
190
+ for (const property of node.properties) {
191
+ if (property.name === skip)
192
+ continue;
193
+ const member = value[property.name];
194
+ if (property.optional && member === undefined)
195
+ continue;
196
+ if (!matches(member, property.type, refs, nestedDepth))
197
+ return false;
198
+ }
199
+ return true;
200
+ }
201
+ function unionMatches(value, node, refs, depth) {
202
+ if (node.members.length === 0)
203
+ return false;
204
+ const discriminant = discriminantOf(node.members);
205
+ if (discriminant) {
206
+ if (!isRecord(value))
207
+ return false;
208
+ const tag = value[discriminant.key];
209
+ for (const arm of discriminant.arms) {
210
+ if (tag === arm.value)
211
+ return objectMatches(value, arm.node, refs, discriminant.key, depth);
212
+ }
213
+ return false;
214
+ }
215
+ for (const member of node.members)
216
+ if (matches(value, member, refs, depth))
217
+ return true;
218
+ return false;
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // issues
222
+ // ---------------------------------------------------------------------------
223
+ function report(out, path, expected, value) {
224
+ out.push({ path, expected, value, message: messageFor(expected) });
225
+ }
226
+ /**
227
+ * The same bounds as `constraintsMatch`, reported instead of summed.
228
+ *
229
+ * boundary: the casts are the ones `constraintsMatch` carries, and sound for the same
230
+ * reason — the scalar check has run, and `ok` is the passing comparison, so a value of the
231
+ * wrong kind makes it `false` and produces an issue rather than silently passing. The two
232
+ * functions stay separate because this one allocates and that one must not.
233
+ */
234
+ function constraintIssues(constraints, value, path, out) {
235
+ if (!constraints)
236
+ return;
237
+ const check = (keyword, ok, bound) => {
238
+ if (!ok)
239
+ report(out, path, expectedForConstraint(keyword, bound), value);
240
+ };
241
+ const length = value.length;
242
+ if (constraints.minimum !== undefined) {
243
+ check('minimum', value >= constraints.minimum, constraints.minimum);
244
+ }
245
+ if (constraints.maximum !== undefined) {
246
+ check('maximum', value <= constraints.maximum, constraints.maximum);
247
+ }
248
+ if (constraints.minLength !== undefined) {
249
+ check('minLength', length >= constraints.minLength, constraints.minLength);
250
+ }
251
+ if (constraints.maxLength !== undefined) {
252
+ check('maxLength', length <= constraints.maxLength, constraints.maxLength);
253
+ }
254
+ if (constraints.pattern !== undefined) {
255
+ check('pattern', getCachedRegExp(constraints.pattern).test(value), constraints.pattern);
256
+ }
257
+ }
258
+ function collectIssues(value, node, path, out, refs, depth) {
259
+ switch (node.kind) {
260
+ case 'unknown':
261
+ return;
262
+ case 'null':
263
+ case 'undefined':
264
+ case 'literal':
265
+ if (!matches(value, node, refs, depth))
266
+ report(out, path, expectedOf(node), value);
267
+ return;
268
+ case 'scalar':
269
+ // The shape is reported first and stops the walk: `minLength 3` about a number
270
+ // would be two issues where one is the truth.
271
+ if (!scalarMatches(node.scalar, value))
272
+ report(out, path, expectedOf(node), value);
273
+ else
274
+ constraintIssues(node.constraints, value, path, out);
275
+ return;
276
+ case 'array': {
277
+ if (!Array.isArray(value)) {
278
+ report(out, path, 'array', value);
279
+ return;
280
+ }
281
+ constraintIssues(node.constraints, value, path, out);
282
+ if (depth !== undefined && depth <= 1)
283
+ return;
284
+ const nestedDepth = childDepth(depth);
285
+ for (const [index, item] of value.entries()) {
286
+ collectIssues(item, node.element, `${path}[${index}]`, out, refs, nestedDepth);
287
+ }
288
+ return;
289
+ }
290
+ case 'tuple': {
291
+ if (!Array.isArray(value) || value.length !== node.elements.length) {
292
+ report(out, path, expectedOf(node), value);
293
+ return;
294
+ }
295
+ if (depth !== undefined && depth <= 1)
296
+ return;
297
+ const nestedDepth = childDepth(depth);
298
+ for (const [index, element] of node.elements.entries()) {
299
+ collectIssues(value[index], element, `${path}[${index}]`, out, refs, nestedDepth);
300
+ }
301
+ return;
302
+ }
303
+ case 'object':
304
+ objectIssues(value, node, path, out, refs, depth);
305
+ return;
306
+ case 'union':
307
+ unionIssues(value, node, path, out, refs, depth);
308
+ return;
309
+ case 'ref': {
310
+ const target = refs.get(node.name);
311
+ if (target)
312
+ objectIssues(value, target, path, out, refs, depth);
313
+ else
314
+ report(out, path, node.name, value);
315
+ return;
316
+ }
317
+ case 'unsupported':
318
+ report(out, path, expectedOf(node), value);
319
+ return;
320
+ }
321
+ }
322
+ function objectIssues(value, node, path, out, refs, depth) {
323
+ if (!isRecord(value)) {
324
+ report(out, path, expectedOf(node), value);
325
+ return;
326
+ }
327
+ if (depth === 0)
328
+ return;
329
+ const nestedDepth = childDepth(depth);
330
+ for (const property of node.properties) {
331
+ const member = value[property.name];
332
+ if (property.optional && member === undefined)
333
+ continue;
334
+ collectIssues(member, property.type, `${path}${accessorPath(property.name)}`, out, refs, nestedDepth);
335
+ }
336
+ }
337
+ function unionIssues(value, node, path, out, refs, depth) {
338
+ const discriminant = discriminantOf(node.members);
339
+ if (!discriminant) {
340
+ // No arm to blame: one issue naming the whole union, at the union's own path.
341
+ if (!matches(value, node, refs, depth))
342
+ report(out, path, expectedOf(node), value);
343
+ return;
344
+ }
345
+ if (!isRecord(value)) {
346
+ report(out, path, expectedOf(node), value);
347
+ return;
348
+ }
349
+ const tag = value[discriminant.key];
350
+ for (const arm of discriminant.arms) {
351
+ if (tag === arm.value) {
352
+ objectIssues(value, arm.node, path, out, refs, depth);
353
+ return;
354
+ }
355
+ }
356
+ // With a discriminant the failure is precise: the tag itself is wrong.
357
+ report(out, `${path}${accessorPath(discriminant.key)}`, expectedForDiscriminant(discriminant), tag);
358
+ }
359
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
360
+ /** `.email`, or `["odd name"]` — the same spelling the emitter's `join` produces. */
361
+ function accessorPath(name) {
362
+ return IDENTIFIER.test(name) ? `.${name}` : `[${JSON.stringify(name)}]`;
363
+ }
364
+ // ---------------------------------------------------------------------------
365
+ // excess
366
+ // ---------------------------------------------------------------------------
367
+ /**
368
+ * Whether the value carries a property its type does not declare.
369
+ *
370
+ * Only ever called after `matches` has passed, which is what lets the all-required case
371
+ * reduce to a key count: every declared property is known to be present, so "no excess"
372
+ * is "the counts agree". Same reduction as the emitted form.
373
+ */
374
+ function hasNoExcessKeys(value, node, refs) {
375
+ switch (node.kind) {
376
+ case 'object':
377
+ return objectHasNoExcessKeys(value, node, refs);
378
+ case 'array': {
379
+ if (!Array.isArray(value) || !hasExcessCheck(node.element))
380
+ return true;
381
+ for (const item of value)
382
+ if (!hasNoExcessKeys(item, node.element, refs))
383
+ return false;
384
+ return true;
385
+ }
386
+ case 'tuple': {
387
+ if (!Array.isArray(value))
388
+ return true;
389
+ for (const [index, element] of node.elements.entries()) {
390
+ if (!hasExcessCheck(element))
391
+ continue;
392
+ if (!hasNoExcessKeys(value[index], element, refs))
393
+ return false;
394
+ }
395
+ return true;
396
+ }
397
+ case 'union': {
398
+ // A value can satisfy several arms of an undiscriminated union, so "which arm's
399
+ // property list is the declared one" has no answer and neither walk asks it.
400
+ const discriminant = discriminantOf(node.members);
401
+ if (!discriminant || !isRecord(value))
402
+ return true;
403
+ const tag = value[discriminant.key];
404
+ for (const arm of discriminant.arms) {
405
+ if (tag === arm.value)
406
+ return objectHasNoExcessKeys(value, arm.node, refs);
407
+ }
408
+ return true;
409
+ }
410
+ case 'ref': {
411
+ const target = refs.get(node.name);
412
+ return target ? objectHasNoExcessKeys(value, target, refs) : true;
413
+ }
414
+ default:
415
+ return true;
416
+ }
417
+ }
418
+ function objectHasNoExcessKeys(value, node, refs) {
419
+ if (!isRecord(value))
420
+ return true;
421
+ let allRequired = true;
422
+ for (const property of node.properties) {
423
+ if (property.optional) {
424
+ allRequired = false;
425
+ break;
426
+ }
427
+ }
428
+ if (allRequired && node.properties.length > 0) {
429
+ // No Set and no allocation: count own enumerable keys and compare.
430
+ let actual = 0;
431
+ for (const _ in value) {
432
+ if (++actual > node.properties.length)
433
+ return false;
434
+ }
435
+ if (actual !== node.properties.length)
436
+ return false;
437
+ }
438
+ else {
439
+ for (const key in value) {
440
+ if (!node.properties.some(property => property.name === key))
441
+ return false;
442
+ }
443
+ }
444
+ for (const property of node.properties) {
445
+ if (!hasExcessCheck(property.type))
446
+ continue;
447
+ const member = value[property.name];
448
+ // `for…in undefined` throws, and an optional or nullable member may be neither an
449
+ // object nor present. The emitted form guards the same way.
450
+ if (typeof member !== 'object' || member === null)
451
+ continue;
452
+ if (!hasNoExcessKeys(member, property.type, refs))
453
+ return false;
454
+ }
455
+ return true;
456
+ }
457
+ // ---------------------------------------------------------------------------
458
+ // sample
459
+ // ---------------------------------------------------------------------------
460
+ /**
461
+ * Where `sample` draws from — `Math.random` unless a caller passed something else.
462
+ *
463
+ * A parameter on `sample` would be threaded through eight recursive cases to reach two leaf
464
+ * functions, and every one of them would carry an argument it does not use. A module-level
465
+ * source is the shape that costs nothing on the default path, and it is safe here for one
466
+ * specific reason rather than by luck: `sample` is synchronous and `random` restores the
467
+ * previous value in a `finally`, so there is no interleaving to get wrong.
468
+ *
469
+ * It exists so a seeded generator can be *the same* generator. `@zmdb/orm/seeding`
470
+ * needs "same seed ⇒ same rows" and needs the values to satisfy the column's constraints;
471
+ * those are one requirement, and answering it with a second value generator is how the
472
+ * repo ended up with five walkers over one schema.
473
+ */
474
+ let entropy = Math.random;
475
+ function randomInt(min, max) {
476
+ return min + Math.floor(entropy() * (max - min + 1));
477
+ }
478
+ function randomString(min, max) {
479
+ let s = '';
480
+ while (s.length < Math.max(min, 1))
481
+ s += entropy().toString(36).slice(2);
482
+ return s.slice(0, max);
483
+ }
484
+ /**
485
+ * A value that satisfies `node` by construction, or a thrown refusal.
486
+ *
487
+ * Refusing is the point. The generator this replaced returned `'x'` for any pattern it
488
+ * did not recognise, so `is(random(d), d)` — the single property it claimed — was false
489
+ * for most patterns. Nothing here inverts a regular expression, so it says so.
490
+ *
491
+ * No `RefTable`, unlike its siblings: a `ref` is where sampling stops, either dropped by
492
+ * the union above it or refused outright, so there is never a name to resolve.
493
+ */
494
+ function sample(node, path) {
495
+ switch (node.kind) {
496
+ case 'unknown':
497
+ case 'null':
498
+ return null;
499
+ case 'undefined':
500
+ return undefined;
501
+ case 'literal':
502
+ return node.value;
503
+ case 'scalar':
504
+ return scalarSample(node, path);
505
+ case 'array': {
506
+ const min = node.constraints?.minLength ?? 1;
507
+ const max = node.constraints?.maxLength ?? Math.max(min, 3);
508
+ if (min > max)
509
+ throw refusal(path, `an array with minLength ${min} above maxLength ${max}`);
510
+ return Array.from({ length: randomInt(min, max) }, () => sample(node.element, `${path}[]`));
511
+ }
512
+ case 'tuple':
513
+ return node.elements.map((element, index) => sample(element, `${path}[${index}]`));
514
+ case 'object': {
515
+ const out = {};
516
+ for (const property of node.properties) {
517
+ out[property.name] = sample(property.type, `${path}.${property.name}`);
518
+ }
519
+ return out;
520
+ }
521
+ case 'union': {
522
+ // A `ref` member is dropped rather than sampled, so `Node { next: Node | null }`
523
+ // terminates on `null`. A union of nothing but refs cannot terminate at all.
524
+ const usable = node.members.filter(member => member.kind !== 'ref');
525
+ if (usable.length === 0)
526
+ throw refusal(path, 'a union of nothing but back-references cannot be sampled');
527
+ // boundary: `usable` is non-empty — the line above throws otherwise — and the index is
528
+ // drawn from `0 … length - 1`, so both reads are in bounds. The `??` is there for the
529
+ // same reason the cast is: `noUncheckedIndexedAccess` types an in-bounds read as
530
+ // possibly `undefined`, and there is no run of this branch that produces one.
531
+ const chosen = usable[randomInt(0, usable.length - 1)] ?? usable[0];
532
+ return sample(chosen, path);
533
+ }
534
+ case 'ref':
535
+ throw refusal(path, `\`${node.name}\` recurs with no terminating arm, so no finite value satisfies it`);
536
+ case 'unsupported':
537
+ throw refusal(path, node.reason);
538
+ }
539
+ }
540
+ function scalarSample(node, path) {
541
+ const constraints = node.constraints;
542
+ switch (node.scalar) {
543
+ case 'boolean':
544
+ return entropy() < 0.5;
545
+ case 'date':
546
+ // An instant drawn from the same source, not `new Date()`: a sample that ignores the
547
+ // generator is a sample a seed cannot reproduce, and "same seed ⇒ same rows" is a
548
+ // property, not a nicety. The range is the epoch to roughly 2024.
549
+ return new Date(Math.floor(entropy() * 1_700_000_000_000));
550
+ case 'number':
551
+ case 'integer':
552
+ case 'bigint': {
553
+ const min = constraints?.minimum ?? 0;
554
+ const max = constraints?.maximum ?? min + 1000;
555
+ if (min > max)
556
+ throw refusal(path, `a bound with minimum ${min} above maximum ${max}`);
557
+ const value = randomInt(min, max);
558
+ return node.scalar === 'bigint' ? BigInt(value) : value;
559
+ }
560
+ case 'string': {
561
+ if (constraints?.pattern !== undefined) {
562
+ throw refusal(path, 'a sample cannot be built from a `pattern`; nothing here inverts a regular expression');
563
+ }
564
+ const min = constraints?.minLength ?? 1;
565
+ const max = constraints?.maxLength ?? Math.max(min, 12);
566
+ if (min > max)
567
+ throw refusal(path, `a string with minLength ${min} above maxLength ${max}`);
568
+ return randomString(min, max);
569
+ }
570
+ }
571
+ }
572
+ function refusal(path, reason) {
573
+ return new Error(path === '' ? `cannot sample: ${reason}` : `cannot sample \`${path}\`: ${reason}`);
574
+ }
575
+ // ---------------------------------------------------------------------------
576
+ // Public surface
577
+ // ---------------------------------------------------------------------------
578
+ const MISSING = 'runtime type witness required in test/fallback mode';
579
+ const INVALID_SHALLOW_DEPTH = 'shallow validation fallback depth must be a positive integer';
580
+ function required(schema) {
581
+ if (!schema)
582
+ throw new Error(MISSING);
583
+ return schema;
584
+ }
585
+ function shallowDepth(depth) {
586
+ const resolved = depth ?? 1;
587
+ if (!Number.isInteger(resolved) || resolved <= 0)
588
+ throw new Error(INVALID_SHALLOW_DEPTH);
589
+ return resolved;
590
+ }
591
+ function certified(input) {
592
+ // boundary: the caller reaches this only after the runtime witness walk has
593
+ // accepted `input`; this single certification point serves every returning
594
+ // assertion form instead of adding one type assertion per public function.
595
+ return input;
596
+ }
597
+ export function is(input, schema) {
598
+ const node = required(schema);
599
+ return matches(input, node, refsOf(node));
600
+ }
601
+ export function assert(input, schema) {
602
+ const node = required(schema);
603
+ const refs = refsOf(node);
604
+ // Two passes, as the emitted form does it: the allocation-free check first, and the
605
+ // issue walk only once we already know a throw is coming (REQ-AV-7).
606
+ if (matches(input, node, refs)) {
607
+ return certified(input);
608
+ }
609
+ const issues = [];
610
+ collectIssues(input, node, 'input', issues, refs);
611
+ failWith(issues);
612
+ }
613
+ export function validate(input, schema) {
614
+ const node = required(schema);
615
+ const refs = refsOf(node);
616
+ if (matches(input, node, refs))
617
+ return { success: true, data: certified(input) };
618
+ const issues = [];
619
+ collectIssues(input, node, 'input', issues, refs);
620
+ return { success: false, errors: issues };
621
+ }
622
+ /**
623
+ * Validate only through depth `D`; an ordinary call is replaced at build time.
624
+ *
625
+ * The optional witness and runtime depth exist for tests and generated fallback
626
+ * modules. A real untransformed call has neither and throws `MISSING`, exactly as
627
+ * the full-depth utility family does.
628
+ */
629
+ export function isShallow(input, schema, depth) {
630
+ const node = required(schema);
631
+ return matches(input, node, refsOf(node), shallowDepth(depth));
632
+ }
633
+ export function assertShallow(input, schema, depth) {
634
+ const node = required(schema);
635
+ const refs = refsOf(node);
636
+ const limit = shallowDepth(depth);
637
+ if (matches(input, node, refs, limit))
638
+ return certified(input);
639
+ const issues = [];
640
+ collectIssues(input, node, 'input', issues, refs, limit);
641
+ failWith(issues);
642
+ }
643
+ export function validateShallow(input, schema, depth) {
644
+ const node = required(schema);
645
+ const refs = refsOf(node);
646
+ const limit = shallowDepth(depth);
647
+ if (matches(input, node, refs, limit))
648
+ return { success: true, data: certified(input) };
649
+ const issues = [];
650
+ collectIssues(input, node, 'input', issues, refs, limit);
651
+ return { success: false, errors: issues };
652
+ }
653
+ export function equals(input, schema) {
654
+ const node = required(schema);
655
+ const refs = refsOf(node);
656
+ return matches(input, node, refs) && hasNoExcessKeys(input, node, refs);
657
+ }
658
+ export function assertEquals(input, schema) {
659
+ const node = required(schema);
660
+ const refs = refsOf(node);
661
+ if (matches(input, node, refs) && hasNoExcessKeys(input, node, refs)) {
662
+ return certified(input);
663
+ }
664
+ const issues = [];
665
+ collectIssues(input, node, 'input', issues, refs);
666
+ // Excess properties are one issue about the value as a whole, and only worth
667
+ // reporting when nothing else was wrong: "you also passed `extra`" is noise next to
668
+ // "`email` is not a string".
669
+ if (issues.length === 0 && !hasNoExcessKeys(input, node, refs)) {
670
+ report(issues, 'input', 'no excess properties', input);
671
+ }
672
+ failWith(issues);
673
+ }
674
+ export function random(schema, rng) {
675
+ const node = required(schema);
676
+ const previous = entropy;
677
+ entropy = rng ?? Math.random;
678
+ try {
679
+ // boundary: `sample` builds the value FROM the IR, so it satisfies it by
680
+ // construction — the `is(random(d), d)` property test guards this.
681
+ return sample(node, '');
682
+ }
683
+ finally {
684
+ entropy = previous;
685
+ }
686
+ }
687
+ /** Every issue, for a caller that wants them without a `ValidateResult` wrapper. */
688
+ export function issuesFor(input, schema, path = 'input') {
689
+ const node = schema;
690
+ const issues = [];
691
+ collectIssues(input, node, path, issues, refsOf(node));
692
+ return issues;
693
+ }
694
+ //# sourceMappingURL=index.js.map