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.
- package/README.md +159 -37
- package/bin/ata.js +35 -2
- package/compat.js +15 -2
- package/compiled-standard.d.ts +6 -0
- package/compiled-standard.mjs +66 -0
- package/index.d.ts +7 -0
- package/index.js +10 -0
- package/lib/aot-build.js +27 -1
- package/lib/aot-impl.js +108 -17
- package/lib/buffer-gate.js +25 -14
- package/lib/bun-target.js +21 -5
- package/lib/clone-emit.js +115 -10
- package/lib/codegen-engine.js +94 -18
- package/lib/compiled-common.js +11 -4
- package/lib/compiled-standard.js +33 -0
- package/lib/compiled-verdict.js +2 -2
- package/lib/compiled.js +1 -1
- package/lib/data-positions.js +18 -0
- package/lib/defaults.js +101 -23
- package/lib/describe-schema.js +38 -5
- package/lib/diagnose.js +51 -29
- package/lib/enrich-error.js +23 -1
- package/lib/error-sort-rank.js +12 -0
- package/lib/formats-source.js +12 -1
- package/lib/formats.js +19 -19
- package/lib/interpreter-core.js +23 -4
- package/lib/js-compiler-errors.js +70 -72
- package/lib/js-compiler.js +212 -72
- package/lib/json-rejections.js +1 -1
- package/lib/migrate.js +24 -5
- package/lib/native-attach.browser.js +7 -0
- package/lib/native-attach.js +76 -0
- package/lib/native-positions.js +7 -0
- package/lib/plan-compiler.js +7 -1
- package/lib/regex-linear.js +88 -0
- package/lib/rejections.js +13 -3
- package/lib/render-compact.js +5 -5
- package/lib/render-pretty.js +23 -10
- package/lib/safe-regex.js +29 -4
- package/lib/scan-compiler.js +16 -4
- package/lib/schema-diff.js +103 -0
- package/lib/stream.js +293 -0
- package/lib/tier0.js +6 -2
- package/lib/ts-gen.js +25 -12
- package/lib/unique-items.js +11 -3
- package/lib/validator-core.js +61 -66
- package/lib/verify.js +25 -6
- package/lib/version.js +1 -1
- package/package.json +24 -8
- package/stream.d.ts +51 -0
- package/stream.js +3 -0
- 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;
|
package/lib/native-positions.js
CHANGED
|
@@ -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);
|
package/lib/plan-compiler.js
CHANGED
|
@@ -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:
|
|
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
|
package/lib/regex-linear.js
CHANGED
|
@@ -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
|
|
230
|
-
// moves the errors in tandem with no index array; equal keys fall back
|
|
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];
|
package/lib/render-compact.js
CHANGED
|
@@ -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)) {
|
package/lib/render-pretty.js
CHANGED
|
@@ -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
|
-
|
|
80
|
-
lines.push(
|
|
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
|
-
|
|
160
|
-
|
|
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) => {
|
|
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) {
|
package/lib/scan-compiler.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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
|
-
|
|
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 };
|