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/CHANGELOG.md +34 -0
- package/README.md +36 -1
- package/bin/ata.js +189 -13
- package/build.d.ts +6 -0
- package/index.browser.mjs +1 -1
- package/index.d.ts +71 -0
- package/index.js +272 -16
- package/index.mjs +1 -1
- package/lib/aot-build.js +25 -1
- package/lib/branch-collapse.js +75 -0
- package/lib/data-position-cache.js +43 -0
- package/lib/data-positions.js +104 -0
- package/lib/enrich-error.js +125 -0
- package/lib/error-codes.js +96 -0
- package/lib/js-compiler.js +108 -25
- package/lib/levenshtein.js +28 -0
- package/lib/render-compact.js +35 -0
- package/lib/render-json.js +12 -0
- package/lib/render-pretty.js +102 -0
- package/lib/render-shared.js +58 -0
- package/lib/source-positions.js +148 -0
- package/lib/suggestions.js +132 -0
- package/package.json +7 -4
- package/prebuilds/ata-darwin-arm64/node-napi-v10.node +0 -0
- package/prebuilds/ata-linux-arm64/node-napi-v10.node +0 -0
- package/prebuilds/ata-linux-arm64-musl/node-napi-v10.node +0 -0
- package/prebuilds/ata-linux-x64/node-napi-v10.node +0 -0
- package/prebuilds/ata-linux-x64-musl/node-napi-v10.node +0 -0
- package/prebuilds/ata-win32-x64/node-napi-v10.node +0 -0
- package/scripts/check-doc-coverage.js +34 -0
- package/scripts/regen-error-codes-doc.js +40 -0
- package/scripts/regen-lock.js +19 -0
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({
|
|
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
|
|
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
|
-
|
|
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(
|
|
532
|
-
const applyCoerce = options.coerceTypes ? buildCoercer(
|
|
640
|
+
const applyDefaults = buildDefaultsApplier(preprocessSchema);
|
|
641
|
+
const applyCoerce = options.coerceTypes ? buildCoercer(preprocessSchema) : null;
|
|
533
642
|
const applyRemove = options.removeAdditional
|
|
534
|
-
? buildRemover(
|
|
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
|
-
//
|
|
630
|
-
//
|
|
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
|
|
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({
|
|
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
|
|
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 };
|