ata-validator 1.8.2 → 1.10.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/CHANGELOG.md +23 -0
- package/index.js +67 -7
- package/lib/correlate.js +106 -0
- package/lib/data-positions.js +21 -3
- package/lib/diagnose.js +312 -0
- package/lib/diagnostic-source.js +46 -0
- package/lib/enrich-error.js +76 -0
- package/lib/formats.js +57 -0
- package/lib/interpreter.js +3 -2
- package/lib/js-compiler.js +244 -126
- package/lib/levenshtein.js +13 -2
- package/lib/render-compact.js +21 -11
- package/lib/render-pretty.js +89 -40
- package/lib/version.js +1 -1
- package/package.json +9 -9
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to ata-validator are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning.
|
|
4
4
|
|
|
5
|
+
## 1.10.0 - 2026-08-30
|
|
6
|
+
|
|
7
|
+
### Performance
|
|
8
|
+
|
|
9
|
+
- The code generator takes shapes it used to decline for no correctness reason: boolean subschemas in `items`, `properties`, `patternProperties`, `dependentSchemas`, `propertyNames`, `allOf`, `anyOf`, `not`, `contains` and `if`/`then`/`else`, recursive `#/$defs/` references as named functions, and `additionalProperties` as a schema alongside composition or `patternProperties`. Each lands in all three generators and the closure path, held to the interpreter by `tests/test_codegen_edge_shapes.js` and the entry-point agreement test over the whole official suite. Every suite group that moved off the interpreter got faster, 31 of 31 on draft 2020-12 and 28 of 28 on draft 7, summed per-group time down 70 percent. The suite-wide figure, measured interleaved against the previous release in one process, did not move outside that measurement's noise; `benchmark/verdict-bench.md` has the numbers and says why.
|
|
10
|
+
- `date` and `ipv4` format checks read the string once with no regular expression and no allocation: 45.7 to 14.2 ns and 54.5 to 27.1 ns on a valid value, interleaved medians. Fuzzed against the previous forms with 0 mismatches; `tests/test_formats_single_pass.js` keeps it that way.
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- A key matched only by the second of two `patternProperties` entries, alongside `additionalProperties: false`, was rejected: the generated key loop returned at the first pattern that missed. Found while rewriting that loop; covered by the edge-shape test.
|
|
15
|
+
- A declared property that also matched a `patternProperties` entry skipped the pattern's schema in the generated code when `additionalProperties` was a schema. The suite's own interaction case caught it the moment the shape was allowed to compile.
|
|
16
|
+
|
|
17
|
+
## 1.9.0 - 2026-08-28
|
|
18
|
+
|
|
19
|
+
### Errors
|
|
20
|
+
|
|
21
|
+
- Rendered diagnostics now say where the problem is. Every `renderPretty` and `renderCompact` block carries the JSON path, and a source frame with a caret on the failing token when one can be built faithfully: from the text on the `validateJSON` path, or from the object when the renderer is handed `{ data }`. Object-input frames are reconstructed by re-serializing the value and the output says so once, because the line numbers refer to that reconstruction. No frame is reconstructed under `coerceTypes`, `removeAdditional` or a schema with `default` values, since the object in hand is not what the caller sent; the output names the option instead.
|
|
22
|
+
- Headlines state the observation rather than the rule: `expected integer, found string` in place of `must be integer`. Two `additionalProperties` violations no longer render as identical blocks; the property name is in the headline. A composition failure with a `const` discriminator names it, `no variant matches kind "circle"`, anchors inside the closest branch, and its branch notes read `minimum: expected ≥0, found -1`.
|
|
23
|
+
- A property typed `nmae` where `name` was required renders as one diagnostic with `did you mean "name"?` instead of two. The correlation requires that no other missing or extra key in the same object is equally close; on a tie nothing is merged. Both errors stay in the array and point at each other through the new `related` field, and the footer reads `8 schema violations in input, shown as 7 diagnostics` so the count never drifts from `errors.length`.
|
|
24
|
+
- Diagnostics are ordered by document position, with cause before effect only as a tie-break within one container. Carets are clamped to the line they sit under; a root-level error used to draw one the width of the whole document.
|
|
25
|
+
- Under `richErrors` (the default) errors gain `detail`, `related`, `anchor` and `rank`. Existing fields, array order and array length are unchanged; `richErrors: false` still returns the v0.14 shape. `useDefaults`, on by default, is now documented.
|
|
26
|
+
- Measured on a ten-case corpus scored on four questions per diagnostic (says where, states what was found, distinguishable from its neighbours, offers a way forward): 3 of 60 before, 58 of 58 after, and `tests/test_diagnostics_score.js` holds the floor at 95%. Cost on the reject path when `.errors` is read, master against this change on the same machine: a one-error document 330 to 395 ns, a seven-error document with a typo pair 2.45 to 2.95 µs. `validate().valid` is unchanged at 5 ns. Fastify's own suite through fastify-ata stays at 178 of 184.
|
|
27
|
+
|
|
5
28
|
## 1.8.2 - 2026-08-28
|
|
6
29
|
|
|
7
30
|
### Fixed
|
package/index.js
CHANGED
|
@@ -992,6 +992,13 @@ class Validator {
|
|
|
992
992
|
};
|
|
993
993
|
}
|
|
994
994
|
this._applyDefaults = preprocess;
|
|
995
|
+
// Whether validate() can change the caller's object before the verdict.
|
|
996
|
+
// This is a capability, not an option: `useDefaults` is on by default, but
|
|
997
|
+
// buildDefaultsApplier returns null when the schema declares no defaults,
|
|
998
|
+
// so a plain schema is genuinely non-mutating. The renderers refuse to
|
|
999
|
+
// synthesize a frame when this is true, because a frame built from mutated
|
|
1000
|
+
// data would show the reader a value they never sent.
|
|
1001
|
+
this._mutatesInput = !!(preprocess || options.coerceTypes || options.removeAdditional);
|
|
995
1002
|
this._preprocess = preprocess;
|
|
996
1003
|
|
|
997
1004
|
// Detect if schema is "selective" -- doesn't recurse into arrays/deep objects.
|
|
@@ -1426,6 +1433,16 @@ class Validator {
|
|
|
1426
1433
|
schemaFile: self._source ? self._source.path : undefined,
|
|
1427
1434
|
}))
|
|
1428
1435
|
: raw;
|
|
1436
|
+
// Correlation is published, never applied. Both halves of a
|
|
1437
|
+
// typo pair stay in the array; `related` only says they are
|
|
1438
|
+
// one mistake, so a wrong pairing costs a sentence rather
|
|
1439
|
+
// than a hidden violation.
|
|
1440
|
+
if (enrich && cached.length > 1) attachRelated(cached);
|
|
1441
|
+
// No diagnostic payload here. validate(data) is the library
|
|
1442
|
+
// hot path, and attaching one cost about 100 ns per rejection
|
|
1443
|
+
// for a consumer that never renders. The text path attaches
|
|
1444
|
+
// it below, and a renderer given `{ data }` builds frames for
|
|
1445
|
+
// object input on request.
|
|
1429
1446
|
}
|
|
1430
1447
|
return cached;
|
|
1431
1448
|
},
|
|
@@ -1451,21 +1468,34 @@ class Validator {
|
|
|
1451
1468
|
if (result && !result.valid && result.errors && result.errors.length) {
|
|
1452
1469
|
// If errors came from the inner path that already ran through the
|
|
1453
1470
|
// wrapped this.validate (codegen jsonValidateFn -> validate path),
|
|
1454
|
-
// they may already be enriched. Detect by presence of `
|
|
1471
|
+
// they may already be enriched. Detect by presence of `docUrl`:
|
|
1472
|
+
// only enrich() sets it. `code` is not a safe signal because
|
|
1473
|
+
// branch-collapse attaches codes to raw errors, and detecting on
|
|
1474
|
+
// it left every collapsed oneOf/anyOf error unenriched on the
|
|
1475
|
+
// text path.
|
|
1455
1476
|
const first = result.errors[0];
|
|
1456
|
-
|
|
1477
|
+
// Re-parse the input once so the enrich pass can pluck `received`
|
|
1478
|
+
// and feed the suggestion engine (required-typo, format hints,
|
|
1479
|
+
// coercion nudges all need the live value tree), and so the
|
|
1480
|
+
// diagnostic payload carries the data on both paths below.
|
|
1481
|
+
let parsedData;
|
|
1482
|
+
try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
|
|
1483
|
+
if (!first || !first.docUrl) {
|
|
1457
1484
|
const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
|
|
1458
|
-
// Re-parse the input once so the enrich pass can pluck `received`
|
|
1459
|
-
// and feed the suggestion engine (required-typo, format hints,
|
|
1460
|
-
// coercion nudges all need the live value tree).
|
|
1461
|
-
let parsedData;
|
|
1462
|
-
try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
|
|
1463
1485
|
const enriched = result.errors.map((e) => enrich(e, {
|
|
1464
1486
|
data: parsedData,
|
|
1465
1487
|
positions,
|
|
1466
1488
|
schemaPositions: this._schemaPositions,
|
|
1467
1489
|
schemaFile: this._source ? this._source.path : undefined,
|
|
1468
1490
|
}));
|
|
1491
|
+
if (enriched.length > 1) attachRelated(enriched);
|
|
1492
|
+
attachDiagnosticSource(enriched, {
|
|
1493
|
+
data: parsedData,
|
|
1494
|
+
text: jsonStr,
|
|
1495
|
+
positions,
|
|
1496
|
+
schema: this._schemaObj,
|
|
1497
|
+
mutatesInput: this._mutatesInput === true,
|
|
1498
|
+
});
|
|
1469
1499
|
if (positions) this._posCache.reset();
|
|
1470
1500
|
this._lastRawInput = null;
|
|
1471
1501
|
return { valid: false, errors: enriched };
|
|
@@ -1482,6 +1512,13 @@ class Validator {
|
|
|
1482
1512
|
}
|
|
1483
1513
|
this._posCache.reset();
|
|
1484
1514
|
}
|
|
1515
|
+
if (result.errors.length > 1) attachRelated(result.errors);
|
|
1516
|
+
attachDiagnosticSource(result.errors, {
|
|
1517
|
+
data: parsedData,
|
|
1518
|
+
text: jsonStr,
|
|
1519
|
+
schema: this._schemaObj,
|
|
1520
|
+
mutatesInput: this._mutatesInput === true,
|
|
1521
|
+
});
|
|
1485
1522
|
}
|
|
1486
1523
|
this._lastRawInput = null;
|
|
1487
1524
|
return result;
|
|
@@ -1945,6 +1982,26 @@ function _walkPointer (root, pointer) {
|
|
|
1945
1982
|
// inflate the gzipped bundle beyond the size budget). Consumers who want
|
|
1946
1983
|
// suggestions pass the error array through this helper after validation.
|
|
1947
1984
|
// AOT errors don't carry `received`, so we re-derive it from `data` here.
|
|
1985
|
+
const { setDiagnosticSource } = require('./lib/diagnostic-source');
|
|
1986
|
+
const attachDiagnosticSource = setDiagnosticSource;
|
|
1987
|
+
|
|
1988
|
+
// Resolved once. A require() inside the function was re-resolving the path
|
|
1989
|
+
// on every rejection, which the profile showed as internalModuleStat at the
|
|
1990
|
+
// top of the reject path, above the correlation it was loading.
|
|
1991
|
+
let _correlateTypos = null;
|
|
1992
|
+
function attachRelated (errors) {
|
|
1993
|
+
if (_correlateTypos === null) _correlateTypos = require('./lib/correlate').correlateTypos;
|
|
1994
|
+
const pairs = _correlateTypos(errors);
|
|
1995
|
+
if (pairs === null) return errors;
|
|
1996
|
+
for (const [from, to] of pairs) {
|
|
1997
|
+
const e = errors[from];
|
|
1998
|
+
if (!e) continue;
|
|
1999
|
+
if (e.related) { if (!e.related.includes(to)) e.related.push(to); }
|
|
2000
|
+
else e.related = [to];
|
|
2001
|
+
}
|
|
2002
|
+
return errors;
|
|
2003
|
+
}
|
|
2004
|
+
|
|
1948
2005
|
function attachSuggestions (errors, data) {
|
|
1949
2006
|
if (!errors) return errors;
|
|
1950
2007
|
for (const e of errors) {
|
|
@@ -1961,6 +2018,9 @@ function attachSuggestions (errors, data) {
|
|
|
1961
2018
|
const s = suggestFor(probe, data);
|
|
1962
2019
|
if (s) e.suggestion = s;
|
|
1963
2020
|
}
|
|
2021
|
+
// AOT modules import nothing, so this is their only route to a frame. The
|
|
2022
|
+
// caller holds the original object and ran no preprocessing through here.
|
|
2023
|
+
attachDiagnosticSource(errors, { data, mutatesInput: false });
|
|
1964
2024
|
return errors;
|
|
1965
2025
|
}
|
|
1966
2026
|
|
package/lib/correlate.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { levenshtein } = require('./levenshtein');
|
|
4
|
+
|
|
5
|
+
const MAX_DISTANCE = 2;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Pair a `required` error naming a missing property with an
|
|
9
|
+
* `additionalProperties` error naming an extra property, when the two names
|
|
10
|
+
* are close enough to be one typo and nothing else in the same container is
|
|
11
|
+
* equally close.
|
|
12
|
+
*
|
|
13
|
+
* The tie checks are the point. `suggestRequiredTypo` in lib/suggestions.js
|
|
14
|
+
* takes the first key within distance 2 and does not check for ties, which is
|
|
15
|
+
* fine for a hint appended to an error that stands on its own. It is not fine
|
|
16
|
+
* as a reason to present two errors as one problem: an ambiguous pairing would
|
|
17
|
+
* tell the reader a confident story about the wrong key.
|
|
18
|
+
*
|
|
19
|
+
* Nothing is deleted. The result is a symmetric index map that callers may use
|
|
20
|
+
* to group; every error stays in the array.
|
|
21
|
+
*
|
|
22
|
+
* @param {Array} errors enriched or raw validation errors
|
|
23
|
+
* @returns {Map<number, number>|null} symmetric index pairs, null when there are none
|
|
24
|
+
*/
|
|
25
|
+
function correlateTypos (errors) {
|
|
26
|
+
if (!Array.isArray(errors) || errors.length < 2) return null;
|
|
27
|
+
|
|
28
|
+
// Most rejections have no candidate pair at all. Find that out with one
|
|
29
|
+
// pass over the keywords before allocating anything.
|
|
30
|
+
let sawMissing = false;
|
|
31
|
+
let sawExtra = false;
|
|
32
|
+
for (let i = 0; i < errors.length; i++) {
|
|
33
|
+
const e = errors[i];
|
|
34
|
+
if (!e) continue;
|
|
35
|
+
if (e.keyword === 'required') sawMissing = true;
|
|
36
|
+
else if (e.keyword === 'additionalProperties') sawExtra = true;
|
|
37
|
+
if (sawMissing && sawExtra) break;
|
|
38
|
+
}
|
|
39
|
+
if (!sawMissing || !sawExtra) return null;
|
|
40
|
+
|
|
41
|
+
const out = new Map();
|
|
42
|
+
|
|
43
|
+
// Bucket by container pointer. A missing key in one object has nothing to do
|
|
44
|
+
// with an extra key in another.
|
|
45
|
+
const byContainer = new Map();
|
|
46
|
+
for (let i = 0; i < errors.length; i++) {
|
|
47
|
+
const e = errors[i];
|
|
48
|
+
if (!e || !e.params) continue;
|
|
49
|
+
const missing = e.keyword === 'required' ? e.params.missingProperty : undefined;
|
|
50
|
+
const additional = e.keyword === 'additionalProperties' ? e.params.additionalProperty : undefined;
|
|
51
|
+
if (typeof missing !== 'string' && typeof additional !== 'string') continue;
|
|
52
|
+
const key = e.instancePath != null ? e.instancePath : (e.path || '');
|
|
53
|
+
let bucket = byContainer.get(key);
|
|
54
|
+
if (!bucket) { bucket = { missing: [], extra: [] }; byContainer.set(key, bucket); }
|
|
55
|
+
if (typeof missing === 'string') bucket.missing.push({ index: i, name: missing });
|
|
56
|
+
else bucket.extra.push({ index: i, name: additional });
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
for (const bucket of byContainer.values()) {
|
|
60
|
+
if (bucket.missing.length === 0 || bucket.extra.length === 0) continue;
|
|
61
|
+
|
|
62
|
+
// Full distance table for this container. Sizes here are the number of
|
|
63
|
+
// wrong keys in one object, so this stays small in practice.
|
|
64
|
+
const dist = [];
|
|
65
|
+
for (let m = 0; m < bucket.missing.length; m++) {
|
|
66
|
+
dist.push([]);
|
|
67
|
+
for (let x = 0; x < bucket.extra.length; x++) {
|
|
68
|
+
const d = levenshtein(bucket.missing[m].name, bucket.extra[x].name, MAX_DISTANCE);
|
|
69
|
+
dist[m].push(d);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
for (let m = 0; m < bucket.missing.length; m++) {
|
|
74
|
+
// Best extra key for this missing key, and whether it is unique.
|
|
75
|
+
let bestX = -1;
|
|
76
|
+
let bestD = Infinity;
|
|
77
|
+
let tied = false;
|
|
78
|
+
for (let x = 0; x < bucket.extra.length; x++) {
|
|
79
|
+
const d = dist[m][x];
|
|
80
|
+
if (d < bestD) { bestD = d; bestX = x; tied = false; }
|
|
81
|
+
else if (d === bestD && d !== Infinity) tied = true;
|
|
82
|
+
}
|
|
83
|
+
if (bestX === -1 || tied) continue;
|
|
84
|
+
if (!(bestD > 0 && bestD <= MAX_DISTANCE)) continue;
|
|
85
|
+
|
|
86
|
+
// And the same question from the other side: is this missing key the
|
|
87
|
+
// unambiguous best match for that extra key?
|
|
88
|
+
let backM = -1;
|
|
89
|
+
let backD = Infinity;
|
|
90
|
+
let backTied = false;
|
|
91
|
+
for (let m2 = 0; m2 < bucket.missing.length; m2++) {
|
|
92
|
+
const d = dist[m2][bestX];
|
|
93
|
+
if (d < backD) { backD = d; backM = m2; backTied = false; }
|
|
94
|
+
else if (d === backD && d !== Infinity) backTied = true;
|
|
95
|
+
}
|
|
96
|
+
if (backTied || backM !== m) continue;
|
|
97
|
+
|
|
98
|
+
out.set(bucket.missing[m].index, bucket.extra[bestX].index);
|
|
99
|
+
out.set(bucket.extra[bestX].index, bucket.missing[m].index);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return out.size === 0 ? null : out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = { correlateTypos };
|
package/lib/data-positions.js
CHANGED
|
@@ -52,7 +52,7 @@ function buildDataPositionMap (input) {
|
|
|
52
52
|
return '/' + path.map(escapePtr).join('/');
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
function walk (path) {
|
|
55
|
+
function walk (path, keySpan) {
|
|
56
56
|
skipWs();
|
|
57
57
|
if (i >= n) return;
|
|
58
58
|
const start = i;
|
|
@@ -66,11 +66,22 @@ function buildDataPositionMap (input) {
|
|
|
66
66
|
if (text.charCodeAt(i) === 0x7d) { i++; break; }
|
|
67
67
|
if (text.charCodeAt(i) === 0x2c) { i++; continue; }
|
|
68
68
|
skipWs();
|
|
69
|
+
// Record the key token before consuming it. The caret for an error
|
|
70
|
+
// that names a property belongs on the property, not on the value and
|
|
71
|
+
// not on the enclosing object.
|
|
72
|
+
const keyStart = i;
|
|
73
|
+
const keyPos = offsetToLineCol(keyStart);
|
|
69
74
|
const key = readString();
|
|
75
|
+
const span = {
|
|
76
|
+
keyOffset: keyStart,
|
|
77
|
+
keyLength: i - keyStart,
|
|
78
|
+
keyLine: keyPos.line,
|
|
79
|
+
keyCol: keyPos.col,
|
|
80
|
+
};
|
|
70
81
|
skipWs();
|
|
71
82
|
if (text.charCodeAt(i) !== 0x3a) throw new Error('expected ":" at offset ' + i);
|
|
72
83
|
i++;
|
|
73
|
-
walk(path.concat([key]));
|
|
84
|
+
walk(path.concat([key]), span);
|
|
74
85
|
}
|
|
75
86
|
} else if (ch === 0x5b) {
|
|
76
87
|
i++;
|
|
@@ -93,7 +104,14 @@ function buildDataPositionMap (input) {
|
|
|
93
104
|
}
|
|
94
105
|
|
|
95
106
|
const length = i - start;
|
|
96
|
-
|
|
107
|
+
const entry = { byteOffset: start, length, line: pos.line, col: pos.col, text: pos.text };
|
|
108
|
+
if (keySpan) {
|
|
109
|
+
entry.keyOffset = keySpan.keyOffset;
|
|
110
|
+
entry.keyLength = keySpan.keyLength;
|
|
111
|
+
entry.keyLine = keySpan.keyLine;
|
|
112
|
+
entry.keyCol = keySpan.keyCol;
|
|
113
|
+
}
|
|
114
|
+
map[pointerOf(path)] = entry;
|
|
97
115
|
}
|
|
98
116
|
|
|
99
117
|
if (text.charCodeAt(0) === 0xfeff) i = 1;
|
package/lib/diagnose.js
ADDED
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { correlateTypos } = require('./correlate');
|
|
4
|
+
const { buildDataPositionMap } = require('./data-positions');
|
|
5
|
+
const { pathToDotted } = require('./render-shared');
|
|
6
|
+
const { reprValue, expectedFor } = require('./enrich-error');
|
|
7
|
+
|
|
8
|
+
const MAX_SYNTHESIZED_BYTES = 256 * 1024;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turn a validation error array into presentation-ready diagnostics.
|
|
12
|
+
*
|
|
13
|
+
* Pure: no I/O, no ANSI, no terminal width, deterministic for a given input.
|
|
14
|
+
* That is deliberate. The merge below is the one place in this library that
|
|
15
|
+
* can present two problems as one, so it has to be provable as data rather
|
|
16
|
+
* than by grepping terminal output.
|
|
17
|
+
*
|
|
18
|
+
* @param {Array} errors validation errors, enriched or raw
|
|
19
|
+
* @param {{text?: string, data?: any, positions?: object, schema?: object, mutatesInput?: boolean}} [source]
|
|
20
|
+
* @returns {Array} diagnostics
|
|
21
|
+
*/
|
|
22
|
+
function toDiagnostics (errors, source) {
|
|
23
|
+
if (!Array.isArray(errors) || errors.length === 0) return [];
|
|
24
|
+
const src = source || {};
|
|
25
|
+
|
|
26
|
+
const resolved = resolveFrames(src);
|
|
27
|
+
const pairs = correlateTypos(errors);
|
|
28
|
+
|
|
29
|
+
const diagnostics = [];
|
|
30
|
+
const consumed = new Set();
|
|
31
|
+
|
|
32
|
+
for (let i = 0; i < errors.length; i++) {
|
|
33
|
+
if (consumed.has(i)) continue;
|
|
34
|
+
const e = errors[i];
|
|
35
|
+
if (!e) continue;
|
|
36
|
+
|
|
37
|
+
const partner = pairs === null ? undefined : pairs.get(i);
|
|
38
|
+
if (partner !== undefined && !consumed.has(partner)) {
|
|
39
|
+
consumed.add(i);
|
|
40
|
+
consumed.add(partner);
|
|
41
|
+
diagnostics.push(mergedDiagnostic(errors, i, partner, resolved, src));
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
diagnostics.push(singleDiagnostic(e, i, resolved, src));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
diagnostics.sort((a, b) => {
|
|
49
|
+
if (a.sortPos !== b.sortPos) return a.sortPos - b.sortPos;
|
|
50
|
+
if (a.rank !== b.rank) return a.rank - b.rank;
|
|
51
|
+
return a.pointer < b.pointer ? -1 : a.pointer > b.pointer ? 1 : 0;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
for (const d of diagnostics) delete d.sortPos;
|
|
55
|
+
return diagnostics;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Resolve one position map for the whole call, not one per error.
|
|
59
|
+
function resolveFrames (src) {
|
|
60
|
+
const none = { positions: null, synthesized: false, refusal: null };
|
|
61
|
+
if (src.positions) return { positions: src.positions, synthesized: false, refusal: null };
|
|
62
|
+
|
|
63
|
+
if (src.text != null) {
|
|
64
|
+
try {
|
|
65
|
+
return { positions: buildDataPositionMap(src.text), synthesized: false, refusal: null };
|
|
66
|
+
} catch {
|
|
67
|
+
return none;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (src.data === undefined) return none;
|
|
72
|
+
|
|
73
|
+
if (src.mutatesInput) {
|
|
74
|
+
// The object in hand is not what the caller sent. Drawing a caret under a
|
|
75
|
+
// coerced value or an injected default would be output that is wrong and
|
|
76
|
+
// draws no complaint, which is the failure mode this library treats as the
|
|
77
|
+
// worst one. Say why instead.
|
|
78
|
+
return {
|
|
79
|
+
positions: null,
|
|
80
|
+
synthesized: false,
|
|
81
|
+
refusal: 'no frame; the value was modified in place before validation (coerceTypes, useDefaults or removeAdditional)',
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let text;
|
|
86
|
+
try {
|
|
87
|
+
text = JSON.stringify(src.data, null, 2);
|
|
88
|
+
} catch {
|
|
89
|
+
return none;
|
|
90
|
+
}
|
|
91
|
+
if (typeof text !== 'string') return none;
|
|
92
|
+
if (text.length > MAX_SYNTHESIZED_BYTES) {
|
|
93
|
+
// The string cannot be measured without being built. It is built, measured
|
|
94
|
+
// and dropped; there is no way to skip the allocation and still know.
|
|
95
|
+
return { positions: null, synthesized: false, refusal: 'no frame; the value is larger than 256 KB' };
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return { positions: buildDataPositionMap(text), synthesized: true, refusal: null };
|
|
99
|
+
} catch {
|
|
100
|
+
return none;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function frameFor (err, resolved) {
|
|
105
|
+
if (!resolved.positions) return frameFromError(err);
|
|
106
|
+
const path = err.instancePath != null ? err.instancePath : (err.path || '');
|
|
107
|
+
const named = (err.params && (err.params.additionalProperty || err.params.unevaluatedProperty)) || null;
|
|
108
|
+
const hit = (named && resolved.positions[path + '/' + named]) || resolved.positions[path];
|
|
109
|
+
if (!hit) return null;
|
|
110
|
+
|
|
111
|
+
// A caret on the key token when the error names a property, on the value
|
|
112
|
+
// otherwise. Never wider than the line it is drawn under.
|
|
113
|
+
const useKey = named != null && hit.keyOffset !== undefined;
|
|
114
|
+
const col = useKey ? hit.keyCol : hit.col;
|
|
115
|
+
const rawLen = useKey ? hit.keyLength : hit.length;
|
|
116
|
+
const lineLen = (hit.text || '').length;
|
|
117
|
+
const length = Math.max(1, Math.min(rawLen || 1, Math.max(1, lineLen - col + 1)));
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
line: useKey ? hit.keyLine : hit.line,
|
|
121
|
+
col,
|
|
122
|
+
length,
|
|
123
|
+
text: hit.text,
|
|
124
|
+
spans: rawLen > length,
|
|
125
|
+
synthesized: resolved.synthesized,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// No position map in hand, but the error itself may carry one: validateJSON
|
|
130
|
+
// attaches `dataFrame` and `anchor` at enrichment time, and errors handed
|
|
131
|
+
// over from another process or a fixture arrive with only those.
|
|
132
|
+
function frameFromError (err) {
|
|
133
|
+
const df = err.dataFrame;
|
|
134
|
+
if (!df || typeof df.line !== 'number') return null;
|
|
135
|
+
const a = err.anchor;
|
|
136
|
+
const named = (err.params && (err.params.additionalProperty || err.params.unevaluatedProperty)) || null;
|
|
137
|
+
const useKey = named != null && a && a.keyLine !== undefined;
|
|
138
|
+
const col = useKey ? a.keyCol : df.col;
|
|
139
|
+
const rawLen = useKey ? a.keyLength : df.length;
|
|
140
|
+
const lineLen = (df.text || '').length;
|
|
141
|
+
const length = Math.max(1, Math.min(rawLen || 1, Math.max(1, lineLen - col + 1)));
|
|
142
|
+
return {
|
|
143
|
+
line: useKey ? a.keyLine : df.line,
|
|
144
|
+
col,
|
|
145
|
+
length,
|
|
146
|
+
text: df.text,
|
|
147
|
+
spans: rawLen > length,
|
|
148
|
+
synthesized: false,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function headlineFor (err, src) {
|
|
153
|
+
const p = err.params || {};
|
|
154
|
+
switch (err.keyword) {
|
|
155
|
+
case 'additionalProperties':
|
|
156
|
+
return `unknown property "${p.additionalProperty}"`;
|
|
157
|
+
case 'unevaluatedProperties':
|
|
158
|
+
return `unevaluated property "${p.unevaluatedProperty}"`;
|
|
159
|
+
case 'required':
|
|
160
|
+
return `missing required property "${p.missingProperty}"`;
|
|
161
|
+
case 'oneOf': case 'anyOf': {
|
|
162
|
+
const disc = discriminatorFor(err, src);
|
|
163
|
+
if (disc) return `no variant matches ${disc.key} ${JSON.stringify(disc.value)}`;
|
|
164
|
+
return err.detail || err.message || 'no variant matched';
|
|
165
|
+
}
|
|
166
|
+
default:
|
|
167
|
+
return err.detail || err.message || 'validation failed';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// A discriminator is a property every branch pins with `const`, with distinct
|
|
172
|
+
// values. Anything looser and the code does not guess.
|
|
173
|
+
function discriminatorFor (err, src) {
|
|
174
|
+
const schema = src.schema;
|
|
175
|
+
const data = src.data;
|
|
176
|
+
if (!schema || !data || typeof data !== 'object') return null;
|
|
177
|
+
const branches = branchesAt(schema, err.schemaPath, err.keyword);
|
|
178
|
+
if (!Array.isArray(branches) || branches.length < 2) return null;
|
|
179
|
+
|
|
180
|
+
const first = branches[0] && branches[0].properties;
|
|
181
|
+
if (!first) return null;
|
|
182
|
+
for (const key of Object.keys(first)) {
|
|
183
|
+
const values = [];
|
|
184
|
+
let ok = true;
|
|
185
|
+
for (const b of branches) {
|
|
186
|
+
const prop = b && b.properties && b.properties[key];
|
|
187
|
+
if (!prop || prop.const === undefined) { ok = false; break; }
|
|
188
|
+
if (values.includes(prop.const)) { ok = false; break; }
|
|
189
|
+
values.push(prop.const);
|
|
190
|
+
}
|
|
191
|
+
if (!ok) continue;
|
|
192
|
+
const actual = data[key];
|
|
193
|
+
if (actual === undefined) continue;
|
|
194
|
+
return { key, value: actual };
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Branch errors arrive raw: keyword, message, instancePath, params. Give each
|
|
200
|
+
// a copy carrying what was expected and what was found, so the note under a
|
|
201
|
+
// composition failure reads as an observation rather than a rule. Copies,
|
|
202
|
+
// never the originals: the array on the error object is a contract.
|
|
203
|
+
function decorateBranches (subs, src) {
|
|
204
|
+
return subs.map((sub) => {
|
|
205
|
+
if (!sub || typeof sub !== 'object') return sub;
|
|
206
|
+
const copy = Object.assign({}, sub);
|
|
207
|
+
const exp = expectedFor(sub);
|
|
208
|
+
if (exp !== undefined) copy.expected = exp;
|
|
209
|
+
if (copy.received === undefined) {
|
|
210
|
+
const got = valueAt(src.data, sub.instancePath);
|
|
211
|
+
if (got !== null) copy.received = got;
|
|
212
|
+
}
|
|
213
|
+
if (Array.isArray(sub.branchErrors)) copy.branchErrors = decorateBranches(sub.branchErrors, src);
|
|
214
|
+
return copy;
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// The value a JSON pointer names, as the same short repr enrich() uses for
|
|
219
|
+
// `received`. Null when the data is not in hand or the path does not resolve.
|
|
220
|
+
function valueAt (data, pointer) {
|
|
221
|
+
if (data === undefined || typeof pointer !== 'string') return null;
|
|
222
|
+
if (pointer === '') return reprValue(data);
|
|
223
|
+
let cur = data;
|
|
224
|
+
for (const seg of pointer.split('/').slice(1)) {
|
|
225
|
+
if (cur == null || typeof cur !== 'object') return null;
|
|
226
|
+
cur = cur[seg.replace(/~1/g, '/').replace(/~0/g, '~')];
|
|
227
|
+
}
|
|
228
|
+
return cur === undefined ? null : reprValue(cur);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// Walk `#/a/b/anyOf` down to the branch array it names.
|
|
232
|
+
function branchesAt (schema, schemaPath, keyword) {
|
|
233
|
+
if (typeof schemaPath !== 'string' || !schemaPath.startsWith('#')) {
|
|
234
|
+
return schema[keyword];
|
|
235
|
+
}
|
|
236
|
+
const parts = schemaPath.slice(1).split('/').filter(Boolean).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
237
|
+
let cur = schema;
|
|
238
|
+
for (const part of parts) {
|
|
239
|
+
if (cur == null || typeof cur !== 'object') return null;
|
|
240
|
+
cur = cur[part];
|
|
241
|
+
}
|
|
242
|
+
return Array.isArray(cur) ? cur : null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function singleDiagnostic (e, index, resolved, src) {
|
|
246
|
+
const pointer = e.instancePath != null ? e.instancePath : (e.path || '');
|
|
247
|
+
// A composition failure is reported at the container, but the closest
|
|
248
|
+
// branch already says where inside it the mismatch is. Point there.
|
|
249
|
+
const closest = e.branchErrors && e.branchErrors.length ? e.branchErrors[0] : null;
|
|
250
|
+
const anchorErr = closest && closest.instancePath ? closest : e;
|
|
251
|
+
const frame = frameFor(anchorErr, resolved);
|
|
252
|
+
// What was found at the caret. A branch error carries no `received`, so
|
|
253
|
+
// read it off the data when the data is in hand.
|
|
254
|
+
let found = e.received != null ? e.received : null;
|
|
255
|
+
if (anchorErr !== e) {
|
|
256
|
+
found = closest.received != null ? closest.received : valueAt(src.data, closest.instancePath);
|
|
257
|
+
}
|
|
258
|
+
const notes = [];
|
|
259
|
+
if (!frame && resolved.refusal) notes.push(resolved.refusal);
|
|
260
|
+
if (frame && frame.spans) notes.push('value continues past the end of this line');
|
|
261
|
+
if (e.docUrl) notes.push('see ' + e.docUrl);
|
|
262
|
+
|
|
263
|
+
return {
|
|
264
|
+
code: e.code,
|
|
265
|
+
headline: headlineFor(e, src),
|
|
266
|
+
pointer,
|
|
267
|
+
dotted: pathToDotted(anchorErr.instancePath != null ? anchorErr.instancePath : pointer),
|
|
268
|
+
frame,
|
|
269
|
+
found,
|
|
270
|
+
help: e.suggestion ? e.suggestion.text : null,
|
|
271
|
+
notes,
|
|
272
|
+
branchErrors: e.branchErrors ? decorateBranches(e.branchErrors, src) : null,
|
|
273
|
+
mergedFrom: [e],
|
|
274
|
+
rank: typeof e.rank === 'number' ? e.rank : 2,
|
|
275
|
+
sortPos: frame ? frame.line * 100000 + frame.col : index,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function mergedDiagnostic (errors, i, j, resolved, src) {
|
|
280
|
+
const a = errors[i];
|
|
281
|
+
const b = errors[j];
|
|
282
|
+
const missing = a.keyword === 'required' ? a : b;
|
|
283
|
+
const extra = a.keyword === 'required' ? b : a;
|
|
284
|
+
const missingName = missing.params.missingProperty;
|
|
285
|
+
const extraName = extra.params.additionalProperty;
|
|
286
|
+
|
|
287
|
+
// Anchor on the extra key: that token is the thing the reader edits. The
|
|
288
|
+
// missing half has no token to point at.
|
|
289
|
+
const frame = frameFor(extra, resolved);
|
|
290
|
+
const notes = [];
|
|
291
|
+
if (!frame && resolved.refusal) notes.push(resolved.refusal);
|
|
292
|
+
const docUrl = missing.docUrl || extra.docUrl;
|
|
293
|
+
if (docUrl) notes.push('see ' + docUrl);
|
|
294
|
+
|
|
295
|
+
const pointer = missing.instancePath != null ? missing.instancePath : (missing.path || '');
|
|
296
|
+
return {
|
|
297
|
+
code: missing.code,
|
|
298
|
+
headline: `unknown property "${extraName}"`,
|
|
299
|
+
pointer,
|
|
300
|
+
dotted: pathToDotted(pointer),
|
|
301
|
+
frame,
|
|
302
|
+
found: null,
|
|
303
|
+
help: `did you mean "${missingName}"?`,
|
|
304
|
+
notes,
|
|
305
|
+
branchErrors: null,
|
|
306
|
+
mergedFrom: [missing, extra],
|
|
307
|
+
rank: 0,
|
|
308
|
+
sortPos: frame ? frame.line * 100000 + frame.col : Math.min(i, j),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
module.exports = { toDiagnostics };
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Attach to an errors array the material a renderer needs to build frames:
|
|
4
|
+
// the data, the original text when there was one, the schema, and whether
|
|
5
|
+
// validate() changed the data before the verdict.
|
|
6
|
+
//
|
|
7
|
+
// It rides on the array as a non-enumerable symbol property, never on the
|
|
8
|
+
// error objects. JSON.stringify, Object.keys, length and deep equality against
|
|
9
|
+
// a fixture are unchanged. A WeakMap side table was tried and measured: it
|
|
10
|
+
// keyed short-lived arrays, and the ephemeron work pushed the garbage
|
|
11
|
+
// collector to a third of the reject path. A property costs about 80 ns and
|
|
12
|
+
// nothing at collection time.
|
|
13
|
+
const KEY = Symbol.for('ata.diagnosticSource');
|
|
14
|
+
|
|
15
|
+
// One descriptor, reused. Building a fresh one per call was most of this
|
|
16
|
+
// function's cost in the single-error profile, through the allocation and
|
|
17
|
+
// the collection that followed it.
|
|
18
|
+
const DESCRIPTOR = { value: null, enumerable: false, configurable: true, writable: true };
|
|
19
|
+
|
|
20
|
+
function setDiagnosticSource (errors, payload) {
|
|
21
|
+
if (!Array.isArray(errors) || !payload || !Object.isExtensible(errors)) return errors;
|
|
22
|
+
const prev = errors[KEY];
|
|
23
|
+
let value = payload;
|
|
24
|
+
if (prev) {
|
|
25
|
+
// validateJSON's already-enriched path runs after the validate() wrapper
|
|
26
|
+
// has recorded the parsed data. Merge, so the text arrives without the
|
|
27
|
+
// data going missing.
|
|
28
|
+
value = Object.assign({}, prev);
|
|
29
|
+
for (const k of Object.keys(payload)) if (payload[k] !== undefined) value[k] = payload[k];
|
|
30
|
+
}
|
|
31
|
+
try {
|
|
32
|
+
DESCRIPTOR.value = value;
|
|
33
|
+
Object.defineProperty(errors, KEY, DESCRIPTOR);
|
|
34
|
+
DESCRIPTOR.value = null;
|
|
35
|
+
} catch {
|
|
36
|
+
// A validator that throws from its own error path is worse than a
|
|
37
|
+
// missing frame. The renderer degrades to pointer-only output.
|
|
38
|
+
}
|
|
39
|
+
return errors;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function getDiagnosticSource (errors) {
|
|
43
|
+
return (Array.isArray(errors) && errors[KEY]) || null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
module.exports = { setDiagnosticSource, getDiagnosticSource };
|