ata-validator 1.61.3 → 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 (48) hide show
  1. package/README.md +67 -10
  2. package/bin/ata.js +3 -2
  3. package/compat.js +15 -2
  4. package/index.d.ts +7 -0
  5. package/index.js +10 -0
  6. package/lib/aot-build.js +27 -1
  7. package/lib/aot-impl.js +108 -17
  8. package/lib/buffer-gate.js +25 -14
  9. package/lib/bun-target.js +21 -5
  10. package/lib/clone-emit.js +115 -10
  11. package/lib/codegen-engine.js +94 -18
  12. package/lib/compiled-common.js +11 -4
  13. package/lib/compiled-verdict.js +2 -2
  14. package/lib/compiled.js +1 -1
  15. package/lib/data-positions.js +18 -0
  16. package/lib/defaults.js +101 -23
  17. package/lib/describe-schema.js +38 -5
  18. package/lib/diagnose.js +51 -29
  19. package/lib/enrich-error.js +23 -1
  20. package/lib/error-sort-rank.js +12 -0
  21. package/lib/formats-source.js +12 -1
  22. package/lib/formats.js +19 -19
  23. package/lib/interpreter-core.js +23 -4
  24. package/lib/js-compiler-errors.js +70 -72
  25. package/lib/js-compiler.js +212 -72
  26. package/lib/json-rejections.js +1 -1
  27. package/lib/migrate.js +24 -5
  28. package/lib/native-attach.browser.js +7 -0
  29. package/lib/native-attach.js +76 -0
  30. package/lib/native-positions.js +7 -0
  31. package/lib/plan-compiler.js +7 -1
  32. package/lib/regex-linear.js +88 -0
  33. package/lib/rejections.js +13 -3
  34. package/lib/render-compact.js +5 -5
  35. package/lib/render-pretty.js +23 -10
  36. package/lib/safe-regex.js +29 -4
  37. package/lib/scan-compiler.js +16 -4
  38. package/lib/stream.js +293 -0
  39. package/lib/tier0.js +6 -2
  40. package/lib/ts-gen.js +25 -12
  41. package/lib/unique-items.js +11 -3
  42. package/lib/validator-core.js +61 -66
  43. package/lib/verify.js +25 -6
  44. package/lib/version.js +1 -1
  45. package/package.json +17 -8
  46. package/stream.d.ts +51 -0
  47. package/stream.js +3 -0
  48. package/stream.mjs +3 -0
package/README.md CHANGED
@@ -201,16 +201,24 @@ documents, Node 24:
201
201
 
202
202
  | | documents a second, the verdict | same, `new Function` refused |
203
203
  |---|---:|---:|
204
- | ata | 3,295,880 | 607,614 |
205
- | ata, compiled module | 3,319,594 | 3,344,948 |
206
- | typebox | 1,638,813 | EvalError |
207
- | ajv | 1,285,492 | EvalError |
208
- | typia | 939,572 | 964,507 |
209
- | zod | 440,473 | 237,176 |
210
-
211
- Where ata is behind is in the same tables: on a four-field body that fails, ajv answers the
212
- verdict faster, and with every error read it does 6.6M a second against ata's 2.9M with rich
213
- errors (7.6M with `richErrors: false`).
204
+ | ata | 3,274,803 | 1,109,881 |
205
+ | ata, compiled module | 3,270,947 | 3,353,351 |
206
+ | typia | 1,957,392 | 1,931,134 |
207
+ | typebox | 1,615,239 | EvalError |
208
+ | ajv | 1,245,508 | EvalError |
209
+ | zod | 436,632 | 230,808 |
210
+
211
+ Run of 2026-10-30 with typia 15.2.2, which reuses a pattern's RegExp and shortcuts length
212
+ checks after a report from this benchmark; its order figure doubled.
213
+
214
+ The same repository runs every SchemaStore schema that ships sample documents, 446 of them,
215
+ against the default validator on those samples: ata ahead on 430, median 1.83x, 2.82x on
216
+ schemas of 50 to 200 KB, and never below 0.74x on the 16 where it is behind
217
+ ([RESULTS-schemastore.md](https://github.com/ata-core/validator-benchmarks/blob/main/RESULTS-schemastore.md),
218
+ Node 24, ata 1.61.3). Where ata is behind is
219
+ in the same tables: on a four-field body that fails, ajv answers the verdict faster (15.5M
220
+ against 10.4M), and with every error read it does 7.1M a second against ata's 3.6M with rich
221
+ errors, 5.2M with `richErrors: false`.
214
222
 
215
223
  ## Why AOT
216
224
 
@@ -793,6 +801,55 @@ is rejected by the new one, so a pull request that tightens a schema fails
793
801
  the check until someone looks. Generated documents are a sample, not a proof:
794
802
  a change this reports is real, one it does not report may still exist.
795
803
 
804
+ ### Validation as the body arrives
805
+
806
+ A request that carries many documents, a JSON array or NDJSON, is usually read to the end,
807
+ parsed as a whole and then validated, so a bad third record is reported after the last byte.
808
+ `ata-validator/stream` validates each record the moment its last byte lands and stops reading
809
+ at the first failure, with memory that does not grow with the body.
810
+
811
+ ```js
812
+ import { validateStream } from 'ata-validator/stream'
813
+
814
+ app.post('/orders/import', async (c) => {
815
+ const r = await validateStream(orders, c.req.raw.body) // a Web ReadableStream, or a Node Readable
816
+ if (!r.valid) return c.json({ record: r.errors[0].record, errors: r.errors[0].errors }, 400)
817
+ return c.json({ imported: r.records })
818
+ })
819
+ ```
820
+
821
+ `v.stream(opts)` on Node, or `streamValidator(v, opts)` from `ata-validator/stream` anywhere (the lite, edge and browser entries do not carry the method, so their bundles stay what they were), is the same thing fed by hand: `push(chunk)` takes strings or bytes and returns
822
+ the first failure as soon as its record is complete, `end()` returns `{ valid, records, errors }`.
823
+ `onRecord(doc, index)` hands over each record that passed, parsed, so an import does not parse
824
+ twice; `stopOnFirst: false` lists every failing record instead of stopping. The format is read
825
+ from the first character (`[` is an array, anything else NDJSON) or set with `format`. A JSON
826
+ array is split by a scan that tracks strings and nesting, not by parsing it, so the elements are
827
+ parsed one at a time.
828
+
829
+ Measured on a 100 MB body of 128,419 order documents, read in 64 KB chunks, Node 25, Apple M4
830
+ Pro, one process per row:
831
+
832
+ | | invalid record 3 | invalid last record |
833
+ |---|---|---|
834
+ | read it all, `JSON.parse`, validate the array | 517 ms, 783 MB | 571 ms, 845 MB |
835
+ | read it all, split lines, parse and validate each | 237 ms, 553 MB | 462 ms, 660 MB |
836
+ | `ata-validator/stream`, JSON array | 13.5 ms, 122 MB, 0.1 MB read | 577 ms, 195 MB |
837
+ | `ata-validator/stream`, NDJSON | 19.6 ms, 122 MB, 0.1 MB read | 461 ms, 197 MB |
838
+
839
+ Peak RSS; the 100 MB figure is the process without the body. A body that is valid to the end
840
+ costs the same time as before and a third of the memory. `tests/test_stream.js` holds the stream
841
+ to whole-document validation under every way of cutting the bytes into chunks, including inside
842
+ multi-byte characters, strings with brackets and escaped quotes, nested arrays, scalars, CRLF
843
+ and blank lines, and checks that a failing source is destroyed or cancelled rather than read on.
844
+
845
+ A Node http request handed to `validateStream` is not destroyed at the failure: closing the
846
+ socket under the response turns the answer into a network error at the client (Node's `fetch`
847
+ uploading 100 MB got `write EPIPE`, Chrome "Failed to fetch", neither the 400). Reading stops and
848
+ the rest of the body is read and dropped, so the response goes out and memory stays flat. Node's
849
+ `fetch` then had the 400 after 24 ms and 1 MB sent; Chrome, which reads only after its upload,
850
+ after 58 ms for a 50 MB file over loopback; the Hono example above, whose Node adapter cancels
851
+ the stream without closing the socket, after 28 ms. A plain `Readable` is still destroyed.
852
+
796
853
  ### Bun, compiled ahead of time
797
854
 
798
855
  Bun's ahead-of-time compiler (`bun build --compile --aot`, an open pull
package/bin/ata.js CHANGED
@@ -533,7 +533,8 @@ function cmdValidate (args) {
533
533
  try {
534
534
  schema = JSON.parse(schemaContent);
535
535
  } catch (e) {
536
- process.stderr.write(`error: ${schemaPath} is not valid JSON: ${e.message}\n`);
536
+ // The parser quotes the text it stopped at, and the text is the file's.
537
+ process.stderr.write(`error: ${schemaPath} is not valid JSON: ${require('../lib/diagnose').term(e.message)}\n`);
537
538
  process.exit(2);
538
539
  }
539
540
 
@@ -550,7 +551,7 @@ function cmdValidate (args) {
550
551
  try {
551
552
  v = new Validator(schema, { source: { path: schemaPath, content: schemaContent } });
552
553
  } catch (e) {
553
- process.stderr.write(`error: schema compile failed: ${e.message}\n`);
554
+ process.stderr.write(`error: schema compile failed: ${require('../lib/diagnose').term(e.message)}\n`);
554
555
  process.exit(2);
555
556
  }
556
557
 
package/compat.js CHANGED
@@ -129,7 +129,9 @@ class Ata {
129
129
  const formatNames = Object.keys(this._formats);
130
130
  if (formatNames.length > 0) out.formats = { ...this._formats };
131
131
  const keywordNames = Object.keys(this._keywords);
132
- const kws = {};
132
+ // Null prototype: a keyword named __proto__ written into a plain object
133
+ // would set its prototype and never reach the validator.
134
+ const kws = Object.create(null);
133
135
  for (const name of keywordNames) if (this._keywords[name] !== true) kws[name] = this._keywords[name];
134
136
  if (Object.keys(kws).length > 0) out.keywords = kws;
135
137
  const registry = this._registrySnapshot();
@@ -646,8 +648,16 @@ function referencedDefaults(rootDoc, registry) {
646
648
  }
647
649
  return node;
648
650
  };
651
+ // `done`, per call: the schemas already applied to each object. A second
652
+ // application fills nothing new, and following every edge of a chain such
653
+ // as n_i = allOf [n_(i-1), n_(i-1)] applied the bottom schema 2^n times.
654
+ let done = null;
649
655
  const fill = (schema, data, active) => {
650
656
  if (schema === null || typeof schema !== 'object' || data === null || typeof data !== 'object' || active.has(schema)) return;
657
+ let applied = done.get(data);
658
+ if (applied === undefined) done.set(data, (applied = new Set()));
659
+ if (applied.has(schema)) return;
660
+ applied.add(schema);
651
661
  active.add(schema);
652
662
  if (typeof schema.$ref === 'string') fill(resolve(schema.$ref), data, active);
653
663
  if (Array.isArray(schema.allOf)) for (const sub of schema.allOf) fill(sub, data, active);
@@ -670,7 +680,10 @@ function referencedDefaults(rootDoc, registry) {
670
680
  }
671
681
  active.delete(schema);
672
682
  };
673
- return (data) => fill(rootDoc, data, new Set());
683
+ return (data) => {
684
+ done = new Map();
685
+ try { fill(rootDoc, data, new Set()); } finally { done = null; }
686
+ };
674
687
  }
675
688
 
676
689
  // The registry id a fragment-only key is kept under: a `#...` string is not an
package/index.d.ts CHANGED
@@ -509,6 +509,13 @@ export interface Validator<T = unknown> {
509
509
  /** Validate a JSON string. Uses simdjson fast path for large documents. */
510
510
  validateJSON(jsonString: string): ValidationResult<T>;
511
511
 
512
+ /**
513
+ * Validation as the body arrives: a record stream over this validator for a
514
+ * JSON array or NDJSON fed in chunks, reporting the first failing record the
515
+ * moment it is complete. See `ata-validator/stream` for the types.
516
+ */
517
+ stream(opts?: import('./stream').StreamOptions): import('./stream').RecordStream;
518
+
512
519
  /** Fast boolean check for a JSON string */
513
520
  isValidJSON(jsonString: string): boolean;
514
521
 
package/index.js CHANGED
@@ -6,6 +6,16 @@
6
6
  // lib/codegen-engine.js, so that the edge entry (index.edge.js) and
7
7
  // ata-validator/lite can load the core without it.
8
8
  const core = require('./lib/validator-core.js');
9
+
10
+ // Validation as the body arrives: `v.stream(opts)` is a record stream over the
11
+ // validator (lib/stream.js), loaded on first use. Defined here, on the Node
12
+ // entry, and not in the core: the lite, edge and browser builds are measured
13
+ // to the byte and a static require from the core would put the stream into
14
+ // every one of them; there `ata-validator/stream` is imported where it is used.
15
+ Object.defineProperty(core.Validator.prototype, 'stream', {
16
+ configurable: true, writable: true,
17
+ value: function stream (opts) { return require('./lib/stream.js').streamValidator(this, opts); },
18
+ });
9
19
  // The code generator loads on first need, or once the event loop is free,
10
20
  // not with the package: it is most of what requiring the package parsed. The
11
21
  // timer is unref'd, so a script that has finished does not stay to load it.
package/lib/aot-build.js CHANGED
@@ -95,7 +95,13 @@ function runtimePathFor(outputs, opts, format) {
95
95
  let dir = path.resolve(path.dirname(outputs[0]));
96
96
  for (const o of outputs) {
97
97
  const d = path.resolve(path.dirname(o));
98
- while (dir !== d && !d.startsWith(dir + path.sep)) dir = path.dirname(dir);
98
+ // The root already ends in a separator ('/' or a drive root), and dirname of the
99
+ // root is the root: compared against root + sep the walk never ended.
100
+ while (dir !== d && !d.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)) {
101
+ const up = path.dirname(dir);
102
+ if (up === dir) break;
103
+ dir = up;
104
+ }
99
105
  }
100
106
  return path.join(dir, name);
101
107
  }
@@ -245,6 +251,15 @@ async function buildWith(opts) {
245
251
  // check once warm.
246
252
  if (opts.mode !== undefined && opts.mode !== 'code' && opts.mode !== 'schema') throw new Error(`build: mode must be code or schema (got "${opts.mode}")`);
247
253
  const schemaMode = opts.mode === 'schema' && !opts.abortEarly;
254
+ // The options that change what a module holds, recorded with each cache
255
+ // entry and required to match. Without them a module built for production
256
+ // (no schema-source frames) was reported up to date by a development build
257
+ // and kept, since the input and the output had not changed.
258
+ const optionsKey = JSON.stringify([
259
+ !!opts.abortEarly, opts.onePass === true, !!opts.plainErrors, !!opts.verdictOnly,
260
+ opts.order === undefined ? null : opts.order, opts.formatMode === undefined ? null : opts.formatMode,
261
+ opts.baseUri === undefined ? null : opts.baseUri, opts.target === undefined ? null : opts.target, !!opts.types,
262
+ ]);
248
263
 
249
264
  const compiled = [];
250
265
  const cached = [];
@@ -367,6 +382,8 @@ async function buildWith(opts) {
367
382
  cacheEntry.output === output &&
368
383
  !!cacheEntry.sharable === sharable &&
369
384
  !!cacheEntry.schemaMode === schemaMode &&
385
+ cacheEntry.source === source &&
386
+ cacheEntry.options === optionsKey &&
370
387
  // A module built against the other inputs depends on them too, and
371
388
  // the registry is put together from such modules: always rebuilt.
372
389
  !cacheEntry.usesSiblings &&
@@ -522,6 +539,8 @@ async function buildWith(opts) {
522
539
  outputHash: hashContent(Buffer.from(src, 'utf8')),
523
540
  sharable,
524
541
  schemaMode,
542
+ source,
543
+ options: optionsKey,
525
544
  mode: e.mode,
526
545
  runtime: e.mode !== 'inline' ? runtime.path : undefined,
527
546
  fullGz: e.fullGz,
@@ -577,8 +596,13 @@ async function watch(opts, onReport) {
577
596
  }
578
597
  };
579
598
 
599
+ // The build writes its cache file, JSON and often beside the schemas; a
600
+ // change to it is the build's own and starts nothing, or every build woke
601
+ // the watcher for the next one.
602
+ const cachePath = opts.cacheFile ? path.resolve(opts.cacheFile) : null;
580
603
  const watchers = dirs.map((d) => fs.watch(d, (_event, filename) => {
581
604
  if (!filename) return;
605
+ if (cachePath !== null && path.resolve(d, filename) === cachePath) return;
582
606
  const ext = path.extname(filename).toLowerCase();
583
607
  if (ext !== '.json' && ext !== '.yaml' && ext !== '.yml') return;
584
608
  if (debounceTimer) clearTimeout(debounceTimer);
@@ -754,3 +778,5 @@ module.exports = {
754
778
  toStandaloneModule,
755
779
  schemaHash,
756
780
  };
781
+ // For tests only, not part of ata-validator/build.
782
+ Object.defineProperty(module.exports, '_runtimePathFor', { value: runtimePathFor, enumerable: false });
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}`)