ata-validator 1.6.2 → 1.7.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 +40 -0
- package/README.md +15 -5
- package/build.d.ts +12 -0
- package/index.d.ts +7 -0
- package/index.js +178 -57
- package/lib/aot-build.js +1 -0
- package/lib/aot.js +70 -20
- package/lib/buffer-gate.js +132 -0
- package/lib/draft7.js +24 -2
- package/lib/interpreter.js +644 -208
- package/lib/js-compiler.js +187 -55
- package/lib/metaschemas.js +21 -0
- package/lib/plan-compiler.js +545 -0
- package/lib/safe-regex-source.js +1 -1
- package/lib/safe-regex.js +209 -29
- package/lib/version.js +1 -1
- package/package.json +8 -8
package/lib/aot.js
CHANGED
|
@@ -21,7 +21,7 @@ const SAFE_REGEX_SOURCE = require('./safe-regex-source');
|
|
|
21
21
|
const _CP_LEN_SOURCE = `function _cpLen(s) {
|
|
22
22
|
const len = s.length;
|
|
23
23
|
for (let i = 0; i < len; i++) {
|
|
24
|
-
if (s.charCodeAt(i)
|
|
24
|
+
if (((s.charCodeAt(i) - 0xD800) >>> 0) < 0x400) {
|
|
25
25
|
let n = 0; for (const _ of s) n++; return n;
|
|
26
26
|
}
|
|
27
27
|
}
|
|
@@ -98,6 +98,48 @@ module.exports = { boolFn, hybridFactory, errFn };
|
|
|
98
98
|
//
|
|
99
99
|
// format: 'esm' | 'cjs'. Default 'esm'.
|
|
100
100
|
// abortEarly: if true, invalid result is a shared stub; smaller output.
|
|
101
|
+
|
|
102
|
+
// Custom format functions in standalone output.
|
|
103
|
+
//
|
|
104
|
+
// 'embed' (default) writes each function's source into the module through
|
|
105
|
+
// Function#toString. That only works for a plain function: one that closes
|
|
106
|
+
// over nothing and is not rewritten by a coverage or transpile step (istanbul
|
|
107
|
+
// injects `cov_` counters; a bundler may hoist helpers). The check below
|
|
108
|
+
// rejects those at build time with a named error instead of emitting a module
|
|
109
|
+
// that throws on first use.
|
|
110
|
+
//
|
|
111
|
+
// 'inject' writes no function source. The module exports `setFormats(map)`
|
|
112
|
+
// and each format is looked up from that registry when a value is checked,
|
|
113
|
+
// so the caller supplies the functions at load time.
|
|
114
|
+
function emitFormatDecls(closures, mode, declKW) {
|
|
115
|
+
if (!closures || closures.length === 0) return { decls: '', exportsSetFormats: false };
|
|
116
|
+
if (mode === 'inject') {
|
|
117
|
+
let out = `${declKW} __formats = Object.create(null);\n`;
|
|
118
|
+
out += `function setFormats(map) { for (const k in map) __formats[k] = map[k]; }\n`;
|
|
119
|
+
for (const { name, format } of closures) {
|
|
120
|
+
const key = JSON.stringify(format === null ? name.slice(4) : format);
|
|
121
|
+
out += `${declKW} ${name} = function (v) { const f = __formats[${key}]; if (typeof f !== 'function') throw new Error('ata: format ' + ${key} + ' is not registered; call setFormats({ [' + ${key} + ']: fn }) before validating'); return f(v); };\n`;
|
|
122
|
+
}
|
|
123
|
+
return { decls: out, exportsSetFormats: true };
|
|
124
|
+
}
|
|
125
|
+
let out = '';
|
|
126
|
+
for (const { name, fn, format } of closures) {
|
|
127
|
+
const src = fn.toString();
|
|
128
|
+
const label = format === null ? name : format;
|
|
129
|
+
if (/\bcov_[A-Za-z0-9_$]+\b/.test(src)) {
|
|
130
|
+
throw new Error(`ata: custom format "${label}" is instrumented for coverage and cannot be embedded; use { formatMode: 'inject' } and register it with setFormats() at load time`);
|
|
131
|
+
}
|
|
132
|
+
try {
|
|
133
|
+
// eslint-disable-next-line no-new-func
|
|
134
|
+
new Function('return (' + src + ')');
|
|
135
|
+
} catch {
|
|
136
|
+
throw new Error(`ata: custom format "${label}" has no standalone source (a bound function, a class method, or a native); use { formatMode: 'inject' } and register it with setFormats() at load time`);
|
|
137
|
+
}
|
|
138
|
+
out += `${declKW} ${name} = ${src};\n`;
|
|
139
|
+
}
|
|
140
|
+
return { decls: out, exportsSetFormats: false };
|
|
141
|
+
}
|
|
142
|
+
|
|
101
143
|
function toStandaloneModule(validator, opts) {
|
|
102
144
|
validator._ensureCompiled();
|
|
103
145
|
const jsFn = validator._jsFn;
|
|
@@ -170,20 +212,17 @@ function toStandaloneModule(validator, opts) {
|
|
|
170
212
|
// User-supplied format functions are referenced as _uf_<name> by both the
|
|
171
213
|
// boolean (_fn) and error (errFn) bodies. Embed them via Function#toString
|
|
172
214
|
// so the standalone module stays self-contained.
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
formatDecls = jsFn._formatClosures
|
|
176
|
-
.map(({ name, fn }) => `const ${name} = ${fn.toString()};`)
|
|
177
|
-
.join('\n') + '\n';
|
|
178
|
-
}
|
|
215
|
+
const fmt = emitFormatDecls(jsFn._formatClosures, opts && opts.formatMode, 'const');
|
|
216
|
+
const formatDecls = fmt.decls;
|
|
179
217
|
|
|
180
218
|
const validBody = errCore
|
|
181
219
|
? 'return _fn(data) ? VALID : { valid: false, errors: errFn(data, true).errors }'
|
|
182
220
|
: 'return _fn(data) ? VALID : ABORT';
|
|
183
221
|
|
|
222
|
+
const names = fmt.exportsSetFormats ? 'validate, isValid, setFormats' : 'validate, isValid';
|
|
184
223
|
const exports = format === 'esm'
|
|
185
|
-
? `export {
|
|
186
|
-
: `module.exports = {
|
|
224
|
+
? `export { ${names} };\nexport default { ${names} };\n`
|
|
225
|
+
: `module.exports = { ${names} };\nmodule.exports.default = module.exports;\n`;
|
|
187
226
|
|
|
188
227
|
return `// Auto-generated by ata-validator — do not edit.
|
|
189
228
|
// Schema is embedded; runtime has zero dependency on ata-validator.
|
|
@@ -240,6 +279,7 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
240
279
|
const format = (opts && opts.format) || 'cjs';
|
|
241
280
|
const R = 'Object.freeze({valid:true,errors:Object.freeze([])})';
|
|
242
281
|
let bundleUsesSafeRe = false;
|
|
282
|
+
let bundleInjects = false;
|
|
243
283
|
const fns = schemas.map((schema) => {
|
|
244
284
|
const v = new Validator(schema, bundleOpts);
|
|
245
285
|
v._ensureCompiled();
|
|
@@ -255,12 +295,13 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
255
295
|
jsErrFn && jsErrFn._errSource
|
|
256
296
|
? jsErrFn._errSource
|
|
257
297
|
: "return{valid:false,errors:[{code:'error',path:'',message:'validation failed'}]}";
|
|
258
|
-
//
|
|
298
|
+
// Custom format closures: embedded, or bound to the bundle-level
|
|
299
|
+
// registry when opts.formats is 'inject'.
|
|
259
300
|
let preamble = '';
|
|
260
301
|
if (jsFn._formatClosures) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
302
|
+
const f = emitFormatDecls(jsFn._formatClosures, opts && opts.formatMode, 'var');
|
|
303
|
+
preamble = f.decls.replace(/^var __formats = [^\n]*\n|^function setFormats[^\n]*\n/gm, '');
|
|
304
|
+
if (f.exportsSetFormats) bundleInjects = true;
|
|
264
305
|
}
|
|
265
306
|
// Include hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) so the
|
|
266
307
|
// bundle output is self-contained. `toStandalone` emits this same source
|
|
@@ -277,10 +318,15 @@ function bundleStandalone(Validator, schemas, opts) {
|
|
|
277
318
|
});
|
|
278
319
|
const arr = `[${fns.join(',')}]`;
|
|
279
320
|
const safeEmbed = bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : '';
|
|
321
|
+
const registry = bundleInjects
|
|
322
|
+
? `var __formats=Object.create(null);\nfunction setFormats(map){for(var k in map)__formats[k]=map[k]}\n`
|
|
323
|
+
: '';
|
|
280
324
|
if (format === 'esm') {
|
|
281
|
-
|
|
325
|
+
const extra = bundleInjects ? 'export { validators, setFormats };' : 'export { validators };';
|
|
326
|
+
return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}${registry}const R=${R};\nconst validators=${arr};\nexport default validators;\n${extra}\n`;
|
|
282
327
|
}
|
|
283
|
-
|
|
328
|
+
const attach = bundleInjects ? 'module.exports.setFormats=setFormats;\n' : '';
|
|
329
|
+
return `'use strict';\n${safeEmbed}${registry}var R=${R};\nmodule.exports=[${fns.join(',')}];\n${attach}`;
|
|
284
330
|
}
|
|
285
331
|
|
|
286
332
|
// Compact bundle: deduplicated code. Shared template functions + per-schema params.
|
|
@@ -355,14 +401,17 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
355
401
|
// bodies. Collect them across all schemas (deduped by name) and embed via
|
|
356
402
|
// Function#toString so the bundle stays self-contained.
|
|
357
403
|
const fmtSeen = new Set();
|
|
404
|
+
const fmtAll = [];
|
|
358
405
|
for (const e of entries) {
|
|
359
406
|
if (!e || !e.fmt) continue;
|
|
360
|
-
for (const
|
|
361
|
-
if (fmtSeen.has(name)) continue;
|
|
362
|
-
fmtSeen.add(name);
|
|
363
|
-
|
|
407
|
+
for (const entry of e.fmt) {
|
|
408
|
+
if (fmtSeen.has(entry.name)) continue;
|
|
409
|
+
fmtSeen.add(entry.name);
|
|
410
|
+
fmtAll.push(entry);
|
|
364
411
|
}
|
|
365
412
|
}
|
|
413
|
+
const fmtOut = emitFormatDecls(fmtAll, opts && opts.formatMode, declKW);
|
|
414
|
+
out += fmtOut.decls;
|
|
366
415
|
|
|
367
416
|
// Shared hybrid factories
|
|
368
417
|
out += `${declKW} H=[\n`;
|
|
@@ -385,9 +434,10 @@ function bundleCompact(Validator, schemas, opts) {
|
|
|
385
434
|
})
|
|
386
435
|
.join(',');
|
|
387
436
|
if (isEsm) {
|
|
388
|
-
out += `const validators=[${arrBody}];\nexport default validators;\nexport { validators };\n`;
|
|
437
|
+
out += `const validators=[${arrBody}];\nexport default validators;\nexport { validators${fmtOut.exportsSetFormats ? ', setFormats' : ''} };\n`;
|
|
389
438
|
} else {
|
|
390
439
|
out += `module.exports=[${arrBody}];\n`;
|
|
440
|
+
if (fmtOut.exportsSetFormats) out += 'module.exports.setFormats=setFormats;\n';
|
|
391
441
|
}
|
|
392
442
|
|
|
393
443
|
return out;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// The buffer APIs (isValid, countValid, batchIsValid, isValidNDJSON,
|
|
4
|
+
// isValidParallel, isValidPrepadded, and isValidJSON above the simdjson
|
|
5
|
+
// threshold) answer from the native engine's own walker, which is not the
|
|
6
|
+
// engine validate() uses. Measured over the official suite, the two disagreed
|
|
7
|
+
// on 245 of 2222 cases, and 195 of those were the native walker accepting a
|
|
8
|
+
// document validate() rejects. A buffer API that is faster and sometimes
|
|
9
|
+
// wrong in the accepting direction is not a fast path, it is a hole.
|
|
10
|
+
//
|
|
11
|
+
// This module decides, once per schema, whether the native walker can be
|
|
12
|
+
// trusted for it. When it cannot, the buffer APIs are replaced with versions
|
|
13
|
+
// that parse the bytes and call validate(), so every entry point gives the
|
|
14
|
+
// same verdict. The list below is the set of shapes where the suite showed
|
|
15
|
+
// the walker disagreeing, each with the reason. It is deliberately a list of
|
|
16
|
+
// shapes rather than a count: a new disagreement is a new entry here, with
|
|
17
|
+
// tests/test_buffer_path_parity.js holding the total at zero.
|
|
18
|
+
|
|
19
|
+
// Keywords the native walker does not implement, or implements differently
|
|
20
|
+
// enough to disagree on the suite.
|
|
21
|
+
const UNSUPPORTED_KEYWORDS = new Set([
|
|
22
|
+
'contains', 'minContains', 'maxContains',
|
|
23
|
+
'unevaluatedProperties', 'unevaluatedItems',
|
|
24
|
+
'dependencies', 'dependentSchemas', 'dependentRequired',
|
|
25
|
+
'propertyNames', 'patternProperties',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
// Formats the native checkers answer differently from the JS ones.
|
|
29
|
+
const DIVERGENT_FORMATS = new Set(['hostname', 'date-time', 'time', 'uri-reference', 'duration']);
|
|
30
|
+
|
|
31
|
+
const SUBSCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas', 'dependencies'];
|
|
32
|
+
const SUBSCHEMA_NODES = [
|
|
33
|
+
'items', 'additionalItems', 'additionalProperties', 'contains', 'propertyNames', 'not',
|
|
34
|
+
'if', 'then', 'else', 'unevaluatedProperties', 'unevaluatedItems',
|
|
35
|
+
'allOf', 'anyOf', 'oneOf', 'prefixItems',
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function walk(schema, depth) {
|
|
39
|
+
if (schema === true || schema === false) {
|
|
40
|
+
// A boolean root is answered wrong by the walker; nested booleans are
|
|
41
|
+
// handled where they appear (items and prefixItems below).
|
|
42
|
+
return depth === 0;
|
|
43
|
+
}
|
|
44
|
+
if (schema === null || typeof schema !== 'object') return false;
|
|
45
|
+
if (Array.isArray(schema)) {
|
|
46
|
+
for (const s of schema) if (walk(s, depth + 1)) return true;
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
for (const key of Object.keys(schema)) {
|
|
50
|
+
const v = schema[key];
|
|
51
|
+
if (UNSUPPORTED_KEYWORDS.has(key)) return true;
|
|
52
|
+
// Cross-document references: the native engine has no registry of
|
|
53
|
+
// external schemas, and embedded $id changes the base URI in ways its
|
|
54
|
+
// resolver gets wrong.
|
|
55
|
+
if (key === '$ref' && typeof v === 'string' && !v.startsWith('#')) return true;
|
|
56
|
+
if (key === '$id' && depth > 0) return true;
|
|
57
|
+
// An empty enum rejects everything; the walker accepts everything.
|
|
58
|
+
if (key === 'enum' && Array.isArray(v) && v.length === 0) return true;
|
|
59
|
+
if (key === 'format' && DIVERGENT_FORMATS.has(v)) return true;
|
|
60
|
+
// Unicode property escapes: RE2 cannot parse them and the walker then
|
|
61
|
+
// skips the pattern instead of failing.
|
|
62
|
+
if (key === 'pattern' && typeof v === 'string' && /\\[pP]\{/.test(v)) return true;
|
|
63
|
+
// Tuple forms: prefixItems and the draft-07 array form of items are
|
|
64
|
+
// checked against the wrong positions by the walker.
|
|
65
|
+
if (key === 'prefixItems') return true;
|
|
66
|
+
if ((key === 'items' || key === 'additionalItems') && (typeof v === 'boolean' || Array.isArray(v))) return true;
|
|
67
|
+
|
|
68
|
+
if (SUBSCHEMA_MAPS.includes(key)) {
|
|
69
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
70
|
+
for (const k of Object.keys(v)) if (walk(v[k], depth + 1)) return true;
|
|
71
|
+
}
|
|
72
|
+
} else if (SUBSCHEMA_NODES.includes(key)) {
|
|
73
|
+
if (walk(v, depth + 1)) return true;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// True when the buffer APIs must answer through validate() for this schema.
|
|
80
|
+
function bufferNeedsSlowPath(schema, schemaMap) {
|
|
81
|
+
if (walk(schema, 0)) return true;
|
|
82
|
+
if (schemaMap && schemaMap.size > 0) {
|
|
83
|
+
for (const s of schemaMap.values()) if (walk(s, 1)) return true;
|
|
84
|
+
}
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function toText(input, name) {
|
|
89
|
+
if (typeof input === 'string') return input;
|
|
90
|
+
if (input instanceof Uint8Array) return Buffer.from(input.buffer, input.byteOffset, input.byteLength).toString('utf8');
|
|
91
|
+
throw new TypeError(`${name}() requires a Buffer, Uint8Array, or string. For parsed objects, use isValidObject().`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Replaces the instance's buffer APIs with versions that parse and call
|
|
95
|
+
// validate(). Installed after compilation, so `validator.validate` is final.
|
|
96
|
+
function installSlowBufferApis(validator) {
|
|
97
|
+
const isValidText = (text) => {
|
|
98
|
+
let value;
|
|
99
|
+
try { value = JSON.parse(text); } catch { return false; }
|
|
100
|
+
return validator.validate(value).valid;
|
|
101
|
+
};
|
|
102
|
+
validator.isValid = (input) => isValidText(toText(input, 'isValid'));
|
|
103
|
+
validator.isValidJSON = (jsonStr) => isValidText(jsonStr);
|
|
104
|
+
validator.isValidPrepadded = (paddedBuffer, jsonLength) =>
|
|
105
|
+
isValidText(Buffer.from(paddedBuffer.buffer, paddedBuffer.byteOffset, jsonLength).toString('utf8'));
|
|
106
|
+
const ndjson = (input, name) => {
|
|
107
|
+
const lines = toText(input, name).split('\n');
|
|
108
|
+
const out = [];
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line === '') continue; // the native loop skips zero-length lines only
|
|
111
|
+
out.push(isValidText(line));
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
};
|
|
115
|
+
validator.isValidNDJSON = (input) => ndjson(input, 'isValidNDJSON');
|
|
116
|
+
validator.isValidParallel = (input) => ndjson(input, 'isValidParallel');
|
|
117
|
+
validator.countValid = (input) => {
|
|
118
|
+
let n = 0;
|
|
119
|
+
for (const ok of ndjson(input, 'countValid')) if (ok) n++;
|
|
120
|
+
return n;
|
|
121
|
+
};
|
|
122
|
+
validator.batchIsValid = (buffers) => {
|
|
123
|
+
let n = 0;
|
|
124
|
+
for (const b of buffers) {
|
|
125
|
+
if (!(b instanceof Uint8Array)) throw new TypeError('batchIsValid() requires Buffer or Uint8Array elements');
|
|
126
|
+
if (isValidText(toText(b, 'batchIsValid'))) n++;
|
|
127
|
+
}
|
|
128
|
+
return n;
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
module.exports = { bufferNeedsSlowPath, installSlowBufferApis };
|
package/lib/draft7.js
CHANGED
|
@@ -9,15 +9,37 @@ function isDraft7(schema) {
|
|
|
9
9
|
return !!(schema && schema.$schema && DRAFT7_SCHEMAS.has(schema.$schema))
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
// `force` applies the draft-07 rules to a document that declares no dialect
|
|
13
|
+
// of its own, which is how a retrieved schema inherits the root's draft.
|
|
14
|
+
function normalizeDraft7(schema, force) {
|
|
15
|
+
if (!force && !isDraft7(schema)) return schema
|
|
14
16
|
_normalize(schema)
|
|
15
17
|
return schema
|
|
16
18
|
}
|
|
17
19
|
|
|
20
|
+
// Keywords that may sit next to `$ref` in draft-07 without being applied:
|
|
21
|
+
// definitions are addressable by pointer, annotations are inert.
|
|
22
|
+
const REF_SIBLINGS_KEPT = new Set(['$ref', '$defs', 'definitions', '$schema', '$comment', 'title', 'description', 'examples', 'default', 'readOnly', 'writeOnly'])
|
|
23
|
+
|
|
18
24
|
function _normalize(schema) {
|
|
19
25
|
if (typeof schema !== 'object' || schema === null) return
|
|
20
26
|
|
|
27
|
+
// In draft-07 an object with `$ref` is that reference and nothing else:
|
|
28
|
+
// sibling keywords, `$id` included, are ignored. Dropping them here gives
|
|
29
|
+
// every engine the same reading without each having to know the draft.
|
|
30
|
+
if (typeof schema.$ref === 'string') {
|
|
31
|
+
for (const key of Object.keys(schema)) {
|
|
32
|
+
if (!REF_SIBLINGS_KEPT.has(key)) delete schema[key]
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// A fragment-only `$id` is a plain-name anchor in draft-07; 2020-12 spells
|
|
37
|
+
// it `$anchor`, which every engine already resolves.
|
|
38
|
+
if (typeof schema.$id === 'string' && /^#[A-Za-z][A-Za-z0-9_.:-]*$/.test(schema.$id)) {
|
|
39
|
+
if (schema.$anchor === undefined) schema.$anchor = schema.$id.slice(1)
|
|
40
|
+
delete schema.$id
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
// definitions → $defs
|
|
22
44
|
if (schema.definitions && !schema.$defs) {
|
|
23
45
|
schema.$defs = schema.definitions
|