ata-validator 0.14.0 → 0.15.1

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.
@@ -355,6 +363,19 @@ function buildSchemaMap(schemas) {
355
363
  return map
356
364
  }
357
365
 
366
+ // Compile-cache key for a root schema plus its external schemas. Must include
367
+ // the external schema CONTENT, not just their $ids: two validators can share a
368
+ // root schema string and the same $id while pointing that $id at different
369
+ // schemas (separate app instances, test suites, multi-tenant). Keying on $id
370
+ // alone reuses the wrong compiled validator and silently mis-validates.
371
+ function compileCacheKey(schemaStr, schemaMap) {
372
+ if (!schemaMap || schemaMap.size === 0) return schemaStr
373
+ const parts = []
374
+ for (const [id, s] of schemaMap) parts.push(id + '=' + JSON.stringify(s))
375
+ parts.sort()
376
+ return schemaStr + '\0' + parts.join('\0')
377
+ }
378
+
358
379
  // Resolve a relative URI ref against a base URI
359
380
  function resolveRelativeRef(ref, baseId) {
360
381
  if (!baseId || ref.includes('://') || ref.startsWith('#')) return ref
@@ -363,6 +384,66 @@ function resolveRelativeRef(ref, baseId) {
363
384
  return baseId.substring(0, lastSlash + 1) + ref
364
385
  }
365
386
 
387
+ // Resolve a cross-schema $ref to its target schema for preprocessing purposes.
388
+ // Handles whole-schema refs (`shared#`), relative-id matching, and JSON pointer
389
+ // fragments (`shared#/properties/id`). Returns null for local-only refs or when
390
+ // the target cannot be found. Used only to read `type`/`properties` for
391
+ // coercion/defaults/removeAdditional, never for validation.
392
+ function resolveRefForPreprocess(ref, schemaMap) {
393
+ if (!schemaMap || schemaMap.size === 0 || typeof ref !== 'string') return null
394
+ const hashIdx = ref.indexOf('#')
395
+ const baseId = hashIdx >= 0 ? ref.slice(0, hashIdx) : ref
396
+ const fragment = hashIdx >= 0 ? ref.slice(hashIdx + 1) : ''
397
+ if (!baseId) return null
398
+ let base = null
399
+ if (schemaMap.has(baseId)) base = schemaMap.get(baseId)
400
+ else if (!ref.includes('://')) {
401
+ for (const [id, s] of schemaMap) {
402
+ if (id.endsWith('/' + baseId)) { base = s; break }
403
+ }
404
+ }
405
+ if (!base) return null
406
+ if (!fragment) return base
407
+ let target = base
408
+ for (const part of fragment.split('/')) {
409
+ if (part === '') continue
410
+ if (target == null || typeof target !== 'object') return null
411
+ target = target[part.replace(/~1/g, '/').replace(/~0/g, '~')]
412
+ }
413
+ return target == null ? null : target
414
+ }
415
+
416
+ // Preprocessing (coerce/defaults/removeAdditional) reads `schema.properties` and
417
+ // each property's `type`. When the data shape lives behind a cross-schema $ref
418
+ // (a whole-schema ref like Fastify's `params: { $ref: 'shared#' }`, or a
419
+ // property ref like `{ id: { $ref: 'shared#/properties/id' } }`), follow the
420
+ // ref so the preprocessor can see the referenced shape. Returns the schema with
421
+ // such refs resolved, cloning only when a substitution is made.
422
+ function resolveSchemaForPreprocess(schema, schemaMap) {
423
+ if (!schema || typeof schema !== 'object' || !schemaMap || schemaMap.size === 0) return schema
424
+ let s = schema
425
+ // Whole-schema ref (only when it has no own properties, to avoid dropping
426
+ // sibling keywords on schemas that mix $ref with properties).
427
+ if (s.$ref && !s.properties) {
428
+ const t = resolveRefForPreprocess(s.$ref, schemaMap)
429
+ if (t && typeof t === 'object') s = t
430
+ }
431
+ if (!s.properties) return s
432
+ // Property-level refs: substitute the resolved target so coercion sees `type`.
433
+ let cloned = null
434
+ for (const key of Object.keys(s.properties)) {
435
+ const p = s.properties[key]
436
+ if (p && typeof p === 'object' && p.$ref && !p.type) {
437
+ const t = resolveRefForPreprocess(p.$ref, schemaMap)
438
+ if (t && typeof t === 'object') {
439
+ if (!cloned) { cloned = Object.assign({}, s); cloned.properties = Object.assign({}, s.properties) }
440
+ cloned.properties[key] = t
441
+ }
442
+ }
443
+ }
444
+ return cloned || s
445
+ }
446
+
366
447
  class Validator {
367
448
  constructor(schema, opts) {
368
449
  const options = opts || {};
@@ -402,6 +483,32 @@ class Validator {
402
483
  // produced the error). Matches ajv's `verbose: true` behavior.
403
484
  this._verbose = !!options.verbose;
404
485
 
486
+ // richErrors: default true. Only the literal `false` opts back into the
487
+ // v0.14 error shape (no code/expected/received/docUrl, no aliases).
488
+ this._richErrors = options && options.richErrors === false ? false : true;
489
+
490
+ // Optional schema source descriptor. When supplied, the renderer pipeline
491
+ // can attach a `schemaSource` frame to enriched errors.
492
+ this._source = options && options.source && typeof options.source === 'object'
493
+ ? { path: String(options.source.path || ''), content: String(options.source.content || '') }
494
+ : null;
495
+
496
+ // Build a JSON pointer -> position map for the schema text once at
497
+ // construction so each runtime error can resolve `schemaSource` without
498
+ // re-scanning the source on every validate() call.
499
+ if (this._source) {
500
+ const { buildPositionMap } = require('./lib/source-positions');
501
+ this._schemaPositions = buildPositionMap(this._source.content);
502
+ } else {
503
+ this._schemaPositions = null;
504
+ }
505
+
506
+ // Per-validate data position cache. Populated by validateJSON before
507
+ // dispatching to inner validate(); consulted by the rich-error wrap
508
+ // to attach dataFrame entries to each enriched error.
509
+ this._posCache = require('./lib/data-position-cache').createCache();
510
+ this._lastRawInput = null;
511
+
405
512
  // Lazy stubs: trigger compilation on first call, then re-dispatch
406
513
  this.validate = (data) => {
407
514
  this._ensureCompiled();
@@ -497,9 +604,7 @@ class Validator {
497
604
 
498
605
  // Check cache first -- reuse compiled functions for same schema
499
606
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
500
- const mapKey = this._schemaMap.size > 0
501
- ? this._schemaStr + '\0' + [...this._schemaMap.keys()].sort().join('\0')
502
- : this._schemaStr;
607
+ const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
503
608
  // Custom formats are JS functions: bypass the compile cache since they can
504
609
  // differ between validators that share the same schema string.
505
610
  const cached = this._userFormats ? null : _compileCache.get(mapKey);
@@ -525,13 +630,17 @@ class Validator {
525
630
  }
526
631
  this._jsFn = jsFn;
527
632
 
528
- // Data mutators -- try codegen first (12x faster), fallback to closure arrays
529
- let preprocess = buildPreprocessCodegen(schemaObj, options);
633
+ // Data mutators -- try codegen first (12x faster), fallback to closure arrays.
634
+ // Follow cross-refs so coercion/defaults/removeAdditional see the referenced
635
+ // shape (e.g. Fastify `params: { $ref: 'shared#' }` or property refs like
636
+ // `{ id: { $ref: 'shared#/properties/id' } }`).
637
+ const preprocessSchema = resolveSchemaForPreprocess(schemaObj, this._schemaMap);
638
+ let preprocess = buildPreprocessCodegen(preprocessSchema, options);
530
639
  if (!preprocess) {
531
- const applyDefaults = buildDefaultsApplier(schemaObj);
532
- const applyCoerce = options.coerceTypes ? buildCoercer(schemaObj) : null;
640
+ const applyDefaults = buildDefaultsApplier(preprocessSchema);
641
+ const applyCoerce = options.coerceTypes ? buildCoercer(preprocessSchema) : null;
533
642
  const applyRemove = options.removeAdditional
534
- ? buildRemover(schemaObj)
643
+ ? buildRemover(preprocessSchema)
535
644
  : null;
536
645
  const mutators = [applyRemove, applyCoerce, applyDefaults].filter(Boolean);
537
646
  preprocess =
@@ -626,8 +735,10 @@ class Validator {
626
735
  }
627
736
 
628
737
  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.
738
+ // abortEarly: do NOT enrich. Skip position lookups, suggestions, source maps.
739
+ // This is the perf-critical path for edge gateways. The richErrors wrap
740
+ // below recognises the ATA9000 stub keyword and passes the frozen result
741
+ // through unchanged, so a single shared object is returned per failure.
631
742
  const _fn = jsFn;
632
743
  this.validate = preprocess
633
744
  ? (data) => { preprocess(data); return _fn(data) ? VALID_RESULT : ABORT_EARLY_RESULT; }
@@ -851,6 +962,86 @@ class Validator {
851
962
  }
852
963
  }
853
964
 
965
+ // richErrors enrichment: layered on top of whichever validate path was
966
+ // bound above. Verbose's parentSchema flows through because enrich()
967
+ // copies it. Opt-out (`richErrors: false`) leaves the raw v0.14 shape.
968
+ if (this._richErrors && this.validate) {
969
+ const inner = this.validate;
970
+ const enrich = require('./lib/enrich-error').enrich;
971
+ this.validate = (data) => {
972
+ const result = inner(data);
973
+ if (result && !result.valid && result.errors && result.errors.length) {
974
+ // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
975
+ // perf fast path stays allocation-free and the documented code stays stable.
976
+ if (result === ABORT_EARLY_RESULT) return result;
977
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
978
+ const enriched = result.errors.map((e) => enrich(e, {
979
+ data,
980
+ positions,
981
+ schemaPositions: this._schemaPositions,
982
+ schemaFile: this._source ? this._source.path : undefined,
983
+ }));
984
+ if (positions) this._posCache.reset();
985
+ return { valid: false, errors: enriched };
986
+ }
987
+ return result;
988
+ };
989
+
990
+ // validateJSON also enriches: set _lastRawInput so the position cache
991
+ // can lazily build a map for dataFrame attachment. Only validateJSON
992
+ // wires this — validate(data) takes a pre-parsed object, by design.
993
+ if (this.validateJSON) {
994
+ const innerJson = this.validateJSON;
995
+ this.validateJSON = (jsonStr) => {
996
+ this._lastRawInput = jsonStr;
997
+ let result;
998
+ try {
999
+ result = innerJson(jsonStr);
1000
+ } finally {
1001
+ // Don't clear here; the enrich step below needs the cache. We
1002
+ // clear after enrich, or in the early-return path.
1003
+ }
1004
+ if (result && !result.valid && result.errors && result.errors.length) {
1005
+ // If errors came from the inner path that already ran through the
1006
+ // wrapped this.validate (codegen jsonValidateFn -> validate path),
1007
+ // they may already be enriched. Detect by presence of `code`.
1008
+ const first = result.errors[0];
1009
+ if (!first || !first.code) {
1010
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
1011
+ // Re-parse the input once so the enrich pass can pluck `received`
1012
+ // and feed the suggestion engine (required-typo, format hints,
1013
+ // coercion nudges all need the live value tree).
1014
+ let parsedData;
1015
+ try { parsedData = JSON.parse(jsonStr); } catch { parsedData = undefined; }
1016
+ const enriched = result.errors.map((e) => enrich(e, {
1017
+ data: parsedData,
1018
+ positions,
1019
+ schemaPositions: this._schemaPositions,
1020
+ schemaFile: this._source ? this._source.path : undefined,
1021
+ }));
1022
+ if (positions) this._posCache.reset();
1023
+ this._lastRawInput = null;
1024
+ return { valid: false, errors: enriched };
1025
+ }
1026
+ // Already-enriched path: still attach dataFrame if missing.
1027
+ const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
1028
+ if (positions) {
1029
+ for (const e of result.errors) {
1030
+ if (e && !e.dataFrame) {
1031
+ const path = e.path != null ? e.path : (e.instancePath || '');
1032
+ const p = positions[path];
1033
+ if (p) e.dataFrame = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text };
1034
+ }
1035
+ }
1036
+ this._posCache.reset();
1037
+ }
1038
+ }
1039
+ this._lastRawInput = null;
1040
+ return result;
1041
+ };
1042
+ }
1043
+ }
1044
+
854
1045
  // Save to identity cache for ultra-fast reuse with same schema object
855
1046
  if (this._schemaObj && typeof this._schemaObj === 'object') {
856
1047
  _identityCache.set(this._schemaObj, this);
@@ -891,9 +1082,7 @@ class Validator {
891
1082
  if (typeof process !== 'undefined' && process.env && process.env.ATA_FORCE_NAPI) return;
892
1083
  if (!this._schemaStr) this._schemaStr = JSON.stringify(this._schemaObj);
893
1084
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
894
- const mapKey = this._schemaMap.size > 0
895
- ? this._schemaStr + '\0' + [...this._schemaMap.keys()].sort().join('\0')
896
- : this._schemaStr;
1085
+ const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
897
1086
  // Custom formats are JS functions: skip the shared cache so different
898
1087
  // validators with the same schema string but different formats don't collide.
899
1088
  const cached = this._userFormats ? null : _compileCache.get(mapKey);
@@ -965,12 +1154,18 @@ module.exports = { boolFn, hybridFactory, errFn };
965
1154
  if (!jsFn || !jsFn._source) return null;
966
1155
  const format = (opts && opts.format) || 'esm';
967
1156
  const abortEarly = !!(opts && opts.abortEarly);
1157
+ const source = !!(opts && opts.source);
1158
+ const sourceMap = opts && opts.sourceMap ? opts.sourceMap : null;
1159
+ const schemaFile = opts && opts.schemaFile ? opts.schemaFile : null;
968
1160
  const src = jsFn._source;
969
1161
 
970
1162
  let errCore = '';
971
1163
  if (!abortEarly) {
972
1164
  const jsErrFn = compileToJSCodegenWithErrors(
973
1165
  typeof this._schemaObj === 'object' ? this._schemaObj : {},
1166
+ null,
1167
+ undefined,
1168
+ (source && sourceMap && schemaFile) ? { sourceMap, schemaFile } : null,
974
1169
  );
975
1170
  const errSrc = jsErrFn && jsErrFn._errSource ? jsErrFn._errSource : '';
976
1171
  if (errSrc) {
@@ -978,6 +1173,16 @@ module.exports = { boolFn, hybridFactory, errFn };
978
1173
  }
979
1174
  }
980
1175
 
1176
+ // Schema-source frames are baked as literals inside each emitted error so
1177
+ // consumers don't need a runtime lookup. We still expose the schema file
1178
+ // as a sentinel constant when --source is on — handy for introspection
1179
+ // and visible in source graphs. With --no-source, the constant is omitted
1180
+ // entirely so size budgets and grep-based "is this source-mapped?" checks
1181
+ // both work.
1182
+ const schemaSourceConst = (source && schemaFile)
1183
+ ? `const __ATA_SCHEMA_SOURCE__ = ${JSON.stringify({ file: schemaFile })};\n`
1184
+ : '';
1185
+
981
1186
  // Serialize closure vars referenced in _fn body: regex, sub-validators, sets.
982
1187
  let closureDecls = '';
983
1188
  if (jsFn._closures && jsFn._closures.length > 0) {
@@ -1016,8 +1221,16 @@ module.exports = { boolFn, hybridFactory, errFn };
1016
1221
  // Schema is embedded; runtime has zero dependency on ata-validator.
1017
1222
  'use strict';
1018
1223
  ${_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' })]) });
1224
+ ${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
1225
+ const ABORT = Object.freeze({
1226
+ valid: false,
1227
+ errors: Object.freeze([Object.freeze({
1228
+ code: 'ATA9000',
1229
+ message: 'validation failed',
1230
+ keyword: '__abort_early__',
1231
+ path: '',
1232
+ })]),
1233
+ });
1021
1234
  ${closureDecls}const _fn = function(d) {
1022
1235
  ${src}
1023
1236
  };
@@ -1389,6 +1602,45 @@ function compile(schema, opts) {
1389
1602
  }
1390
1603
 
1391
1604
  const { toTypeScript } = require("./lib/ts-gen");
1605
+ const { renderPretty } = require("./lib/render-pretty");
1606
+ const { renderCompact } = require("./lib/render-compact");
1607
+ const { renderJSON } = require("./lib/render-json");
1608
+ const { suggestFor } = require("./lib/suggestions");
1609
+ const { reprValue } = require("./lib/enrich-error");
1610
+
1611
+ // Walk a JSON pointer (RFC 6901 escapes) into a data tree. Mirrors the helper
1612
+ // inside lib/suggestions.js — kept local to avoid exporting an internal.
1613
+ function _walkPointer (root, pointer) {
1614
+ if (!pointer) return root;
1615
+ const parts = pointer.replace(/^\//, '').split('/').map(s => s.replace(/~1/g, '/').replace(/~0/g, '~'));
1616
+ let cur = root;
1617
+ for (const p of parts) { if (cur == null) return undefined; cur = cur[p]; }
1618
+ return cur;
1619
+ }
1620
+
1621
+ // Post-hoc suggestion enrichment for AOT-compiled validators. The standalone
1622
+ // modules do not embed the suggestion engine (Levenshtein + format hints would
1623
+ // inflate the gzipped bundle beyond the size budget). Consumers who want
1624
+ // suggestions pass the error array through this helper after validation.
1625
+ // AOT errors don't carry `received`, so we re-derive it from `data` here.
1626
+ function attachSuggestions (errors, data) {
1627
+ if (!errors) return errors;
1628
+ for (const e of errors) {
1629
+ if (!e || e.suggestion) continue;
1630
+ let received = e.received;
1631
+ if (received === undefined && data !== undefined) {
1632
+ const ptr = e.instancePath != null ? e.instancePath : (e.path || '');
1633
+ const raw = _walkPointer(data, ptr);
1634
+ if (raw !== undefined || ptr === '') received = reprValue(raw);
1635
+ }
1636
+ const probe = received !== undefined && e.received === undefined
1637
+ ? Object.assign({}, e, { received })
1638
+ : e;
1639
+ const s = suggestFor(probe, data);
1640
+ if (s) e.suggestion = s;
1641
+ }
1642
+ return errors;
1643
+ }
1392
1644
 
1393
1645
  module.exports = {
1394
1646
  Validator,
@@ -1399,4 +1651,8 @@ module.exports = {
1399
1651
  SIMDJSON_PADDING,
1400
1652
  parseJSON,
1401
1653
  toTypeScript,
1654
+ renderPretty,
1655
+ renderCompact,
1656
+ renderJSON,
1657
+ attachSuggestions,
1402
1658
  };
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 };