ata-validator 1.45.0 → 1.46.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/migrate.js ADDED
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ // `ata migrate`: what switching a project from ajv to ata-validator/compat
4
+ // would change, and what it cannot translate. The report comes first and
5
+ // touches nothing; `--write` applies only the two mechanical rewrites (the
6
+ // import specifier, and the `addFormats()` call compat refuses on purpose),
7
+ // so a site the report flags is never changed behind the reader's back.
8
+ //
9
+ // Sources are read through a small masker that blanks comments and the
10
+ // insides of string and template literals, so a specifier is matched only
11
+ // where it is code: `require('ajv')` in a comment or in a longer string is
12
+ // not an import. Regular expression literals are not recognised; a pattern
13
+ // such as /from 'ajv'/ would be read as code, which only ever adds a line to
14
+ // the report, never a rewrite the report did not show.
15
+
16
+ const fs = require('node:fs');
17
+ const path = require('node:path');
18
+
19
+ const SOURCE_EXT = new Set(['.js', '.mjs', '.cjs', '.jsx', '.ts', '.mts', '.cts', '.tsx']);
20
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.nuxt', 'out', '.turbo', '.cache']);
21
+
22
+ // Module specifiers and what each means for the switch.
23
+ const SPECIFIERS = {
24
+ ajv: { kind: 'rewrite', to: 'ata-validator/compat' },
25
+ 'ajv/dist/ajv': { kind: 'rewrite', to: 'ata-validator/compat' },
26
+ 'ajv/dist/2020': { kind: 'rewrite-note', to: 'ata-validator/compat', note: 'compat reads a schema with no $schema as draft-07, where this import read it as Draft 2020-12: give each such schema a $schema, or keep this import until it has one' },
27
+ 'ajv/dist/2019': { kind: 'rewrite-note', to: 'ata-validator/compat', note: 'compat reads a schema with no $schema as draft-07, where this import read it as Draft 2019-09, which ata does not implement: give each such schema a Draft 2020-12 or draft-07 $schema first' },
28
+ 'ajv/dist/jtd': { kind: 'refuse', note: 'JSON Type Definition is not JSON Schema; ata has no equivalent' },
29
+ 'ajv-formats': { kind: 'delete-import', note: 'the formats this plugin adds are built into ata; the import and the addFormats() call go' },
30
+ 'ajv-errors': { kind: 'refuse', note: 'ata reads the errorMessage keyword natively (docs/migration-from-ajv.md, "Error UX"); the plugin call must be removed by hand and the keyword stays as it is' },
31
+ 'ajv-keywords': { kind: 'refuse', note: 'not supported; each keyword used needs a definition through addKeyword with validate, compile or macro' },
32
+ 'ajv-i18n': { kind: 'refuse', note: 'not supported; messages are English' },
33
+ 'ajv-merge-patch': { kind: 'refuse', note: '$merge and $patch are not supported' },
34
+ };
35
+
36
+ // Masks comments and string contents with spaces, keeping every index and
37
+ // line break where it was, so matches in the mask map back to the source.
38
+ function mask (src) {
39
+ const out = src.split('');
40
+ const n = src.length;
41
+ let i = 0;
42
+ const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; };
43
+ while (i < n) {
44
+ const c = src[i];
45
+ const d = src[i + 1];
46
+ if (c === '/' && d === '/') {
47
+ let j = i;
48
+ while (j < n && src[j] !== '\n') j++;
49
+ blank(i, j); i = j; continue;
50
+ }
51
+ if (c === '/' && d === '*') {
52
+ let j = src.indexOf('*/', i + 2);
53
+ if (j < 0) j = n; else j += 2;
54
+ blank(i, j); i = j; continue;
55
+ }
56
+ if (c === '"' || c === "'" || c === '`') {
57
+ let j = i + 1;
58
+ while (j < n) {
59
+ if (src[j] === '\\') { j += 2; continue; }
60
+ if (src[j] === c) break;
61
+ if (c !== '`' && src[j] === '\n') break;
62
+ j++;
63
+ }
64
+ blank(i + 1, j); i = j + 1; continue;
65
+ }
66
+ i++;
67
+ }
68
+ return out.join('');
69
+ }
70
+
71
+ function lineOf (src, index) {
72
+ let line = 1;
73
+ for (let i = 0; i < index; i++) if (src.charCodeAt(i) === 10) line++;
74
+ return line;
75
+ }
76
+
77
+ // Every module specifier in `src`: the string following `from`, inside
78
+ // `require(` or `import(`, and in a bare `import '...'`. Returns the span of
79
+ // the string contents, so a rewrite replaces only the specifier.
80
+ function specifiers (src) {
81
+ const m = mask(src);
82
+ const found = [];
83
+ const re = /(\bfrom\s*|\brequire\s*\(\s*|\bimport\s*\(\s*|\bimport\s+)(['"])/g;
84
+ let x;
85
+ while ((x = re.exec(m)) !== null) {
86
+ const quote = x[2];
87
+ const start = x.index + x[0].length;
88
+ const end = src.indexOf(quote, start);
89
+ if (end < 0) continue;
90
+ found.push({ start, end, value: src.slice(start, end), line: lineOf(src, start) });
91
+ }
92
+ return found;
93
+ }
94
+
95
+ // `addFormats(x)` call statements, with the span to delete when the call is a
96
+ // statement on its own.
97
+ function addFormatsCalls (src) {
98
+ const m = mask(src);
99
+ const calls = [];
100
+ const re = /\baddFormats\s*\(/g;
101
+ let x;
102
+ while ((x = re.exec(m)) !== null) {
103
+ let depth = 1;
104
+ let j = x.index + x[0].length;
105
+ while (j < m.length && depth > 0) { if (m[j] === '(') depth++; else if (m[j] === ')') depth--; j++; }
106
+ let end = j;
107
+ if (m[end] === ';') end++;
108
+ // A statement of its own: only whitespace between the previous line
109
+ // break and the call, and between the call and the next one.
110
+ let ls = x.index;
111
+ while (ls > 0 && m[ls - 1] !== '\n') ls--;
112
+ let le = end;
113
+ while (le < m.length && m[le] !== '\n') le++;
114
+ const alone = /^\s*$/.test(m.slice(ls, x.index)) && /^\s*$/.test(m.slice(end, le));
115
+ calls.push({ start: x.index, end, lineStart: ls, lineEnd: le < m.length ? le + 1 : le, alone, line: lineOf(src, x.index) });
116
+ }
117
+ return calls;
118
+ }
119
+
120
+ // `$data` as an object key, bare or quoted. Read from the source rather than
121
+ // the mask, since a quoted key is a string the mask blanks.
122
+ function usesDollarData (src) {
123
+ const re = /(['"])\$data\1\s*:|\$data\s*:/g;
124
+ const lines = [];
125
+ let x;
126
+ while ((x = re.exec(src)) !== null) lines.push(lineOf(src, x.index));
127
+ return [...new Set(lines)];
128
+ }
129
+
130
+ function walk (dir, files) {
131
+ let entries;
132
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return files; }
133
+ for (const e of entries) {
134
+ if (e.isDirectory()) { if (!SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name), files); continue; }
135
+ if (e.isFile() && SOURCE_EXT.has(path.extname(e.name))) files.push(path.join(dir, e.name));
136
+ }
137
+ return files;
138
+ }
139
+
140
+ // One file. Returns null when the file mentions nothing of ajv. A file that
141
+ // already imports ata-validator/compat is read too: what compat refuses
142
+ // ($data, a code keyword, addFormats) is still a finding there, so a second
143
+ // run after --write, or a CI check, keeps reporting it until it is fixed.
144
+ const COMPAT = 'ata-validator/compat';
145
+ function inspectFile (file, src) {
146
+ const specs = specifiers(src).filter((s) => s.value === 'ajv' || s.value.startsWith('ajv/') || s.value.startsWith('ajv-') || s.value === COMPAT);
147
+ if (specs.length === 0) return null;
148
+ const findings = [];
149
+ for (const s of specs) {
150
+ if (s.value === COMPAT) continue;
151
+ const rule = SPECIFIERS[s.value];
152
+ if (!rule) {
153
+ findings.push({ line: s.line, kind: 'flag', text: `import of "${s.value}": no translation known; check docs/migration-from-ajv.md` });
154
+ continue;
155
+ }
156
+ if (rule.kind === 'rewrite') findings.push({ line: s.line, kind: 'rewrite', text: `"${s.value}" becomes "${rule.to}"`, span: s, to: rule.to });
157
+ else if (rule.kind === 'rewrite-note') findings.push({ line: s.line, kind: 'rewrite', text: `"${s.value}" becomes "${rule.to}"; ${rule.note}`, span: s, to: rule.to, note: true });
158
+ else if (rule.kind === 'delete-import') findings.push({ line: s.line, kind: 'delete', text: `import of "${s.value}" goes: ${rule.note}`, span: s, deleteImport: true });
159
+ else findings.push({ line: s.line, kind: 'flag', text: `import of "${s.value}": ${rule.note}` });
160
+ }
161
+ const usesAjv = specs.some((s) => s.value === 'ajv' || s.value.startsWith('ajv/') || s.value === COMPAT);
162
+ if (usesAjv) {
163
+ for (const c of addFormatsCalls(src)) {
164
+ if (c.alone) findings.push({ line: c.line, kind: 'delete', text: 'addFormats() call goes: these formats are built in, and compat refuses the call', call: c });
165
+ else findings.push({ line: c.line, kind: 'flag', text: 'addFormats() is part of a larger statement; remove it by hand' });
166
+ }
167
+ }
168
+ if (usesAjv) {
169
+ for (const line of usesDollarData(src)) findings.push({ line, kind: 'flag', text: '$data reference: compat refuses $data; rewrite the constraint without it' });
170
+ const m = mask(src);
171
+ const kw = /\.addKeyword\s*\(/g;
172
+ let x;
173
+ while ((x = kw.exec(m)) !== null) {
174
+ // The definition object follows; a `code:` key in it is the form compat
175
+ // cannot take. Looked for up to the matching parenthesis.
176
+ let depth = 1; let j = x.index + x[0].length;
177
+ while (j < m.length && depth > 0) { if (m[j] === '(') depth++; else if (m[j] === ')') depth--; j++; }
178
+ if (/\bcode\s*[:(]/.test(m.slice(x.index, j))) findings.push({ line: lineOf(src, x.index), kind: 'flag', text: 'addKeyword with a code-generating definition: compat takes validate, compile or macro only' });
179
+ }
180
+ }
181
+ if (findings.length === 0) return null;
182
+ findings.sort((a, b) => a.line - b.line);
183
+ return findings;
184
+ }
185
+
186
+ // The source with the mechanical rewrites applied: specifiers replaced,
187
+ // standalone addFormats() statements removed, and the ajv-formats import
188
+ // line removed when its binding has no other use.
189
+ function rewriteFile (src, findings) {
190
+ const edits = [];
191
+ for (const f of findings) {
192
+ if (f.kind === 'rewrite') edits.push({ start: f.span.start, end: f.span.end, text: f.to });
193
+ else if (f.kind === 'delete' && f.call) edits.push({ start: f.call.lineStart, end: f.call.lineEnd, text: '' });
194
+ else if (f.kind === 'delete' && f.deleteImport) {
195
+ let ls = f.span.start; while (ls > 0 && src[ls - 1] !== '\n') ls--;
196
+ let le = f.span.end; while (le < src.length && src[le] !== '\n') le++;
197
+ if (le < src.length) le++;
198
+ const stmt = src.slice(ls, le);
199
+ // Only a whole import or require statement on its own line.
200
+ if (/^\s*(import\b[^;\n]*from\s*['"]ajv-formats['"]\s*;?\s*\n?|(const|let|var)\s+\w+\s*=\s*require\s*\(\s*['"]ajv-formats['"]\s*\)\s*;?\s*\n?)$/.test(stmt)) edits.push({ start: ls, end: le, text: '' });
201
+ }
202
+ }
203
+ edits.sort((a, b) => b.start - a.start);
204
+ let out = src;
205
+ for (const e of edits) out = out.slice(0, e.start) + e.text + out.slice(e.end);
206
+ return out;
207
+ }
208
+
209
+ function migrate (dir, opts = {}) {
210
+ const root = path.resolve(dir || '.');
211
+ const files = walk(root, []);
212
+ const report = [];
213
+ let flagged = 0;
214
+ let written = 0;
215
+ for (const file of files) {
216
+ let src;
217
+ try { src = fs.readFileSync(file, 'utf8'); } catch { continue; }
218
+ const findings = inspectFile(file, src);
219
+ if (findings === null) continue;
220
+ // Forward slashes on every platform: the path is for people to read and
221
+ // paste, and Windows gave `src\\plain.js` where the report and its test
222
+ // name `src/plain.js`.
223
+ report.push({ file: path.relative(root, file).split(path.sep).join('/'), findings });
224
+ flagged += findings.filter((f) => f.kind === 'flag').length;
225
+ if (opts.write && findings.some((f) => f.kind !== 'flag')) {
226
+ const out = rewriteFile(src, findings);
227
+ if (out !== src) { fs.writeFileSync(file, out); written++; }
228
+ }
229
+ }
230
+ return { root, scanned: files.length, report, flagged, written };
231
+ }
232
+
233
+ function formatReport (result, opts = {}) {
234
+ const lines = [];
235
+ if (result.report.length === 0) {
236
+ lines.push(`no use of ajv found in ${result.scanned} source files under ${result.root}`);
237
+ return lines.join('\n');
238
+ }
239
+ const label = { rewrite: 'rewrite', delete: 'delete ', flag: 'flag ' };
240
+ for (const r of result.report) {
241
+ lines.push(r.file);
242
+ for (const f of r.findings) lines.push(` ${label[f.kind]} line ${String(f.line).padStart(4)} ${f.text}`);
243
+ }
244
+ const rewrites = result.report.reduce((n, r) => n + r.findings.filter((f) => f.kind !== 'flag').length, 0);
245
+ lines.push('');
246
+ lines.push(`${result.report.length} of ${result.scanned} source files use ajv: ${rewrites} mechanical change${rewrites === 1 ? '' : 's'}, ${result.flagged} site${result.flagged === 1 ? '' : 's'} to look at by hand`);
247
+ if (opts.write) lines.push(`${result.written} file${result.written === 1 ? '' : 's'} written; flagged sites were left as they are`);
248
+ else if (rewrites > 0) lines.push('nothing was changed; run again with --write to apply the mechanical changes');
249
+ if (result.flagged > 0) lines.push('each flagged site is explained in docs/migration-from-ajv.md');
250
+ return lines.join('\n');
251
+ }
252
+
253
+ module.exports = { migrate, formatReport, inspectFile, rewriteFile, mask, specifiers, SPECIFIERS };
@@ -3,10 +3,12 @@
3
3
  const { parse } = require('./safe-regex')
4
4
 
5
5
  // True when the platform RegExp, a backtracking engine, also runs `src` in
6
- // linear time, so it can take the pattern: every quantifier on one character,
7
- // class or `.`; at most one unbounded, and then anchored with `^`; few bounded
8
- // choices; only escapes both engines read alike; nothing outside the BMP, so
9
- // both see the same UTF-16 units. tests/test_regex_native_linear.js holds it.
6
+ // linear time, so it can take the pattern: every unbounded quantifier on one
7
+ // character, class or `.`; at most one of them, and then anchored with `^`;
8
+ // a finite repeat may cover a group that holds no unbounded quantifier; few
9
+ // bounded choices in all; only escapes both engines read alike; nothing
10
+ // outside the BMP, so both see the same UTF-16 units.
11
+ // tests/test_regex_native_linear.js holds it.
10
12
  const NATIVE_ESCAPES = new Set(['d', 'D', 'w', 'W', 's', 'S', 'n', 'r', 't', 'f', 'v', '.', '\\', '/', '^', '$', '|', '?', '*', '+', '(', ')', '[', ']', '{', '}', '-', 'x', 'u'])
11
13
  function nativeIsLinear (src) {
12
14
  if (typeof src !== 'string' || src.length > 512) return false
@@ -23,33 +25,40 @@ function nativeIsLinear (src) {
23
25
  }
24
26
  let ast
25
27
  try { ast = parse(src) } catch { return false }
26
- let unbounded = 0
27
- let bounded = 0
28
- let ok = true
28
+ // Per subtree: how many unbounded quantifiers it holds (`u`) and how many
29
+ // bounded choices a backtracking engine could try (`b`), or null where the
30
+ // shape is not provably linear. A finite repeat of a group is linear when
31
+ // the group holds no unbounded quantifier: `(?:[A-F0-9]{2}:){31}` has
32
+ // nothing to retry, and its choices multiply through the nesting.
29
33
  const atom = (n) => n.t === 'char' || n.t === 'class' || n.t === 'any'
30
- const walk = (n) => {
31
- if (!ok) return
34
+ const info = (n) => {
32
35
  switch (n.t) {
33
36
  case 'star': case 'plus':
34
- if (!atom(n.child)) ok = false
35
- unbounded++
36
- break
37
- case 'quest':
38
- if (!atom(n.child)) ok = false
39
- bounded += 1
40
- break
41
- case 'repeat':
42
- if (!atom(n.child)) ok = false
43
- if (n.max === Infinity) unbounded++
44
- else bounded += n.max - n.min
45
- break
46
- case 'group': walk(n.child); break
47
- case 'concat': n.parts.forEach(walk); break
48
- case 'alt': n.opts.forEach(walk); break
49
- default: break
37
+ return atom(n.child) ? { u: 1, b: 0 } : null
38
+ case 'quest': {
39
+ if (atom(n.child)) return { u: 0, b: 1 }
40
+ const c = info(n.child)
41
+ return c === null || c.u > 0 ? null : { u: 0, b: 1 + c.b }
42
+ }
43
+ case 'repeat': {
44
+ if (atom(n.child)) return n.max === Infinity ? { u: 1, b: 0 } : { u: 0, b: n.max - n.min }
45
+ if (n.max === Infinity) return null
46
+ const c = info(n.child)
47
+ return c === null || c.u > 0 ? null : { u: 0, b: (n.max - n.min) + n.max * c.b }
48
+ }
49
+ case 'group': return info(n.child)
50
+ case 'concat': case 'alt': {
51
+ let u = 0, b = 0
52
+ for (const part of (n.t === 'concat' ? n.parts : n.opts)) { const c = info(part); if (c === null) return null; u += c.u; b += c.b }
53
+ return { u, b }
54
+ }
55
+ default: return { u: 0, b: 0 }
50
56
  }
51
57
  }
52
- walk(ast)
58
+ const r = info(ast)
59
+ const ok = r !== null
60
+ const unbounded = ok ? r.u : 0
61
+ const bounded = ok ? r.b : 0
53
62
  if (!ok || unbounded > 1 || bounded > 64) return false
54
63
  if (unbounded === 1) {
55
64
  // Anchored at the start on every top-level branch.
@@ -755,6 +755,17 @@ class Validator {
755
755
  // richErrors: default true. Only the literal `false` opts back into the
756
756
  // v0.14 error shape (no code/expected/received/docUrl, no aliases).
757
757
  this._richErrors = options && options.richErrors === false ? false : true;
758
+ // Declared here, set later (the one-pass switch): a property added to an
759
+ // instance after construction moves every instance that gets it to a
760
+ // new hidden class, which cost the suite benchmark nine percent.
761
+ this._onePass = undefined;
762
+ this._tierOnePass = undefined;
763
+ this._skipNativeFast = false;
764
+ this._scanner = undefined;
765
+ this._lastParsed = undefined;
766
+ this._twin = undefined;
767
+ this._coldCalls = undefined;
768
+ this._posCache = undefined;
758
769
 
759
770
  // Optional schema source descriptor. When supplied, the renderer pipeline
760
771
  // can attach a `schemaSource` frame to enriched errors.
@@ -1362,9 +1373,13 @@ class Validator {
1362
1373
  let _errorsNow = _buildErrors;
1363
1374
  let _onePass = null;
1364
1375
  let _richLater = (_plain !== null && _oneShotRich !== null && _richBuilder !== null && !_extra) ? 1 : 0;
1365
- const _errorsOf = (data) => {
1366
- if (_richLater !== 0 && ++_richLater > 2) {
1367
- _richLater = 0;
1376
+ // The switch the third read makes, also reachable from validateJSON
1377
+ // (index.js) for a validator whose rejections only ever arrive as
1378
+ // text: those reads never pass through `_errorsOf`.
1379
+ const _switchOnePass = () => {
1380
+ if (_richLater === 0) return _onePass;
1381
+ _richLater = 0;
1382
+ {
1368
1383
  const combRich = _oneShotRich();
1369
1384
  if (combRich) {
1370
1385
  const clean = !this._richErrors && _isCleanShape !== null && _isCleanShape(combRich);
@@ -1384,9 +1399,16 @@ class Validator {
1384
1399
  // the verdict, so one pass measured no faster on rejections and
1385
1400
  // slower on acceptances.
1386
1401
  const onePass = !this._richErrors && _onePassOf !== null && _isCombined !== null && _isCombined(combRich) ? _onePassOf(EMPTY_ERRORS, _plainValidate) : null;
1387
- if (onePass) { _onePass = onePass; self.validate = onePass; }
1402
+ // Also where validateJSON can reach it (index.js): a rejected
1403
+ // text is parsed and handed to this function directly.
1404
+ if (onePass) { _onePass = onePass; self.validate = onePass; self._onePass = onePass; }
1388
1405
  }
1389
1406
  }
1407
+ return _onePass;
1408
+ };
1409
+ if (_richLater !== 0) this._tierOnePass = _switchOnePass;
1410
+ const _errorsOf = (data) => {
1411
+ if (_richLater !== 0 && ++_richLater > 2) _switchOnePass();
1390
1412
  return _errorsNow(data);
1391
1413
  };
1392
1414
  _errorsOf.final = false;
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // Kept in lockstep with package.json by `tests/test_version_sync.js`.
9
9
 
10
- module.exports = '1.45.0';
10
+ module.exports = '1.46.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.45.0",
3
+ "version": "1.46.1",
4
4
  "description": "JSON Schema validator that compiles to plain JavaScript, still runs where code generation is blocked, and can be compiled away at build time. Passes the full official test suite.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -143,13 +143,13 @@
143
143
  "LICENSE"
144
144
  ],
145
145
  "optionalDependencies": {
146
- "@ata-validator/native-darwin-arm64": "1.45.0",
147
- "@ata-validator/native-darwin-x64": "1.45.0",
148
- "@ata-validator/native-linux-arm64-gnu": "1.45.0",
149
- "@ata-validator/native-linux-arm64-musl": "1.45.0",
150
- "@ata-validator/native-linux-x64-gnu": "1.45.0",
151
- "@ata-validator/native-linux-x64-musl": "1.45.0",
152
- "@ata-validator/native-win32-x64": "1.45.0"
146
+ "@ata-validator/native-darwin-arm64": "1.46.1",
147
+ "@ata-validator/native-darwin-x64": "1.46.1",
148
+ "@ata-validator/native-linux-arm64-gnu": "1.46.1",
149
+ "@ata-validator/native-linux-arm64-musl": "1.46.1",
150
+ "@ata-validator/native-linux-x64-gnu": "1.46.1",
151
+ "@ata-validator/native-linux-x64-musl": "1.46.1",
152
+ "@ata-validator/native-win32-x64": "1.46.1"
153
153
  },
154
154
  "peerDependencies": {
155
155
  "yaml": "^2.0.0"