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/CHANGELOG.md +42 -0
- package/README.md +69 -1
- package/bin/ata.js +189 -13
- package/build.d.ts +6 -0
- package/index.browser.mjs +1 -1
- package/index.d.ts +87 -19
- package/index.js +188 -5
- 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 +12 -5
- 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.
|
|
@@ -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
|
-
//
|
|
630
|
-
//
|
|
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({
|
|
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
|
|
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 };
|