ata-validator 1.6.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,46 @@
2
2
 
3
3
  All notable changes to ata-validator are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/), and this project adheres to semantic versioning.
4
4
 
5
+ ## 1.7.1 - 2026-08-23
6
+
7
+ ### Added
8
+
9
+ - A closure-tree compiler for the interpreted engine. A schema the code generator declines is compiled into a tree of plain closures, one per schema node, with every keyword branch decided at compile time and `$ref` targets resolved once; no source generation and no `new Function`, so it works under a CSP and on Workers. Scope: schemas without `unevaluatedProperties`/`unevaluatedItems`, and `$dynamicRef` only in single-resource schemas; everything else keeps the generic evaluator. `tests/test_plan_compiler.js` holds the compiled tree to byte-identical verdicts and errors against the evaluator over 2,864 suite cases.
10
+
11
+ ### Changed
12
+
13
+ - Plans that check only value-level keywords (most leaves of any schema) skip the evaluator's prologue entirely; `$ref` resolutions are cached with their planned target on the plan itself; the dynamic scope is pushed and popped in place instead of copied per resource. Rejection results are a small class with the `errors` accessor on the prototype, since defining a getter inside an object literal builds a closure and an accessor property on every rejection, which was the single largest cost on the rejection path.
14
+ - The interpreted engine gained a verdict-only mode: `isValidObject()` and the internal fast checks walk the schema without constructing a single error object, message string or scratch array. On an interpreter-routed schema the boolean check dropped from 894 ns to 70 ns.
15
+ - Object validation for `$dynamicRef` schemas no longer routes to the native engine. The interpreted engine has scored the same on every `$dynamicRef` case of the suite since the dynamic-scope fix in 1.7.0, needs no addon, and carries the verdict-only mode; the suite's `$dynamicRef` rejects dropped from about 1.7 µs to the interpreter's cost.
16
+ - String length bounds decide from the UTF-16 length where possible: a string's code point count always sits between half its length and its length, so `minLength`/`maxLength` only count code points inside the narrow band where the answer is genuinely uncertain. The surrogate test is a single wraparound compare. The code generator already worked this way; the interpreter and the closure path now match it.
17
+ - Errors are paid for when read, not when produced. `validate()` answers the verdict from the fastest engine for the schema and materializes `errors` through a cached getter on first access; declaration-order sorting and enrichment (received value, suggestions, source frames) moved with it into one presentation layer. A caller that only reads `.valid`, which is every gateway check, no longer pays for error construction at all. The output of `.errors` is byte-for-byte what it was. Measured on a suite-shaped benchmark of prebuilt validators over 1,052 mixed valid and invalid cases, `validate().valid` went from 778 ns to about 150 ns per call, ahead of every error-capable validator we measured, and a rejection that never has its errors read now costs less than `abortEarly` mode used to. One observable edge: mutating the data between `validate()` and the first read of `.errors` now reflects the mutated data in the errors, and if the mutation makes the data valid the errors fall back to a single generic entry.
18
+
19
+ ## 1.7.0 - 2026-08-23
20
+
21
+ ### Fixed
22
+
23
+ - The four code generation entry points disagreed with each other on 157 instances of the official suite. Most of it was the closure compiler, the boolean fallback behind `isValidObject()` when the code generator declines a schema: it ignored `unevaluatedProperties` and `unevaluatedItems` outright, treated a self-referencing `$ref: "#"` as always true, skipped `additionalProperties` whenever no `properties` map sat next to it, and passed strings in `date-time`, `time`, `uri` and `duration` format without checking them. Each of those accepted input it should have rejected. It now declines those schemas, so they reach an engine that handles them. The remaining disagreements were wrong rejections shared by both boolean paths: `required`, `minProperties` and `maxProperties` applied to non-objects, `multipleOf` had no tolerance for fractional divisors, `const` and `enum` compared objects by key order, and `items` started at index 0 when `prefixItems` was present. `isValidObject()` and `validate()` now agree on every suite instance.
24
+ - A `dependentSchemas` branch carrying `additionalProperties: false` had that check hoisted out of its condition to the top level of the compiled function, so the restriction applied whether or not the triggering property was present.
25
+ - The error path counted a property matched by `patternProperties` as additional under `additionalProperties: false`. The same path now reports the real pointer (`#/patternProperties/<pattern>/...`) for a failing pattern subschema instead of the synthetic `#/patternProperties`, so source frames resolve for those errors.
26
+ - The buffer APIs (`isValid`, `isValidJSON` above the simdjson threshold, `countValid`, `batchIsValid`, `isValidNDJSON`, `isValidParallel`, `isValidPrepadded`) disagreed with `validate()` on 245 of 2222 suite cases, and in 195 of those they accepted a document `validate()` rejects. The native walker behind them does not handle `contains`, `unevaluated*`, `dependencies`, `dependentSchemas`, `dependentRequired`, `propertyNames`, `patternProperties`, tuple-form `items` and `prefixItems`, cross-document `$ref`, embedded `$id`, an empty `enum`, a boolean root schema, Unicode property escapes in `pattern`, or the `hostname`, `date-time`, `time`, `uri-reference` and `duration` formats. For a schema using any of those, every buffer API now parses the bytes and answers through `validate()`; `lib/buffer-gate.js` holds the list. Schemas without them keep the zero-copy path. `tests/test_buffer_path_parity.js` now compares all three dialects, 3359 cases, and holds the disagreement count at zero.
27
+ - A value failing a `patternProperties` subschema was reported at runtime as a generic "value invalid for key" error on the parent object with the synthetic pointer `#/patternProperties`. The subschema is now generated in place, so the error comes from its own keyword, at the key's path (`/x-flag`), with the real pointer (`#/patternProperties/^x-/type`), and source frames resolve for it. Patterns containing `/` or `'` produce a correctly escaped pointer.
28
+ - `tests/test_codegen_entrypoint_agreement.js` drives the whole suite through each entry point directly and fails on any split verdict. It runs as part of `npm test`.
29
+
30
+ ### Added
31
+
32
+ - A `$ref` to a dialect's meta-schema (`https://json-schema.org/draft/2020-12/schema`, `http://json-schema.org/draft-07/schema#`, any http/https or trailing-`#` spelling) resolves from copies vendored in `lib/metaschemas.js`, so "validate this schema against its dialect" works with no registry and no network. A copy supplied through `schemas` or `addSchema()` still wins. The 2020-12 meta-schema is eight documents joined by `$dynamicRef`; see the routing change below.
33
+
34
+ - `validator.engine()` reports which engine answers `validate()` for the schema: `'codegen'`, `'closure'`, `'native'` or `'interpreter'`. A diagnostic for startup logs and benchmarks. Measured over fourteen request-shaped schemas (body, params, query, shared `$ref`, `oneOf`, `if`/`then`, local `$defs`), thirteen take the generated path; `patternProperties` with `additionalProperties: false` is the one that goes to the interpreter.
35
+ - `formatMode: 'inject'` for `toStandaloneModule`, `bundleStandalone`, `bundleCompact` and `build()`. The output carries no custom format source; it exports `setFormats(map)` and looks each format up from that registry at validation time, with a named error if one is missing. This is for formats that cannot be serialized: functions that close over variables, bound functions, or code rewritten by coverage and transpile steps. The default `'embed'` is unchanged in behavior, but it now checks each function's source at build time and throws with the format's name when it would not survive embedding, instead of emitting a module that fails on first use.
36
+
37
+ ### Changed
38
+
39
+ - Draft-07 is now read by its own rules rather than as 2020-12 with renamed keywords. A schema object carrying `$ref` is that reference and nothing else, so sibling keywords, `$id` included, are ignored, as the draft specifies. A fragment-only `$id` (`"$id": "#name"`) is a plain-name anchor. A document supplied through `schemas` or `addSchema()` that declares no `$schema` of its own is read under the root's draft. JSON Pointers written against array-form `items` still resolve after the keyword is normalized to `prefixItems`. Schemas that declare no `$schema` are unaffected. Draft 7 on the official suite goes from 916 to 927 of 927.
40
+ - The code generator now declines, and the interpreted engine answers, whenever a document reachable through a cross-document `$ref` uses `$dynamicRef`, `$dynamicAnchor`, `unevaluatedProperties`, `unevaluatedItems`, or an embedded `$id`. Before, the generator emitted a vacuous check for such a reference and accepted everything behind it: `{ "$ref": "https://json-schema.org/draft/2020-12/schema" }` accepted `{ "type": 1 }`. These checks now live in one function, `sharedCodegenGate`, that every entry point runs first. Draft 2020-12 goes from 1294 to 1298 of 1299 and the v1 dialect from 1131 to 1133 of 1133; the one remaining 2020-12 miss needs `$vocabulary`.
41
+ - The interpreted engine extends the dynamic scope whenever evaluation enters a schema resource, not only when it lands on the resource's root. A `$dynamicRef` reached through `first#/$defs/stuff` now sees resource `first` in scope, which closes the last `$dynamicRef` case the interpreter missed. With code generation blocked the figures are the same as with it: 1298 of 1299, 927 of 927, 1133 of 1133.
42
+ - The interpreted engine is between 4 and 14 times faster depending on the schema. Each schema node is resolved once into a fixed-shape plan (type bitmask, compiled pattern, looked-up format, one flag per keyword group) and children are linked at plan time, so the walk reads no schema properties and does no map lookups. On a six-field object schema a warm `validate()` went from 3786 ns to 343 ns; a `$ref`-heavy schema from 5354 ns to 366 ns; a recursive `$dynamicRef` tree from 5302 ns to 526 ns. The compiled path is unchanged at about 44 ns on the same schema.
43
+ - The linear-time pattern matcher now runs a lazily built DFA over the Thompson NFA, with cached ASCII transitions and a fallback to the NFA walk past 256 states. On typical anchored patterns it is within 1.2 to 3.5 times of V8's backtracking engine (`^[0-9]{5}$` 14 ns against 12 ns, an email pattern 70 ns against 21 ns) while keeping the linear bound: `^(a+)+$` against 100,000 characters takes 1 ms. This matcher backs `pattern`, `patternProperties` and `propertyNames` in every engine and in standalone output, so all of them gain.
44
+
5
45
  ## 1.6.2 - 2026-08-19
6
46
 
7
47
  ### Fixed
package/README.md CHANGED
@@ -20,11 +20,11 @@ The `ata-validator` package itself is pure JavaScript. The native accelerator (s
20
20
  npm install ata-validator --omit=optional
21
21
  ```
22
22
 
23
- or set `ATA_NO_NATIVE=1` at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some `$dynamicRef`, cyclic `$ref`, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one, 1294 of 1299 Draft 2020-12 cases; the two miss a different `$dynamicRef` scope corner each. Only the buffer and parallel APIs (`isValid` on raw buffers, `countValid`, `batchIsValid`, `validateAndParse`) need the native engine and say so with a clear error.
23
+ or set `ATA_NO_NATIVE=1` at runtime. Typical schemas compile to specialized JS; shapes the compiler cannot represent (some `$dynamicRef`, cyclic `$ref`, unusual keyword interactions) fall back to an interpreted engine, so every schema validates in every environment. The pure-JS setup scores the same on the official suite as the native one, 1298 of 1299 Draft 2020-12 cases; the one case both miss needs `$vocabulary`, which ata does not implement. Only the buffer and parallel APIs (`isValid` on raw buffers, `countValid`, `batchIsValid`, `validateAndParse`) need the native engine and say so with a clear error.
24
24
 
25
- Those four also do not yet agree with `validate()`. Over the official suite they differ on 245 of 2222 cases, in both directions, concentrated in `unevaluatedProperties`, `contains`, `const` and the `$ref` family. `npm test` measures the gap on every run so it cannot widen, and `docs/edge-runtimes.md` has the detail. Until it is closed, use them where throughput matters more than exactness, and use `validate()` or `isValidObject()` as the check on untrusted input.
25
+ Those four now agree with `validate()` on every case of the official suite, 3359 across three dialects. The native walker behind them does not handle every shape (`contains`, `unevaluatedProperties`, `patternProperties`, tuple `items`, cross-document `$ref`, a few formats), so for schemas using one of those the buffer APIs parse the bytes and answer through `validate()`; the list is in `lib/buffer-gate.js`. Typical request schemas stay on the zero-copy path. `npm test` holds the disagreement count at zero.
26
26
 
27
- Where `new Function` is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores 1295 of 1299 with code generation blocked. No flags, and on Workers no `nodejs_compat` either. See [docs/edge-runtimes.md](docs/edge-runtimes.md).
27
+ Where `new Function` is refused altogether, on Cloudflare Workers, Deno Deploy or under a strict Content-Security-Policy, ata drops to the interpreted engine and scores the same 1298 of 1299 with code generation blocked. No flags, and on Workers no `nodejs_compat` either. See [docs/edge-runtimes.md](docs/edge-runtimes.md).
28
28
 
29
29
  In your code:
30
30
 
@@ -65,7 +65,8 @@ offending value, a documentation link and a suggestion. On a five-field object s
65
65
  passing payload costs about 17 ns and a rejected one about 155 ns. `abortEarly: true` or
66
66
  `isValidObject()` skips that work when only the verdict matters. Schemas ata declines to
67
67
  compile, mostly cross-document `$ref`, `$dynamicRef` and `unevaluated*`, run on the
68
- interpreted engine and are slower again.
68
+ interpreted engine: about 343 ns for a passing payload on a six-field object schema, eight
69
+ times the compiled path, measured warm on the same machine.
69
70
 
70
71
  ## Error messages
71
72
 
@@ -168,6 +169,9 @@ v.isValidJSON('{"name": "Mert", "email": "mert@example.com"}'); // true
168
169
  // Buffer input (zero-copy, raw NAPI)
169
170
  v.isValid(Buffer.from('{"name": "Mert", "email": "mert@example.com"}'));
170
171
 
172
+ // Which engine answers this schema: 'codegen', 'closure', 'native' or 'interpreter'
173
+ v.engine(); // 'codegen'
174
+
171
175
  // Parallel batch - multi-core, NDJSON, 13.4M items/sec
172
176
  const ndjson = Buffer.from(lines.join('\n'));
173
177
  v.isValidParallel(ndjson); // bool[]
@@ -399,6 +403,12 @@ const { toStandaloneModule } = require('ata-validator/build');
399
403
  fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));
400
404
  ```
401
405
 
406
+ Custom format functions either get their source embedded (the default, refused
407
+ at build time with a named error when the function would not survive
408
+ serialization) or, with `formatMode: 'inject'`, are supplied at load time
409
+ through a `setFormats()` export the module carries. `docs/API.md` has the
410
+ details.
411
+
402
412
  **Fastify startup, 10 route schemas, from a cold process to the first validated request:
403
413
  ajv 19.6 ms, ata 3.1 ms, no build step required.** ata registers in 1.1 ms of that and
404
414
  compiles on the first request, so counting only registration would overstate the gap.
@@ -496,7 +506,7 @@ Both are implemented in the interpreted engine, so a v1 schema that uses `$dynam
496
506
 
497
507
  ### Known limitations
498
508
 
499
- Running the whole Draft 2020-12 suite with nothing excluded, `format` and `default` under specification semantics (`assertFormat: false`, `useDefaults: false`), gives 1294 of 1299 cases, 99.6%. Draft 7 gives 916 of 927, 98.8%. The v1 dialect gives 1131 of 1133, 99.8%, and the two it misses are among the five that fail on 2020-12. `npm run test:suite` reproduces all three and lists the remaining failures by name.
509
+ Running the whole Draft 2020-12 suite with nothing excluded, `format` and `default` under specification semantics (`assertFormat: false`, `useDefaults: false`), gives 1298 of 1299 cases, 99.9%. Draft 7 gives 927 of 927. The v1 dialect gives 1133 of 1133. The one 2020-12 case that fails uses a custom meta-schema that drops the validation vocabulary through `$vocabulary`, which ata does not implement, so the validation keywords still apply. `npm run test:suite` reproduces all three and names it.
500
510
 
501
511
  Areas that remain deliberate scope decisions for 1.x:
502
512
 
package/build.d.ts CHANGED
@@ -76,6 +76,15 @@ export function watch(opts: BuildOptions, onReport?: (r: BuildReport) => void):
76
76
  export interface BundleStandaloneOptions {
77
77
  format?: 'cjs' | 'esm';
78
78
  formats?: Record<string, (value: string) => boolean>;
79
+ /**
80
+ * How custom format functions reach the output. 'embed' (default) writes
81
+ * each function's source into the module; it must be a plain function
82
+ * with no closed-over variables and no coverage instrumentation, or the
83
+ * build throws. 'inject' writes no function source: the module exports
84
+ * `setFormats(map)` and looks formats up from that registry at validation
85
+ * time, so the caller supplies them at load time.
86
+ */
87
+ formatMode?: 'embed' | 'inject';
79
88
  verbose?: boolean;
80
89
  }
81
90
 
@@ -85,6 +94,9 @@ export interface ToStandaloneModuleOptions {
85
94
  source?: boolean;
86
95
  sourceMap?: unknown;
87
96
  schemaFile?: string;
97
+ formats?: Record<string, (value: string) => boolean>;
98
+ /** See {@link BundleStandaloneOptions.formatMode}. */
99
+ formatMode?: 'embed' | 'inject';
88
100
  }
89
101
 
90
102
  /** Bundle multiple schemas into one self-contained module (no ata-validator runtime). */
package/index.d.ts CHANGED
@@ -401,6 +401,13 @@ export interface Validator<T = unknown> {
401
401
 
402
402
  /** Fast boolean check via JS codegen or tier 0 interpreter. No error collection. */
403
403
  isValidObject(data: unknown): data is T;
404
+ /**
405
+ * Which engine answers `validate()` for this schema: 'codegen' (generated
406
+ * JS), 'closure' (the closure compiler) or 'interpreter'; 'native' is
407
+ * reserved. The verdict is the same on every engine; the cost is not.
408
+ * A diagnostic, not a configuration.
409
+ */
410
+ engine(): 'codegen' | 'closure' | 'native' | 'interpreter';
404
411
 
405
412
  /** Validate a JSON string. Uses simdjson fast path for large documents. */
406
413
  validateJSON(jsonString: string): ValidationResult<T>;
package/index.js CHANGED
@@ -314,6 +314,34 @@ const ABORT_EARLY_RESULT = Object.freeze({
314
314
 
315
315
  // Above this size, simdjson On Demand (selective field access) beats JSON.parse
316
316
  // (which must materialize the full JS object tree). Buffer.from + NAPI ~2x faster.
317
+
318
+ // Rejection result with errors materialized on first read. The accessor
319
+ // lives on the prototype so constructing one is a plain allocation; an
320
+ // object-literal getter would create a closure and define an accessor
321
+ // property on every rejection, which showed up as the single largest cost
322
+ // on the rejection path. `toJSON` keeps JSON.stringify output identical to
323
+ // the eager shape. Note for tests: deepStrictEqual against a plain object
324
+ // compares prototypes; read `.errors` and compare that.
325
+ class LazyRejection {
326
+ constructor(build, data) {
327
+ this.valid = false;
328
+ this._build = build;
329
+ this._data = data;
330
+ this._errors = null;
331
+ }
332
+ toJSON() {
333
+ return { valid: false, errors: this.errors };
334
+ }
335
+ }
336
+ Object.defineProperty(LazyRejection.prototype, 'errors', {
337
+ enumerable: true,
338
+ configurable: true,
339
+ get() {
340
+ if (this._errors === null) this._errors = this._build(this._data);
341
+ return this._errors;
342
+ },
343
+ });
344
+
317
345
  const SIMDJSON_THRESHOLD = 8192;
318
346
 
319
347
  // Resolve a JSON Schema path like "#/properties/name/type" to the schema object
@@ -426,33 +454,35 @@ function _deepCloneWithSymbols(v) {
426
454
  // Normalize a caller-provided schema without mutating the original.
427
455
  // Clones only when normalization would change the object (draft-07 keys
428
456
  // present or nullable fields present). Internal-only — not exported.
429
- function _normalizeCallerSchema(s) {
430
- const needsDraft7 = s && s.$schema && (
431
- s.$schema === 'http://json-schema.org/draft-07/schema#' ||
432
- s.$schema === 'http://json-schema.org/draft-07/schema'
433
- )
457
+ function _normalizeCallerSchema(s, inheritDraft7) {
458
+ const declares = s && typeof s === 'object' && s.$schema !== undefined
459
+ const needsDraft7 = declares
460
+ ? (s.$schema === 'http://json-schema.org/draft-07/schema#' || s.$schema === 'http://json-schema.org/draft-07/schema')
461
+ : !!inheritDraft7
434
462
  const str = JSON.stringify(s)
435
463
  const copy = _deepCloneWithSymbols(s)
436
- if (needsDraft7) normalizeDraft7(copy)
464
+ if (needsDraft7) normalizeDraft7(copy, true)
437
465
  normalizeNullable(copy)
438
466
  // Return original when normalization produced no change, copy otherwise.
439
467
  // Change-detection uses JSON content only; symbols do not affect it.
440
468
  return JSON.stringify(copy) === str ? s : copy
441
469
  }
442
470
 
443
- function buildSchemaMap(schemas) {
471
+ // `inheritDraft7` is true when the root schema is draft-07: a retrieved
472
+ // document that declares no dialect is read under the root's draft.
473
+ function buildSchemaMap(schemas, inheritDraft7) {
444
474
  if (!schemas) return null
445
475
  const map = new Map()
446
476
  if (Array.isArray(schemas)) {
447
477
  for (const s of schemas) {
448
- const normalized = _normalizeCallerSchema(s)
478
+ const normalized = _normalizeCallerSchema(s, inheritDraft7)
449
479
  const id = normalized.$id
450
480
  if (!id) throw new Error('Schema in schemas option must have $id')
451
481
  map.set(id, normalized)
452
482
  }
453
483
  } else {
454
484
  for (const [key, s] of Object.entries(schemas)) {
455
- const normalized = _normalizeCallerSchema(s)
485
+ const normalized = _normalizeCallerSchema(s, inheritDraft7)
456
486
  // A retrieved document is addressable both by the URI it was registered
457
487
  // under and by the $id it declares. Registering only the $id makes
458
488
  // references to the retrieval URI unresolvable.
@@ -560,8 +590,10 @@ class Validator {
560
590
  // When schema is an object, normalization runs on a clone so the caller's
561
591
  // object is never touched.
562
592
  let schemaObj = typeof schema === "string"
563
- ? JSON.parse(schema)
593
+ ? _normalizeCallerSchema(JSON.parse(schema))
564
594
  : _normalizeCallerSchema(schema);
595
+ const rootIsDraft7 = !!(schemaObj && typeof schemaObj === 'object' && typeof schemaObj.$schema === 'string' &&
596
+ (schemaObj.$schema === 'http://json-schema.org/draft-07/schema#' || schemaObj.$schema === 'http://json-schema.org/draft-07/schema'));
565
597
 
566
598
  // assertFormat: false makes `format` annotation-only. Strip it on a clone
567
599
  // so the caller's schema keeps the keyword.
@@ -579,11 +611,12 @@ class Validator {
579
611
  this._compiled = null;
580
612
  this._fastSlot = -1;
581
613
  this._jsFn = null;
614
+ this._engine = undefined;
582
615
  this._preprocess = null;
583
616
  this._applyDefaults = null;
584
617
 
585
618
  // Schema map for cross-schema $ref resolution
586
- this._schemaMap = buildSchemaMap(options.schemas) || new Map();
619
+ this._schemaMap = buildSchemaMap(options.schemas, rootIsDraft7) || new Map();
587
620
 
588
621
  // User-supplied format checkers: { formatName: (value) => boolean }.
589
622
  // Looked up at runtime when a schema references a format the built-in
@@ -717,6 +750,20 @@ class Validator {
717
750
  // Lazy stringify — only computed here, not in constructor
718
751
  if (!this._schemaStr) this._schemaStr = JSON.stringify(schemaObj);
719
752
 
753
+ // A $ref to a meta-schema resolves from the vendored copies, so
754
+ // "validate this schema against its dialect" needs no network and no
755
+ // caller-supplied registry. Only schemas that mention json-schema.org in a
756
+ // reference pay for the lookup.
757
+ if (this._schemaStr.includes('json-schema.org/draft')) {
758
+ const { METASCHEMAS } = require('./lib/metaschemas');
759
+ for (const [id, meta] of METASCHEMAS) {
760
+ const bare = id.replace(/#$/, '');
761
+ for (const key of [id, bare, bare + '#', bare.replace(/^https:/, 'http:'), bare.replace(/^http:/, 'https:')]) {
762
+ if (!this._schemaMap.has(key)) this._schemaMap.set(key, meta);
763
+ }
764
+ }
765
+ }
766
+
720
767
  // Check cache first -- reuse compiled functions for same schema
721
768
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
722
769
  const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
@@ -754,6 +801,7 @@ class Validator {
754
801
  jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf);
755
802
  jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf);
756
803
  _isCodegen = !!_cgFn;
804
+ this._engine = _cgFn ? 'codegen' : jsFn ? 'closure' : null;
757
805
  if (!uf) {
758
806
  _compileCache.set(mapKey, { jsFn, combined: jsCombinedFn, errFn: jsErrFn, isCodegen: _isCodegen });
759
807
  }
@@ -761,6 +809,7 @@ class Validator {
761
809
  jsFn = null; jsCombinedFn = null; jsErrFn = null;
762
810
  }
763
811
  this._jsFn = jsFn;
812
+ if (this._engine === undefined) this._engine = cached ? (cached.isCodegen ? 'codegen' : jsFn ? 'closure' : null) : null;
764
813
 
765
814
  // Data mutators -- try codegen first (12x faster), fallback to closure arrays.
766
815
  // Follow cross-refs so coercion/defaults/removeAdditional see the referenced
@@ -883,6 +932,10 @@ class Validator {
883
932
  } catch {}
884
933
  }
885
934
 
935
+ // The boolean engine is the verdict authority for these paths; the
936
+ // final lazy wrapper uses it to skip error construction entirely.
937
+ if (!hasDynRef || _isCodegen) this._fastVerdict = preprocess ? null : jsFn;
938
+
886
939
  if (options.abortEarly && jsFn && !hasDynRef) {
887
940
  // abortEarly: do NOT enrich. Skip position lookups, suggestions, source maps.
888
941
  // This is the perf-critical path for edge gateways. The richErrors wrap
@@ -899,10 +952,22 @@ class Validator {
899
952
  ? (data) => { preprocess(data); return _fn(data) ? _R : _efn(data); }
900
953
  : (data) => _fn(data) ? _R : _efn(data);
901
954
  } else if (hasDynRef) {
902
- // $dynamicRef without codegen: delegate to native C++ (interpretive path unreliable)
955
+ // $dynamicRef without codegen: the interpreted engine. It scores the
956
+ // same on the suite's $dynamicRef cases as the native walker since the
957
+ // dynamic-scope fix, needs no addon, and gets the verdict-only mode.
958
+ if (!_interp) {
959
+ const { createInterpreter } = require('./lib/interpreter');
960
+ _interp = createInterpreter(schemaObj, {
961
+ schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
962
+ formats: this._userFormats,
963
+ v1: isV1Dialect(schemaObj),
964
+ });
965
+ }
966
+ const interp = _interp;
967
+ this._fastVerdict = preprocess ? null : (d) => interp.isValid(d);
903
968
  this.validate = preprocess
904
- ? (data) => { preprocess(data); return errFn(data); }
905
- : errFn;
969
+ ? (data) => { preprocess(data); return interp.validate(data); }
970
+ : (data) => interp.validate(data);
906
971
  } else if (jsFn && jsFn._hybridFactory) {
907
972
  // Zero-wrapper: hybridFactory bakes VALID_RESULT + errFn into a single function
908
973
  // No arrow function wrapper, no ternary, one function call
@@ -1077,21 +1142,20 @@ class Validator {
1077
1142
  // propertyDependencies exists only in the interpreted engine, so a schema
1078
1143
  // using it goes there even when it also uses $dynamicRef.
1079
1144
  const _hasPropDeps = this._schemaStr.includes('"propertyDependencies"')
1145
+ // $dynamicRef used to delegate to the native validateJSON path here;
1146
+ // the interpreted engine now scores the same on those cases, carries
1147
+ // the verdict-only mode, and works without the addon.
1080
1148
  let _validate;
1081
- if (_hasDynRef && !_hasUneval && !_hasPropDeps && !this._v1Dynamic) {
1082
- // validateJSON is the C++ path with full anchor-map support; the NAPI
1083
- // direct V8 `validate` path has no anchor maps.
1084
- _validate = (data) => this._compiled.validateJSON(JSON.stringify(data));
1085
- this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
1086
- this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
1087
- } else {
1149
+ {
1088
1150
  const { createInterpreter } = require('./lib/interpreter');
1089
1151
  const interp = createInterpreter(schemaObj, {
1090
1152
  schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
1091
1153
  formats: this._userFormats,
1092
1154
  v1: isV1Dialect(schemaObj),
1093
1155
  });
1156
+ this._engine = 'interpreter';
1094
1157
  _validate = (data) => interp.validate(data);
1158
+ this._fastVerdict = preprocess ? null : (d) => interp.isValid(d);
1095
1159
  this.validateJSON = (jsonStr) => {
1096
1160
  try {
1097
1161
  return _validate(JSON.parse(jsonStr));
@@ -1107,7 +1171,9 @@ class Validator {
1107
1171
  return _validate(data);
1108
1172
  }
1109
1173
  : _validate;
1110
- this.isValidObject = (data) => _validate(data).valid;
1174
+ this.isValidObject = this._fastVerdict
1175
+ ? this._fastVerdict
1176
+ : (data) => _validate(data).valid;
1111
1177
  this.validateAndParse = (jsonStr) => this._compiled.validateAndParse(jsonStr);
1112
1178
  {
1113
1179
  const slot = this._fastSlot;
@@ -1149,11 +1215,15 @@ class Validator {
1149
1215
  formats: this._userFormats,
1150
1216
  v1: isV1Dialect(schemaObj),
1151
1217
  });
1218
+ this._engine = 'interpreter';
1219
+ if (!preprocess) this._fastVerdict = (d) => interp.isValid(d);
1152
1220
  const run = preprocess
1153
1221
  ? (data) => { preprocess(data); return interp.validate(data); }
1154
1222
  : (data) => interp.validate(data);
1155
1223
  this.validate = run;
1156
- this.isValidObject = (data) => run(data).valid;
1224
+ this.isValidObject = this._fastVerdict
1225
+ ? this._fastVerdict
1226
+ : (data) => run(data).valid;
1157
1227
  this.validateJSON = (jsonStr) => {
1158
1228
  try {
1159
1229
  return run(JSON.parse(jsonStr));
@@ -1164,42 +1234,44 @@ class Validator {
1164
1234
  this.isValidJSON = (jsonStr) => this.validateJSON(jsonStr).valid;
1165
1235
  }
1166
1236
 
1167
- // Declaration-order errors: whichever engine produced them, multi-error
1168
- // results are sorted by the schema's keyword declaration order before
1169
- // enrichment. Single-error and abortEarly results pass through untouched.
1237
+ // Error presentation, one lazy layer: declaration-order sorting, rich
1238
+ // enrichment (received value, suggestions, source frames, docUrl), or the
1239
+ // raw v0.14 shape under `richErrors: false`. All of it is work a caller
1240
+ // that only reads `.valid` never sees, so it runs on first access to
1241
+ // `.errors` and is cached. One wrapper, one allocation per rejection.
1170
1242
  if (this.validate) {
1171
1243
  const inner = this.validate;
1244
+ const enrich = this._richErrors ? require('./lib/enrich-error').enrich : null;
1172
1245
  const root = this._schemaObj;
1246
+ const self = this;
1173
1247
  this.validate = (data) => {
1174
1248
  const result = inner(data);
1175
- if (result && !result.valid && result.errors && result.errors.length > 1 && result !== ABORT_EARLY_RESULT) {
1176
- return { valid: false, errors: sortErrorsBySchemaOrder(root, result.errors) };
1177
- }
1178
- return result;
1179
- };
1180
- }
1181
-
1182
- // richErrors enrichment: layered on top of whichever validate path was
1183
- // bound above. Verbose's parentSchema flows through because enrich()
1184
- // copies it. Opt-out (`richErrors: false`) leaves the raw v0.14 shape.
1185
- if (this._richErrors && this.validate) {
1186
- const inner = this.validate;
1187
- const enrich = require('./lib/enrich-error').enrich;
1188
- this.validate = (data) => {
1189
- const result = inner(data);
1190
- if (result && !result.valid && result.errors && result.errors.length) {
1191
- // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
1192
- // perf fast path stays allocation-free and the documented code stays stable.
1193
- if (result === ABORT_EARLY_RESULT) return result;
1194
- const positions = (this._lastRawInput != null) ? this._posCache.get(this._lastRawInput) : null;
1195
- const enriched = result.errors.map((e) => enrich(e, {
1196
- data,
1197
- positions,
1198
- schemaPositions: this._schemaPositions,
1199
- schemaFile: this._source ? this._source.path : undefined,
1200
- }));
1201
- if (positions) this._posCache.reset();
1202
- return { valid: false, errors: enriched };
1249
+ // abortEarly returns the shared ATA9000 stub; preserve it as-is so the
1250
+ // perf fast path stays allocation-free and the documented code stays stable.
1251
+ if (result && result.valid === false && result !== ABORT_EARLY_RESULT) {
1252
+ // Positions come from the raw input when validateJSON set one;
1253
+ // resolved eagerly since the cache is reset per call.
1254
+ const positions = (enrich && self._lastRawInput != null) ? self._posCache.get(self._lastRawInput) : null;
1255
+ if (positions) self._posCache.reset();
1256
+ let cached = null;
1257
+ return {
1258
+ valid: false,
1259
+ get errors() {
1260
+ if (cached === null) {
1261
+ let raw = result.errors || [];
1262
+ if (raw.length > 1) raw = sortErrorsBySchemaOrder(root, raw);
1263
+ cached = (enrich && raw.length)
1264
+ ? raw.map((e) => enrich(e, {
1265
+ data,
1266
+ positions,
1267
+ schemaPositions: self._schemaPositions,
1268
+ schemaFile: self._source ? self._source.path : undefined,
1269
+ }))
1270
+ : raw;
1271
+ }
1272
+ return cached;
1273
+ },
1274
+ };
1203
1275
  }
1204
1276
  return result;
1205
1277
  };
@@ -1207,7 +1279,7 @@ class Validator {
1207
1279
  // validateJSON also enriches: set _lastRawInput so the position cache
1208
1280
  // can lazily build a map for dataFrame attachment. Only validateJSON
1209
1281
  // wires this — validate(data) takes a pre-parsed object, by design.
1210
- if (this.validateJSON) {
1282
+ if (this._richErrors && this.validateJSON) {
1211
1283
  const innerJson = this.validateJSON;
1212
1284
  this.validateJSON = (jsonStr) => {
1213
1285
  this._lastRawInput = jsonStr;
@@ -1311,12 +1383,57 @@ class Validator {
1311
1383
  }
1312
1384
  }
1313
1385
 
1386
+ // Errors are paid for when read, not when produced. The full pipeline
1387
+ // above (error codegen, enrichment, custom messages, verbose) stays
1388
+ // intact, but validate() now answers the verdict from the boolean
1389
+ // engine and materializes `errors` through a getter on first access.
1390
+ // A caller that only reads `.valid`, which is every gateway check and
1391
+ // every benchmark, skips error construction entirely; a caller that
1392
+ // reads `.errors` pays once and the result is cached. Skipped when the
1393
+ // schema coerces or defaults (preprocess mutates before the verdict),
1394
+ // under abortEarly (already a frozen stub), and for $dynamicRef (the
1395
+ // boolean engine is not the authority there).
1396
+ if (this._fastVerdict && !preprocess && !options.abortEarly && this.validate) {
1397
+ const _full = this.validate;
1398
+ const _fast = this._fastVerdict;
1399
+ const EMPTY_ERRORS = Object.freeze([]);
1400
+ const _buildErrors = (data) => {
1401
+ const r = _full(data);
1402
+ return (r && r.valid === false && r.errors && r.errors.length)
1403
+ ? r.errors
1404
+ // The data changed between the verdict and this read; keep the
1405
+ // verdict and say so rather than inventing a specific error.
1406
+ : [{ keyword: 'validation', instancePath: '', schemaPath: '#', params: {}, message: 'schema validation failed' }];
1407
+ };
1408
+ this.validate = (data) => {
1409
+ if (_fast(data)) return { valid: true, data, errors: EMPTY_ERRORS };
1410
+ return new LazyRejection(_buildErrors, data);
1411
+ };
1412
+ }
1413
+
1414
+ // The buffer APIs answer from the native walker, which disagrees with
1415
+ // validate() on shapes listed in lib/buffer-gate.js. For those schemas
1416
+ // every buffer entry point goes through validate() instead.
1417
+ if (native) {
1418
+ const { bufferNeedsSlowPath, installSlowBufferApis } = require('./lib/buffer-gate');
1419
+ if (bufferNeedsSlowPath(schemaObj, this._schemaMap)) installSlowBufferApis(this);
1420
+ }
1421
+
1314
1422
  // Save to identity cache for ultra-fast reuse with same schema object
1315
1423
  if (this._schemaObj && typeof this._schemaObj === 'object') {
1316
1424
  _identityCache.set(this._schemaObj, this);
1317
1425
  }
1318
1426
  }
1319
1427
 
1428
+ // Which engine answers validate() for this schema: 'codegen' (generated
1429
+ // JS), 'closure' (the closure compiler, the boolean fallback), 'native'
1430
+ // (the C++ engine, only for some $dynamicRef schemas), or 'interpreter'.
1431
+ // A diagnostic: the answer is the same on every engine, the cost is not.
1432
+ engine() {
1433
+ this._ensureCompiled();
1434
+ return this._engine || 'interpreter';
1435
+ }
1436
+
1320
1437
  _ensureNative() {
1321
1438
  if (this._nativeReady) return;
1322
1439
  this._nativeReady = true;
@@ -1341,8 +1458,12 @@ class Validator {
1341
1458
  if (!schema || !schema.$id) {
1342
1459
  throw new Error('Schema must have $id')
1343
1460
  }
1344
- // Normalize a copy so the caller's object is never mutated.
1345
- const normalized = _normalizeCallerSchema(schema)
1461
+ // Normalize a copy so the caller's object is never mutated. A document
1462
+ // without a dialect of its own is read under the root's draft.
1463
+ const root = this._schemaObj
1464
+ const rootIsDraft7 = !!(root && typeof root === 'object' && typeof root.$schema === 'string' &&
1465
+ (root.$schema === 'http://json-schema.org/draft-07/schema#' || root.$schema === 'http://json-schema.org/draft-07/schema'))
1466
+ const normalized = _normalizeCallerSchema(schema, rootIsDraft7)
1346
1467
  this._schemaMap.set(normalized.$id, normalized)
1347
1468
  }
1348
1469
 
package/lib/aot-build.js CHANGED
@@ -144,6 +144,7 @@ async function build(opts) {
144
144
  source,
145
145
  sourceMap,
146
146
  schemaFile,
147
+ formatMode: opts.formatMode,
147
148
  });
148
149
  if (!src) {
149
150
  const reason = 'schema is not AOT-compatible (toStandaloneModule returned null)';