ata-validator 1.18.1 → 1.20.0

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/index.js CHANGED
@@ -1461,9 +1461,18 @@ class Validator {
1461
1461
  });
1462
1462
  this._engine = 'interpreter';
1463
1463
  if (!preprocess) this._fastVerdict = (d) => interp.isValid(d);
1464
- const run = preprocess
1465
- ? (data) => { preprocess(data); return interp.validate(data); }
1466
- : (data) => interp.validate(data);
1464
+ // abortEarly is a documented contract, not a property of whichever engine
1465
+ // answered: it promises the frozen ATA9000 stub instead of a detailed
1466
+ // error, so code that branches on it has to behave the same with and
1467
+ // without code generation. Taking the verdict path here also skips
1468
+ // building the errors the caller said it did not want.
1469
+ const run = options.abortEarly
1470
+ ? (preprocess
1471
+ ? (data) => { preprocess(data); return interp.isValid(data) ? VALID_RESULT : ABORT_EARLY_RESULT; }
1472
+ : (data) => (interp.isValid(data) ? VALID_RESULT : ABORT_EARLY_RESULT))
1473
+ : (preprocess
1474
+ ? (data) => { preprocess(data); return interp.validate(data); }
1475
+ : (data) => interp.validate(data));
1467
1476
  this.validate = run;
1468
1477
  this.isValidObject = this._fastVerdict
1469
1478
  ? this._fastVerdict
@@ -2039,6 +2048,7 @@ function compile(schema, opts) {
2039
2048
  const { toTypeScript } = require("./lib/ts-gen");
2040
2049
  const { renderPretty } = require("./lib/render-pretty");
2041
2050
  const { renderCompact } = require("./lib/render-compact");
2051
+ const { toOutput } = require("./lib/output-format");
2042
2052
  const { renderJSON } = require("./lib/render-json");
2043
2053
  const { suggestFor } = require("./lib/suggestions");
2044
2054
  const { reprValue } = require("./lib/enrich-error");
@@ -2212,7 +2222,12 @@ _defineLazyMethod('isValidObject', (self) => (data) => {
2212
2222
  return r;
2213
2223
  };
2214
2224
  } else {
2215
- self._ensureCodegen();
2225
+ // `new Function` is a property of the realm, not of the schema: under a
2226
+ // strict CSP or `--disallow-code-generation-from-strings` this throws
2227
+ // rather than declining, and the EvalError reached the caller of a verdict
2228
+ // method. The full compile can answer without code generation, so fall
2229
+ // through to it. The tier-0 branch above already guarded its own call.
2230
+ try { self._ensureCodegen(); } catch { /* no codegen in this realm */ }
2216
2231
  // Codegen can bail on shapes it cannot represent; the full compile
2217
2232
  // binds the native path or the unsupported thrower instead of
2218
2233
  // leaving this stub to re-dispatch to itself.
@@ -2263,6 +2278,7 @@ module.exports = {
2263
2278
  defineSchema,
2264
2279
  renderPretty,
2265
2280
  renderCompact,
2281
+ toOutput,
2266
2282
  renderJSON,
2267
2283
  attachSuggestions, // internal: used by the renderers; not public API
2268
2284
  };
package/lib/aot-impl.js CHANGED
@@ -54,13 +54,26 @@ function safeRePrelude(...fns) {
54
54
  return fns.some((f) => f && f._usesSafeRe) ? getSafeRegexEmbed() + '\n' : '';
55
55
  }
56
56
 
57
+ // A validator can enforce more than its schema says. `withKeywords` from
58
+ // @ata-project/keywords wraps an instance's entry points, and a wrapper
59
+ // declares that by setting `_externalChecks`. The emitters below build their
60
+ // module from the compiled schema alone, so a wrapped instance would emit a
61
+ // module that accepts documents the validator itself rejects. Refuse instead.
62
+ // Declining to compile is recoverable. A module that wrongly accepts is not.
63
+ function assertEmittable(validator, fnName) {
64
+ if (validator._usesKeywords) {
65
+ throw new Error(fnName + ': a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
66
+ }
67
+ if (validator._externalChecks) {
68
+ throw new Error(fnName + ': this validator enforces checks that are not in its schema, so a standalone module would be weaker than the validator it came from. Those checks come from a wrapper such as withKeywords() from @ata-project/keywords, and JSON Schema has no spelling for them. Compile the unwrapped validator if the extra checks are not needed.');
69
+ }
70
+ }
71
+
57
72
  // --- Standalone pre-compilation ---
58
73
  // Generate a JS module string that can be written to a file.
59
74
  // On next startup, load with Validator.fromStandalone() -- zero compile time.
60
75
  function toStandalone(validator) {
61
- if (validator._usesKeywords) {
62
- throw new Error('toStandalone: a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
63
- }
76
+ assertEmittable(validator, 'toStandalone');
64
77
  validator._ensureCompiled();
65
78
  const jsFn = validator._jsFn;
66
79
  if (!jsFn || !jsFn._source) return null;
@@ -233,9 +246,7 @@ function emitClone(node, access, depth) {
233
246
  }
234
247
 
235
248
  function toStandaloneModule(validator, opts) {
236
- if (validator._usesKeywords) {
237
- throw new Error('toStandaloneModule: a schema that uses custom keywords cannot be compiled into a standalone module (option "keywords")');
238
- }
249
+ assertEmittable(validator, 'toStandaloneModule');
239
250
  validator._ensureCompiled();
240
251
  const jsFn = validator._jsFn;
241
252
  if (!jsFn || !jsFn._source) return null;
@@ -299,10 +310,39 @@ function toStandaloneModule(validator, opts) {
299
310
  if (lines.length) closureDecls = lines.join('\n') + '\n';
300
311
  }
301
312
 
313
+ // A few helpers are self-contained and named the same wherever they appear,
314
+ // the email check being the big one at about 2.7 KB. The boolean function
315
+ // and the error function each hoisted their own copy, so every module that
316
+ // used a format carried it twice. They are declared once at module scope
317
+ // here and removed from both bodies. Everything else in the preamble stays
318
+ // where it is: emitConstant and the generated branch checks name things per
319
+ // compilation, and two of them at module scope would collide.
320
+ const sharedDecls = [];
321
+ {
322
+ const seen = new Set();
323
+ for (const fn of [jsFn, jsErrFn]) {
324
+ if (!fn || !fn._sharedHelpers) continue;
325
+ for (const h of fn._sharedHelpers) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
326
+ }
327
+ if (sharedDecls.length && errCore) {
328
+ for (const h of sharedDecls) errCore = errCore.split(h).join('');
329
+ }
330
+ }
331
+ const sharedBlock = sharedDecls.length ? sharedDecls.join('\n') + '\n' : '';
332
+
302
333
  // Hoisted oneOf/anyOf branch checks live in the boolean fn's preamble (the
303
334
  // runtime emits them before the function). The standalone module must declare
304
335
  // them at module scope too, or _fn references undefined names (e.g. _af1_b0).
305
- const preambleDecls = jsFn._preambleSource ? jsFn._preambleSource + '\n' : '';
336
+ const sharedSet = new Set(sharedDecls);
337
+ const preambleParts = jsFn._preambleParts
338
+ ? jsFn._preambleParts.filter((part) => !sharedSet.has(part))
339
+ : null;
340
+ const preambleBody = preambleParts !== null
341
+ ? (preambleParts.length ? preambleParts.join('\n ') + '\n ' : '')
342
+ : (jsFn._preambleSource || '');
343
+ const preambleDecls = preambleBody || (jsFn._preambleGuard || '')
344
+ ? (jsFn._preambleParts ? (jsFn._preambleGuard || '') + preambleBody : preambleBody) + '\n'
345
+ : '';
306
346
 
307
347
  // User-supplied format functions are referenced as _uf_<name> by both the
308
348
  // boolean (_fn) and error (errFn) bodies. Embed them via Function#toString
@@ -353,7 +393,7 @@ const ABORT = Object.freeze({
353
393
  path: '',
354
394
  })]),
355
395
  });
356
- ${closureDecls}${preambleDecls}${formatDecls}const _fn = function(d) {
396
+ ${closureDecls}${sharedBlock}${preambleDecls}${formatDecls}const _fn = function(d) {
357
397
  ${src}
358
398
  };
359
399
  ${errCore}function isValid(data) { return _fn(data); }
@@ -404,11 +444,14 @@ function bundleStandalone(Validator, schemas, opts) {
404
444
  const R = 'Object.freeze({valid:true,errors:Object.freeze([])})';
405
445
  let bundleUsesSafeRe = false;
406
446
  let bundleInjects = false;
407
- const fns = schemas.map((schema) => {
447
+ // Pass one compiles; pass two emits. The helpers that several schemas hoist
448
+ // are the same text declaring the same name, so the bundle keeps one copy at
449
+ // module scope, and pass two needs to know the set before it writes anything.
450
+ const compiledEntries = schemas.map((schema) => {
408
451
  const v = new Validator(schema, bundleOpts);
409
452
  v._ensureCompiled();
410
453
  const jsFn = v._jsFn;
411
- if (!jsFn || !jsFn._hybridSource) return 'null';
454
+ if (!jsFn || !jsFn._hybridSource) return null;
412
455
  // The schema the validator compiled, not the one the caller passed. They
413
456
  // differ whenever anything prepared the schema: draft-07 normalization,
414
457
  // `format` removed under assertFormat: false, keywords outside the
@@ -420,10 +463,30 @@ function bundleStandalone(Validator, schemas, opts) {
420
463
  v._userFormats,
421
464
  );
422
465
  if (jsFn._usesSafeRe || (jsErrFn && jsErrFn._usesSafeRe)) bundleUsesSafeRe = true;
423
- const errBody =
466
+ return { v, jsFn, jsErrFn };
467
+ });
468
+
469
+ const sharedDecls = [];
470
+ {
471
+ const seen = new Set();
472
+ for (const e of compiledEntries) {
473
+ if (!e) continue;
474
+ for (const fn of [e.jsFn, e.jsErrFn]) {
475
+ if (!fn || !fn._sharedHelpers) continue;
476
+ for (const h of fn._sharedHelpers) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
477
+ }
478
+ }
479
+ }
480
+ const sharedSet = new Set(sharedDecls);
481
+
482
+ const fns = compiledEntries.map((e) => {
483
+ if (!e) return 'null';
484
+ const { v, jsFn, jsErrFn } = e;
485
+ let errBody =
424
486
  jsErrFn && jsErrFn._errSource
425
487
  ? jsErrFn._errSource
426
488
  : "return{valid:false,errors:[{code:'error',path:'',message:'validation failed'}]}";
489
+ for (const h of sharedDecls) errBody = errBody.split(h).join('');
427
490
  // Custom format closures: embedded, or bound to the bundle-level
428
491
  // registry when opts.formats is 'inject'.
429
492
  let preamble = '';
@@ -433,11 +496,14 @@ function bundleStandalone(Validator, schemas, opts) {
433
496
  if (f.exportsSetFormats) bundleInjects = true;
434
497
  }
435
498
  // Include hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) so the
436
- // bundle output is self-contained. `toStandalone` emits this same source
437
- // for single-schema standalone output.
438
- if (jsFn._preambleSource) {
439
- preamble = preamble ? `${preamble}\n${jsFn._preambleSource}` : jsFn._preambleSource;
440
- }
499
+ // bundle output is self-contained, minus anything lifted to module scope.
500
+ const kept = jsFn._preambleParts
501
+ ? jsFn._preambleParts.filter((part) => !sharedSet.has(part))
502
+ : null;
503
+ const preambleSrc = kept !== null
504
+ ? (jsFn._preambleGuard || '') + (kept.length ? kept.join('\n ') + '\n ' : '')
505
+ : (jsFn._preambleSource || '');
506
+ if (preambleSrc) preamble = preamble ? `${preamble}\n${preambleSrc}` : preambleSrc;
441
507
  if (opts && opts.verbose) {
442
508
  // Embed the schema and a small resolver so errors carry parentSchema.
443
509
  const schemaLit = JSON.stringify(v._schemaObj);
@@ -446,7 +512,8 @@ function bundleStandalone(Validator, schemas, opts) {
446
512
  return `(function(R){${preamble}var E=function(d){var _all=true;${errBody}};return function(d){${jsFn._hybridSource}}})(R)`;
447
513
  });
448
514
  const arr = `[${fns.join(',')}]`;
449
- const safeEmbed = bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : '';
515
+ const safeEmbed = (bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : '') +
516
+ (sharedDecls.length ? sharedDecls.join('\n') + '\n' : '');
450
517
  const registry = bundleInjects
451
518
  ? `var __formats=Object.create(null);\nfunction setFormats(map){for(var k in map)__formats[k]=map[k]}\n`
452
519
  : '';
@@ -489,16 +556,43 @@ function bundleCompact(Validator, schemas, opts) {
489
556
  // Hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) must travel with the
490
557
  // hybrid body or it references undefined names. Prepending keeps dedup honest:
491
558
  // schemas with different branch sets no longer collide on body alone.
492
- const hybrid = jsFn._preambleSource
493
- ? `${jsFn._preambleSource}\n${jsFn._hybridSource}`
494
- : jsFn._hybridSource;
495
559
  return {
496
- hybrid,
560
+ guard: jsFn._preambleGuard || '',
561
+ parts: jsFn._preambleParts || null,
562
+ preamble: jsFn._preambleSource || '',
563
+ hybridBody: jsFn._hybridSource,
497
564
  err: jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : null,
498
565
  fmt: jsFn._formatClosures || null,
566
+ shared: [
567
+ ...(jsFn._sharedHelpers || []),
568
+ ...((jsErrFn && jsErrFn._sharedHelpers) || []),
569
+ ],
499
570
  };
500
571
  });
501
572
 
573
+ // A helper hoisted by several schemas is the same text declaring the same
574
+ // name, so the bundle holds one copy at module scope rather than one per
575
+ // schema. The rest of each preamble stays with its schema: those names are
576
+ // per compilation and would collide.
577
+ const sharedDecls = [];
578
+ {
579
+ const seen = new Set();
580
+ for (const e of entries) {
581
+ if (!e) continue;
582
+ for (const h of e.shared) if (!seen.has(h)) { seen.add(h); sharedDecls.push(h); }
583
+ }
584
+ }
585
+ const sharedSet = new Set(sharedDecls);
586
+ for (const e of entries) {
587
+ if (!e) continue;
588
+ const kept = e.parts ? e.parts.filter((part) => !sharedSet.has(part)) : null;
589
+ const preamble = kept !== null
590
+ ? e.guard + (kept.length ? kept.join('\n ') + '\n ' : '')
591
+ : e.preamble;
592
+ e.hybrid = preamble ? `${preamble}\n${e.hybridBody}` : e.hybridBody;
593
+ if (e.err) for (const h of sharedDecls) e.err = e.err.split(h).join('');
594
+ }
595
+
502
596
  // Deduplicate function bodies — many schemas produce identical or near-identical code
503
597
  const bodyMap = new Map(); // body → index
504
598
  const bodies = [];
@@ -531,6 +625,7 @@ function bundleCompact(Validator, schemas, opts) {
531
625
  ? '// Auto-generated by ata-validator — do not edit\n'
532
626
  : "'use strict';\n";
533
627
  if (bundleUsesSafeRe) out += getSafeRegexEmbed() + '\n';
628
+ if (sharedDecls.length) out += sharedDecls.join('\n') + '\n';
534
629
  const declKW = isEsm ? 'const' : 'var';
535
630
  out += `${declKW} R=Object.freeze({valid:true,errors:Object.freeze([])});\n`;
536
631
 
@@ -1,6 +1,23 @@
1
1
  'use strict';
2
2
 
3
- const SEVERITY = {
3
+ // Branch collapse: when no branch of an anyOf or oneOf matches, listing every
4
+ // branch's errors tells a reader that nothing fit without telling them which
5
+ // branch they meant. This scores the branches, reports the one that came
6
+ // closest, and keeps its errors under `branchErrors` for the pretty renderer.
7
+ //
8
+ // There is one implementation, and it is `__ataCollapse` below. A standalone
9
+ // module imports nothing, so the generated code cannot require this file; it
10
+ // carries a copy emitted by `embedSource()` from these very functions instead
11
+ // of a second hand-written one. That is why the internals are written in the
12
+ // positional form the generated code calls, and why `__ataCollapse` may close
13
+ // over nothing but `__ataScore` and the severity table: whatever it reaches
14
+ // has to be in the embed too.
15
+
16
+ // How much a failing keyword says about intent. A branch that fails on `type`
17
+ // was probably not the branch the author meant; one that fails on `minLength`
18
+ // probably was, and is the better thing to show. The error count dominates, so
19
+ // a branch with one complaint always beats a branch with three.
20
+ const __ATA_SEVERITY = {
4
21
  type: 10,
5
22
  const: 8,
6
23
  enum: 8,
@@ -15,61 +32,69 @@ const SEVERITY = {
15
32
  unevaluatedProperties: 2,
16
33
  unevaluatedItems: 2,
17
34
  };
18
- const DEFAULT_SEVERITY = 4;
19
35
 
20
- function scoreBranch (errors) {
21
- if (!errors || errors.length === 0) return 0;
22
- let sum = 0;
23
- for (const e of errors) sum += SEVERITY[e.keyword] || DEFAULT_SEVERITY;
24
- return errors.length * 100 + sum; // primary: count, secondary: severity
36
+ function __ataScore (errs) {
37
+ if (!errs || !errs.length) return 0;
38
+ let s = 0;
39
+ for (const e of errs) s += __ATA_SEVERITY[e.keyword] || 4;
40
+ return errs.length * 100 + s; // primary: count, secondary: severity
25
41
  }
26
42
 
27
- /**
28
- * Given an array of branch result objects ({ valid, errors }) for a oneOf
29
- * or anyOf, pick the best branch and emit a single user-facing error.
30
- *
31
- * @param keyword 'oneOf' | 'anyOf'
32
- * @param branchResults Array<{ valid, errors, title? }>
33
- * @param parentPath JSON pointer to the data location
34
- * @param parentSchemaPath JSON pointer to the keyword in the schema
35
- * @returns A ValidationError-shaped object, or null if branch passed (caller treats as success).
36
- */
37
- function collapseBranches ({ keyword, branchResults, parentPath, parentSchemaPath }) {
38
- const passing = branchResults.filter(b => b.valid);
39
- if (keyword === 'oneOf') {
40
- if (passing.length === 1) return null;
41
- if (passing.length > 1) {
43
+ // `br` is one entry per branch in declaration order: {valid, errors, title}.
44
+ // Returns the single error to report, or null when the keyword is satisfied.
45
+ function __ataCollapse (kw, br, pp, sp, o) {
46
+ const pass = br.filter((b) => b.valid);
47
+ if (kw === 'oneOf') {
48
+ if (pass.length === 1) return null;
49
+ if (pass.length > 1) {
50
+ const passIdx = [];
51
+ for (let i = 0; i < br.length; i++) if (br[i].valid) passIdx.push(i);
42
52
  return {
43
- code: 'ATA4002', keyword: 'oneOf', path: parentPath || '',
44
- message: `value matched ${passing.length} of ${branchResults.length} oneOf variants, expected exactly one`,
45
- schemaPath: parentSchemaPath,
46
- params: { matched: passing.length, total: branchResults.length },
53
+ code: 'ATA4002',
54
+ keyword: 'oneOf',
55
+ instancePath: pp || '',
56
+ path: pp || '',
57
+ schemaPath: sp,
58
+ _o: o,
59
+ message: 'value matched ' + pass.length + ' of ' + br.length + ' oneOf variants, expected exactly one',
60
+ params: { passingSchemas: passIdx },
47
61
  };
48
62
  }
49
- // 0 matched, find best
50
- return buildBranchError('ATA4001', 'oneOf', branchResults, parentPath, parentSchemaPath);
63
+ } else if (pass.length >= 1) {
64
+ return null;
51
65
  }
52
- // anyOf
53
- if (passing.length >= 1) return null;
54
- return buildBranchError('ATA4003', 'anyOf', branchResults, parentPath, parentSchemaPath);
55
- }
56
-
57
- function buildBranchError (code, keyword, branchResults, parentPath, parentSchemaPath) {
58
- let bestIdx = 0;
59
- let bestScore = Infinity;
60
- for (let i = 0; i < branchResults.length; i++) {
61
- const s = scoreBranch(branchResults[i].errors);
62
- if (s < bestScore) { bestScore = s; bestIdx = i; }
66
+ let bi = 0;
67
+ let bs = Infinity;
68
+ for (let i = 0; i < br.length; i++) {
69
+ const s = __ataScore(br[i].errors);
70
+ if (s < bs) { bs = s; bi = i; }
63
71
  }
64
- const best = branchResults[bestIdx];
65
- const variantName = best.title || `variant ${bestIdx + 1}`;
72
+ const best = br[bi];
66
73
  return {
67
- code, keyword, path: parentPath || '',
68
- message: `value matched 0 of ${branchResults.length} ${keyword} variants`,
69
- schemaPath: parentSchemaPath,
70
- params: { variants: branchResults.length, closest: bestIdx, closestName: variantName },
71
- branchErrors: best.errors, // surfaced in pretty render
74
+ code: kw === 'oneOf' ? 'ATA4001' : 'ATA4003',
75
+ keyword: kw,
76
+ instancePath: pp || '',
77
+ path: pp || '',
78
+ schemaPath: sp,
79
+ _o: o,
80
+ message: 'value matched 0 of ' + br.length + ' ' + kw + ' variants',
81
+ params: { variants: br.length, closest: bi, closestName: best.title || ('variant ' + (bi + 1)) },
82
+ branchErrors: best.errors,
72
83
  };
73
84
  }
74
85
 
75
- module.exports = { collapseBranches, scoreBranch, SEVERITY };
86
+ // The named-argument form the interpreter and the tests call.
87
+ function collapseBranches ({ keyword, branchResults, parentPath, parentSchemaPath, ordinal }) {
88
+ return __ataCollapse(keyword, branchResults, parentPath, parentSchemaPath, ordinal === undefined ? null : ordinal);
89
+ }
90
+
91
+ // The three declarations as source, for the generated code to carry. Derived
92
+ // from the functions above rather than written out again, so a fix to one
93
+ // cannot miss the other. `tests/test_branch_collapse.js` runs both.
94
+ function embedSource () {
95
+ return 'const __ATA_SEVERITY=' + JSON.stringify(__ATA_SEVERITY) + ';' +
96
+ 'const __ataScore=' + __ataScore.toString() + ';' +
97
+ 'const __ataCollapse=' + __ataCollapse.toString() + ';';
98
+ }
99
+
100
+ module.exports = { collapseBranches, scoreBranch: __ataScore, SEVERITY: __ATA_SEVERITY, embedSource };
@@ -1,4 +1,5 @@
1
1
  'use strict';
2
+ const { collapseBranches } = require('./branch-collapse');
2
3
 
3
4
  // Interpreted draft 2020-12 engine. This is the correctness fallback for
4
5
  // schemas the JS codegen cannot represent when the native engine is absent
@@ -523,7 +524,7 @@ function evalLeafV(P, data) {
523
524
  if (!found) return false;
524
525
  }
525
526
  if (P.hasConst && !deepEqual(P.const, data)) return false;
526
- if (P.hasNumber && typeof data === 'number') {
527
+ if (P.hasNumber && typeof data === 'number' && isFinite(data)) {
527
528
  if (P.minimum !== undefined && !(data >= P.minimum)) return false;
528
529
  if (P.maximum !== undefined && !(data <= P.maximum)) return false;
529
530
  if (P.exclusiveMinimum !== undefined && !(data > P.exclusiveMinimum)) return false;
@@ -640,7 +641,7 @@ function evalLeaf(P, data, errors, instancePath, schemaPath) {
640
641
  if (errors !== NOERRORS) errors.push(err('const', 'const', instancePath, schemaPath + '/const', { allowedValue: P.const }, 'must be equal to constant'));
641
642
  valid = false;
642
643
  }
643
- if (P.hasNumber && typeof data === 'number') {
644
+ if (P.hasNumber && typeof data === 'number' && isFinite(data)) {
644
645
  if (P.minimum !== undefined && !(data >= P.minimum)) { errors === NOERRORS || errors.push(err('minimum', 'minimum', instancePath, schemaPath + '/minimum', { comparison: '>=', limit: P.minimum }, `must be >= ${P.minimum}`)); valid = false; }
645
646
  if (P.maximum !== undefined && !(data <= P.maximum)) { errors === NOERRORS || errors.push(err('maximum', 'maximum', instancePath, schemaPath + '/maximum', { comparison: '<=', limit: P.maximum }, `must be <= ${P.maximum}`)); valid = false; }
646
647
  if (P.exclusiveMinimum !== undefined && !(data > P.exclusiveMinimum)) { errors === NOERRORS || errors.push(err('exclusiveMinimum', 'exclusiveMinimum', instancePath, schemaPath + '/exclusiveMinimum', { comparison: '>', limit: P.exclusiveMinimum }, `must be > ${P.exclusiveMinimum}`)); valid = false; }
@@ -866,7 +867,7 @@ class Interpreter {
866
867
  valid = false;
867
868
  } else {
868
869
  const sub = collect ? { props: null, items: null } : DISCARD;
869
- if (!this.eval(child, data, refBase, dynScope, errors, instancePath, schemaPath + '/$ref', stack, sub)) valid = false;
870
+ if (!this.eval(child, data, refBase, dynScope, errors, instancePath, schemaPath, stack, sub)) valid = false;
870
871
  else if (collect) mergeAnnotations(local, sub);
871
872
  }
872
873
  }
@@ -905,7 +906,7 @@ class Interpreter {
905
906
  valid = false;
906
907
  } else {
907
908
  const sub = collect ? { props: null, items: null } : DISCARD;
908
- if (!this.eval(this.node(node), data, refBase, dynScope, errors, instancePath, schemaPath + '/$dynamicRef', stack, sub)) valid = false;
909
+ if (!this.eval(this.node(node), data, refBase, dynScope, errors, instancePath, schemaPath, stack, sub)) valid = false;
909
910
  else if (collect) mergeAnnotations(local, sub);
910
911
  }
911
912
  }
@@ -931,7 +932,7 @@ class Interpreter {
931
932
  }
932
933
 
933
934
  // numbers
934
- if (P.hasNumber && typeof data === 'number') {
935
+ if (P.hasNumber && typeof data === 'number' && isFinite(data)) {
935
936
  if (P.minimum !== undefined && !(data >= P.minimum)) { errors === NOERRORS || errors.push(err('minimum', 'minimum', instancePath, schemaPath + '/minimum', { comparison: '>=', limit: P.minimum }, `must be >= ${P.minimum}`)); valid = false; }
936
937
  if (P.maximum !== undefined && !(data <= P.maximum)) { errors === NOERRORS || errors.push(err('maximum', 'maximum', instancePath, schemaPath + '/maximum', { comparison: '<=', limit: P.maximum }, `must be <= ${P.maximum}`)); valid = false; }
937
938
  if (P.exclusiveMinimum !== undefined && !(data > P.exclusiveMinimum)) { errors === NOERRORS || errors.push(err('exclusiveMinimum', 'exclusiveMinimum', instancePath, schemaPath + '/exclusiveMinimum', { comparison: '>', limit: P.exclusiveMinimum }, `must be > ${P.exclusiveMinimum}`)); valid = false; }
@@ -985,7 +986,7 @@ class Interpreter {
985
986
  }
986
987
  const minC = P.minContains !== undefined ? P.minContains : 1;
987
988
  if (matched.length < minC) { errors === NOERRORS || errors.push(err('contains', 'contains', instancePath, schemaPath + '/contains', { minContains: minC }, `must contain at least ${minC} valid item(s)`)); valid = false; }
988
- if (P.maxContains !== undefined && matched.length > P.maxContains) { errors === NOERRORS || errors.push(err('maxContains', 'maxContains', instancePath, schemaPath + '/maxContains', { limit: P.maxContains }, `must NOT contain more than ${P.maxContains} valid item(s)`)); valid = false; }
989
+ if (P.maxContains !== undefined && matched.length > P.maxContains) { errors === NOERRORS || errors.push(err('contains', 'contains', instancePath, schemaPath + '/contains', { minContains: minC, maxContains: P.maxContains }, `must NOT contain more than ${P.maxContains} valid item(s)`)); valid = false; }
989
990
  if (collect && matched.length) {
990
991
  if (!local.items) local.items = new Set();
991
992
  for (const i of matched) local.items.add(i);
@@ -1016,7 +1017,7 @@ class Interpreter {
1016
1017
  }
1017
1018
  if (P.propertyNames !== undefined) {
1018
1019
  for (const key of keys) {
1019
- if (!this.eval(P.propertyNames, key, base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/propertyNames', stack, DISCARD)) valid = false;
1020
+ if (!this.eval(P.propertyNames, key, base, dynScope, errors, instancePath, schemaPath + '/propertyNames', stack, DISCARD)) valid = false;
1020
1021
  }
1021
1022
  }
1022
1023
  const props = P.properties;
@@ -1039,7 +1040,13 @@ class Interpreter {
1039
1040
  }
1040
1041
  }
1041
1042
  if (!evaluated && P.additionalProperties !== undefined) {
1042
- if (!this.eval(P.additionalProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/additionalProperties', stack, DISCARD)) valid = false;
1043
+ // `additionalProperties: false` keeps its own keyword and names the
1044
+ // property it turned away. Running it as a plain false subschema
1045
+ // reported `not` at the key's path, which drops both.
1046
+ if (P.additionalProperties === false) {
1047
+ valid = false;
1048
+ if (errors !== NOERRORS) errors.push(err('additionalProperties', 'additionalProperties', instancePath, schemaPath + '/additionalProperties', { additionalProperty: key }, 'must NOT have additional properties'));
1049
+ } else if (!this.eval(P.additionalProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/additionalProperties', stack, DISCARD)) valid = false;
1043
1050
  evaluated = true;
1044
1051
  }
1045
1052
  if (evaluated && collect) {
@@ -1098,16 +1105,24 @@ class Interpreter {
1098
1105
  if (P.anyOf !== null) {
1099
1106
  let any = false;
1100
1107
  const scratch = errors === NOERRORS ? NOERRORS : [];
1108
+ // Per-branch errors, taken as a slice of the shared scratch rather than
1109
+ // an array each, so a branch that passes costs nothing extra.
1110
+ const branches = errors === NOERRORS ? null : [];
1101
1111
  for (let i = 0; i < P.anyOf.length; i++) {
1102
1112
  const sub = collect ? { props: null, items: null } : DISCARD;
1103
- if (this.eval(P.anyOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/anyOf/' + i, stack, sub)) {
1113
+ const mark = branches === null ? 0 : scratch.length;
1114
+ const ok = this.eval(P.anyOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/anyOf/' + i, stack, sub);
1115
+ if (branches !== null) branches.push({ valid: ok, errors: scratch.slice(mark), title: branchTitle(P.anyOf[i]) });
1116
+ if (ok) {
1104
1117
  any = true;
1105
1118
  if (collect) mergeAnnotations(local, sub);
1106
1119
  }
1107
1120
  }
1108
1121
  if (!any) {
1109
- if (errors !== NOERRORS) for (const e of scratch) errors.push(e);
1110
- if (errors !== NOERRORS) errors.push(err('anyOf', 'anyOf', instancePath, schemaPath + '/anyOf', {}, 'must match a schema in anyOf'));
1122
+ if (branches !== null) {
1123
+ const collapsed = collapseBranches({ keyword: 'anyOf', branchResults: branches, parentPath: instancePath, parentSchemaPath: schemaPath + '/anyOf' });
1124
+ if (collapsed !== null) errors.push(collapsed);
1125
+ }
1111
1126
  valid = false;
1112
1127
  }
1113
1128
  }
@@ -1115,17 +1130,25 @@ class Interpreter {
1115
1130
  let count = 0;
1116
1131
  let winner = null;
1117
1132
  const scratch = errors === NOERRORS ? NOERRORS : [];
1133
+ // Per-branch results, so a failing oneOf collapses to the branch that
1134
+ // came closest instead of every branch's complaints at once.
1135
+ const branches = errors === NOERRORS ? null : [];
1118
1136
  for (let i = 0; i < P.oneOf.length; i++) {
1119
1137
  const sub = collect ? { props: null, items: null } : DISCARD;
1120
- if (this.eval(P.oneOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/oneOf/' + i, stack, sub)) {
1138
+ const mark = branches === null ? 0 : scratch.length;
1139
+ const ok = this.eval(P.oneOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/oneOf/' + i, stack, sub);
1140
+ if (branches !== null) branches.push({ valid: ok, errors: scratch.slice(mark), title: branchTitle(P.oneOf[i]) });
1141
+ if (ok) {
1121
1142
  count++;
1122
1143
  winner = sub;
1123
1144
  }
1124
1145
  }
1125
1146
  if (count === 1) { if (collect) mergeAnnotations(local, winner); }
1126
1147
  else {
1127
- if (count === 0) for (const e of scratch) errors.push(e);
1128
- if (errors !== NOERRORS) errors.push(err('oneOf', 'oneOf', instancePath, schemaPath + '/oneOf', { passingSchemas: count }, 'must match exactly one schema in oneOf'));
1148
+ if (branches !== null) {
1149
+ const collapsed = collapseBranches({ keyword: 'oneOf', branchResults: branches, parentPath: instancePath, parentSchemaPath: schemaPath + '/oneOf' });
1150
+ if (collapsed !== null) errors.push(collapsed);
1151
+ }
1129
1152
  valid = false;
1130
1153
  }
1131
1154
  }
@@ -1182,6 +1205,12 @@ class Interpreter {
1182
1205
  }
1183
1206
  }
1184
1207
 
1208
+ // One branch title, for the collapsed anyOf/oneOf error. The compiled node
1209
+ // keeps the schema it came from, which is where a `title` lives.
1210
+ function branchTitle (node) {
1211
+ return node && node.schema && typeof node.schema.title === 'string' ? node.schema.title : '';
1212
+ }
1213
+
1185
1214
  function escapePointer(s) {
1186
1215
  if (typeof s !== 'string') s = String(s);
1187
1216
  // Keys almost never contain `~` or `/`; skip the two regex passes when so.