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/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) >= 0xD800 && s.charCodeAt(i) <= 0xDBFF) {
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
- let formatDecls = '';
174
- if (jsFn._formatClosures && jsFn._formatClosures.length > 0) {
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 { validate, isValid };\nexport default { validate, isValid };\n`
186
- : `module.exports = { validate, isValid };\nmodule.exports.default = module.exports;\n`;
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
- // Serialize custom format closures so the bundle has no runtime dep on ata.
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
- preamble = jsFn._formatClosures
262
- .map(({ name, fn }) => `var ${name}=${fn.toString()};`)
263
- .join('\n');
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
- return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}const R=${R};\nconst validators=${arr};\nexport default validators;\nexport { validators };\n`;
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
- return `'use strict';\n${safeEmbed}var R=${R};\nmodule.exports=[${fns.join(',')}];\n`;
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 { name, fn } of e.fmt) {
361
- if (fmtSeen.has(name)) continue;
362
- fmtSeen.add(name);
363
- out += `${declKW} ${name}=${fn.toString()};\n`;
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
- function normalizeDraft7(schema) {
13
- if (!isDraft7(schema)) return schema
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