ata-validator 1.61.2 → 1.62.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.
Files changed (52) hide show
  1. package/README.md +159 -37
  2. package/bin/ata.js +35 -2
  3. package/compat.js +15 -2
  4. package/compiled-standard.d.ts +6 -0
  5. package/compiled-standard.mjs +66 -0
  6. package/index.d.ts +7 -0
  7. package/index.js +10 -0
  8. package/lib/aot-build.js +27 -1
  9. package/lib/aot-impl.js +108 -17
  10. package/lib/buffer-gate.js +25 -14
  11. package/lib/bun-target.js +21 -5
  12. package/lib/clone-emit.js +115 -10
  13. package/lib/codegen-engine.js +94 -18
  14. package/lib/compiled-common.js +11 -4
  15. package/lib/compiled-standard.js +33 -0
  16. package/lib/compiled-verdict.js +2 -2
  17. package/lib/compiled.js +1 -1
  18. package/lib/data-positions.js +18 -0
  19. package/lib/defaults.js +101 -23
  20. package/lib/describe-schema.js +38 -5
  21. package/lib/diagnose.js +51 -29
  22. package/lib/enrich-error.js +23 -1
  23. package/lib/error-sort-rank.js +12 -0
  24. package/lib/formats-source.js +12 -1
  25. package/lib/formats.js +19 -19
  26. package/lib/interpreter-core.js +23 -4
  27. package/lib/js-compiler-errors.js +70 -72
  28. package/lib/js-compiler.js +212 -72
  29. package/lib/json-rejections.js +1 -1
  30. package/lib/migrate.js +24 -5
  31. package/lib/native-attach.browser.js +7 -0
  32. package/lib/native-attach.js +76 -0
  33. package/lib/native-positions.js +7 -0
  34. package/lib/plan-compiler.js +7 -1
  35. package/lib/regex-linear.js +88 -0
  36. package/lib/rejections.js +13 -3
  37. package/lib/render-compact.js +5 -5
  38. package/lib/render-pretty.js +23 -10
  39. package/lib/safe-regex.js +29 -4
  40. package/lib/scan-compiler.js +16 -4
  41. package/lib/schema-diff.js +103 -0
  42. package/lib/stream.js +293 -0
  43. package/lib/tier0.js +6 -2
  44. package/lib/ts-gen.js +25 -12
  45. package/lib/unique-items.js +11 -3
  46. package/lib/validator-core.js +61 -66
  47. package/lib/verify.js +25 -6
  48. package/lib/version.js +1 -1
  49. package/package.json +24 -8
  50. package/stream.d.ts +51 -0
  51. package/stream.js +3 -0
  52. package/stream.mjs +3 -0
@@ -0,0 +1,76 @@
1
+ 'use strict';
2
+
3
+ // Sets up the native engine for one validator: its compiled schema and its
4
+ // slot in the addon's fast registry, which the buffer APIs read. Kept out of
5
+ // lib/validator-core.js so the browser build, which has no addon, maps this
6
+ // file to lib/native-attach.browser.js and leaves it out.
7
+ function attachNative(self, native) {
8
+ if (!native) return;
9
+ if (!self._schemaStr) self._schemaStr = JSON.stringify(self._schemaObj);
10
+ let nativeSchemaStr = self._schemaStr;
11
+ // A boolean root references nothing, so the registry has nowhere to go and
12
+ // is left out; merging it used to throw on `true` and `false`.
13
+ if (self._schemaMap.size > 0 && typeof self._schemaObj === 'object' && self._schemaObj !== null) {
14
+ const merged = JSON.parse(self._schemaStr);
15
+ if (!merged.$defs) merged.$defs = {};
16
+ // The registry goes into $defs under generated names. A name the
17
+ // schema already uses, or two ids that flatten to one name, would put
18
+ // one schema where another is referenced; the native engine is then
19
+ // left out and the JS engines answer.
20
+ for (const [id, s] of self._schemaMap) {
21
+ const name = '__ext_' + id.replace(/[^a-zA-Z0-9]/g, '_');
22
+ if (Object.prototype.hasOwnProperty.call(merged.$defs, name)) { self._fastSlot = -1; return; }
23
+ merged.$defs[name] = s;
24
+ }
25
+ nativeSchemaStr = JSON.stringify(merged);
26
+ }
27
+ // The fast registry is a fixed array of slots in the addon, and registering
28
+ // a distinct schema past the last one throws. It is an accelerator for the
29
+ // buffer path, not a requirement, so a full registry leaves the slot at -1
30
+ // and the buffer methods answer from the JS engine instead. Letting this
31
+ // throw made every validator built after the 4096th unusable on that path.
32
+ // A schema the native engine declines to compile (a pattern RE2 cannot
33
+ // take) goes the same way.
34
+ try {
35
+ self._compiled = new native.CompiledSchema(nativeSchemaStr);
36
+ self._fastSlot = native.fastRegister(nativeSchemaStr);
37
+ } catch {
38
+ self._compiled = null;
39
+ self._fastSlot = -1;
40
+ }
41
+ }
42
+
43
+ // The buffer APIs on the native engine's fast slot, or through validate()
44
+ // for the shapes lib/buffer-gate.js routes and a validator with no slot.
45
+ function installFast(self, native, bufferNeedsSlowPath, installSlowBufferApis) {
46
+ if (bufferNeedsSlowPath(self._schemaObj, self._schemaMap, self._keywords, self._userFormats)) return installSlowBufferApis(self);
47
+ self._ensureNative();
48
+ const slot = self._fastSlot;
49
+ if (!(slot >= 0)) return installSlowBufferApis(self);
50
+ const bytes = (b, who) => {
51
+ if (typeof b === 'string') return Buffer.from(b);
52
+ if (b instanceof Uint8Array) return b;
53
+ throw new TypeError(`${who}() requires a Buffer, Uint8Array, or string. For parsed objects, use isValidObject().`);
54
+ };
55
+ self.isValid = (b) => native.rawFastValidate(slot, bytes(b, 'isValid'));
56
+ self.isValidPrepadded = (paddedBuffer, jsonLength) => native.rawFastValidate(slot, paddedBuffer, jsonLength);
57
+ self.isValidParallel = (b) => native.rawParallelValidate(slot, bytes(b, 'isValidParallel'));
58
+ self.isValidNDJSON = (b) => native.rawNDJSONValidate(slot, bytes(b, 'isValidNDJSON'));
59
+ self.countValid = (b) => {
60
+ const r = native.rawNDJSONValidate(slot, bytes(b, 'countValid'));
61
+ let n = 0;
62
+ for (let i = 0; i < r.length; i++) if (r[i]) n++;
63
+ return n;
64
+ };
65
+ self.batchIsValid = (buffers) => {
66
+ let n = 0;
67
+ for (const b of buffers) {
68
+ if (!(b instanceof Uint8Array)) throw new TypeError('batchIsValid() requires Buffer or Uint8Array elements');
69
+ if (native.rawFastValidate(slot, b)) n++;
70
+ }
71
+ return n;
72
+ };
73
+ }
74
+
75
+ attachNative.installFast = installFast;
76
+ module.exports = attachNative;
@@ -37,6 +37,13 @@ function nativeTargeted (text, wanted) {
37
37
  const off = offs[k];
38
38
  const at = lineOf(off);
39
39
  const entry = { byteOffset: off, length: found[2 * k + 1], line: at.line, col: off - at.start + 1, text: at.text };
40
+ // A long line is cut around the value, as in lib/data-positions.js.
41
+ if (entry.text.length > 200) {
42
+ let s = entry.col > 61 ? entry.col - 61 : 0;
43
+ if (s + 200 > entry.text.length) s = entry.text.length - 200;
44
+ entry.text = entry.text.slice(s, s + 200);
45
+ entry.textCol = entry.col - s;
46
+ }
40
47
  const ks = keyStartBefore(text, off);
41
48
  if (ks >= 0 && ptrs[k] !== '') {
42
49
  const kat = lineOf(ks);
@@ -530,9 +530,15 @@ function install(deps) {
530
530
  // lookup, and most schemas declare a handful.
531
531
  const propKeys = props !== null ? [...props.keys()] : null;
532
532
  // req: the key is also required, so holding undefined it is missing.
533
+ // Flagged only when the leaf folded `required` into this walk
534
+ // (reqFolded in compileLeafV). With an undeclared required name the
535
+ // leaf checks every required key itself, and a flag here would be
536
+ // counted against a total of zero, so a document with the key was
537
+ // refused until the closure plan replaced this one.
533
538
  const reqIn = P.requiredInProps;
539
+ const reqWalked = P.required !== null && P.requiredUndef === null && reqIn !== null;
534
540
  const propList = props !== null
535
- ? [...props].map(([key, entry]) => ({ fn: child(entry.node), seg: entry.seg, schemaSeg: entry.schemaSeg, req: reqIn !== null && reqIn.has(key) }))
541
+ ? [...props].map(([key, entry]) => ({ fn: child(entry.node), seg: entry.seg, schemaSeg: entry.schemaSeg, req: reqWalked && reqIn.has(key) }))
536
542
  : null;
537
543
  const propMap = props !== null && props.size > 8 ? new Map(propKeys.map((k, i) => [k, propList[i]])) : null;
538
544
  const lookup = propMap !== null
@@ -290,8 +290,96 @@ function delimitedLoopRule (ast) {
290
290
  return run(suffix)
291
291
  }
292
292
 
293
+ // A Unicode property escape, `\p{L}` or `\P{Nd}`, read as one class so the
294
+ // rules above can judge a pattern that uses one: exact on ASCII, where the
295
+ // platform answers for each of the 128 characters, and taken to match
296
+ // everything above it, which is the conservative side (an overlap that is
297
+ // not there only declines the pattern). The class is written back into the
298
+ // pattern as ranges, in brackets outside a class and bare inside one, and
299
+ // the result parsed as any other pattern. A pattern the platform refuses
300
+ // in unicode mode is declined: without the flag `\p{L}` is the letters p,
301
+ // {, L, }, and the generators write such a pattern with the flag only when
302
+ // it is valid with it.
303
+ const PROP_ESCAPE = /\\[pP]\{/
304
+ const _propRanges = new Map()
305
+ function propRanges (esc) {
306
+ let out = _propRanges.get(esc)
307
+ if (out !== undefined) return out
308
+ const re = new RegExp(esc, 'u')
309
+ const hex = (c) => '\\u' + c.toString(16).padStart(4, '0')
310
+ out = ''
311
+ for (let c = 0; c < 128; c++) {
312
+ if (!re.test(String.fromCharCode(c))) continue
313
+ let d = c
314
+ while (d + 1 < 128 && re.test(String.fromCharCode(d + 1))) d++
315
+ out += d === c ? hex(c) : hex(c) + '-' + hex(d)
316
+ c = d
317
+ }
318
+ _propRanges.set(esc, out)
319
+ return out
320
+ }
321
+ function expandPropertyEscapes (src) {
322
+ try { new RegExp(src, 'u') } catch { return null }
323
+ let out = ''
324
+ let inClass = false
325
+ let negated = false
326
+ for (let i = 0; i < src.length; i++) {
327
+ const c = src[i]
328
+ if (c === '\\') {
329
+ const e = src[i + 1]
330
+ if ((e === 'p' || e === 'P') && src[i + 2] === '{') {
331
+ const close = src.indexOf('}', i + 3)
332
+ if (close < 0) return null
333
+ const ascii = propRanges(src.slice(i, close + 1))
334
+ // Everything above ASCII is taken to match, except inside a negated
335
+ // class, where adding it would take it away: there the ASCII part
336
+ // alone is written, and the class keeps every character above.
337
+ if (!inClass) out += '[' + ascii + '\\u0080-\\uffff]'
338
+ else out += negated ? ascii : ascii + '\\u0080-\\uffff'
339
+ i = close
340
+ continue
341
+ }
342
+ out += c + (e === undefined ? '' : e)
343
+ i++
344
+ continue
345
+ }
346
+ if (c === '[' && !inClass) { inClass = true; negated = src[i + 1] === '^' }
347
+ else if (c === ']' && inClass) inClass = false
348
+ out += c
349
+ }
350
+ // A negated class left with nothing in it, `[^]`, would read as any
351
+ // character; the parser may not take that form, so such a pattern declines.
352
+ if (/\[\^\]/.test(out)) return null
353
+ return out
354
+ }
355
+
356
+ // A pattern with no `$` that ends in a greedy loop over one character class
357
+ // (`\p{L}+`, the usual patternProperties key) is tested for a match anywhere:
358
+ // the loop takes what it can and the match succeeds, so it never backtracks
359
+ // into the loop, and for test() `X+` at the end costs what `X` costs and `X*`
360
+ // costs nothing. The rules above refuse an unanchored loop, so such a key
361
+ // went to the interpreted engine for want of a linear-time reading. Applied
362
+ // to patterns with a property escape only, which have no other engine to go
363
+ // to; the rest keep their current routing.
364
+ const TRAILING_LOOP = /(\[(?:\\.|[^\]\\])*\]|\\[dDwWsS]|\.)([+*])$/
365
+ function dropTrailingLoop (src) {
366
+ if (src.endsWith('$')) return src
367
+ const m = TRAILING_LOOP.exec(src)
368
+ if (!m) return src
369
+ // The atom must start at a real class or escape, not inside an escape.
370
+ let bs = 0
371
+ for (let i = m.index - 1; i >= 0 && src[i] === '\\'; i--) bs++
372
+ if (bs % 2) return src
373
+ return src.slice(0, m.index) + (m[2] === '+' ? m[1] : '')
374
+ }
375
+
293
376
  function nativeIsLinear (src) {
294
377
  if (typeof src !== 'string' || src.length > 512) return false
378
+ if (PROP_ESCAPE.test(src)) {
379
+ src = expandPropertyEscapes(src)
380
+ if (src === null) return false
381
+ src = dropTrailingLoop(src)
382
+ }
295
383
  for (let i = 0; i < src.length; i++) {
296
384
  const c = src.charCodeAt(i)
297
385
  if (c >= 0xd800 && c <= 0xdfff) return false
package/lib/rejections.js CHANGED
@@ -226,9 +226,19 @@ function sortErrorsBySchemaOrder(rootSchema, errors) {
226
226
  prevPath = e.schemaPath;
227
227
  }
228
228
  if (sorted) return errors;
229
- // Error lists are short. A stable insertion sort over the integer keys
230
- // moves the errors in tandem with no index array; equal keys fall back to
231
- // the rank only when the paths differ.
229
+ // Error lists are usually short. A stable insertion sort over the integer
230
+ // keys moves the errors in tandem with no index array; equal keys fall back
231
+ // to the rank only when the paths differ. It is quadratic, though: 20000
232
+ // errors out of order took seconds. Past a short list the platform's sort,
233
+ // which is stable, orders an index array by the same comparison.
234
+ if (n > 32) {
235
+ const idx = new Array(n);
236
+ for (let i = 0; i < n; i++) idx[i] = i;
237
+ idx.sort((a, b) => (keys[a] - keys[b]) || (keys[a] === keys[b] && errors[a].schemaPath !== errors[b].schemaPath ? tieOrder(rootSchema, errors[a].schemaPath, errors[b].schemaPath) : 0) || a - b);
238
+ const res = new Array(n);
239
+ for (let i = 0; i < n; i++) res[i] = errors[idx[i]];
240
+ return res;
241
+ }
232
242
  const out = errors.slice();
233
243
  for (let i = 1; i < n; i++) {
234
244
  const k = keys[i];
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { color, ANSI, resolveColor, trimCwd } = require('./render-shared');
4
- const { toDiagnostics } = require('./diagnose');
4
+ const { toDiagnostics, term } = require('./diagnose');
5
5
 
6
6
  const { getDiagnosticSource } = require('./diagnostic-source');
7
7
 
@@ -19,12 +19,12 @@ function renderCompact (errors, opts) {
19
19
  let prefix = '';
20
20
  const raw = d.mergedFrom[0];
21
21
  if (raw && raw.schemaSource) {
22
- const f = trimCwd(raw.schemaSource.file, cwd);
22
+ const f = term(trimCwd(raw.schemaSource.file, cwd));
23
23
  prefix = color(useColor, ANSI.cyan, `${f}:${raw.schemaSource.line}:${raw.schemaSource.col}`) + ' - ';
24
24
  }
25
- const codeStr = color(useColor, ANSI.red + ANSI.bold, d.code ? `error ${d.code}` : 'error');
25
+ const codeStr = color(useColor, ANSI.red + ANSI.bold, d.code ? `error ${term(d.code)}` : 'error');
26
26
  const pathStr = color(useColor, ANSI.cyan, d.dotted);
27
- const got = raw && raw.received != null ? `got ${raw.received}` : '';
27
+ const got = raw && raw.received != null ? `got ${term(String(raw.received))}` : '';
28
28
  const sugg = d.help ? color(useColor, ANSI.yellow, `, ${d.help}`) : '';
29
29
  const tail = got || sugg ? ` (${got}${sugg})` : '';
30
30
  lines.push(`${prefix}${codeStr}: ${pathStr} ${d.headline}${tail}`);
@@ -33,7 +33,7 @@ function renderCompact (errors, opts) {
33
33
  const n = errors.length;
34
34
  const shown = diags.length;
35
35
  lines.push('');
36
- let summary = `Found ${n} error${n === 1 ? '' : 's'} in ${opts.context || 'input'}`;
36
+ let summary = `Found ${n} error${n === 1 ? '' : 's'} in ${term(opts.context || 'input')}`;
37
37
  if (shown !== n) summary += `, shown as ${shown} diagnostic${shown === 1 ? '' : 's'}`;
38
38
  lines.push(summary + '.');
39
39
  if (!(typeof process !== 'undefined' && process.stdout && process.stdout.isTTY)) {
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { color, ANSI, resolveColor, trimCwd, truncateLine, terminalWidth } = require('./render-shared');
4
- const { toDiagnostics } = require('./diagnose');
4
+ const { toDiagnostics, term, termLine } = require('./diagnose');
5
5
 
6
6
  const { getDiagnosticSource } = require('./diagnostic-source');
7
7
 
@@ -14,6 +14,18 @@ function sourceFor (errors, opts) {
14
14
  // Never wider than the terminal. The old form repeated the caret for the
15
15
  // full span of the value, which for a root-level error was the whole
16
16
  // document drawn under a one-character line.
17
+ // The part of a long line around the caret that fits the terminal, and the
18
+ // caret's column in it. Cut from the start alone, a value past the terminal
19
+ // width had its caret drawn beyond the end of the text.
20
+ function windowAround (text, col, max) {
21
+ if (!text || text.length <= max) return { text, col };
22
+ const s = Math.max(0, Math.min(col - 1 - Math.floor(max / 3), text.length - max));
23
+ let t = text.slice(s, s + max);
24
+ if (s > 0) t = '\u2026' + t.slice(1);
25
+ if (s + max < text.length) t = t.slice(0, -1) + '\u2026';
26
+ return { text: t, col: col - s };
27
+ }
28
+
17
29
  function caretLine (col, length, gutter) {
18
30
  const pad = ' '.repeat(gutter);
19
31
  const lead = ' '.repeat(Math.max(0, col - 1));
@@ -29,7 +41,7 @@ const NO_SUFFIX = new Set(['additionalProperties', 'unevaluatedProperties', 'une
29
41
  function caretSuffix (d, raw, useColor) {
30
42
  if (!raw) return '';
31
43
  if (raw.keyword === 'required') {
32
- return raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${raw.expected}`) : '';
44
+ return raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${term(raw.expected)}`) : '';
33
45
  }
34
46
  // A composition error anchored inside the closest branch has a value there.
35
47
  if (d.found != null && (raw.keyword === 'oneOf' || raw.keyword === 'anyOf')) {
@@ -50,7 +62,7 @@ function renderOne (d, useColor, opts) {
50
62
 
51
63
  // Headline carries the code when there is one. A code-less error, which the
52
64
  // LazyRejection fallback can produce, must not render as "error[undefined]".
53
- const label = d.code ? `error[${d.code}]: ` : 'error: ';
65
+ const label = d.code ? `error[${term(d.code)}]: ` : 'error: ';
54
66
  lines.push(color(useColor, ANSI.red + ANSI.bold, label) + d.headline);
55
67
 
56
68
  // Schema source frame, when the Validator was built with a `source` option.
@@ -59,12 +71,12 @@ function renderOne (d, useColor, opts) {
59
71
  // points at the value.
60
72
  const raw = d.mergedFrom[0];
61
73
  if (raw && raw.schemaSource) {
62
- const f = trimCwd(raw.schemaSource.file, opts.cwd);
74
+ const f = term(trimCwd(raw.schemaSource.file, opts.cwd));
63
75
  lines.push(` --> ${color(useColor, ANSI.cyan, `${f}:${raw.schemaSource.line}:${raw.schemaSource.col}`)}`);
64
76
  const sl = raw.schemaSource.line;
65
77
  lines.push(bar(sl));
66
- lines.push(src(sl, truncateLine(raw.schemaSource.text, width - 8)));
67
- const inlineHint = raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${raw.expected}`) : '';
78
+ lines.push(src(sl, truncateLine(termLine(raw.schemaSource.text), width - 8)));
79
+ const inlineHint = raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${term(raw.expected)}`) : '';
68
80
  lines.push(caretLine(raw.schemaSource.col, 1, bar(sl).length - 1) + inlineHint);
69
81
  lines.push(bar(sl));
70
82
  }
@@ -76,8 +88,9 @@ function renderOne (d, useColor, opts) {
76
88
  lines.push(` --> ${color(useColor, ANSI.cyan, where)} ${color(useColor, ANSI.dim, '(' + d.dotted + ')')}`);
77
89
  const fl = d.frame.line;
78
90
  lines.push(bar(fl));
79
- lines.push(src(fl, truncateLine(d.frame.text, width - 8)));
80
- lines.push(caretLine(d.frame.col, d.frame.length, bar(fl).length - 1) + caretSuffix(d, raw, useColor));
91
+ const shown = windowAround(d.frame.text, d.frame.textCol !== undefined ? d.frame.textCol : d.frame.col, width - 8);
92
+ lines.push(src(fl, shown.text));
93
+ lines.push(caretLine(shown.col, d.frame.length, bar(fl).length - 1) + caretSuffix(d, raw, useColor));
81
94
  lines.push(bar(fl));
82
95
  } else {
83
96
  lines.push(` --> ${color(useColor, ANSI.cyan, 'at ' + d.dotted)}`);
@@ -85,7 +98,7 @@ function renderOne (d, useColor, opts) {
85
98
 
86
99
  if (d.help) lines.push(' = ' + color(useColor, ANSI.yellow, 'help: ') + d.help);
87
100
  if (d.branchErrors && d.branchErrors.length) {
88
- const variant = (raw && raw.params && raw.params.closestName) || 'closest variant';
101
+ const variant = term((raw && raw.params && raw.params.closestName) || 'closest variant');
89
102
  const n = d.branchErrors.length;
90
103
  lines.push(' = ' + color(useColor, ANSI.dim, 'note: ') + `closest match was ${variant} with ${n} error${n === 1 ? '' : 's'}:`);
91
104
  renderBranchErrors(d.branchErrors, 1, lines, useColor);
@@ -125,7 +138,7 @@ function renderPretty (errors, opts) {
125
138
  opts = opts || {};
126
139
  const useColor = resolveColor(opts.color || 'auto');
127
140
  const maxErrors = opts.maxErrors != null ? opts.maxErrors : 20;
128
- const context = opts.context || 'input';
141
+ const context = term(opts.context || 'input');
129
142
 
130
143
  const diags = toDiagnostics(errors, sourceFor(errors, opts));
131
144
 
package/lib/safe-regex.js CHANGED
@@ -155,10 +155,18 @@ function __ataSafeRegex () {
155
155
  case 't': return { t: 'char', c: 9 }
156
156
  case 'f': return { t: 'char', c: 12 }
157
157
  case 'v': return { t: 'char', c: 11 }
158
- case '0': return { t: 'char', c: 0 }
159
- case 'x': { const h = src.slice(i, i + 2); i += 2; return { t: 'char', c: parseInt(h, 16) } }
160
- case 'u': { const h = src.slice(i, i + 4); i += 4; return { t: 'char', c: parseInt(h, 16) } }
158
+ case '0': if (/[0-9]/.test(src[i] || '')) throw new Error('octal escapes are not supported in pattern'); return { t: 'char', c: 0 }
159
+ // A malformed \x or \u is a syntax error under the u flag and the letter
160
+ // under the legacy one; here it read as NaN and matched U+0000. Refused.
161
+ case 'x': { const h = src.slice(i, i + 2); if (!/^[0-9a-fA-F]{2}$/.test(h)) throw new Error('malformed \\x escape in pattern'); i += 2; return { t: 'char', c: parseInt(h, 16) } }
162
+ case 'u': { const h = src.slice(i, i + 4); if (!/^[0-9a-fA-F]{4}$/.test(h)) throw new Error('malformed \\u escape in pattern'); i += 4; return { t: 'char', c: parseInt(h, 16) } }
161
163
  case 'b': if (inClass) return { t: 'char', c: 8 }; throw new Error('\\b word boundary is not supported')
164
+ // Escapes with a meaning the engine does not implement: \B (non word
165
+ // boundary), \c (control letter), \k (named backreference), \p and
166
+ // \P (Unicode property), and above a legacy octal \0n. Read as the
167
+ // letter, ^\B(a|a)$ accepted "Ba" and ^\p{L}+$ accepted "p{L}"; the
168
+ // callers that ask patternIsSafe directly reached the second.
169
+ case 'B': case 'c': case 'k': case 'p': case 'P': throw new Error('\\' + ch + ' is not supported in pattern')
162
170
  default:
163
171
  if (/[1-9]/.test(ch)) throw new Error('backreferences are not supported in pattern')
164
172
  return { t: 'char', c: ch.charCodeAt(0) }
@@ -170,9 +178,26 @@ function __ataSafeRegex () {
170
178
  return ast
171
179
  }
172
180
 
181
+ // A counted repeat is compiled by copying its child, so the program grows
182
+ // with the product of the counts through nesting: (((a|a)*){1000}){1000}
183
+ // was a million copies and one level more ran the process out of memory.
184
+ // Past this many instructions the pattern is refused (compileSafe throws,
185
+ // patternIsSafe answers false) and the caller takes the platform RegExp.
186
+ const MAX_PROG = 1 << 16
187
+
188
+ // The size the program would have, counted before anything is emitted, so a
189
+ // repeat of an empty group ((){1e5}){1e5} is refused without looping either.
190
+ // An iteration costs one even when its child emits nothing. Capped past the
191
+ // budget so nested products stay finite.
192
+ const BUDGET = 'pattern expands past the instruction budget'
173
193
  function compileProg (ast) {
174
194
  const prog = []
175
- const emit = (op, extra) => { const idx = prog.length; prog.push(Object.assign({ op }, extra)); return idx }
195
+ const emit = (op, extra) => {
196
+ const idx = prog.length
197
+ if (idx >= MAX_PROG) throw new Error(BUDGET)
198
+ prog.push(Object.assign({ op }, extra))
199
+ return idx
200
+ }
176
201
 
177
202
  function rec (n) {
178
203
  switch (n.t) {
@@ -211,6 +211,9 @@ function resolveLocalRef(root, ref) {
211
211
  // takes, from every branch, either that branch's subschema for it or that
212
212
  // branch's `additionalProperties`.
213
213
 
214
+ // Past this many layers in one merge the scanner declines the schema.
215
+ const MAX_LAYERS = 256;
216
+
214
217
  // Flattens a node and its branches into layers, resolving references as it goes.
215
218
  function collectLayers(node, ctx, out, depth) {
216
219
  if (depth > MAX_SCHEMA_DEPTH) decline('allOf:too-deep');
@@ -232,12 +235,18 @@ function collectLayers(node, ctx, out, depth) {
232
235
  ctx.refStack.pop();
233
236
  return;
234
237
  }
235
- const own = {};
238
+ const own = Object.create(null);
236
239
  for (const k of Object.keys(node)) {
237
240
  if (k === 'allOf' || IGNORED.has(k)) continue;
238
241
  own[k] = node[k];
239
242
  }
240
- if (Object.keys(own).length > 0) out.push(own);
243
+ if (Object.keys(own).length > 0) {
244
+ // Depth is bounded above, width is not: allOf of two $refs to the same
245
+ // definition, chained, doubles the layers at every step, and 2^n of
246
+ // them ran the process out of memory before any size check.
247
+ if (out.length >= MAX_LAYERS) decline('allOf:too-wide');
248
+ out.push(own);
249
+ }
241
250
  if (node.allOf !== undefined) {
242
251
  if (!Array.isArray(node.allOf)) decline('allOf:not-array');
243
252
  for (const branch of node.allOf) collectLayers(branch, ctx, out, depth + 1);
@@ -273,7 +282,8 @@ function mergeAllOf(schema, ctx) {
273
282
  if (L.unevaluatedProperties !== undefined || L.unevaluatedItems !== undefined) decline('allOf:unevaluated');
274
283
  }
275
284
 
276
- const out = {};
285
+ // Null prototype for the same reason as the merged property map below.
286
+ const out = Object.create(null);
277
287
 
278
288
  // type: the intersection of the named sets, with `integer` inside `number`,
279
289
  // so number and integer together are integer rather than nothing. Empty means
@@ -334,7 +344,9 @@ function mergeAllOf(schema, ctx) {
334
344
  for (const k of Object.keys(L.properties)) if (!names.includes(k)) names.push(k);
335
345
  }
336
346
  if (names.length > 0) {
337
- out.properties = {};
347
+ // Null prototype: a property named __proto__ written into a plain object
348
+ // sets its prototype and the property's schema is silently lost.
349
+ out.properties = Object.create(null);
338
350
  for (const name of names) {
339
351
  const parts = [];
340
352
  for (const L of layers) {
@@ -0,0 +1,103 @@
1
+ 'use strict';
2
+
3
+ // What a change to a schema does to documents: which ones the old schema
4
+ // accepted and the new one rejects (a client sending what used to pass gets a
5
+ // 400), and which ones the new one accepts and the old one did not (a rule
6
+ // was loosened). Reading two schemas side by side tells you which keywords
7
+ // changed; it does not tell you which documents change verdict, and that is
8
+ // the question before a deploy. The documents come from `generate` in
9
+ // lib/verify.js, made from both schemas, half of them with one rule broken,
10
+ // plus the values on either side of every bound, plus the project's own
11
+ // samples. Generated documents are a sample, not a proof: a change this
12
+ // finds is real, one it does not find may still exist.
13
+
14
+ const { Validator } = require('../index.js');
15
+ const { generate, readSamples } = require('./verify');
16
+
17
+ const clone = (v) => (v === undefined ? v : JSON.parse(JSON.stringify(v)));
18
+ const errorKey = (e) => `${e.keyword || ''}@${e.instancePath || ''}`;
19
+ const first = (errors) => (errors && errors.length ? errors[0] : { keyword: 'validation', instancePath: '', message: 'validation failed' });
20
+
21
+ // `samples`: paths of JSON or JSON Lines files, or directories of them.
22
+ function diffSchemas (oldSchema, newSchema, { generate: count = 200, seed = 1, samples = [] } = {}) {
23
+ const before = new Validator(oldSchema);
24
+ const after = new Validator(newSchema);
25
+ const docs = [];
26
+ const seen = new Set();
27
+ const add = (doc, source) => {
28
+ if (doc === undefined) return;
29
+ const key = JSON.stringify(doc);
30
+ if (seen.has(key)) return;
31
+ seen.add(key);
32
+ docs.push({ doc, source });
33
+ };
34
+ for (const d of generate(oldSchema, count, seed, (x) => before.isValidObject(clone(x)))) add(d, 'generated from the old schema');
35
+ for (const d of generate(newSchema, count, seed + 1, (x) => after.isValidObject(clone(x)))) add(d, 'generated from the new schema');
36
+ for (const s of readSamples(samples)) if (!s.bad) add(s.doc, s.source);
37
+
38
+ const nowRejected = [];
39
+ const nowAccepted = [];
40
+ let bothAccept = 0;
41
+ let bothReject = 0;
42
+ for (const { doc, source } of docs) {
43
+ const a = before.validate(clone(doc));
44
+ const b = after.validate(clone(doc));
45
+ if (a.valid && b.valid) bothAccept++;
46
+ else if (!a.valid && !b.valid) bothReject++;
47
+ else if (a.valid) nowRejected.push({ doc, source, error: first(b.errors) });
48
+ else nowAccepted.push({ doc, source, error: first(a.errors) });
49
+ }
50
+ return {
51
+ documents: docs.length,
52
+ bothAccept,
53
+ bothReject,
54
+ nowRejected: grouped(nowRejected),
55
+ nowAccepted: grouped(nowAccepted),
56
+ };
57
+ }
58
+
59
+ // The documents of one kind, grouped by the rule that decides them (the
60
+ // first error's keyword and place), each group with one example: one of the
61
+ // project's own documents when the group holds one, since that is a document
62
+ // somebody actually sends, else the first generated one.
63
+ const generated = (source) => source.startsWith('generated');
64
+ function grouped (list) {
65
+ const groups = new Map();
66
+ for (const item of list) {
67
+ const key = errorKey(item.error);
68
+ let g = groups.get(key);
69
+ if (g === undefined) groups.set(key, (g = { rule: key, message: item.error.message, count: 0, samples: 0, example: item.doc, source: item.source }));
70
+ g.count++;
71
+ if (!generated(item.source)) { g.samples++; if (generated(g.source)) { g.example = item.doc; g.source = item.source; } }
72
+ }
73
+ return { count: list.length, rules: [...groups.values()].sort((x, y) => y.count - x.count) };
74
+ }
75
+
76
+ function breaking (report) { return report.nowRejected.count > 0; }
77
+
78
+ function renderDiff (report, { json = false, oldName = 'old', newName = 'new' } = {}) {
79
+ if (json) return JSON.stringify(report, null, 2);
80
+ const lines = [];
81
+ const show = (doc) => { const s = JSON.stringify(doc); return s.length > 120 ? s.slice(0, 117) + '...' : s; };
82
+ lines.push(`${report.documents} documents: ${report.bothAccept} accepted by both, ${report.bothReject} rejected by both, ${report.nowRejected.count} rejected only by ${newName}, ${report.nowAccepted.count} accepted only by ${newName}`);
83
+ if (report.nowRejected.count) {
84
+ lines.push('');
85
+ lines.push(`Rejected by ${newName}, accepted by ${oldName} (a client sending these gets a rejection after the change):`);
86
+ for (const g of report.nowRejected.rules) {
87
+ lines.push(` ${g.rule}: ${g.count} ${g.count === 1 ? 'document' : 'documents'}${g.samples ? `, ${g.samples} of them the project's own` : ''}, ${g.message}`);
88
+ lines.push(` ${show(g.example)}${generated(g.source) ? '' : ` (${g.source})`}`);
89
+ }
90
+ }
91
+ if (report.nowAccepted.count) {
92
+ lines.push('');
93
+ lines.push(`Accepted by ${newName}, rejected by ${oldName} (what ${oldName} refused with):`);
94
+ for (const g of report.nowAccepted.rules) {
95
+ lines.push(` ${g.rule}: ${g.count} ${g.count === 1 ? 'document' : 'documents'}${g.samples ? `, ${g.samples} of them the project's own` : ''}, ${g.message}`);
96
+ lines.push(` ${show(g.example)}${generated(g.source) ? '' : ` (${g.source})`}`);
97
+ }
98
+ }
99
+ if (!report.nowRejected.count && !report.nowAccepted.count) lines.push('No document changed verdict.');
100
+ return lines.join('\n');
101
+ }
102
+
103
+ module.exports = { diffSchemas, renderDiff, breaking };