ata-validator 1.44.0 → 1.46.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/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 };
@@ -52,12 +52,16 @@ const branchTitle = (n) => (n && n.schema && typeof n.schema.title === 'string'
52
52
 
53
53
  function install(deps) {
54
54
  const { Plan, NOERRORS, err, evalLeaf, evalLeafV, runCustomV, dataBits, escapePointer,
55
- deepEqual, multipleOfOk, cpAtLeast, cpAtMost,
55
+ deepEqual, enumMember, allDistinct, multipleOfOk, cpAtLeast, cpAtMost,
56
56
  T_STRING, T_ARRAY, T_OBJECT, resolveRef, splitFragment, resolveUri } = deps;
57
57
 
58
58
  const TRUE_FN = () => true;
59
- const TRUE_PAIR = { v: TRUE_FN, c: TRUE_FN };
59
+ // `leaf`: the node compiled no subschema of its own (see compileNode). A
60
+ // collecting caller asks a leaf for its verdict first and builds the paths
61
+ // for its errors only when it fails.
62
+ const TRUE_PAIR = { v: TRUE_FN, c: TRUE_FN, leaf: true };
60
63
  const FALSE_PAIR = {
64
+ leaf: true,
61
65
  v: () => false,
62
66
  c: (data, errors, instancePath, schemaPath) => {
63
67
  if (errors !== NOERRORS) errors.push(err('false schema', 'not', instancePath, schemaPath, {}, 'boolean schema is false'));
@@ -164,12 +168,8 @@ function install(deps) {
164
168
  checks.push((data, bits) => (bits & mask) !== 0);
165
169
  }
166
170
  if (P.enum !== null) {
167
- const vals = P.enum;
168
- if (vals.length === 0) checks.push(() => false);
169
- else checks.push((data) => {
170
- for (let i = 0; i < vals.length; i++) if (deepEqual(vals[i], data)) return true;
171
- return false;
172
- });
171
+ if (P.enum.length === 0) checks.push(() => false);
172
+ else checks.push((data) => enumMember(P, data));
173
173
  }
174
174
  if (P.hasConst) {
175
175
  const c = P.const;
@@ -205,14 +205,7 @@ function install(deps) {
205
205
  const arrs = [];
206
206
  if (P.minItems !== undefined) { const m = P.minItems; arrs.push((d) => d.length >= m); }
207
207
  if (P.maxItems !== undefined) { const m = P.maxItems; arrs.push((d) => d.length <= m); }
208
- if (P.uniqueItems) {
209
- arrs.push((d) => {
210
- for (let i = 0; i < d.length; i++) {
211
- for (let j = i + 1; j < d.length; j++) if (deepEqual(d[i], d[j])) return false;
212
- }
213
- return true;
214
- });
215
- }
208
+ if (P.uniqueItems) arrs.push(allDistinct);
216
209
  const run = combineValue(arrs);
217
210
  if (run !== null) { needsBits = true; checks.push((data, bits) => bits !== T_ARRAY || run(data)); }
218
211
  }
@@ -300,6 +293,7 @@ function install(deps) {
300
293
  const box = { v: null, c: null };
301
294
  const pair = {
302
295
  open: true,
296
+ leaf: false,
303
297
  v: (d, st, rec) => box.v(d, st, rec),
304
298
  c: (d, e, ip, sp, st, rec) => box.c(d, e, ip, sp, st, rec),
305
299
  };
@@ -308,8 +302,11 @@ function install(deps) {
308
302
  // In-place children collect when this node does; child applicators
309
303
  // never do. `rec` is threaded as the trailing argument of every step
310
304
  // and is undefined on non-collecting nodes, where nothing reads it.
311
- const inPlace = (n, b) => compileNode(ctx, n, b, chain, collect);
312
- const child = (n) => compileNode(ctx, n, base, chain, false);
305
+ // Whether this node compiles any subschema: a node that does not is a
306
+ // leaf, whose verdict is a few comparisons.
307
+ let nested = false;
308
+ const inPlace = (n, b) => { nested = true; return compileNode(ctx, n, b, chain, collect); };
309
+ const child = (n) => { nested = true; return compileNode(ctx, n, base, chain, false); };
313
310
 
314
311
  const steps = []; // collect variant: (data, errors, ip, sp, stack, rec)
315
312
  const vsteps = []; // verdict variant: (data, stack, rec)
@@ -399,7 +396,7 @@ function install(deps) {
399
396
  let ok = true;
400
397
  const n = Math.min(fns.length, data.length);
401
398
  for (let i = 0; i < n; i++) {
402
- if (!fns[i].c(data[i], errors, instancePath + '/' + i, schemaPath + '/prefixItems/' + i, stack)) {
399
+ if (!((fns[i].leaf && fns[i].v(data[i], stack)) || fns[i].c(data[i], errors, instancePath + '/' + i, schemaPath + '/prefixItems/' + i, stack))) {
403
400
  ok = false;
404
401
  if (errors === NOERRORS) return false;
405
402
  }
@@ -422,7 +419,7 @@ function install(deps) {
422
419
  if (dataBits(data) !== T_ARRAY) return true;
423
420
  let ok = true;
424
421
  for (let i = start; i < data.length; i++) {
425
- if (!fn.c(data[i], errors, instancePath + '/' + i, schemaPath + '/items', stack)) {
422
+ if (!((fn.leaf && fn.v(data[i], stack)) || fn.c(data[i], errors, instancePath + '/' + i, schemaPath + '/items', stack))) {
426
423
  ok = false;
427
424
  if (errors === NOERRORS) return false;
428
425
  }
@@ -536,7 +533,7 @@ function install(deps) {
536
533
  let evaluated = false;
537
534
  const prop = lookup !== null ? lookup(key) : undefined;
538
535
  if (prop !== undefined) {
539
- if (!prop.fn.c(data[key], errors, instancePath + prop.seg, schemaPath + prop.schemaSeg, stack)) {
536
+ if (!((prop.fn.leaf && prop.fn.v(data[key], stack)) || prop.fn.c(data[key], errors, instancePath + prop.seg, schemaPath + prop.schemaSeg, stack))) {
540
537
  ok = false;
541
538
  if (errors === NOERRORS) return false;
542
539
  }
@@ -546,7 +543,7 @@ function install(deps) {
546
543
  for (let pi = 0; pi < patterns.length; pi++) {
547
544
  const pp = patterns[pi];
548
545
  if (pp.re.test(key)) {
549
- if (!pp.fn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/patternProperties/' + escapePointer(pp.src), stack)) {
546
+ if (!((pp.fn.leaf && pp.fn.v(data[key], stack)) || pp.fn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/patternProperties/' + escapePointer(pp.src), stack))) {
550
547
  ok = false;
551
548
  if (errors === NOERRORS) return false;
552
549
  }
@@ -559,7 +556,7 @@ function install(deps) {
559
556
  ok = false;
560
557
  if (errors === NOERRORS) return false;
561
558
  errors.push(err('additionalProperties', 'additionalProperties', instancePath, schemaPath + '/additionalProperties', { additionalProperty: key }, 'must NOT have additional properties'));
562
- } else if (!apFn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/additionalProperties', stack)) {
559
+ } else if (!((apFn.leaf && apFn.v(data[key], stack)) || apFn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/additionalProperties', stack))) {
563
560
  ok = false;
564
561
  if (errors === NOERRORS) return false;
565
562
  }
@@ -927,7 +924,7 @@ function install(deps) {
927
924
  for (let k = 0; k < keys.length; k++) {
928
925
  const key = keys[k];
929
926
  if (hasProp(rec, key)) continue;
930
- if (!fn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack)) {
927
+ if (!((fn.leaf && fn.v(data[key], stack)) || fn.c(data[key], errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack))) {
931
928
  ok = false;
932
929
  if (errors === NOERRORS) return false;
933
930
  }
@@ -983,7 +980,7 @@ function install(deps) {
983
980
  let ok = true;
984
981
  for (let i = 0; i < data.length; i++) {
985
982
  if (hasItem(rec, i)) continue;
986
- if (!fn.c(data[i], errors, instancePath + '/' + i, schemaPath + '/unevaluatedItems', stack)) {
983
+ if (!((fn.leaf && fn.v(data[i], stack)) || fn.c(data[i], errors, instancePath + '/' + i, schemaPath + '/unevaluatedItems', stack))) {
987
984
  ok = false;
988
985
  if (errors === NOERRORS) return false;
989
986
  }
@@ -1068,6 +1065,7 @@ function install(deps) {
1068
1065
  box.v = vfn;
1069
1066
  pair.c = cfn;
1070
1067
  pair.v = vfn;
1068
+ pair.leaf = !nested;
1071
1069
  pair.open = false;
1072
1070
  return pair;
1073
1071
  }
@@ -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.
@@ -0,0 +1,139 @@
1
+ 'use strict';
2
+
3
+ // uniqueItems for every engine. These are real functions: the interpreted
4
+ // engine calls them, and the code generator emits their source text
5
+ // (`String(fn)`) into the function it builds, so both run the same code.
6
+ // They read only each other, `_deq` and `_uqpI`, which the generator emits
7
+ // beside them.
8
+ //
9
+ // Short arrays compare pairwise and allocate nothing. Longer arrays of
10
+ // primitives key a Set or Map by the value itself. Longer arrays that hold an
11
+ // object key a Map by a numeric structural hash, and only items whose hashes
12
+ // collide are compared with `_deq`. Equal values always hash alike: numbers
13
+ // by value (so 1 and 1.0, 0 and -0 agree), strings by their characters,
14
+ // arrays by position, and objects by the sum of their own key-value hashes,
15
+ // whatever the key order. The hash replaced a sorted-key canonical string
16
+ // built for every item on every call, which was over a quarter of the time a
17
+ // generated verdict spent on SchemaStore's sample documents.
18
+
19
+ // JSON equality, the same function the generator hoists as DEQ_HELPER.
20
+ function _deq(a, b) {
21
+ if (a === b) return true;
22
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
23
+ var aa = Array.isArray(a);
24
+ if (aa !== Array.isArray(b)) return false;
25
+ var i;
26
+ if (aa) {
27
+ if (a.length !== b.length) return false;
28
+ for (i = 0; i < a.length; i++) if (!_deq(a[i], b[i])) return false;
29
+ return true;
30
+ }
31
+ var ka = Object.keys(a);
32
+ if (ka.length !== Object.keys(b).length) return false;
33
+ for (i = 0; i < ka.length; i++) {
34
+ var k = ka[i];
35
+ if (!Object.prototype.hasOwnProperty.call(b, k) || !_deq(a[k], b[k])) return false;
36
+ }
37
+ return true;
38
+ }
39
+
40
+ function _uqh(x) {
41
+ if (x === null) return 1;
42
+ var t = typeof x, h, i;
43
+ if (t === 'boolean') return x ? 2 : 3;
44
+ if (t === 'number') {
45
+ if (x === 0) return 4;
46
+ h = x | 0;
47
+ return Math.imul(h ^ (((x - h) * 1e9) | 0), 0x9e3779b1) ^ 5;
48
+ }
49
+ if (t === 'string') {
50
+ h = 0x811c9dc5 ^ x.length;
51
+ for (i = 0; i < x.length; i++) h = Math.imul(h ^ x.charCodeAt(i), 16777619);
52
+ return h;
53
+ }
54
+ if (t !== 'object') return 6;
55
+ if (Array.isArray(x)) {
56
+ h = 0x2545f491 ^ x.length;
57
+ for (i = 0; i < x.length; i++) h = Math.imul(h ^ _uqh(x[i]), 16777619);
58
+ return h;
59
+ }
60
+ var ks = Object.keys(x);
61
+ h = 0x6c8e9cf5 ^ ks.length;
62
+ for (i = 0; i < ks.length; i++) h = (h + Math.imul(_uqh(ks[i]) ^ Math.imul(_uqh(x[ks[i]]), 0x5bd1e995), 0x27d4eb2d)) | 0;
63
+ return h;
64
+ }
65
+
66
+ // Whether every item is distinct.
67
+ function _uq(a) {
68
+ var n = a.length, i, k;
69
+ if (n < 2) return true;
70
+ if (n <= 12) {
71
+ for (i = 1; i < n; i++) for (k = 0; k < i; k++) if (_deq(a[i], a[k])) return false;
72
+ return true;
73
+ }
74
+ var prim = true;
75
+ for (i = 0; i < n; i++) { var x = a[i]; if (x !== null && typeof x === 'object') { prim = false; break; } }
76
+ if (prim) {
77
+ var s = new Set();
78
+ for (i = 0; i < n; i++) { if (s.has(a[i])) return false; s.add(a[i]); }
79
+ return true;
80
+ }
81
+ var m = new Map();
82
+ for (i = 0; i < n; i++) {
83
+ var hh = _uqh(a[i]), b = m.get(hh);
84
+ if (b === undefined) m.set(hh, i);
85
+ else if (typeof b === 'number') { if (_deq(a[b], a[i])) return false; m.set(hh, [b, i]); }
86
+ else { for (k = 0; k < b.length; k++) if (_deq(a[b[k]], a[i])) return false; b.push(i); }
87
+ }
88
+ return true;
89
+ }
90
+
91
+ // The duplicate pair an error reports: the first item that has a later
92
+ // equal, and its first later equal. Returns that later index and leaves the
93
+ // first in _uqpI, or returns -1 when every item is distinct.
94
+ var _uqpI = 0;
95
+ function _uqp(a) {
96
+ var n = a.length, i, k;
97
+ if (n < 2) return -1;
98
+ if (n <= 12) {
99
+ for (i = 0; i < n - 1; i++) for (k = i + 1; k < n; k++) if (_deq(a[i], a[k])) { _uqpI = i; return k; }
100
+ return -1;
101
+ }
102
+ var prim = true;
103
+ for (i = 0; i < n; i++) { var x = a[i]; if (x !== null && typeof x === 'object') { prim = false; break; } }
104
+ var bi = -1, bj = -1, m = new Map();
105
+ if (prim) {
106
+ for (i = 0; i < n; i++) {
107
+ var p = m.get(a[i]);
108
+ if (p === undefined) m.set(a[i], i);
109
+ else if (p >= 0) { if (bi < 0 || p < bi) { bi = p; bj = i; } m.set(a[i], -1); }
110
+ }
111
+ } else {
112
+ // Each bucket holds the first occurrence of every distinct value that
113
+ // hashed there, in index order; a value is paired with its first later
114
+ // equal only once.
115
+ var paired = null;
116
+ for (i = 0; i < n; i++) {
117
+ var hh = _uqh(a[i]), b = m.get(hh), r = -1;
118
+ if (b === undefined) { m.set(hh, i); continue; }
119
+ if (typeof b === 'number') { if (_deq(a[b], a[i])) r = b; else m.set(hh, [b, i]); }
120
+ else { for (k = 0; k < b.length; k++) if (_deq(a[b[k]], a[i])) { r = b[k]; break; } if (r < 0) b.push(i); }
121
+ if (r >= 0) {
122
+ if (paired === null) paired = new Set();
123
+ if (!paired.has(r)) { paired.add(r); if (bi < 0 || r < bi) { bi = r; bj = i; } }
124
+ }
125
+ }
126
+ }
127
+ if (bi < 0) return -1;
128
+ _uqpI = bi;
129
+ return bj;
130
+ }
131
+
132
+ // For the interpreted engine.
133
+ function allDistinct(a) { return _uq(a); }
134
+ function duplicatePair(a) {
135
+ var j = _uqp(a);
136
+ return j < 0 ? null : [_uqpI, j];
137
+ }
138
+
139
+ module.exports = { allDistinct, duplicatePair, _deq, _uqh, _uq, _uqp };