ata-validator 0.13.4 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.js CHANGED
@@ -272,7 +272,15 @@ const _identityCache = new WeakMap();
272
272
 
273
273
  const SIMDJSON_PADDING = 64;
274
274
  const VALID_RESULT = Object.freeze({ valid: true, errors: Object.freeze([]) });
275
- const ABORT_EARLY_RESULT = Object.freeze({ valid: false, errors: Object.freeze([Object.freeze({ message: 'validation failed' })]) });
275
+ const ABORT_EARLY_RESULT = Object.freeze({
276
+ valid: false,
277
+ errors: Object.freeze([Object.freeze({
278
+ code: 'ATA9000',
279
+ message: 'validation failed',
280
+ keyword: '__abort_early__',
281
+ path: '',
282
+ })]),
283
+ });
276
284
 
277
285
  // Embedded verbatim in standalone modules so the output file has no runtime
278
286
  // dependency on ata-validator. ASCII fast-path plus surrogate-aware slow path.
@@ -402,6 +410,32 @@ class Validator {
402
410
  // produced the error). Matches ajv's `verbose: true` behavior.
403
411
  this._verbose = !!options.verbose;
404
412
 
413
+ // richErrors: default true. Only the literal `false` opts back into the
414
+ // v0.14 error shape (no code/expected/received/docUrl, no aliases).
415
+ this._richErrors = options && options.richErrors === false ? false : true;
416
+
417
+ // Optional schema source descriptor. When supplied, the renderer pipeline
418
+ // can attach a `schemaSource` frame to enriched errors.
419
+ this._source = options && options.source && typeof options.source === 'object'
420
+ ? { path: String(options.source.path || ''), content: String(options.source.content || '') }
421
+ : null;
422
+
423
+ // Build a JSON pointer -> position map for the schema text once at
424
+ // construction so each runtime error can resolve `schemaSource` without
425
+ // re-scanning the source on every validate() call.
426
+ if (this._source) {
427
+ const { buildPositionMap } = require('./lib/source-positions');
428
+ this._schemaPositions = buildPositionMap(this._source.content);
429
+ } else {
430
+ this._schemaPositions = null;
431
+ }
432
+
433
+ // Per-validate data position cache. Populated by validateJSON before
434
+ // dispatching to inner validate(); consulted by the rich-error wrap
435
+ // to attach dataFrame entries to each enriched error.
436
+ this._posCache = require('./lib/data-position-cache').createCache();
437
+ this._lastRawInput = null;
438
+
405
439
  // Lazy stubs: trigger compilation on first call, then re-dispatch
406
440
  this.validate = (data) => {
407
441
  this._ensureCompiled();
@@ -626,8 +660,10 @@ class Validator {
626
660
  }
627
661
 
628
662
  if (options.abortEarly && jsFn && !hasDynRef) {
629
- // Abort-early fast path: skip detailed error collection on failure.
630
- // Returns a shared frozen result, no per-call allocation, no errFn work.
663
+ // abortEarly: do NOT enrich. Skip position lookups, suggestions, source maps.
664
+ // This is the perf-critical path for edge gateways. The richErrors wrap
665
+ // below recognises the ATA9000 stub keyword and passes the frozen result
666
+ // through unchanged, so a single shared object is returned per failure.
631
667
  const _fn = jsFn;
632
668
  this.validate = preprocess
633
669
  ? (data) => { preprocess(data); return _fn(data) ? VALID_RESULT : ABORT_EARLY_RESULT; }
@@ -851,6 +887,86 @@ class Validator {
851
887
  }
852
888
  }
853
889
 
890
+ // richErrors enrichment: layered on top of whichever validate path was
891
+ // bound above. Verbose's parentSchema flows through because enrich()
892
+ // copies it. Opt-out (`richErrors: false`) leaves the raw v0.14 shape.
893
+ if (this._richErrors && this.validate) {
894
+ const inner = this.validate;
895
+ const enrich = require('./lib/enrich-error').enrich;
896
+ this.validate = (data) => {
897
+ const result = inner(data);
898
+ if (result && !result.valid && result.errors && result.errors.length) {
899
+ // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
900
+ // perf fast path stays allocation-free and the documented code stays stable.
901
+ if (result === ABORT_EARLY_RESULT) return result;
902
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
903
+ const enriched = result.errors.map((e) => enrich(e, {
904
+ data,
905
+ positions,
906
+ schemaPositions: this._schemaPositions,
907
+ schemaFile: this._source ? this._source.path : undefined,
908
+ }));
909
+ if (positions) this._posCache.reset();
910
+ return { valid: false, errors: enriched };
911
+ }
912
+ return result;
913
+ };
914
+
915
+ // validateJSON also enriches: set _lastRawInput so the position cache
916
+ // can lazily build a map for dataFrame attachment. Only validateJSON
917
+ // wires this — validate(data) takes a pre-parsed object, by design.
918
+ if (this.validateJSON) {
919
+ const innerJson = this.validateJSON;
920
+ this.validateJSON = (jsonStr) => {
921
+ this._lastRawInput = jsonStr;
922
+ let result;
923
+ try {
924
+ result = innerJson(jsonStr);
925
+ } finally {
926
+ // Don't clear here; the enrich step below needs the cache. We
927
+ // clear after enrich, or in the early-return path.
928
+ }
929
+ if (result && !result.valid && result.errors && result.errors.length) {
930
+ // If errors came from the inner path that already ran through the
931
+ // wrapped this.validate (codegen jsonValidateFn -> validate path),
932
+ // they may already be enriched. Detect by presence of `code`.
933
+ const first = result.errors[0];
934
+ if (!first || !first.code) {
935
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
936
+ // Re-parse the input once so the enrich pass can pluck `received`
937
+ // and feed the suggestion engine (required-typo, format hints,
938
+ // coercion nudges all need the live value tree).
939
+ let parsedData;
940
+ try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
941
+ const enriched = result.errors.map((e) => enrich(e, {
942
+ data: parsedData,
943
+ positions,
944
+ schemaPositions: this._schemaPositions,
945
+ schemaFile: this._source ? this._source.path : undefined,
946
+ }));
947
+ if (positions) this._posCache.reset();
948
+ this._lastRawInput = null;
949
+ return { valid: false, errors: enriched };
950
+ }
951
+ // Already-enriched path: still attach dataFrame if missing.
952
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
953
+ if (positions) {
954
+ for (const e of result.errors) {
955
+ if (e && !e.dataFrame) {
956
+ const path = e.path != null ? e.path : (e.instancePath || '');
957
+ const p = positions[path];
958
+ if (p) e.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
959
+ }
960
+ }
961
+ this._posCache.reset();
962
+ }
963
+ }
964
+ this._lastRawInput = null;
965
+ return result;
966
+ };
967
+ }
968
+ }
969
+
854
970
  // Save to identity cache for ultra-fast reuse with same schema object
855
971
  if (this._schemaObj && typeof this._schemaObj === 'object') {
856
972
  _identityCache.set(this._schemaObj, this);
@@ -965,12 +1081,18 @@ module.exports = { boolFn, hybridFactory, errFn };
965
1081
  if (!jsFn || !jsFn._source) return null;
966
1082
  const format = (opts && opts.format) || 'esm';
967
1083
  const abortEarly = !!(opts && opts.abortEarly);
1084
+ const source = !!(opts && opts.source);
1085
+ const sourceMap = opts && opts.sourceMap ? opts.sourceMap : null;
1086
+ const schemaFile = opts && opts.schemaFile ? opts.schemaFile : null;
968
1087
  const src = jsFn._source;
969
1088
 
970
1089
  let errCore = '';
971
1090
  if (!abortEarly) {
972
1091
  const jsErrFn = compileToJSCodegenWithErrors(
973
1092
  typeof this._schemaObj === 'object' ? this._schemaObj : {},
1093
+ null,
1094
+ undefined,
1095
+ (source && sourceMap && schemaFile) ? { sourceMap, schemaFile } : null,
974
1096
  );
975
1097
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
976
1098
  if (errSrc) {
@@ -978,6 +1100,16 @@ module.exports = { boolFn, hybridFactory, errFn };
978
1100
  }
979
1101
  }
980
1102
 
1103
+ // Schema-source frames are baked as literals inside each emitted error so
1104
+ // consumers don't need a runtime lookup. We still expose the schema file
1105
+ // as a sentinel constant when --source is on — handy for introspection
1106
+ // and visible in source graphs. With --no-source, the constant is omitted
1107
+ // entirely so size budgets and grep-based "is this source-mapped?" checks
1108
+ // both work.
1109
+ const schemaSourceConst = (source && schemaFile)
1110
+ ? `const __ATA_SCHEMA_SOURCE__ = ${JSON.stringify({ file: schemaFile })};\n`
1111
+ : '';
1112
+
981
1113
  // Serialize closure vars referenced in _fn body: regex, sub-validators, sets.
982
1114
  let closureDecls = '';
983
1115
  if (jsFn._closures && jsFn._closures.length > 0) {
@@ -1016,8 +1148,16 @@ module.exports = { boolFn, hybridFactory, errFn };
1016
1148
  // Schema is embedded; runtime has zero dependency on ata-validator.
1017
1149
  'use strict';
1018
1150
  ${_CP_LEN_SOURCE}
1019
- const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
1020
- const ABORT = Object.freeze({ valid: false, errors: Object.freeze([Object.freeze({ message: 'validation failed' })]) });
1151
+ ${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
1152
+ const ABORT = Object.freeze({
1153
+ valid: false,
1154
+ errors: Object.freeze([Object.freeze({
1155
+ code: 'ATA9000',
1156
+ message: 'validation failed',
1157
+ keyword: '__abort_early__',
1158
+ path: '',
1159
+ })]),
1160
+ });
1021
1161
  ${closureDecls}const _fn = function(d) {
1022
1162
  ${src}
1023
1163
  };
@@ -1389,6 +1529,45 @@ function compile(schema, opts) {
1389
1529
  }
1390
1530
 
1391
1531
  const { toTypeScript } = require("./lib/ts-gen");
1532
+ const { renderPretty } = require("./lib/render-pretty");
1533
+ const { renderCompact } = require("./lib/render-compact");
1534
+ const { renderJSON } = require("./lib/render-json");
1535
+ const { suggestFor } = require("./lib/suggestions");
1536
+ const { reprValue } = require("./lib/enrich-error");
1537
+
1538
+ // Walk a JSON pointer (RFC 6901 escapes) into a data tree. Mirrors the helper
1539
+ // inside lib/suggestions.js — kept local to avoid exporting an internal.
1540
+ function _walkPointer (root, pointer) {
1541
+ if (!pointer) return root;
1542
+ const parts = pointer.replace(/^\//, '').split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
1543
+ let cur = root;
1544
+ for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; }
1545
+ return cur;
1546
+ }
1547
+
1548
+ // Post-hoc suggestion enrichment for AOT-compiled validators. The standalone
1549
+ // modules do not embed the suggestion engine (Levenshtein + format hints would
1550
+ // inflate the gzipped bundle beyond the size budget). Consumers who want
1551
+ // suggestions pass the error array through this helper after validation.
1552
+ // AOT errors don't carry `received`, so we re-derive it from `data` here.
1553
+ function attachSuggestions (errors, data) {
1554
+ if (!errors) return errors;
1555
+ for (const e of errors) {
1556
+ if (!e || e.suggestion) continue;
1557
+ let received = e.received;
1558
+ if (received === undefined && data !== undefined) {
1559
+ const ptr = e.instancePath != null ? e.instancePath : (e.path || '');
1560
+ const raw = _walkPointer(data, ptr);
1561
+ if (raw !== undefined || ptr === '') received = reprValue(raw);
1562
+ }
1563
+ const probe = received !== undefined && e.received === undefined
1564
+ ? Object.assign({}, e, { received })
1565
+ : e;
1566
+ const s = suggestFor(probe, data);
1567
+ if (s) e.suggestion = s;
1568
+ }
1569
+ return errors;
1570
+ }
1392
1571
 
1393
1572
  module.exports = {
1394
1573
  Validator,
@@ -1399,4 +1578,8 @@ module.exports = {
1399
1578
  SIMDJSON_PADDING,
1400
1579
  parseJSON,
1401
1580
  toTypeScript,
1581
+ renderPretty,
1582
+ renderCompact,
1583
+ renderJSON,
1584
+ attachSuggestions,
1402
1585
  };
package/index.mjs CHANGED
@@ -1,3 +1,3 @@
1
1
  import mod from './index.js';
2
- export const { Validator, validate, version, createPaddedBuffer, SIMDJSON_PADDING } = mod;
2
+ export const { Validator, validate, version, createPaddedBuffer, SIMDJSON_PADDING, renderPretty, renderCompact, renderJSON } = mod;
3
3
  export default mod;
package/lib/aot-build.js CHANGED
@@ -5,6 +5,13 @@ const fs = require('fs');
5
5
  const path = require('path');
6
6
  const zlib = require('zlib');
7
7
  const { Validator } = require('..');
8
+ const { buildPositionMap } = require('./source-positions');
9
+
10
+ function resolveSourceDefault (opts) {
11
+ if (opts.source === true) return true;
12
+ if (opts.source === false) return false;
13
+ return process.env.NODE_ENV !== 'production';
14
+ }
8
15
 
9
16
  async function expandGlobs(globs) {
10
17
  const out = [];
@@ -120,7 +127,24 @@ async function build(opts) {
120
127
  }
121
128
  const schema = parseSchemaFile(input);
122
129
  const v = new Validator(schema);
123
- const src = v.toStandaloneModule({ format, abortEarly: !!opts.abortEarly });
130
+ const source = resolveSourceDefault(opts);
131
+ let sourceMap = null;
132
+ if (source) {
133
+ // Only attempt position map for JSON (the scanner is JSON-only).
134
+ const ext = path.extname(input).toLowerCase();
135
+ if (ext === '.json') {
136
+ try { sourceMap = buildPositionMap(raw.toString('utf8')); }
137
+ catch { sourceMap = null; }
138
+ }
139
+ }
140
+ const schemaFile = path.relative(process.cwd(), input) || input;
141
+ const src = v.toStandaloneModule({
142
+ format,
143
+ abortEarly: !!opts.abortEarly,
144
+ source,
145
+ sourceMap,
146
+ schemaFile,
147
+ });
124
148
  if (!src) {
125
149
  const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';
126
150
  if (opts.strict) failed.push({ input, error: reason });
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ const SEVERITY = {
4
+ type: 10,
5
+ const: 8,
6
+ enum: 8,
7
+ required: 5,
8
+ format: 3,
9
+ minLength: 3,
10
+ maxLength: 3,
11
+ minimum: 3,
12
+ maximum: 3,
13
+ pattern: 3,
14
+ additionalProperties: 2,
15
+ unevaluatedProperties: 2,
16
+ unevaluatedItems: 2,
17
+ };
18
+ const DEFAULT_SEVERITY = 4;
19
+
20
+ function scoreBranch (errors) {
21
+ if (!errors || errors.length === 0) return 0;
22
+ let sum = 0;
23
+ for (const e of errors) sum += SEVERITY[e.keyword] || DEFAULT_SEVERITY;
24
+ return errors.length * 100 + sum; // primary: count, secondary: severity
25
+ }
26
+
27
+ /**
28
+ * Given an array of branch result objects ({ valid, errors }) for a oneOf
29
+ * or anyOf, pick the best branch and emit a single user-facing error.
30
+ *
31
+ * @param keyword 'oneOf' | 'anyOf'
32
+ * @param branchResults Array<{ valid, errors, title? }>
33
+ * @param parentPath JSON pointer to the data location
34
+ * @param parentSchemaPath JSON pointer to the keyword in the schema
35
+ * @returns A ValidationError-shaped object, or null if branch passed (caller treats as success).
36
+ */
37
+ function collapseBranches ({ keyword, branchResults, parentPath, parentSchemaPath }) {
38
+ const passing = branchResults.filter(b => b.valid);
39
+ if (keyword === 'oneOf') {
40
+ if (passing.length === 1) return null;
41
+ if (passing.length > 1) {
42
+ return {
43
+ code: 'ATA4002', keyword: 'oneOf', path: parentPath || '',
44
+ message: `value matched ${passing.length} of ${branchResults.length} oneOf variants, expected exactly one`,
45
+ schemaPath: parentSchemaPath,
46
+ params: { matched: passing.length, total: branchResults.length },
47
+ };
48
+ }
49
+ // 0 matched, find best
50
+ return buildBranchError('ATA4001', 'oneOf', branchResults, parentPath, parentSchemaPath);
51
+ }
52
+ // anyOf
53
+ if (passing.length >= 1) return null;
54
+ return buildBranchError('ATA4003', 'anyOf', branchResults, parentPath, parentSchemaPath);
55
+ }
56
+
57
+ function buildBranchError (code, keyword, branchResults, parentPath, parentSchemaPath) {
58
+ let bestIdx = 0;
59
+ let bestScore = Infinity;
60
+ for (let i = 0; i < branchResults.length; i++) {
61
+ const s = scoreBranch(branchResults[i].errors);
62
+ if (s < bestScore) { bestScore = s; bestIdx = i; }
63
+ }
64
+ const best = branchResults[bestIdx];
65
+ const variantName = best.title || `variant ${bestIdx + 1}`;
66
+ return {
67
+ code, keyword, path: parentPath || '',
68
+ message: `value matched 0 of ${branchResults.length} ${keyword} variants`,
69
+ schemaPath: parentSchemaPath,
70
+ params: { variants: branchResults.length, closest: bestIdx, closestName: variantName },
71
+ branchErrors: best.errors, // surfaced in pretty render
72
+ };
73
+ }
74
+
75
+ module.exports = { collapseBranches, scoreBranch, SEVERITY };
@@ -0,0 +1,43 @@
1
+ 'use strict';
2
+
3
+ const { buildDataPositionMap } = require('./data-positions');
4
+
5
+ /**
6
+ * Memoize the position map for the duration of a single validate() call.
7
+ * Caller passes the original buffer/string. Identity-keyed: same reference
8
+ * == same map. No global state, caller holds the cache instance.
9
+ */
10
+
11
+ function createCache () {
12
+ const wm = new WeakMap();
13
+ const sm = new Map(); // strings can't go in WeakMap; clear after each validate
14
+ let lastInput = null;
15
+ return {
16
+ get (input) {
17
+ if (input == null) return null;
18
+ if (typeof input === 'string') {
19
+ if (sm.has(input)) return sm.get(input);
20
+ try {
21
+ const m = buildDataPositionMap(input);
22
+ sm.set(input, m);
23
+ return m;
24
+ } catch { return null; }
25
+ }
26
+ if (Buffer.isBuffer(input)) {
27
+ if (wm.has(input)) return wm.get(input);
28
+ try {
29
+ const m = buildDataPositionMap(input);
30
+ wm.set(input, m);
31
+ return m;
32
+ } catch { return null; }
33
+ }
34
+ return null;
35
+ },
36
+ reset () {
37
+ sm.clear();
38
+ lastInput = null;
39
+ },
40
+ };
41
+ }
42
+
43
+ module.exports = { createCache };
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Build pointer → { byteOffset, length, line, col, text } from a JSON
5
+ * input buffer. Called only when validation fails AND richErrors is on
6
+ * AND abortEarly is off. Zero cost on the valid path.
7
+ */
8
+
9
+ const { escapePtr } = require('./source-positions');
10
+
11
+ function buildDataPositionMap (input) {
12
+ const text = Buffer.isBuffer(input) ? input.toString('utf8') : String(input);
13
+ const map = Object.create(null);
14
+ const lines = text.split('\n');
15
+ const lineStart = new Array(lines.length + 1);
16
+ lineStart[0] = 0;
17
+ for (let i = 0; i < lines.length; i++) lineStart[i + 1] = lineStart[i] + lines[i].length + 1;
18
+
19
+ function offsetToLineCol (off) {
20
+ let lo = 0, hi = lineStart.length - 1;
21
+ while (lo < hi) {
22
+ const mid = (lo + hi + 1) >> 1;
23
+ if (lineStart[mid] <= off) lo = mid; else hi = mid - 1;
24
+ }
25
+ return { line: lo + 1, col: off - lineStart[lo] + 1, text: lines[lo] || '' };
26
+ }
27
+
28
+ let i = 0;
29
+ const n = text.length;
30
+
31
+ function skipWs () {
32
+ while (i < n) {
33
+ const ch = text.charCodeAt(i);
34
+ if (ch === 0x20 || ch === 0x09 || ch === 0x0a || ch === 0x0d) i++; else break;
35
+ }
36
+ }
37
+
38
+ function readString () {
39
+ const start = i;
40
+ i++;
41
+ while (i < n) {
42
+ const ch = text.charCodeAt(i);
43
+ if (ch === 0x5c) { i += 2; continue; }
44
+ if (ch === 0x22) { i++; return JSON.parse(text.slice(start, i)); }
45
+ i++;
46
+ }
47
+ throw new Error('unterminated string at offset ' + start);
48
+ }
49
+
50
+ function pointerOf (path) {
51
+ if (path.length === 0) return '';
52
+ return '/' + path.map(escapePtr).join('/');
53
+ }
54
+
55
+ function walk (path) {
56
+ skipWs();
57
+ if (i >= n) return;
58
+ const start = i;
59
+ const pos = offsetToLineCol(start);
60
+
61
+ const ch = text.charCodeAt(i);
62
+ if (ch === 0x7b) {
63
+ i++;
64
+ while (true) {
65
+ skipWs();
66
+ if (text.charCodeAt(i) === 0x7d) { i++; break; }
67
+ if (text.charCodeAt(i) === 0x2c) { i++; continue; }
68
+ skipWs();
69
+ const key = readString();
70
+ skipWs();
71
+ if (text.charCodeAt(i) !== 0x3a) throw new Error('expected ":" at offset ' + i);
72
+ i++;
73
+ walk(path.concat([key]));
74
+ }
75
+ } else if (ch === 0x5b) {
76
+ i++;
77
+ let idx = 0;
78
+ while (true) {
79
+ skipWs();
80
+ if (text.charCodeAt(i) === 0x5d) { i++; break; }
81
+ if (text.charCodeAt(i) === 0x2c) { i++; continue; }
82
+ walk(path.concat([String(idx)]));
83
+ idx++;
84
+ }
85
+ } else if (ch === 0x22) {
86
+ readString();
87
+ } else {
88
+ while (i < n) {
89
+ const c = text.charCodeAt(i);
90
+ if (c === 0x2c || c === 0x7d || c === 0x5d || c === 0x20 || c === 0x09 || c === 0x0a || c === 0x0d) break;
91
+ i++;
92
+ }
93
+ }
94
+
95
+ const length = i - start;
96
+ map[pointerOf(path)] = { byteOffset: start, length, line: pos.line, col: pos.col, text: pos.text };
97
+ }
98
+
99
+ if (text.charCodeAt(0) === 0xfeff) i = 1;
100
+ walk([]);
101
+ return map;
102
+ }
103
+
104
+ module.exports = { buildDataPositionMap };
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const { CODES, codeFor } = require('./error-codes');
4
+ const { suggestFor } = require('./suggestions');
5
+
6
+ const DOC_BASE = 'https://ata-validator.com/e/';
7
+
8
+ function reprValue (v) {
9
+ if (v === undefined) return 'undefined';
10
+ if (v === null) return 'null';
11
+ const t = typeof v;
12
+ if (t === 'string') {
13
+ const s = JSON.stringify(v);
14
+ return s.length > 60 ? s.slice(0, 57) + '..."' : s;
15
+ }
16
+ if (t === 'number' || t === 'boolean') return String(v);
17
+ if (Array.isArray(v)) return `[array, ${v.length} items]`;
18
+ if (t === 'object') {
19
+ try {
20
+ const s = JSON.stringify(v);
21
+ if (s.length <= 60) return s;
22
+ return `[object, ~${(s.length / 1024).toFixed(1)}KB]`;
23
+ } catch {
24
+ return '[object, unserializable]';
25
+ }
26
+ }
27
+ return `[${t}]`;
28
+ }
29
+
30
+ function expectedFor (err) {
31
+ switch (err.keyword) {
32
+ case 'type': return err.params && err.params.type ? String(err.params.type) : undefined;
33
+ case 'minLength': return err.params && err.params.limit != null ? `string with ≥${err.params.limit} chars` : undefined;
34
+ case 'maxLength': return err.params && err.params.limit != null ? `string with ≤${err.params.limit} chars` : undefined;
35
+ case 'minimum': return err.params && err.params.limit != null ? `≥${err.params.limit}` : undefined;
36
+ case 'maximum': return err.params && err.params.limit != null ? `≤${err.params.limit}` : undefined;
37
+ case 'format': return err.params && err.params.format ? `format '${err.params.format}'` : undefined;
38
+ case 'enum': return err.params && err.params.allowedValues
39
+ ? `one of [${err.params.allowedValues.map(reprValue).join(', ')}]`
40
+ : undefined;
41
+ case 'const': return err.params && 'allowedValue' in err.params ? reprValue(err.params.allowedValue) : undefined;
42
+ case 'required': return err.params && err.params.missingProperty ? `property '${err.params.missingProperty}'` : undefined;
43
+ default: return undefined;
44
+ }
45
+ }
46
+
47
+ function pickReceived (err, data) {
48
+ if (!data && data !== 0 && data !== false) return undefined;
49
+ // Walk JSON pointer to extract the actual offending value
50
+ const p = err.instancePath || err.path || '';
51
+ if (!p) return reprValue(data);
52
+ const parts = p.replace(/^\//, '').split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
53
+ let cur = data;
54
+ for (const part of parts) {
55
+ if (cur == null) return undefined;
56
+ cur = cur[part];
57
+ }
58
+ return reprValue(cur);
59
+ }
60
+
61
+ /**
62
+ * Enrich a raw codegen error with code/path/expected/received/docUrl.
63
+ * Pure: returns a new object. Source frames and suggestions are added by
64
+ * other helpers later in the pipeline.
65
+ */
66
+ function enrich (rawErr, opts) {
67
+ const data = opts && opts.data;
68
+ const positions = opts && opts.positions;
69
+ const keyword = rawErr.keyword;
70
+ const format = rawErr.params && rawErr.params.format;
71
+ // Prefer a code the codegen already attached (e.g. branch-collapse emits
72
+ // ATA4001/4002/4003 distinguishing zero/multi/anyOf failure modes). The
73
+ // keyword-derived lookup only finds the first match for `keyword: 'oneOf'`.
74
+ const code = rawErr.code || codeFor(keyword, format) || 'ATA9001';
75
+ const meta = CODES[code];
76
+ const path = rawErr.instancePath != null ? rawErr.instancePath : (rawErr.path || '');
77
+
78
+ const out = {
79
+ code,
80
+ message: rawErr.message || (meta && meta.headline) || 'validation failed',
81
+ keyword,
82
+ path,
83
+ expected: expectedFor(rawErr),
84
+ received: data !== undefined ? pickReceived(rawErr, data) : undefined,
85
+ schemaPath: rawErr.schemaPath,
86
+ docUrl: DOC_BASE + code,
87
+ // Back-compat aliases (additive, present in both rich and legacy paths)
88
+ instancePath: path,
89
+ dataPath: path,
90
+ params: rawErr.params,
91
+ parentSchema: rawErr.parentSchema,
92
+ };
93
+
94
+ // oneOf/anyOf collapse: preserve the nested branch errors so the pretty
95
+ // renderer can surface the closest variant's diagnostics.
96
+ if (rawErr.branchErrors) out.branchErrors = rawErr.branchErrors;
97
+
98
+ if (positions && positions[path]) {
99
+ const p = positions[path];
100
+ out.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
101
+ }
102
+
103
+ // Attach schema source frame when the validator was constructed with a
104
+ // `source` option. schemaPath looks like "#/properties/email/format" — strip
105
+ // the leading "#" before lookup. Fall back to the `#key` variant which the
106
+ // position scanner stores for the keyword name itself.
107
+ if (opts && opts.schemaPositions && rawErr.schemaPath) {
108
+ const sp = rawErr.schemaPath;
109
+ const ptr = sp.startsWith('#') ? sp.slice(1) : sp;
110
+ const hit = opts.schemaPositions[ptr] || opts.schemaPositions[ptr + '#key'];
111
+ if (hit) {
112
+ out.schemaSource = { file: opts.schemaFile, line: hit.line, col: hit.col, text: hit.text };
113
+ }
114
+ }
115
+
116
+ // Suggestion attachment runs last so it can read `received`, `params`, and
117
+ // `keyword` from the enriched shape. `data` is the full input object so the
118
+ // required-typo source can scan sibling keys.
119
+ const sugg = suggestFor(out, opts && opts.data);
120
+ if (sugg) out.suggestion = sugg;
121
+
122
+ return out;
123
+ }
124
+
125
+ module.exports = { enrich, reprValue, expectedFor };