ata-validator 1.8.1 → 1.9.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 CHANGED
@@ -2,6 +2,31 @@
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.9.0 - 2026-08-28
6
+
7
+ ### Errors
8
+
9
+ - 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.
10
+ - 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`.
11
+ - 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`.
12
+ - 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.
13
+ - 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.
14
+ - 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.
15
+
16
+ ## 1.8.2 - 2026-08-28
17
+
18
+ ### Fixed
19
+
20
+ - A `pattern` of the form `^[class]+$`, `^[class]{m,n}$` or `^[class]{n}$` with n above 16 could be violated without `validate()` saying so. The boolean program compiles those patterns to an inline character loop wrapped in an arrow function, and the rewrite that turns the boolean program into the error-reporting one replaced the `return false` and `return true` inside that arrow with `return E(d)` and `return R`. Both are objects, so the arrow started returning a truthy value on mismatch, the enclosing `!(cond && obj)` went false, and the error program accepted what the boolean program had rejected.
21
+
22
+ What that looked like from outside depended on the path. Without preprocessing, `validate()` still rejected, because the boolean verdict runs first, but the error it produced was a generic `schema validation failed` with no keyword, no `params.pattern` and no path. With preprocessing, which means any schema carrying a `default` or a validator built with `coerceTypes` or `removeAdditional`, the error program is installed as `validate()` directly and the result was `valid: true`. `Validator.bundleStandalone` and `bundleCompact` embed the same program and had the same silent accept. `ata compile` output is built from the boolean program and was not affected. The rewrite has been wrong since 0.6.0; the official suite never exercised these pattern shapes, so nothing caught it.
23
+
24
+ The rewrite now leaves the body of an arrow function alone, the same way it already left `function` bodies alone. `tests/test_hybrid_agreement.js` compares the rewritten program with the boolean it came from on every affected shape, at the root, in a property, in array items, through `bundleStandalone` and through a compiled module, and checks the two preprocessing cases directly. `tests/test_codegen_entrypoint_agreement.js` now compares the rewritten program as a fifth participant across the whole official suite. `tests/test_ajv_errors.js`, which had been failing on exactly this for as long as the bug existed, is now part of `npm test`.
25
+
26
+ ### Changed
27
+
28
+ - Ranking an error into schema declaration order no longer re-derives the rank on every error of every failing document. It was splitting the schema path with two regular expressions per segment and then scanning `Object.keys(node)` at each level, which was 8.5% of the error path in a profile, for an answer that depends only on the schema and the path. The rank is now cached per path under its root, each node's keys are indexed once, and escape handling is skipped for segments with no tilde. That function drops from 8.5% to 1.4% of the error path, and the error path as a whole gets 3.5% faster, median of eleven interleaved runs. `docs/performance-notes.md` records the larger gap this was measured against; it is not closed by this change.
29
+
5
30
  ## 1.8.1 - 2026-08-27
6
31
 
7
32
  ### Changed
package/index.js CHANGED
@@ -373,12 +373,64 @@ function resolveSchemaByPath(rootSchema, schemaPath) {
373
373
  // order AJV emits and what schema authors read top to bottom. Segments that
374
374
  // cannot be resolved (cross-schema refs, normalized keys) end the walk; the
375
375
  // stable sort then keeps such errors in engine emission order.
376
+ // The rank of a `schemaPath` under a given root is fixed: the schema does not
377
+ // change between validations, so neither does the answer. It was recomputed for
378
+ // every error of every failing document, and computing it is not cheap. Two
379
+ // caches, both keyed on things that do not change:
380
+ //
381
+ // rootSchema -> schemaPath -> rank, so a path is walked once ever
382
+ // node -> key -> its index, so the walk stops calling Object.keys and
383
+ // scanning the result for a string
384
+ //
385
+ // A failing route sees the same handful of schemaPaths over and over, which is
386
+ // what makes the first one worth having.
387
+ const _rankCache = new WeakMap();
388
+ const _keyIndexCache = new WeakMap();
389
+
390
+ function keyIndex(node, seg) {
391
+ let index = _keyIndexCache.get(node);
392
+ if (index === undefined) {
393
+ index = new Map();
394
+ const keys = Object.keys(node);
395
+ for (let i = 0; i < keys.length; i++) index.set(keys[i], i);
396
+ _keyIndexCache.set(node, index);
397
+ }
398
+ const at = index.get(seg);
399
+ return at === undefined ? -1 : at;
400
+ }
401
+
402
+ // `~1` and `~0` are the only escapes a JSON pointer has, and almost no schema
403
+ // key contains a tilde. Looking for one is far cheaper than two regex passes
404
+ // over every segment of every path.
405
+ function unescapePointerSegment(seg) {
406
+ return seg.indexOf('~') < 0 ? seg : seg.replace(/~1/g, '/').replace(/~0/g, '~');
407
+ }
408
+
376
409
  function schemaOrderRank(rootSchema, schemaPath) {
377
410
  if (!schemaPath || typeof schemaPath !== 'string' || !schemaPath.startsWith('#')) return null;
378
- const parts = schemaPath.slice(1).split('/').filter(Boolean).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
411
+ if (rootSchema === null || typeof rootSchema !== 'object') return null;
412
+
413
+ let byPath = _rankCache.get(rootSchema);
414
+ if (byPath === undefined) { byPath = new Map(); _rankCache.set(rootSchema, byPath); }
415
+ const hit = byPath.get(schemaPath);
416
+ if (hit !== undefined) return hit;
417
+
418
+ const rank = _computeRank(rootSchema, schemaPath);
419
+ byPath.set(schemaPath, rank);
420
+ return rank;
421
+ }
422
+
423
+ function _computeRank(rootSchema, schemaPath) {
379
424
  const rank = [];
380
425
  let node = rootSchema;
381
- for (const seg of parts) {
426
+ let start = 1;
427
+ while (start <= schemaPath.length) {
428
+ let end = schemaPath.indexOf('/', start);
429
+ if (end < 0) end = schemaPath.length;
430
+ if (end === start) { start = end + 1; continue; } // what filter(Boolean) dropped
431
+ const seg = unescapePointerSegment(schemaPath.slice(start, end));
432
+ start = end + 1;
433
+
382
434
  if (node == null || typeof node !== 'object') break;
383
435
  if (Array.isArray(node)) {
384
436
  const idx = Number(seg);
@@ -386,7 +438,7 @@ function schemaOrderRank(rootSchema, schemaPath) {
386
438
  rank.push(idx);
387
439
  node = node[idx];
388
440
  } else {
389
- const idx = Object.keys(node).indexOf(seg);
441
+ const idx = keyIndex(node, seg);
390
442
  if (idx < 0) break;
391
443
  rank.push(idx);
392
444
  node = node[seg];
@@ -940,6 +992,13 @@ class Validator {
940
992
  };
941
993
  }
942
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);
943
1002
  this._preprocess = preprocess;
944
1003
 
945
1004
  // Detect if schema is "selective" -- doesn't recurse into arrays/deep objects.
@@ -1374,6 +1433,16 @@ class Validator {
1374
1433
  schemaFile: self._source ? self._source.path : undefined,
1375
1434
  }))
1376
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.
1377
1446
  }
1378
1447
  return cached;
1379
1448
  },
@@ -1399,21 +1468,34 @@ class Validator {
1399
1468
  if (result && !result.valid && result.errors && result.errors.length) {
1400
1469
  // If errors came from the inner path that already ran through the
1401
1470
  // wrapped this.validate (codegen jsonValidateFn -> validate path),
1402
- // they may already be enriched. Detect by presence of `code`.
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.
1403
1476
  const first = result.errors[0];
1404
- if (!first || !first.code) {
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) {
1405
1484
  const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
1406
- // Re-parse the input once so the enrich pass can pluck `received`
1407
- // and feed the suggestion engine (required-typo, format hints,
1408
- // coercion nudges all need the live value tree).
1409
- let parsedData;
1410
- try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
1411
1485
  const enriched = result.errors.map((e) => enrich(e, {
1412
1486
  data: parsedData,
1413
1487
  positions,
1414
1488
  schemaPositions: this._schemaPositions,
1415
1489
  schemaFile: this._source ? this._source.path : undefined,
1416
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
+ });
1417
1499
  if (positions) this._posCache.reset();
1418
1500
  this._lastRawInput = null;
1419
1501
  return { valid: false, errors: enriched };
@@ -1430,6 +1512,13 @@ class Validator {
1430
1512
  }
1431
1513
  this._posCache.reset();
1432
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
+ });
1433
1522
  }
1434
1523
  this._lastRawInput = null;
1435
1524
  return result;
@@ -1893,6 +1982,26 @@ function _walkPointer (root, pointer) {
1893
1982
  // inflate the gzipped bundle beyond the size budget). Consumers who want
1894
1983
  // suggestions pass the error array through this helper after validation.
1895
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
+
1896
2005
  function attachSuggestions (errors, data) {
1897
2006
  if (!errors) return errors;
1898
2007
  for (const e of errors) {
@@ -1909,6 +2018,9 @@ function attachSuggestions (errors, data) {
1909
2018
  const s = suggestFor(probe, data);
1910
2019
  if (s) e.suggestion = s;
1911
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 });
1912
2024
  return errors;
1913
2025
  }
1914
2026
 
@@ -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 };
@@ -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
- map[pointerOf(path)] = { byteOffset: start, length, line: pos.line, col: pos.col, text: pos.text };
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;
@@ -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 };
@@ -35,6 +35,7 @@ function expectedFor (err) {
35
35
  case 'minimum': return err.params && err.params.limit != null ? `≥${err.params.limit}` : undefined;
36
36
  case 'maximum': return err.params && err.params.limit != null ? `≤${err.params.limit}` : undefined;
37
37
  case 'format': return err.params && err.params.format ? `format '${err.params.format}'` : undefined;
38
+ case 'pattern': return err.params && err.params.pattern ? `string matching /${err.params.pattern}/` : undefined;
38
39
  case 'enum': return err.params && err.params.allowedValues
39
40
  ? `one of [${err.params.allowedValues.map(reprValue).join(', ')}]`
40
41
  : undefined;
@@ -73,6 +74,59 @@ function pickReceived (err, data) {
73
74
  * Pure: returns a new object. Source frames and suggestions are added by
74
75
  * other helpers later in the pipeline.
75
76
  */
77
+ // Observation-first wording. `message` stays as it is for parity, so this is a
78
+ // separate field a consumer opts into.
79
+ function detailFor (err, out) {
80
+ const p = err.params || {};
81
+ switch (err.keyword) {
82
+ case 'type':
83
+ return `expected ${p.type}, found ${typeNameOf(out.received)}`;
84
+ case 'required':
85
+ return `missing required property "${p.missingProperty}"`;
86
+ case 'additionalProperties':
87
+ return `unknown property "${p.additionalProperty}"`;
88
+ case 'unevaluatedProperties':
89
+ return `unevaluated property "${p.unevaluatedProperty}"`;
90
+ case 'enum':
91
+ return out.expected ? `expected ${out.expected}, found ${out.received}` : undefined;
92
+ case 'const':
93
+ return out.expected ? `expected ${out.expected}, found ${out.received}` : undefined;
94
+ case 'format':
95
+ return `not a valid ${p.format}: ${out.received}`;
96
+ case 'minimum': case 'maximum': case 'exclusiveMinimum': case 'exclusiveMaximum':
97
+ return `expected ${out.expected}, found ${out.received}`;
98
+ case 'minLength': case 'maxLength':
99
+ return `expected ${out.expected}, found ${out.received}`;
100
+ default:
101
+ return out.expected ? `expected ${out.expected}, found ${out.received}` : undefined;
102
+ }
103
+ }
104
+
105
+ // Derived from the repr in `received`, which is already a JSON-ish string.
106
+ function typeNameOf (received) {
107
+ if (received === undefined) return 'nothing';
108
+ if (received === 'null') return 'null';
109
+ if (received === 'true' || received === 'false') return 'boolean';
110
+ if (received.startsWith('"')) return 'string';
111
+ if (received.startsWith('[array')) return 'array';
112
+ if (received.startsWith('{') || received.startsWith('[object')) return 'object';
113
+ if (/^-?\d/.test(received)) return 'number';
114
+ return 'value';
115
+ }
116
+
117
+ // Cause before effect, applied only as a tie-break within one container.
118
+ // Ordering across the document is by position, done in the renderer.
119
+ const RANK = {
120
+ required: 0, additionalProperties: 0, unevaluatedProperties: 0,
121
+ unevaluatedItems: 0, dependentRequired: 0, propertyNames: 0,
122
+ type: 1,
123
+ oneOf: 3, anyOf: 3, allOf: 3, not: 3,
124
+ };
125
+ function rankFor (keyword) {
126
+ const r = RANK[keyword];
127
+ return r === undefined ? 2 : r;
128
+ }
129
+
76
130
  function enrich (rawErr, opts) {
77
131
  const data = opts && opts.data;
78
132
  const positions = opts && opts.positions;
@@ -129,6 +183,28 @@ function enrich (rawErr, opts) {
129
183
  const sugg = suggestFor(out, opts && opts.data);
130
184
  if (sugg) out.suggestion = sugg;
131
185
 
186
+ const detail = detailFor(rawErr, out);
187
+ if (detail !== undefined) out.detail = detail;
188
+ out.rank = rankFor(keyword);
189
+
190
+ // Token-level anchor. `dataFrame` already carries the value span; `anchor`
191
+ // adds the key span so a caret can sit on the property that is wrong rather
192
+ // than on the object containing it.
193
+ if (opts && opts.positions) {
194
+ const named = (rawErr.params && (rawErr.params.additionalProperty || rawErr.params.unevaluatedProperty)) || null;
195
+ const own = opts.positions[path];
196
+ const child = named ? opts.positions[(path === '' ? '' : path) + '/' + named] : null;
197
+ const src = child || own;
198
+ if (src) {
199
+ out.anchor = { line: src.line, col: src.col, length: src.length };
200
+ if (src.keyOffset !== undefined) {
201
+ out.anchor.keyLine = src.keyLine;
202
+ out.anchor.keyCol = src.keyCol;
203
+ out.anchor.keyLength = src.keyLength;
204
+ }
205
+ }
206
+ }
207
+
132
208
  return out;
133
209
  }
134
210
 
@@ -1492,9 +1492,17 @@ function compileToJSCodegen(schema, schemaMap, userFormats) {
1492
1492
  function replaceTopLevel(code) {
1493
1493
  let fnDepth = 0, result = '', i = 0
1494
1494
  while (i < code.length) {
1495
- if (code.startsWith('function', i) && (i === 0 || /[^a-zA-Z_$]/.test(code[i - 1]))) {
1496
- // Found a nested function — skip to opening brace, track all braces inside
1497
- let j = i + 8
1495
+ // A nested closure keeps its own returns. Both spellings count: a
1496
+ // `function` keyword and an arrow with a block body. The inline pattern
1497
+ // compiler emits `(()=>{...return false...return true})()` and rewriting
1498
+ // those returns turned the arrow's boolean into an object, which made
1499
+ // `!(cond && obj)` false and let the hybrid accept what the boolean
1500
+ // program rejected.
1501
+ const isFunctionKw = code.startsWith('function', i) && (i === 0 || /[^a-zA-Z_$]/.test(code[i - 1]))
1502
+ const isArrowBlock = code.startsWith('=>{', i)
1503
+ if (isFunctionKw || isArrowBlock) {
1504
+ // Skip to the opening brace, then track all braces inside the body.
1505
+ let j = isArrowBlock ? i + 2 : i + 8
1498
1506
  while (j < code.length && code[j] !== '{') j++
1499
1507
  result += code.slice(i, j + 1)
1500
1508
  i = j + 1
@@ -2,14 +2,25 @@
2
2
 
3
3
  // Bounded Levenshtein. Returns Infinity if distance > maxDistance.
4
4
  // Single-row DP. O(n*m) worst case but typical strings are <30 chars.
5
+ // Two rows of scratch, reused across calls. The function is not recursive
6
+ // and the library is single-threaded, so nothing else is using them; growing
7
+ // them once beats allocating two arrays on every comparison, which the
8
+ // reject-path profile showed as most of this function's cost.
9
+ let scratchA = new Int32Array(64);
10
+ let scratchB = new Int32Array(64);
11
+
5
12
  function levenshtein (a, b, maxDistance) {
6
13
  const max = maxDistance == null ? Infinity : maxDistance;
7
14
  if (a === b) return 0;
8
15
  if (Math.abs(a.length - b.length) > max) return Infinity;
9
16
  if (a.length === 0) return b.length;
10
17
  if (b.length === 0) return a.length;
11
- let prev = new Array(b.length + 1);
12
- let curr = new Array(b.length + 1);
18
+ if (scratchA.length < b.length + 1) {
19
+ scratchA = new Int32Array(b.length + 1);
20
+ scratchB = new Int32Array(b.length + 1);
21
+ }
22
+ let prev = scratchA;
23
+ let curr = scratchB;
13
24
  for (let j = 0; j <= b.length; j++) prev[j] = j;
14
25
  for (let i = 1; i <= a.length; i++) {
15
26
  curr[0] = i;
@@ -1,31 +1,41 @@
1
1
  'use strict';
2
2
 
3
- const { color, ANSI, resolveColor, pathToDotted, trimCwd } = require('./render-shared');
3
+ const { color, ANSI, resolveColor, trimCwd } = require('./render-shared');
4
+ const { toDiagnostics } = require('./diagnose');
5
+
6
+ const { getDiagnosticSource } = require('./diagnostic-source');
4
7
 
5
8
  function renderCompact (errors, opts) {
6
9
  if (!Array.isArray(errors) || errors.length === 0) return '';
7
10
  opts = opts || {};
8
11
  const useColor = resolveColor(opts.color || 'auto');
9
12
  const cwd = opts.cwd;
13
+ const carried = getDiagnosticSource(errors) || {};
14
+ const source = opts.data !== undefined ? Object.assign({}, carried, { data: opts.data }) : carried;
15
+ const diags = toDiagnostics(errors, source);
10
16
  const lines = [];
11
17
 
12
- for (const err of errors) {
18
+ for (const d of diags) {
13
19
  let prefix = '';
14
- if (err.schemaSource) {
15
- const f = trimCwd(err.schemaSource.file, cwd);
16
- prefix = color(useColor, ANSI.cyan, `${f}:${err.schemaSource.line}:${err.schemaSource.col}`) + ' - ';
20
+ const raw = d.mergedFrom[0];
21
+ if (raw && raw.schemaSource) {
22
+ const f = trimCwd(raw.schemaSource.file, cwd);
23
+ prefix = color(useColor, ANSI.cyan, `${f}:${raw.schemaSource.line}:${raw.schemaSource.col}`) + ' - ';
17
24
  }
18
- const codeStr = color(useColor, ANSI.red + ANSI.bold, `error ${err.code}`);
19
- const pathStr = color(useColor, ANSI.cyan, pathToDotted(err.path));
20
- const got = err.received != null ? `got ${err.received}` : '';
21
- const sugg = err.suggestion ? color(useColor, ANSI.yellow, `, ${err.suggestion.text}`) : '';
25
+ const codeStr = color(useColor, ANSI.red + ANSI.bold, d.code ? `error ${d.code}` : 'error');
26
+ const pathStr = color(useColor, ANSI.cyan, d.dotted);
27
+ const got = raw && raw.received != null ? `got ${raw.received}` : '';
28
+ const sugg = d.help ? color(useColor, ANSI.yellow, `, ${d.help}`) : '';
22
29
  const tail = got || sugg ? ` (${got}${sugg})` : '';
23
- lines.push(`${prefix}${codeStr}: ${pathStr} ${err.message}${tail}`);
30
+ lines.push(`${prefix}${codeStr}: ${pathStr} ${d.headline}${tail}`);
24
31
  }
25
32
 
26
33
  const n = errors.length;
34
+ const shown = diags.length;
27
35
  lines.push('');
28
- lines.push(`Found ${n} error${n === 1 ? '' : 's'} in ${opts.context || 'input'}.`);
36
+ let summary = `Found ${n} error${n === 1 ? '' : 's'} in ${opts.context || 'input'}`;
37
+ if (shown !== n) summary += `, shown as ${shown} diagnostic${shown === 1 ? '' : 's'}`;
38
+ lines.push(summary + '.');
29
39
  if (!(process.stdout && process.stdout.isTTY)) {
30
40
  lines.push('(run with --pretty for source frames)');
31
41
  }
@@ -1,60 +1,93 @@
1
1
  'use strict';
2
2
 
3
3
  const { color, ANSI, resolveColor, trimCwd, truncateLine, terminalWidth } = require('./render-shared');
4
+ const { toDiagnostics } = require('./diagnose');
4
5
 
6
+ const { getDiagnosticSource } = require('./diagnostic-source');
7
+
8
+ function sourceFor (errors, opts) {
9
+ const carried = getDiagnosticSource(errors) || {};
10
+ if (opts.data !== undefined) return Object.assign({}, carried, { data: opts.data });
11
+ return carried;
12
+ }
13
+
14
+ // Never wider than the terminal. The old form repeated the caret for the
15
+ // full span of the value, which for a root-level error was the whole
16
+ // document drawn under a one-character line.
5
17
  function caretLine (col, length, gutter) {
6
18
  const pad = ' '.repeat(gutter);
7
19
  const lead = ' '.repeat(Math.max(0, col - 1));
8
- const carets = '^'.repeat(Math.max(1, length || 1));
20
+ const carets = '^'.repeat(Math.max(1, Math.min(length || 1, terminalWidth())));
9
21
  return pad + '| ' + lead + carets;
10
22
  }
11
23
 
12
- function renderOne (err, useColor, opts) {
24
+ // What sits beside the caret. For a value error, the value that was found.
25
+ // For a missing property there is no value to show, and the useful fact is
26
+ // what was expected. For errors about the container's shape or about which
27
+ // branch matched, printing the whole container is noise, so nothing.
28
+ const NO_SUFFIX = new Set(['additionalProperties', 'unevaluatedProperties', 'unevaluatedItems', 'dependentRequired', 'propertyNames', 'oneOf', 'anyOf', 'allOf', 'not']);
29
+ function caretSuffix (d, raw, useColor) {
30
+ if (!raw) return '';
31
+ if (raw.keyword === 'required') {
32
+ return raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${raw.expected}`) : '';
33
+ }
34
+ // A composition error anchored inside the closest branch has a value there.
35
+ if (d.found != null && (raw.keyword === 'oneOf' || raw.keyword === 'anyOf')) {
36
+ return ' ' + color(useColor, ANSI.dim, `found ${d.found}`);
37
+ }
38
+ if (NO_SUFFIX.has(raw.keyword)) return '';
39
+ return d.found != null ? ' ' + color(useColor, ANSI.dim, `found ${d.found}`) : '';
40
+ }
41
+
42
+ function renderOne (d, useColor, opts) {
13
43
  const lines = [];
14
- const cwd = opts.cwd;
15
44
  const width = terminalWidth();
16
45
  const gutter = 3;
17
46
 
18
- // Headline
19
- const headline = `${err.message}`;
20
- lines.push(color(useColor, ANSI.red + ANSI.bold, `error[${err.code}]: `) + headline);
47
+ // Headline carries the code when there is one. A code-less error, which the
48
+ // LazyRejection fallback can produce, must not render as "error[undefined]".
49
+ const label = d.code ? `error[${d.code}]: ` : 'error: ';
50
+ lines.push(color(useColor, ANSI.red + ANSI.bold, label) + d.headline);
21
51
 
22
- // Schema source frame
23
- if (err.schemaSource) {
24
- const f = trimCwd(err.schemaSource.file, cwd);
25
- lines.push(` --> ${color(useColor, ANSI.cyan, `${f}:${err.schemaSource.line}:${err.schemaSource.col}`)}`);
52
+ // Schema source frame, when the Validator was built with a `source` option.
53
+ // This is the part that makes the output read like a compiler, and it must
54
+ // survive the rewrite: it points at the rule, where the data frame below
55
+ // points at the value.
56
+ const raw = d.mergedFrom[0];
57
+ if (raw && raw.schemaSource) {
58
+ const f = trimCwd(raw.schemaSource.file, opts.cwd);
59
+ lines.push(` --> ${color(useColor, ANSI.cyan, `${f}:${raw.schemaSource.line}:${raw.schemaSource.col}`)}`);
26
60
  lines.push(' |');
27
- const ln = String(err.schemaSource.line).padStart(2, ' ');
28
- const srcText = truncateLine(err.schemaSource.text, width - 8);
29
- lines.push(` ${ln} | ${srcText}`);
30
- const inlineHint = err.expected ? ' ' + color(useColor, ANSI.dim, `expected ${err.expected}`) : '';
31
- lines.push(caretLine(err.schemaSource.col, 1, gutter) + inlineHint);
61
+ const sln = String(raw.schemaSource.line).padStart(2, ' ');
62
+ lines.push(` ${sln} | ${truncateLine(raw.schemaSource.text, width - 8)}`);
63
+ const inlineHint = raw.expected ? ' ' + color(useColor, ANSI.dim, `expected ${raw.expected}`) : '';
64
+ lines.push(caretLine(raw.schemaSource.col, 1, gutter) + inlineHint);
32
65
  lines.push(' |');
33
66
  }
34
67
 
35
- // Data frame
36
- if (err.dataFrame) {
37
- lines.push(` --> ${color(useColor, ANSI.dim, `input, byte ${err.dataFrame.byteOffset}`)}`);
68
+ // Data location, always, even without a frame. This is the single most
69
+ // common reason today's output cannot be acted on.
70
+ if (d.frame) {
71
+ const where = `input:${d.frame.line}:${d.frame.col}`;
72
+ lines.push(` --> ${color(useColor, ANSI.cyan, where)} ${color(useColor, ANSI.dim, '(' + d.dotted + ')')}`);
38
73
  lines.push(' |');
39
- const ln = String(err.dataFrame.line).padStart(2, ' ');
40
- const srcText = truncateLine(err.dataFrame.text, width - 8);
41
- lines.push(` ${ln} | ${srcText}`);
42
- const got = err.received != null ? ' ' + color(useColor, ANSI.dim, `got ${err.received}`) : '';
43
- lines.push(caretLine(err.dataFrame.col, err.dataFrame.length, gutter) + got);
74
+ const ln = String(d.frame.line).padStart(2, ' ');
75
+ lines.push(` ${ln} | ${truncateLine(d.frame.text, width - 8)}`);
76
+ lines.push(caretLine(d.frame.col, d.frame.length, gutter) + caretSuffix(d, raw, useColor));
44
77
  lines.push(' |');
78
+ } else {
79
+ lines.push(` --> ${color(useColor, ANSI.cyan, 'at ' + d.dotted)}`);
45
80
  }
46
81
 
47
- if (err.suggestion) {
48
- lines.push(' = ' + color(useColor, ANSI.yellow, 'help: ') + err.suggestion.text);
49
- }
50
- if (err.branchErrors && err.branchErrors.length) {
51
- const variant = (err.params && err.params.closestName) || 'closest variant';
52
- const n = err.branchErrors.length;
82
+ if (d.help) lines.push(' = ' + color(useColor, ANSI.yellow, 'help: ') + d.help);
83
+ if (d.branchErrors && d.branchErrors.length) {
84
+ const variant = (raw && raw.params && raw.params.closestName) || 'closest variant';
85
+ const n = d.branchErrors.length;
53
86
  lines.push(' = ' + color(useColor, ANSI.dim, 'note: ') + `closest match was ${variant} with ${n} error${n === 1 ? '' : 's'}:`);
54
- renderBranchErrors(err.branchErrors, 1, lines, useColor);
87
+ renderBranchErrors(d.branchErrors, 1, lines, useColor);
55
88
  }
56
- if (err.docUrl) {
57
- lines.push(' = ' + color(useColor, ANSI.dim, 'note: see ') + err.docUrl);
89
+ for (const note of d.notes) {
90
+ lines.push(' = ' + color(useColor, ANSI.dim, 'note: ') + note);
58
91
  }
59
92
  return lines.join('\n');
60
93
  }
@@ -70,7 +103,12 @@ function renderBranchErrors (subs, depth, lines, useColor) {
70
103
  const max = 3;
71
104
  const shown = subs.slice(0, max);
72
105
  for (const sub of shown) {
73
- lines.push(' ' + color(useColor, ANSI.dim, `${sub.keyword}: ${sub.message || ''}`));
106
+ // Observation first when the branch error carries one; the rule otherwise.
107
+ let text = sub.message || '';
108
+ if (sub.expected !== undefined) {
109
+ text = `expected ${sub.expected}` + (sub.received !== undefined ? `, found ${sub.received}` : '');
110
+ }
111
+ lines.push(' ' + color(useColor, ANSI.dim, `${sub.keyword}: ${text}`));
74
112
  if (sub.branchErrors && sub.branchErrors.length) renderBranchErrors(sub.branchErrors, depth + 1, lines, useColor);
75
113
  }
76
114
  if (subs.length > max) {
@@ -85,17 +123,28 @@ function renderPretty (errors, opts) {
85
123
  const maxErrors = opts.maxErrors != null ? opts.maxErrors : 20;
86
124
  const context = opts.context || 'input';
87
125
 
126
+ const diags = toDiagnostics(errors, sourceFor(errors, opts));
127
+
88
128
  const blocks = [];
89
- const limit = maxErrors === 0 ? errors.length : Math.min(maxErrors, errors.length);
90
- for (let i = 0; i < limit; i++) {
91
- blocks.push(renderOne(errors[i], useColor, opts));
92
- }
129
+ const limit = maxErrors === 0 ? diags.length : Math.min(maxErrors, diags.length);
130
+ for (let i = 0; i < limit; i++) blocks.push(renderOne(diags[i], useColor, opts));
131
+
93
132
  let out = blocks.join('\n\n');
94
- if (limit < errors.length) {
95
- out += `\n\n... and ${errors.length - limit} more errors (run with --pretty --max-errors=0 to see all)`;
133
+ if (limit < diags.length) {
134
+ out += `\n\n... and ${diags.length - limit} more errors (run with --pretty --max-errors=0 to see all)`;
96
135
  }
136
+ // The count never drifts from errors.length. When correlation collapsed a
137
+ // pair the second clause says so, so the reader can reconcile the two.
97
138
  const n = errors.length;
98
- out += `\n\n` + color(useColor, ANSI.red + ANSI.bold, `error: `) + `${n} schema violation${n === 1 ? '' : 's'} in ${context}`;
139
+ const shown = diags.length;
140
+ let summary = `${n} schema violation${n === 1 ? '' : 's'} in ${context}`;
141
+ if (shown !== n) summary += `, shown as ${shown} diagnostic${shown === 1 ? '' : 's'}`;
142
+ out += '\n\n' + color(useColor, ANSI.red + ANSI.bold, 'error: ') + summary;
143
+ // Said once, not under every block. The reader needs to know their line
144
+ // numbers will not match a file, and needs to be told one time.
145
+ if (diags.some((d) => d.frame && d.frame.synthesized)) {
146
+ out += '\n' + color(useColor, ANSI.dim, 'note: frames were reconstructed from the value, not from your input text; line numbers refer to that reconstruction');
147
+ }
99
148
  return out;
100
149
  }
101
150
 
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // Kept in lockstep with package.json by `tests/test_version_sync.js`.
9
9
 
10
- module.exports = '1.8.1';
10
+ module.exports = '1.9.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.8.1",
3
+ "version": "1.9.0",
4
4
  "description": "JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile to per-schema ESM modules with zero validator dependency. Generic Validator<T> for TypeBox/Zod/Valibot composition. Optional runtime API. Standard Schema V1 compatible.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -44,7 +44,7 @@
44
44
  "release:check": "node scripts/regen-safe-regex-source.js && node tests/test_pack_purity.js && node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js && node tests/test_safe_regex_source_sync.js && node tests/test_version_sync.js",
45
45
  "build": "cmake-js build --target ata",
46
46
  "rebuild": "cmake-js rebuild --target ata",
47
- "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
47
+ "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_hybrid_agreement.js && node tests/test_ajv_errors.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_additive_fields.js && node tests/test_diagnostic_source.js && node tests/test_diagnose.js && node tests/test_correlate.js && node tests/test_diagnostics_score.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
48
48
  "bench:size": "node benchmark/bench_aot_size.mjs",
49
49
  "test:suite": "node tests/run_suite.js && node tests/run_suite.js draft7 && node tests/run_suite.js v1",
50
50
  "test:compat": "node tests/test_compat.js",
@@ -112,13 +112,13 @@
112
112
  "LICENSE"
113
113
  ],
114
114
  "optionalDependencies": {
115
- "@ata-validator/native-darwin-arm64": "1.8.1",
116
- "@ata-validator/native-darwin-x64": "1.8.1",
117
- "@ata-validator/native-linux-x64-gnu": "1.8.1",
118
- "@ata-validator/native-linux-arm64-gnu": "1.8.1",
119
- "@ata-validator/native-linux-x64-musl": "1.8.1",
120
- "@ata-validator/native-linux-arm64-musl": "1.8.1",
121
- "@ata-validator/native-win32-x64": "1.8.1"
115
+ "@ata-validator/native-darwin-arm64": "1.9.0",
116
+ "@ata-validator/native-darwin-x64": "1.9.0",
117
+ "@ata-validator/native-linux-x64-gnu": "1.9.0",
118
+ "@ata-validator/native-linux-arm64-gnu": "1.9.0",
119
+ "@ata-validator/native-linux-x64-musl": "1.9.0",
120
+ "@ata-validator/native-linux-arm64-musl": "1.9.0",
121
+ "@ata-validator/native-win32-x64": "1.9.0"
122
122
  },
123
123
  "peerDependencies": {
124
124
  "yaml": "^2.0.0"