ata-validator 1.61.2 → 1.62.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.
Files changed (52) hide show
  1. package/README.md +159 -37
  2. package/bin/ata.js +35 -2
  3. package/compat.js +15 -2
  4. package/compiled-standard.d.ts +6 -0
  5. package/compiled-standard.mjs +66 -0
  6. package/index.d.ts +7 -0
  7. package/index.js +10 -0
  8. package/lib/aot-build.js +27 -1
  9. package/lib/aot-impl.js +108 -17
  10. package/lib/buffer-gate.js +25 -14
  11. package/lib/bun-target.js +21 -5
  12. package/lib/clone-emit.js +115 -10
  13. package/lib/codegen-engine.js +94 -18
  14. package/lib/compiled-common.js +11 -4
  15. package/lib/compiled-standard.js +33 -0
  16. package/lib/compiled-verdict.js +2 -2
  17. package/lib/compiled.js +1 -1
  18. package/lib/data-positions.js +18 -0
  19. package/lib/defaults.js +101 -23
  20. package/lib/describe-schema.js +38 -5
  21. package/lib/diagnose.js +51 -29
  22. package/lib/enrich-error.js +23 -1
  23. package/lib/error-sort-rank.js +12 -0
  24. package/lib/formats-source.js +12 -1
  25. package/lib/formats.js +19 -19
  26. package/lib/interpreter-core.js +23 -4
  27. package/lib/js-compiler-errors.js +70 -72
  28. package/lib/js-compiler.js +212 -72
  29. package/lib/json-rejections.js +1 -1
  30. package/lib/migrate.js +24 -5
  31. package/lib/native-attach.browser.js +7 -0
  32. package/lib/native-attach.js +76 -0
  33. package/lib/native-positions.js +7 -0
  34. package/lib/plan-compiler.js +7 -1
  35. package/lib/regex-linear.js +88 -0
  36. package/lib/rejections.js +13 -3
  37. package/lib/render-compact.js +5 -5
  38. package/lib/render-pretty.js +23 -10
  39. package/lib/safe-regex.js +29 -4
  40. package/lib/scan-compiler.js +16 -4
  41. package/lib/schema-diff.js +103 -0
  42. package/lib/stream.js +293 -0
  43. package/lib/tier0.js +6 -2
  44. package/lib/ts-gen.js +25 -12
  45. package/lib/unique-items.js +11 -3
  46. package/lib/validator-core.js +61 -66
  47. package/lib/verify.js +25 -6
  48. package/lib/version.js +1 -1
  49. package/package.json +24 -8
  50. package/stream.d.ts +51 -0
  51. package/stream.js +3 -0
  52. package/stream.mjs +3 -0
package/lib/aot-impl.js CHANGED
@@ -160,13 +160,19 @@ module.exports = { boolFn, hybridFactory, errFn };
160
160
  // 'inject' writes no function source. The module exports `setFormats(map)`
161
161
  // and each format is looked up from that registry when a value is checked,
162
162
  // so the caller supplies the functions at load time.
163
+ // The format name a helper name was made from (js-compiler.js ufName):
164
+ // `_uf_` and the name with every character outside [A-Za-z0-9_] written as
165
+ // `$` and four hex digits.
166
+ function formatFromName(name) {
167
+ return name.slice(4).replace(/\$([0-9a-f]{4})/g, (m, h) => String.fromCharCode(parseInt(h, 16)));
168
+ }
163
169
  function emitFormatDecls(closures, mode, declKW) {
164
170
  if (!closures || closures.length === 0) return { decls: '', exportsSetFormats: false };
165
171
  if (mode === 'inject') {
166
172
  let out = `${declKW} __formats = Object.create(null);\n`;
167
173
  out += `function setFormats(map) { for (const k in map) __formats[k] = map[k]; }\n`;
168
174
  for (const { name, format } of closures) {
169
- const key = JSON.stringify(format === null ? name.slice(4) : format);
175
+ const key = JSON.stringify(format === null ? formatFromName(name) : format);
170
176
  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`;
171
177
  }
172
178
  return { decls: out, exportsSetFormats: true };
@@ -199,10 +205,15 @@ function sourceFrameDecls(frames, schemaFile) {
199
205
  const texts = [];
200
206
  const textIndex = new Map();
201
207
  const rows = [];
208
+ // The rows come from the caller's `sourceMap` option. A line or column that
209
+ // is not an integer is written as 0: written as given, a string in one ran
210
+ // as code when the module loaded.
211
+ const int = (n) => (Number.isInteger(n) ? n : 0);
202
212
  for (const f of frames.values()) {
203
- let i = textIndex.get(f.text);
204
- if (i === undefined) { i = texts.length; texts.push(f.text); textIndex.set(f.text, i); }
205
- rows[f.index] = `[${f.line},${f.col},${i}]`;
213
+ const text = typeof f.text === 'string' ? f.text : '';
214
+ let i = textIndex.get(text);
215
+ if (i === undefined) { i = texts.length; texts.push(text); textIndex.set(text, i); }
216
+ rows[f.index] = `[${int(f.line)},${int(f.col)},${i}]`;
206
217
  }
207
218
  return `const __ataSF = ${JSON.stringify(schemaFile)};\n` +
208
219
  `const __ataSL = ${JSON.stringify(texts)};\n` +
@@ -367,7 +378,27 @@ function defaultsDecl(validator) {
367
378
  if (defaults.buildDefaultsApplier(schema) === null) return '';
368
379
  const embedded = require('./schema-strip').stripAnnotationsKeepingDefaults(schema);
369
380
  const text = JSON.stringify(JSON.stringify(embedded)).replace(/</g, '\\u003c');
370
- return `const _ataDefaults = (function () {\nconst MAX_DEPTH = ${defaults.MAX_DEPTH};\n${loadAotOrder().take('defaults.js', defaults.EMBED)}\nreturn buildDefaultsApplier;\n})();\n` +
381
+ // patternProperties keys are tested as validation tests them: on the
382
+ // linear engine where validation uses it, the platform RegExp otherwise.
383
+ // Called without a matcher, the embedded pass took the RegExp for every
384
+ // pattern, and a key that backtracks cost seconds there. A module whose
385
+ // declaration names __ataSafeRe carries the engine (callers check).
386
+ const { useSafeEngine } = require('./js-compiler')._internal;
387
+ const safe = new Set();
388
+ const seen = new Set();
389
+ const walk = (n) => {
390
+ if (n === null || typeof n !== 'object' || seen.has(n)) return;
391
+ seen.add(n);
392
+ if (!Array.isArray(n) && n.patternProperties && typeof n.patternProperties === 'object' && !Array.isArray(n.patternProperties)) {
393
+ for (const k of Object.keys(n.patternProperties)) if (useSafeEngine(k)) safe.add(k);
394
+ }
395
+ for (const k of Object.keys(n)) walk(n[k]);
396
+ };
397
+ walk(embedded);
398
+ const build = safe.size === 0
399
+ ? 'buildDefaultsApplier'
400
+ : `(function () { const SAFE = new Set(${JSON.stringify([...safe])}); return (s) => buildDefaultsApplier(s, (p) => SAFE.has(p) ? __ataSafeRe(p) : keyPattern(p)); })()`;
401
+ return `const _ataDefaults = (function () {\nconst MAX_DEPTH = ${defaults.MAX_DEPTH};\n${loadAotOrder().take('defaults.js', defaults.EMBED)}\nreturn ${build};\n})();\n` +
371
402
  `let _ataFill;\nfunction applyDefaults(d) { (_ataFill || (_ataFill = _ataDefaults(JSON.parse(${text}))))(d); }\n`;
372
403
  }
373
404
 
@@ -577,7 +608,7 @@ function toStandaloneModule(validator, opts) {
577
608
  // which carries it anyway, instead of embedding a copy (2.9 KB gzipped,
578
609
  // minified) in every module that has a pattern the platform RegExp does not
579
610
  // run.
580
- const usesSafeRe = errorRuntime !== null && ((jsFn && jsFn._usesSafeRe) || (jsErrFn && jsErrFn._usesSafeRe));
611
+ let usesSafeRe = errorRuntime !== null && ((jsFn && jsFn._usesSafeRe) || (jsErrFn && jsErrFn._usesSafeRe));
581
612
  let compactCore = '';
582
613
  if (errorRuntime !== null) {
583
614
  const { stripAnnotations } = require('./schema-strip');
@@ -649,9 +680,33 @@ function toStandaloneModule(validator, opts) {
649
680
  // safe-regex prelude, which has a module-scope parse() of its own for
650
681
  // reading patterns. A second declaration of that name shadowed it and the
651
682
  // prelude called this one instead, reaching _fn before its initialiser ran.
652
- const parseCore = cloneExpr
653
- ? `function _ataParse(data) {\n ${pre.call}if (!_fn(data)) { const e = new Error('validation failed'); e.name = 'AtaValidationError'; throw e; }\n return ${cloneExpr};\n}\n`
654
- : '';
683
+ // With a pass before the check (the validator's coerceTypes or
684
+ // removeAdditional, and its defaults unless useDefaults is off), the
685
+ // runtime's parse() takes its verdict on a filled copy of the input and
686
+ // checks the copy it hands back too (buildParse in lib/codegen-engine.js).
687
+ // Checked on the raw input, a default that makes a conditional apply was
688
+ // missed and parse() returned what validate() refuses after the fill.
689
+ const fillDecl = defaultsDecl(validator);
690
+ const fills = !!fillDecl && vopts.useDefaults !== false;
691
+ // The defaults pass tests patternProperties keys on the linear engine
692
+ // where validation does; the module then carries the engine.
693
+ const fillSafeRe = fillDecl.includes('__ataSafeRe(');
694
+ if (fillSafeRe && errorRuntime !== null) usesSafeRe = true;
695
+ const parseCore = !cloneExpr
696
+ ? ''
697
+ : !fills && pre.decl === ''
698
+ ? `function _ataParse(data) {\n if (!_fn(data)) { const e = new Error('validation failed'); e.name = 'AtaValidationError'; throw e; }\n return ${cloneExpr};\n}\n`
699
+ : `function _ataParse(data) {
700
+ let doc;
701
+ try { doc = structuredClone(data); } catch (_) { doc = undefined; }
702
+ if (doc === undefined && data !== undefined) throw new TypeError('parse() is not available for this value: it cannot be copied to fill its defaults before the check. Use validate() instead.');
703
+ if (doc !== null && typeof doc === 'object') { ${pre.decl !== '' ? '_ataPre(doc); ' : ''}${fills ? 'applyDefaults(doc); ' : ''}}
704
+ if (!_fn(doc)) { const e = new Error('validation failed'); e.name = 'AtaValidationError'; throw e; }
705
+ const out = ${cloneExpr};
706
+ if (!_fn(out)) throw new TypeError('parse() is not available for this value: the copy that keeps the declared properties is not one the schema accepts. Use validate() instead.');
707
+ return out;
708
+ }
709
+ `;
655
710
 
656
711
  // validateJSON(): parse the text, validate, and on failure attach a
657
712
  // dataFrame (byte offset, line, col, source line) to every error by
@@ -666,7 +721,7 @@ function toStandaloneModule(validator, opts) {
666
721
  // the worst case of a pointer at the very end. It is also 125 characters
667
722
  // smaller than the full-map builder it replaces.
668
723
  const positionsCore = !(opts && opts.positions) ? '' : `const _ataPosMap = ${buildTargetedPositionMap.toString()};
669
- function _ataFrame(p) { return { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text }; }
724
+ function _ataFrame(p) { const f = { byteOffset: p.byteOffset, length: p.length, line: p.line, col: p.col, text: p.text }; if (p.textCol !== undefined) f.textCol = p.textCol; return f; }
670
725
  function validateJSON(text) {
671
726
  const s = String(text);
672
727
  let data;
@@ -705,7 +760,6 @@ function validateJSON(text) {
705
760
  const hashDecl = `const schemaHash = ${JSON.stringify(hashSrc)};\nconst ataVersion = ${JSON.stringify(ATA_VERSION)};\n`;
706
761
 
707
762
  let names = fmt.exportsSetFormats ? baseNames + ', setFormats' : baseNames;
708
- const fillDecl = defaultsDecl(validator);
709
763
  if (fillDecl) names += ', applyDefaults';
710
764
  names += ', schemaHash, ataVersion';
711
765
  if (positionsCore) names += ', validateJSON';
@@ -726,7 +780,7 @@ ${errorRuntime === null ? '// Schema is embedded; runtime has zero dependency on
726
780
  // Re-run the build after upgrading ata: the version above is what wrote this.
727
781
  ${degradedNote}'use strict';
728
782
  ${errorRuntime === null ? '' : format === 'esm' ? `import { createErrors as _ataCreateErrors${usesSafeRe ? ', compileSafe as _ataCompileSafe' : ''}${usesUriWalk ? ', uriWalk as _ataUriWalk' : ''} } from ${JSON.stringify(errorRuntime)};\n` : `const { createErrors: _ataCreateErrors${usesSafeRe ? ', compileSafe: _ataCompileSafe' : ''}${usesUriWalk ? ', uriWalk: _ataUriWalk' : ''} } = require(${JSON.stringify(errorRuntime)});\n`}${_CP_LEN_SOURCE}
729
- ${errorRuntime !== null ? (usesSafeRe ? 'const __ataSafeRe = _ataCompileSafe;\n' : '') : safeRePrelude(jsFn, jsErrFn, onePass !== null && onePass.usesSafeRe ? { _usesSafeRe: true } : null)}${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
783
+ ${errorRuntime !== null ? (usesSafeRe ? 'const __ataSafeRe = _ataCompileSafe;\n' : '') : safeRePrelude(jsFn, jsErrFn, (onePass !== null && onePass.usesSafeRe) || fillSafeRe ? { _usesSafeRe: true } : null)}${schemaSourceConst}const VALID = Object.freeze({ valid: true, errors: Object.freeze([]) });
730
784
  const ABORT = Object.freeze({
731
785
  valid: false,
732
786
  errors: Object.freeze([Object.freeze({
@@ -885,7 +939,10 @@ function toSchemaModule(validator, opts) {
885
939
  ? `import { createValidator as _ataCreateValidator } from ${spec};\n`
886
940
  : `const { createValidator: _ataCreateValidator } = require(${spec});\n`)
887
941
  + (regSpec === null ? '' : format === 'esm' ? `import _ataRegistry from ${regSpec};\n` : `const _ataRegistry = require(${regSpec});\n`);
888
- const fillDecl = defaultsDecl(validator);
942
+ let fillDecl = defaultsDecl(validator);
943
+ // No verdict code here to carry the linear engine; the defaults pass
944
+ // brings its own when it tests keys on it.
945
+ if (fillDecl.includes('__ataSafeRe(')) fillDecl = getSafeRegexEmbed() + '\n' + fillDecl;
889
946
  const expNames = 'validate, isValid' + (fillDecl ? ', applyDefaults' : '') + ', schemaHash, ataVersion';
890
947
  const exp = format === 'esm'
891
948
  ? `export { ${expNames} };\nexport default { ${expNames} };\n`
@@ -1035,6 +1092,7 @@ function bundleStandalone(Validator, schemas, opts) {
1035
1092
  // The runtime's passes over the data, for the options asked for, and its
1036
1093
  // defaults pass as the validator's applyDefaults.
1037
1094
  const fillDecl = defaultsDecl(v);
1095
+ if (fillDecl.includes('__ataSafeRe(')) bundleUsesSafeRe = true;
1038
1096
  const pre = bundlePreprocess(v, opts, fillDecl, 'bundleStandalone');
1039
1097
  preamble = preamble ? `${preamble}\n${fillDecl}${pre.decl}` : fillDecl + pre.decl;
1040
1098
  const ret = (body) => fillDecl
@@ -1188,13 +1246,21 @@ function bundleCompact(Validator, schemas, opts) {
1188
1246
  // User format functions are referenced as _uf_<name> by the hybrid and error
1189
1247
  // bodies. Collect them across all schemas (deduped by name) and embed via
1190
1248
  // Function#toString so the bundle stays self-contained.
1191
- const fmtSeen = new Set();
1249
+ // One declaration per helper name. The name is one to one with the
1250
+ // format name, so two entries under one name are the same format; were
1251
+ // they bound to different functions the bundle could not serve both, so
1252
+ // that is refused rather than letting one schema run another's checker.
1253
+ const fmtSeen = new Map();
1192
1254
  const fmtAll = [];
1193
1255
  for (const e of entries) {
1194
1256
  if (!e || !e.fmt) continue;
1195
1257
  for (const entry of e.fmt) {
1196
- if (fmtSeen.has(entry.name)) continue;
1197
- fmtSeen.add(entry.name);
1258
+ const prev = fmtSeen.get(entry.name);
1259
+ if (prev !== undefined) {
1260
+ if (prev !== entry.fn) throw new Error(`ata: custom format "${entry.format === null ? formatFromName(entry.name) : entry.format}" is bound to two different functions in this bundle`);
1261
+ continue;
1262
+ }
1263
+ fmtSeen.set(entry.name, entry.fn);
1198
1264
  fmtAll.push(entry);
1199
1265
  }
1200
1266
  }
@@ -1255,9 +1321,24 @@ function loadBundle(Validator, mods, schemas, opts) {
1255
1321
  // do not write but a pattern could carry, has its backslash dropped, since
1256
1322
  // `\x3C` already is the escape. A module with neither sequence, which is
1257
1323
  // nearly every one, comes back as the same string.
1324
+ // The match is the `<` and what follows; the backslashes before it are
1325
+ // counted from the match offset. Matching them as part of the pattern made
1326
+ // the scan re-read a run of backslashes at every `<` after it, so an enum
1327
+ // value of 40000 backslashes and `<a` took 6.5 s. One pass, whatever the run.
1258
1328
  function htmlSafe(src) {
1259
1329
  if (typeof src !== 'string' || src.indexOf('<') === -1) return src;
1260
- return src.replace(/(\\*)<(\/script|!--)/gi, (m, bs, rest) => (bs.length % 2 ? bs.slice(0, -1) : bs) + '\\x3C' + rest);
1330
+ let out = '';
1331
+ let last = 0;
1332
+ const re = /<(\/script|!--)/gi;
1333
+ let m;
1334
+ while ((m = re.exec(src)) !== null) {
1335
+ const at = m.index;
1336
+ let bs = 0;
1337
+ while (at - bs - 1 >= last && src.charCodeAt(at - bs - 1) === 92) bs++;
1338
+ out += src.slice(last, at - (bs % 2)) + '\\x3C' + m[1];
1339
+ last = at + m[0].length;
1340
+ }
1341
+ return last === 0 ? src : out + src.slice(last);
1261
1342
  }
1262
1343
  const htmlSafeResult = (fn) => function () { return htmlSafe(fn.apply(this, arguments)); };
1263
1344
 
@@ -1278,6 +1359,16 @@ module.exports = {
1278
1359
  if (!Array.isArray(schemas)) return er.errorRuntimeSource(format, ATA_VERSION, undefined, !!schemaMode);
1279
1360
  const { stripAnnotations } = require('./schema-strip');
1280
1361
  const embedded = schemas.map((x) => stripAnnotations(x && typeof x === 'object' && x._schemaObj !== undefined ? x._schemaObj : x));
1362
+ // A module reaches the registered schemas (`schemas` option) through
1363
+ // $ref, so what they use the runtime must carry too. Read from the
1364
+ // validator's map, since the validator may not have compiled yet: a
1365
+ // pattern that lived only in a registered schema was missed, the runtime
1366
+ // went out without the pattern engine, and the module matched on the
1367
+ // platform RegExp.
1368
+ for (const x of schemas) {
1369
+ if (!x || typeof x !== 'object' || !(x._schemaMap instanceof Map)) continue;
1370
+ for (const ext of x._schemaMap.values()) if (ext && typeof ext === 'object') embedded.push(stripAnnotations(ext));
1371
+ }
1281
1372
  const features = er.runtimeFeatures(embedded);
1282
1373
  for (const x of schemas) if (x && x._jsFn && x._jsFn._usesSafeRe) features['safe-regex'] = true;
1283
1374
  return er.errorRuntimeSource(format, ATA_VERSION, features, !!schemaMode);
@@ -24,11 +24,16 @@ const UNSUPPORTED_KEYWORDS = new Set([
24
24
  'contains', 'minContains', 'maxContains',
25
25
  'unevaluatedProperties', 'unevaluatedItems',
26
26
  'dependencies', 'dependentSchemas', 'dependentRequired',
27
- 'propertyNames', 'patternProperties',
27
+ 'propertyNames', 'patternProperties', 'propertyDependencies',
28
28
  ]);
29
29
 
30
- // Formats the native checkers answer differently from the JS ones.
31
- const DIVERGENT_FORMATS = new Set(['hostname', 'date-time', 'time', 'uri-reference', 'duration']);
30
+ // Formats the native engine checks by the same rules as lib/formats.js,
31
+ // held to the same answers by tests/test_native_formats.js. Every other
32
+ // format name goes through validate(): the native engine has no check for
33
+ // it and would let any string pass. This was a list of the divergent ones,
34
+ // which missed every format the engine does not know (json-pointer, iri,
35
+ // uri-template, regex and the rest) and a caller's own formats.
36
+ const NATIVE_FORMATS = new Set(['email', 'date', 'uri', 'uuid', 'ipv4', 'ipv6', 'hostname']);
32
37
 
33
38
  const SUBSCHEMA_MAPS = ['properties', 'patternProperties', '$defs', 'definitions', 'dependentSchemas', 'dependencies'];
34
39
  const SUBSCHEMA_NODES = [
@@ -92,7 +97,7 @@ function refersBack(root) {
92
97
  return visit(root);
93
98
  }
94
99
 
95
- function walk(schema, depth) {
100
+ function walk(schema, depth, userFormats) {
96
101
  if (depth > NATIVE_DEPTH) return true;
97
102
  if (schema === true || schema === false) {
98
103
  // A boolean root is answered wrong by the walker; nested booleans are
@@ -101,7 +106,7 @@ function walk(schema, depth) {
101
106
  }
102
107
  if (schema === null || typeof schema !== 'object') return false;
103
108
  if (Array.isArray(schema)) {
104
- for (const s of schema) if (walk(s, depth + 1)) return true;
109
+ for (const s of schema) if (walk(s, depth + 1, userFormats)) return true;
105
110
  return false;
106
111
  }
107
112
  for (const key of Object.keys(schema)) {
@@ -114,7 +119,7 @@ function walk(schema, depth) {
114
119
  if (key === '$id' && depth > 0) return true;
115
120
  // An empty enum rejects everything; the walker accepts everything.
116
121
  if (key === 'enum' && Array.isArray(v) && v.length === 0) return true;
117
- if (key === 'format' && DIVERGENT_FORMATS.has(v)) return true;
122
+ if (key === 'format' && (!NATIVE_FORMATS.has(v) || (userFormats && Object.prototype.hasOwnProperty.call(userFormats, v)))) return true;
118
123
  // Unicode property escapes: RE2 cannot parse them and the walker then
119
124
  // skips the pattern instead of failing.
120
125
  if (key === 'pattern' && typeof v === 'string' && /\\[pP]\{/.test(v)) return true;
@@ -126,10 +131,10 @@ function walk(schema, depth) {
126
131
 
127
132
  if (SUBSCHEMA_MAPS.includes(key)) {
128
133
  if (v && typeof v === 'object' && !Array.isArray(v)) {
129
- for (const k of Object.keys(v)) if (walk(v[k], depth + 1)) return true;
134
+ for (const k of Object.keys(v)) if (walk(v[k], depth + 1, userFormats)) return true;
130
135
  }
131
136
  } else if (SUBSCHEMA_NODES.includes(key)) {
132
- if (walk(v, depth + 1)) return true;
137
+ if (walk(v, depth + 1, userFormats)) return true;
133
138
  }
134
139
  }
135
140
  return false;
@@ -138,13 +143,15 @@ function walk(schema, depth) {
138
143
  // True when the buffer APIs must answer through validate() for this schema.
139
144
  // `keywords` is the validator's normalized custom keyword map, if any: the
140
145
  // native walker knows nothing about those, so a schema using one is routed
141
- // through validate() like the other shapes here.
142
- function bufferNeedsSlowPath(schema, schemaMap, keywords) {
146
+ // through validate() like the other shapes here. `userFormats` is the
147
+ // validator's `formats` option: a format it defines, even under a built-in
148
+ // name, is a check only the JS engines run.
149
+ function bufferNeedsSlowPath(schema, schemaMap, keywords, userFormats) {
143
150
  if (keywords && schemaUsesKeywords(schema, keywords)) return true;
144
- if (walk(schema, 0)) return true;
151
+ if (walk(schema, 0, userFormats)) return true;
145
152
  if (refersBack(schema)) return true;
146
153
  if (schemaMap && schemaMap.size > 0) {
147
- for (const s of schemaMap.values()) if (walk(s, 1)) return true;
154
+ for (const s of schemaMap.values()) if (walk(s, 1, userFormats)) return true;
148
155
  }
149
156
  return false;
150
157
  }
@@ -163,8 +170,12 @@ function toText(input, name) {
163
170
  function installSlowBufferApis(validator) {
164
171
  const isValidText = (text) => validator.isValidJSON(text);
165
172
  validator.isValid = (input) => isValidText(toText(input, 'isValid'));
166
- validator.isValidPrepadded = (paddedBuffer, jsonLength) =>
167
- isValidText(Buffer.from(paddedBuffer.buffer, paddedBuffer.byteOffset, jsonLength).toString('utf8'));
173
+ // The length is the caller's, so it is held to the buffer: a view past its
174
+ // end read the bytes of whatever shared the pool, and answered on them.
175
+ validator.isValidPrepadded = (paddedBuffer, jsonLength) => {
176
+ if (!(paddedBuffer instanceof Uint8Array) || !Number.isInteger(jsonLength) || jsonLength < 0 || jsonLength > paddedBuffer.byteLength) return false;
177
+ return isValidText(Buffer.from(paddedBuffer.buffer, paddedBuffer.byteOffset, jsonLength).toString('utf8'));
178
+ };
168
179
  const ndjson = (input, name) => {
169
180
  const lines = toText(input, name).split('\n');
170
181
  const out = [];
package/lib/bun-target.js CHANGED
@@ -79,15 +79,20 @@ function empty () { return { string: [], number: [], boolean: [], null: [], obje
79
79
  function universal () { const t = empty(); for (const k of KINDS) t[k].push(UNIVERSAL[k]); return t }
80
80
  function single (kind, alt) { const t = empty(); t[kind].push(alt); return t }
81
81
  function union (a, b) { const t = empty(); for (const k of [...KINDS, 'alias']) t[k] = dedupe([...a[k], ...b[k]]); return t }
82
- function dedupe (xs) { return xs.filter((x, i) => xs.indexOf(x) === i) }
82
+ function dedupe (xs) { return xs.length < 2 ? xs : [...new Set(xs)] }
83
83
  function isUniversal (t) { return t.alias.length === 0 && KINDS.every((k) => t[k].length === 1 && t[k][0] === UNIVERSAL[k]) }
84
84
 
85
85
  // Intersection, or null where the two cannot be written as one type; the
86
- // caller then keeps one side in the residual.
86
+ // caller then keeps one side in the residual. The product of two unions
87
+ // distributes, so allOf over n branches of two shapes each is 2^n
88
+ // alternatives: 14 branches took 2.3 s and 16 did not finish. Past this many
89
+ // alternatives for one kind the branch stays in the residual.
90
+ const MAX_ALTERNATIVES = 256
87
91
  function intersect (a, b) {
88
92
  if (a.alias.length || b.alias.length) return null
89
93
  const t = empty()
90
94
  for (const k of KINDS) {
95
+ if (a[k].length * b[k].length > MAX_ALTERNATIVES) return null
91
96
  for (const x of a[k]) {
92
97
  for (const y of b[k]) {
93
98
  const z = meet(k, x, y)
@@ -548,17 +553,28 @@ function schemaToBunType (root, opts = {}) {
548
553
  // the type, and the module's functions with the type on them. `parse`
549
554
  // declares the type where it parses, which is what the compiler reads to make
550
555
  // the objects with that layout; the module then checks the content.
556
+ // A string as a JavaScript literal with every line terminator escaped.
557
+ // JSON.stringify leaves U+2028 and U+2029 as they are, and inside a `//`
558
+ // comment those end the comment the way a line feed does.
559
+ function jsString (s) {
560
+ return JSON.stringify(String(s)).replace(/[\u2028\u2029]/g, (c) => '\\u' + c.charCodeAt(0).toString(16))
561
+ }
562
+ // The same without the quotes, for text that goes into a // comment: a file
563
+ // name holding a line break ended the comment and the rest of the name was
564
+ // source.
565
+ function commentText (s) { return jsString(s).slice(1, -1) }
566
+
551
567
  function toBunModule ({ schemaFile, moduleImport, typeName: tn, typeSource, aliases }) {
552
568
  const lines = []
553
- lines.push(`// Generated by ata build --target bun from ${schemaFile}. Do not edit.`)
569
+ lines.push(`// Generated by ata build --target bun from ${commentText(schemaFile)}. Do not edit.`)
554
570
  lines.push('//')
555
571
  lines.push('// For executables built with `bun build --compile --aot`: the schema as a type')
556
- lines.push(`// for the program, with the plain module (${moduleImport}) answering every`)
572
+ lines.push(`// for the program, with the plain module (${commentText(moduleImport)}) answering every`)
557
573
  lines.push('// check, so the answers and the errors are the module\'s. `parse(text)` declares')
558
574
  lines.push('// the type where it parses and the module checks the content; it is not faster')
559
575
  lines.push('// than a plain parse and the module. Everywhere else, including plain `bun run`,')
560
576
  lines.push('// the plain module does the same without this file.')
561
- lines.push(`import * as _m from ${JSON.stringify(moduleImport)}`)
577
+ lines.push(`import * as _m from ${jsString(moduleImport)}`)
562
578
  lines.push('')
563
579
  for (const [n, src] of aliases) lines.push(`type ${n} = ${src}`)
564
580
  lines.push(`export type ${tn} = ${typeSource}`)
package/lib/clone-emit.js CHANGED
@@ -50,6 +50,12 @@ const _INLINE_ANNOTATIONS = new Set(['$ref', 'title', 'description', '$comment',
50
50
  const _INLINE_DATA_KEYS = new Set(['default', 'const', 'enum', 'examples']);
51
51
  function inlineRefsForClone(root) {
52
52
  const state = { budget: 512 };
53
+ // A schema that embeds another resource (a subschema with its own `$id`)
54
+ // reads a fragment `$ref` inside it against that resource, not against
55
+ // this root, so the pointer resolved here could name a different schema
56
+ // from the one validation applies. No $ref is inlined there; emitClone
57
+ // then declines the $ref as it stands.
58
+ if (embedsResource(root, root, new Set())) return root;
53
59
  // The runtime's useDefaults fills a property's default from the schema
54
60
  // written under the property, the $ref node with any default next to it,
55
61
  // and then the defaults inside the referenced definition, as it fills them
@@ -90,11 +96,83 @@ function inlineRefsForClone(root) {
90
96
  return walk(root, new Set(), false);
91
97
  }
92
98
 
99
+ function embedsResource(node, root, seen) {
100
+ if (!node || typeof node !== 'object' || seen.has(node)) return false;
101
+ seen.add(node);
102
+ if (Array.isArray(node)) return node.some((n) => embedsResource(n, root, seen));
103
+ if (node !== root && typeof node.$id === 'string' && !node.$id.startsWith('#')) return true;
104
+ for (const k of Object.keys(node)) {
105
+ if (_INLINE_DATA_KEYS.has(k)) continue;
106
+ if (embedsResource(node[k], root, seen)) return true;
107
+ }
108
+ return false;
109
+ }
110
+
111
+ // The names a node requires through itself and the in-place applicators
112
+ // below it (allOf, anyOf, oneOf, if, then, else, dependentRequired and the
113
+ // array form of dependencies). Every one has to be a declared property, or
114
+ // the copy, which keeps only declared keys, drops a key the schema demands
115
+ // and hands back a value validate() refuses. `not` is left out: a name
116
+ // required under it is one the value must not carry. Returns false where
117
+ // the walk cannot tell: dependentSchemas, the schema form of dependencies,
118
+ // or a $ref inside an applicator.
119
+ function requiredNames(node, out, depth) {
120
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return true;
121
+ if (depth > 16) return false;
122
+ if (depth > 0 && node.$ref !== undefined) return false;
123
+ if (node.dependentSchemas !== undefined) return false;
124
+ if (Array.isArray(node.required)) for (const n of node.required) out.add(n);
125
+ if (node.dependentRequired && typeof node.dependentRequired === 'object') {
126
+ for (const k of Object.keys(node.dependentRequired)) {
127
+ out.add(k);
128
+ const list = node.dependentRequired[k];
129
+ if (Array.isArray(list)) for (const n of list) out.add(n);
130
+ }
131
+ }
132
+ if (node.dependencies !== undefined) {
133
+ if (!node.dependencies || typeof node.dependencies !== 'object') return false;
134
+ for (const k of Object.keys(node.dependencies)) {
135
+ const v = node.dependencies[k];
136
+ if (!Array.isArray(v)) return false;
137
+ out.add(k);
138
+ for (const n of v) out.add(n);
139
+ }
140
+ }
141
+ for (const k of ['allOf', 'anyOf', 'oneOf']) {
142
+ if (node[k] === undefined) continue;
143
+ if (!Array.isArray(node[k])) return false;
144
+ for (const b of node[k]) if (!requiredNames(b, out, depth + 1)) return false;
145
+ }
146
+ for (const k of ['if', 'then', 'else']) {
147
+ if (node[k] !== undefined && !requiredNames(node[k], out, depth + 1)) return false;
148
+ }
149
+ return true;
150
+ }
151
+
152
+ // The source budget for default values written into one copy. A shared
153
+ // definition is inlined at every $ref to it, and its default with it: 200
154
+ // references to a 50 KB default made 10 MB of source. Past the budget the
155
+ // copy declines, as it does for anything else it cannot do well.
156
+ const DEFAULT_SOURCE_BUDGET = 256 * 1024;
157
+
158
+ // A default as source that builds it fresh on every parse. An object literal
159
+ // reads a "__proto__" key as the prototype slot, so a default holding one (an
160
+ // own key, as JSON.parse writes it) is rebuilt by JSON.parse from its text:
161
+ // as a literal, the filled value had no such key and inherited from the
162
+ // default's inner object. Null past the source budget.
163
+ function defaultSource(v, budget) {
164
+ const text = JSON.stringify(v);
165
+ const src = v !== null && typeof v === 'object' && text.includes('"__proto__"') ? `JSON.parse(${JSON.stringify(text)})` : text;
166
+ budget.left -= src.length;
167
+ return budget.left < 0 ? null : src;
168
+ }
169
+
93
170
  const PROTO_KEY_NAMES = new Set([...Object.getOwnPropertyNames(Object.prototype), '__proto__']);
94
171
 
95
- function emitClone(node, access, depth) {
172
+ function emitClone(node, access, depth, budget) {
96
173
  if (!node || typeof node !== 'object') return null;
97
174
  if (depth > 12) return null;
175
+ if (budget === undefined) budget = { left: DEFAULT_SOURCE_BUDGET };
98
176
  // Only plain object nodes are cloneable, and only when the schema names
99
177
  // every property it keeps. A $ref means the set of allowed keys is not
100
178
  // this node's to decide. In-place applicators (allOf, anyOf, oneOf,
@@ -151,6 +229,13 @@ function emitClone(node, access, depth) {
151
229
  for (const n of names) if (!Object.prototype.hasOwnProperty.call(node.properties, n)) return null;
152
230
  }
153
231
  const required = new Set(Array.isArray(node.required) ? node.required : []);
232
+ // Outside the record shape, where every key is kept, every name the
233
+ // schema requires must be one the copy keeps.
234
+ if (!apSchema) {
235
+ const demanded = new Set();
236
+ if (!requiredNames(node, demanded, 0)) return null;
237
+ for (const n of demanded) if (!node.properties || !Object.prototype.hasOwnProperty.call(node.properties, n)) return null;
238
+ }
154
239
 
155
240
  const fixed = [];
156
241
  const conditional = [];
@@ -174,7 +259,7 @@ function emitClone(node, access, depth) {
174
259
  if (!probe || !probe(prop.default)) return null;
175
260
  }
176
261
  const read = `${access}[${JSON.stringify(key)}]`;
177
- const value = emitValueExpr(prop, read, depth);
262
+ const value = emitValueExpr(prop, read, depth, budget);
178
263
  if (value === null) return null;
179
264
  // A plain `"__proto__":` in an object literal is the prototype slot, not
180
265
  // a property; `["__proto__"]:` is a property. Only that key is written as
@@ -197,7 +282,7 @@ function emitClone(node, access, depth) {
197
282
  if (apSchema) {
198
283
  const kVar = `_k${depth}`;
199
284
  const vVar = `_v${depth}`;
200
- const valueExpr = emitValueExpr(apSchema, vVar, depth);
285
+ const valueExpr = emitValueExpr(apSchema, vVar, depth, budget);
201
286
  if (valueExpr === null) return null;
202
287
  let skip = '';
203
288
  let declSet = '';
@@ -217,8 +302,15 @@ function emitClone(node, access, depth) {
217
302
  if (conditional.length === 0 && !recordLoop) return literal;
218
303
  // Optional properties are added only when present, so the result never
219
304
  // gains a key the input did not have.
305
+ const dfltSrc = [];
306
+ for (const c of conditional) {
307
+ if (c.dflt === undefined) { dfltSrc.push(null); continue; }
308
+ const src = defaultSource(c.dflt, budget);
309
+ if (src === null) return null;
310
+ dfltSrc.push(src);
311
+ }
220
312
  const adds = conditional
221
- .map(({ key, value, dflt }) => {
313
+ .map(({ key, value, dflt }, i) => {
222
314
  // Object.hasOwn, not `in`: `in` also answers for what the instance
223
315
  // inherits, so a property named `constructor` would copy the inherited
224
316
  // function into the output that validate().data does not carry. And a
@@ -235,12 +327,12 @@ function emitClone(node, access, depth) {
235
327
  ? `Object.prototype.hasOwnProperty.call(${access}, ${k})`
236
328
  : `(${k} in ${access} && (${access}.__proto__ === Object.prototype || Object.prototype.hasOwnProperty.call(${access}, ${k})))`;
237
329
  const set = `if (${own}) ${assign}`;
238
- // The default is emitted as a literal inside the call, so an object or
330
+ // The default is emitted as source inside the call, so an object or
239
331
  // array default is a fresh value on every parse, never shared state.
240
332
  if (dflt === undefined) return set;
241
333
  const setDflt = key === '__proto__'
242
- ? `Object.defineProperty(${tmp}, ${JSON.stringify(key)}, { value: ${JSON.stringify(dflt)}, writable: true, enumerable: true, configurable: true });`
243
- : `${tmp}[${JSON.stringify(key)}] = ${JSON.stringify(dflt)};`;
334
+ ? `Object.defineProperty(${tmp}, ${JSON.stringify(key)}, { value: ${dfltSrc[i]}, writable: true, enumerable: true, configurable: true });`
335
+ : `${tmp}[${JSON.stringify(key)}] = ${dfltSrc[i]};`;
244
336
  return `${set} else ${setDflt}`;
245
337
  })
246
338
  .join(' ');
@@ -278,9 +370,22 @@ function isPrimitiveOnly(s) {
278
370
  return false;
279
371
  }
280
372
 
281
- function emitValueExpr(prop, read, depth) {
373
+ function emitValueExpr(prop, read, depth, budget) {
282
374
  if (!prop || typeof prop !== 'object') return null;
283
375
  if (prop.$ref) return null;
376
+ // Positions named by prefixItems (or the array form of items, which
377
+ // normalization turns into prefixItems) each have their own schema, and
378
+ // copying them all with the strategy for `items` handed back an object in
379
+ // a prefix slot as the input's own reference, its undeclared keys kept.
380
+ // A tuple of primitives, with nothing or primitives after it, has nowhere
381
+ // to hide a key, and is copied as a fresh array like any other.
382
+ if (prop.prefixItems !== undefined || Array.isArray(prop.items)) {
383
+ const tuple = Array.isArray(prop.prefixItems) ? prop.prefixItems : prop.items;
384
+ const rest = Array.isArray(prop.items) ? prop.additionalItems : prop.items;
385
+ if (prop.type === 'array' && Array.isArray(tuple) && tuple.every(isPrimitiveOnly) &&
386
+ (rest === false || (rest !== undefined && isPrimitiveOnly(rest)))) return `${read}.slice()`;
387
+ return null;
388
+ }
284
389
  if (isPrimitiveOnly(prop)) return read;
285
390
  // A value checked with `instanceof` (the keyword @ata-project/keywords adds)
286
391
  // is an instance of a class, such as a Date, not a JSON object whose keys
@@ -290,7 +395,7 @@ function emitValueExpr(prop, read, depth) {
290
395
  const isRecordable = (s) => s.properties ||
291
396
  (s.additionalProperties && typeof s.additionalProperties === 'object');
292
397
  if (prop.type === 'object' && isRecordable(prop)) {
293
- return emitClone(prop, read, depth + 1);
398
+ return emitClone(prop, read, depth + 1, budget);
294
399
  }
295
400
  if (prop.type === 'array' && prop.items && typeof prop.items === 'object' &&
296
401
  !Array.isArray(prop.items)) {
@@ -300,7 +405,7 @@ function emitValueExpr(prop, read, depth) {
300
405
  if (isPrimitiveOnly(it)) return `${read}.slice()`;
301
406
  if (it.type === 'object' && isRecordable(it)) {
302
407
  const el = '_e' + depth;
303
- const inner = emitClone(it, el, depth + 1);
408
+ const inner = emitClone(it, el, depth + 1, budget);
304
409
  if (!inner) return null;
305
410
  return `${read}.map((${el}) => (${inner}))`;
306
411
  }