ata-validator 1.6.1 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
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.0 - 2026-08-23
6
+
7
+ ### Fixed
8
+
9
+ - 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.
10
+ - 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.
11
+ - 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.
12
+ - 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.
13
+ - 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.
14
+ - `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`.
15
+
16
+ ### Added
17
+
18
+ - 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.
19
+
20
+ - `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.
21
+ - `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.
22
+
23
+ ### Changed
24
+
25
+ - 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.
26
+ - 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`.
27
+ - 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.
28
+ - 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.
29
+ - 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.
30
+
31
+ ## 1.6.2 - 2026-08-19
32
+
33
+ ### Fixed
34
+
35
+ - The Standard Schema surface carried no output type. `~standard.validate()` returned `{ value: unknown }` and the `types` carrier the specification defines for inference was missing, so every consumer that reads the validated type off `~standard` (Fastify, tRPC, TanStack Form, Drizzle) saw `unknown` and needed a cast. `~standard` is now typed against the validator's own data type, and `types.output` carries it. Type-only: the runtime object is unchanged, and the specification defines `types` as never present at runtime.
36
+ - Boolean schemas were rejected by the `Validator` constructor's TypeScript signature. `true` and `false` are schemas anywhere JSON Schema allows one, and both have always worked at runtime; only the types disagreed, which made a schema of unknown shape (`object | boolean`) impossible to pass without a cast. Nested boolean subschemas are still typed as objects, so `{ properties: { a: true } }` needs `defineSchema` or a cast.
37
+ - The `t` builder's option bags rejected vendor keywords. `t.object({}, { instanceof: 'Date' })` is what a custom keyword package expects to be given, and the option types only allowed the keywords the builder itself emits, so callers wrote `as never`. Every option bag now accepts unknown keywords alongside the typed ones.
38
+ - `tests/test_interop_types.ts` covers all three under `tsc --noEmit`.
39
+
5
40
  ## 1.6.1 - 2026-08-09
6
41
 
7
42
  ### 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
 
@@ -43,15 +43,30 @@ The `.compiled.mjs` modules are self-contained: zero runtime dependency on ata-v
43
43
 
44
44
  | Dimension | Schema | ata-AOT | AJV-runtime | Difference |
45
45
  |---|---|---|---|---|
46
- | Bundle (gzipped) | simple | 955 B | 52.7 KB | 56x smaller |
47
- | Bundle (gzipped) | complex | 1.6 KB | 52.7 KB | 32x smaller |
48
- | Cold start | simple | 21 ms | 38 ms | 1.8x faster |
49
- | Throughput (10M ops) | simple | 345 Mops/s | 116 Mops/s | 3.0x faster |
50
- | Compile time | simple | 6 µs | 1.5 ms | 246x faster |
51
-
52
- Reproduce on your machine with `npm run bench:aot-vs-ajv`. Numbers measured on Apple M4 Pro, Node 25.2.1.
53
-
54
- The wins are largest on bundle size and compile time because AOT moves work from runtime to build time. Throughput and cold start are also faster because the compiled validator is a tight straight-line function with no schema-walk overhead.
46
+ | Bundle (gzipped) | simple | 1.0 KB | 52.7 KB | 50.5x smaller |
47
+ | Bundle (gzipped) | complex | 4.8 KB | 52.7 KB | 11.0x smaller |
48
+ | Bundle (gzipped) | nested | 2.3 KB | 52.7 KB | 23.1x smaller |
49
+ | Cold start | simple | 21 ms | 40 ms | 1.9x faster |
50
+ | Throughput (1M ops) | simple | 258 Mops/s | 102 Mops/s | 2.5x faster |
51
+ | Compile time | simple | 8 µs | 1.61 ms | 191x faster |
52
+
53
+ Reproduce on your machine with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple
54
+ M4 Pro, Node 25.2.1, 2026-08-13. Across three runs throughput moved between 258 and 278
55
+ Mops/s and the compile ratio between 150x and 199x, so treat the last two rows as an order
56
+ of magnitude rather than a constant.
57
+
58
+ The wins are largest on bundle size and compile time because AOT moves work from runtime to
59
+ build time. Throughput and cold start are also faster because the compiled validator is a
60
+ tight straight-line function with no schema-walk overhead.
61
+
62
+ What the table does not cover is data that fails. It measures compiled modules on input that
63
+ passes, and a rejection costs more than a verdict: ata builds an error carrying a code, the
64
+ offending value, a documentation link and a suggestion. On a five-field object schema a
65
+ passing payload costs about 17 ns and a rejected one about 155 ns. `abortEarly: true` or
66
+ `isValidObject()` skips that work when only the verdict matters. Schemas ata declines to
67
+ compile, mostly cross-document `$ref`, `$dynamicRef` and `unevaluated*`, run on the
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.
55
70
 
56
71
  ## Error messages
57
72
 
@@ -385,7 +400,10 @@ const { toStandaloneModule } = require('ata-validator/build');
385
400
  fs.writeFileSync('./user.validator.mjs', toStandaloneModule(schema, { format: 'esm' }));
386
401
  ```
387
402
 
388
- **Fastify startup (10 routes cold): ajv 12.6ms → ata 0.5ms (24x faster boot, no build step required)**
403
+ **Fastify startup, 10 route schemas, from a cold process to the first validated request:
404
+ ajv 19.6 ms, ata 3.1 ms, no build step required.** ata registers in 1.1 ms of that and
405
+ compiles on the first request, so counting only registration would overstate the gap.
406
+ Reproduce with `node benchmark/bench_fastify_boot.mjs`.
389
407
 
390
408
  ### Standard Schema V1
391
409
 
@@ -479,7 +497,7 @@ Both are implemented in the interpreted engine, so a v1 schema that uses `$dynam
479
497
 
480
498
  ### Known limitations
481
499
 
482
- 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.
500
+ 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.
483
501
 
484
502
  Areas that remain deliberate scope decisions for 1.x:
485
503
 
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
@@ -364,13 +364,13 @@ export interface BundleStandaloneOptions extends ValidatorOptions {
364
364
  format?: 'esm' | 'cjs';
365
365
  }
366
366
 
367
- export interface StandardSchemaV1Props {
367
+ export interface StandardSchemaV1Props<Output = unknown, Input = unknown> {
368
368
  version: 1;
369
369
  vendor: "ata-validator";
370
370
  validate(
371
371
  value: unknown
372
372
  ):
373
- | { value: unknown }
373
+ | { value: Output }
374
374
  | {
375
375
  issues: Array<{
376
376
  message: string;
@@ -378,6 +378,11 @@ export interface StandardSchemaV1Props {
378
378
  path?: ReadonlyArray<{ key: PropertyKey }>;
379
379
  }>;
380
380
  };
381
+ /**
382
+ * Type-only carrier the Standard Schema spec uses for inference: consumers
383
+ * read the validated type off `types.output`. Never present at runtime.
384
+ */
385
+ readonly types?: { readonly input: Input; readonly output: Output } | undefined;
381
386
  }
382
387
 
383
388
  export interface StandaloneModule {
@@ -396,6 +401,13 @@ export interface Validator<T = unknown> {
396
401
 
397
402
  /** Fast boolean check via JS codegen or tier 0 interpreter. No error collection. */
398
403
  isValidObject(data: unknown): data is T;
404
+ /**
405
+ * Which engine answers `validate()` for this schema: 'codegen' (generated
406
+ * JS), 'closure' (the closure compiler), 'native' (the C++ engine, only
407
+ * for some $dynamicRef schemas) or 'interpreter'. The verdict is the same
408
+ * on every engine; the cost is not. A diagnostic, not a configuration.
409
+ */
410
+ engine(): 'codegen' | 'closure' | 'native' | 'interpreter';
399
411
 
400
412
  /** Validate a JSON string. Uses simdjson fast path for large documents. */
401
413
  validateJSON(jsonString: string): ValidationResult<T>;
@@ -425,7 +437,7 @@ export interface Validator<T = unknown> {
425
437
  isValidNDJSON(ndjsonBuffer: Buffer): boolean[];
426
438
 
427
439
  /** Standard Schema V1 interface, compatible with Fastify, tRPC, TanStack, etc. */
428
- readonly "~standard": StandardSchemaV1Props;
440
+ readonly "~standard": StandardSchemaV1Props<T>;
429
441
  }
430
442
 
431
443
  /** Constructor + statics for {@link Validator}. */
@@ -433,7 +445,7 @@ export interface ValidatorConstructor {
433
445
  /** Construct from a JSON Schema literal; the validated data type is inferred. */
434
446
  new <const S extends JSONSchema>(schema: S, options?: ValidatorOptions): Validator<Infer<S>>;
435
447
  /** Construct from a plain object/string schema, or with an explicit data type. */
436
- new <T = unknown>(schema: object | string, options?: ValidatorOptions): Validator<T>;
448
+ new <T = unknown>(schema: object | string | boolean, options?: ValidatorOptions): Validator<T>;
437
449
 
438
450
  /** Load a pre-compiled standalone module. Zero schema compilation at startup. */
439
451
  fromStandalone<T = unknown>(mod: StandaloneModule, schema: object | string, options?: ValidatorOptions): Validator<T>;
package/index.js CHANGED
@@ -426,33 +426,35 @@ function _deepCloneWithSymbols(v) {
426
426
  // Normalize a caller-provided schema without mutating the original.
427
427
  // Clones only when normalization would change the object (draft-07 keys
428
428
  // 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
- )
429
+ function _normalizeCallerSchema(s, inheritDraft7) {
430
+ const declares = s && typeof s === 'object' && s.$schema !== undefined
431
+ const needsDraft7 = declares
432
+ ? (s.$schema === 'http://json-schema.org/draft-07/schema#' || s.$schema === 'http://json-schema.org/draft-07/schema')
433
+ : !!inheritDraft7
434
434
  const str = JSON.stringify(s)
435
435
  const copy = _deepCloneWithSymbols(s)
436
- if (needsDraft7) normalizeDraft7(copy)
436
+ if (needsDraft7) normalizeDraft7(copy, true)
437
437
  normalizeNullable(copy)
438
438
  // Return original when normalization produced no change, copy otherwise.
439
439
  // Change-detection uses JSON content only; symbols do not affect it.
440
440
  return JSON.stringify(copy) === str ? s : copy
441
441
  }
442
442
 
443
- function buildSchemaMap(schemas) {
443
+ // `inheritDraft7` is true when the root schema is draft-07: a retrieved
444
+ // document that declares no dialect is read under the root's draft.
445
+ function buildSchemaMap(schemas, inheritDraft7) {
444
446
  if (!schemas) return null
445
447
  const map = new Map()
446
448
  if (Array.isArray(schemas)) {
447
449
  for (const s of schemas) {
448
- const normalized = _normalizeCallerSchema(s)
450
+ const normalized = _normalizeCallerSchema(s, inheritDraft7)
449
451
  const id = normalized.$id
450
452
  if (!id) throw new Error('Schema in schemas option must have $id')
451
453
  map.set(id, normalized)
452
454
  }
453
455
  } else {
454
456
  for (const [key, s] of Object.entries(schemas)) {
455
- const normalized = _normalizeCallerSchema(s)
457
+ const normalized = _normalizeCallerSchema(s, inheritDraft7)
456
458
  // A retrieved document is addressable both by the URI it was registered
457
459
  // under and by the $id it declares. Registering only the $id makes
458
460
  // references to the retrieval URI unresolvable.
@@ -560,8 +562,10 @@ class Validator {
560
562
  // When schema is an object, normalization runs on a clone so the caller's
561
563
  // object is never touched.
562
564
  let schemaObj = typeof schema === "string"
563
- ? JSON.parse(schema)
565
+ ? _normalizeCallerSchema(JSON.parse(schema))
564
566
  : _normalizeCallerSchema(schema);
567
+ const rootIsDraft7 = !!(schemaObj && typeof schemaObj === 'object' && typeof schemaObj.$schema === 'string' &&
568
+ (schemaObj.$schema === 'http://json-schema.org/draft-07/schema#' || schemaObj.$schema === 'http://json-schema.org/draft-07/schema'));
565
569
 
566
570
  // assertFormat: false makes `format` annotation-only. Strip it on a clone
567
571
  // so the caller's schema keeps the keyword.
@@ -579,11 +583,12 @@ class Validator {
579
583
  this._compiled = null;
580
584
  this._fastSlot = -1;
581
585
  this._jsFn = null;
586
+ this._engine = undefined;
582
587
  this._preprocess = null;
583
588
  this._applyDefaults = null;
584
589
 
585
590
  // Schema map for cross-schema $ref resolution
586
- this._schemaMap = buildSchemaMap(options.schemas) || new Map();
591
+ this._schemaMap = buildSchemaMap(options.schemas, rootIsDraft7) || new Map();
587
592
 
588
593
  // User-supplied format checkers: { formatName: (value) => boolean }.
589
594
  // Looked up at runtime when a schema references a format the built-in
@@ -717,6 +722,20 @@ class Validator {
717
722
  // Lazy stringify — only computed here, not in constructor
718
723
  if (!this._schemaStr) this._schemaStr = JSON.stringify(schemaObj);
719
724
 
725
+ // A $ref to a meta-schema resolves from the vendored copies, so
726
+ // "validate this schema against its dialect" needs no network and no
727
+ // caller-supplied registry. Only schemas that mention json-schema.org in a
728
+ // reference pay for the lookup.
729
+ if (this._schemaStr.includes('json-schema.org/draft')) {
730
+ const { METASCHEMAS } = require('./lib/metaschemas');
731
+ for (const [id, meta] of METASCHEMAS) {
732
+ const bare = id.replace(/#$/, '');
733
+ for (const key of [id, bare, bare + '#', bare.replace(/^https:/, 'http:'), bare.replace(/^http:/, 'https:')]) {
734
+ if (!this._schemaMap.has(key)) this._schemaMap.set(key, meta);
735
+ }
736
+ }
737
+ }
738
+
720
739
  // Check cache first -- reuse compiled functions for same schema
721
740
  const sm = this._schemaMap.size > 0 ? this._schemaMap : null;
722
741
  const mapKey = compileCacheKey(this._schemaStr, this._schemaMap);
@@ -754,6 +773,7 @@ class Validator {
754
773
  jsCombinedFn = compileToJSCombined(schemaObj, VALID_RESULT, sm, uf);
755
774
  jsErrFn = compileToJSCodegenWithErrors(schemaObj, sm, uf);
756
775
  _isCodegen = !!_cgFn;
776
+ this._engine = _cgFn ? 'codegen' : jsFn ? 'closure' : null;
757
777
  if (!uf) {
758
778
  _compileCache.set(mapKey, { jsFn, combined: jsCombinedFn, errFn: jsErrFn, isCodegen: _isCodegen });
759
779
  }
@@ -761,6 +781,7 @@ class Validator {
761
781
  jsFn = null; jsCombinedFn = null; jsErrFn = null;
762
782
  }
763
783
  this._jsFn = jsFn;
784
+ if (this._engine === undefined) this._engine = cached ? (cached.isCodegen ? 'codegen' : jsFn ? 'closure' : null) : null;
764
785
 
765
786
  // Data mutators -- try codegen first (12x faster), fallback to closure arrays.
766
787
  // Follow cross-refs so coercion/defaults/removeAdditional see the referenced
@@ -1081,6 +1102,7 @@ class Validator {
1081
1102
  if (_hasDynRef && !_hasUneval && !_hasPropDeps && !this._v1Dynamic) {
1082
1103
  // validateJSON is the C++ path with full anchor-map support; the NAPI
1083
1104
  // direct V8 `validate` path has no anchor maps.
1105
+ this._engine = 'native';
1084
1106
  _validate = (data) => this._compiled.validateJSON(JSON.stringify(data));
1085
1107
  this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
1086
1108
  this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
@@ -1091,6 +1113,7 @@ class Validator {
1091
1113
  formats: this._userFormats,
1092
1114
  v1: isV1Dialect(schemaObj),
1093
1115
  });
1116
+ this._engine = 'interpreter';
1094
1117
  _validate = (data) => interp.validate(data);
1095
1118
  this.validateJSON = (jsonStr) => {
1096
1119
  try {
@@ -1149,6 +1172,7 @@ class Validator {
1149
1172
  formats: this._userFormats,
1150
1173
  v1: isV1Dialect(schemaObj),
1151
1174
  });
1175
+ this._engine = 'interpreter';
1152
1176
  const run = preprocess
1153
1177
  ? (data) => { preprocess(data); return interp.validate(data); }
1154
1178
  : (data) => interp.validate(data);
@@ -1311,12 +1335,29 @@ class Validator {
1311
1335
  }
1312
1336
  }
1313
1337
 
1338
+ // The buffer APIs answer from the native walker, which disagrees with
1339
+ // validate() on shapes listed in lib/buffer-gate.js. For those schemas
1340
+ // every buffer entry point goes through validate() instead.
1341
+ if (native) {
1342
+ const { bufferNeedsSlowPath, installSlowBufferApis } = require('./lib/buffer-gate');
1343
+ if (bufferNeedsSlowPath(schemaObj, this._schemaMap)) installSlowBufferApis(this);
1344
+ }
1345
+
1314
1346
  // Save to identity cache for ultra-fast reuse with same schema object
1315
1347
  if (this._schemaObj && typeof this._schemaObj === 'object') {
1316
1348
  _identityCache.set(this._schemaObj, this);
1317
1349
  }
1318
1350
  }
1319
1351
 
1352
+ // Which engine answers validate() for this schema: 'codegen' (generated
1353
+ // JS), 'closure' (the closure compiler, the boolean fallback), 'native'
1354
+ // (the C++ engine, only for some $dynamicRef schemas), or 'interpreter'.
1355
+ // A diagnostic: the answer is the same on every engine, the cost is not.
1356
+ engine() {
1357
+ this._ensureCompiled();
1358
+ return this._engine || 'interpreter';
1359
+ }
1360
+
1320
1361
  _ensureNative() {
1321
1362
  if (this._nativeReady) return;
1322
1363
  this._nativeReady = true;
@@ -1341,8 +1382,12 @@ class Validator {
1341
1382
  if (!schema || !schema.$id) {
1342
1383
  throw new Error('Schema must have $id')
1343
1384
  }
1344
- // Normalize a copy so the caller's object is never mutated.
1345
- const normalized = _normalizeCallerSchema(schema)
1385
+ // Normalize a copy so the caller's object is never mutated. A document
1386
+ // without a dialect of its own is read under the root's draft.
1387
+ const root = this._schemaObj
1388
+ const rootIsDraft7 = !!(root && typeof root === 'object' && typeof root.$schema === 'string' &&
1389
+ (root.$schema === 'http://json-schema.org/draft-07/schema#' || root.$schema === 'http://json-schema.org/draft-07/schema'))
1390
+ const normalized = _normalizeCallerSchema(schema, rootIsDraft7)
1346
1391
  this._schemaMap.set(normalized.$id, normalized)
1347
1392
  }
1348
1393
 
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)';
package/lib/aot.js CHANGED
@@ -98,6 +98,48 @@ module.exports = { boolFn, hybridFactory, errFn };
98
98
  //
99
99
  // format: 'esm' | 'cjs'. Default 'esm'.
100
100
  // abortEarly: if true, invalid result is a shared stub; smaller output.
101
+
102
+ // Custom format functions in standalone output.
103
+ //
104
+ // 'embed' (default) writes each function's source into the module through
105
+ // Function#toString. That only works for a plain function: one that closes
106
+ // over nothing and is not rewritten by a coverage or transpile step (istanbul
107
+ // injects `cov_` counters; a bundler may hoist helpers). The check below
108
+ // rejects those at build time with a named error instead of emitting a module
109
+ // that throws on first use.
110
+ //
111
+ // 'inject' writes no function source. The module exports `setFormats(map)`
112
+ // and each format is looked up from that registry when a value is checked,
113
+ // so the caller supplies the functions at load time.
114
+ function emitFormatDecls(closures, mode, declKW) {
115
+ if (!closures || closures.length === 0) return { decls: '', exportsSetFormats: false };
116
+ if (mode === 'inject') {
117
+ let out = `${declKW} __formats = Object.create(null);\n`;
118
+ out += `function setFormats(map) { for (const k in map) __formats[k] = map[k]; }\n`;
119
+ for (const { name, format } of closures) {
120
+ const key = JSON.stringify(format === null ? name.slice(4) : format);
121
+ 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`;
122
+ }
123
+ return { decls: out, exportsSetFormats: true };
124
+ }
125
+ let out = '';
126
+ for (const { name, fn, format } of closures) {
127
+ const src = fn.toString();
128
+ const label = format === null ? name : format;
129
+ if (/\bcov_[A-Za-z0-9_$]+\b/.test(src)) {
130
+ throw new Error(`ata: custom format "${label}" is instrumented for coverage and cannot be embedded; use { formatMode: 'inject' } and register it with setFormats() at load time`);
131
+ }
132
+ try {
133
+ // eslint-disable-next-line no-new-func
134
+ new Function('return (' + src + ')');
135
+ } catch {
136
+ throw new Error(`ata: custom format "${label}" has no standalone source (a bound function, a class method, or a native); use { formatMode: 'inject' } and register it with setFormats() at load time`);
137
+ }
138
+ out += `${declKW} ${name} = ${src};\n`;
139
+ }
140
+ return { decls: out, exportsSetFormats: false };
141
+ }
142
+
101
143
  function toStandaloneModule(validator, opts) {
102
144
  validator._ensureCompiled();
103
145
  const jsFn = validator._jsFn;
@@ -170,20 +212,17 @@ function toStandaloneModule(validator, opts) {
170
212
  // User-supplied format functions are referenced as _uf_<name> by both the
171
213
  // boolean (_fn) and error (errFn) bodies. Embed them via Function#toString
172
214
  // so the standalone module stays self-contained.
173
- let formatDecls = '';
174
- if (jsFn._formatClosures && jsFn._formatClosures.length > 0) {
175
- formatDecls = jsFn._formatClosures
176
- .map(({ name, fn }) => `const ${name} = ${fn.toString()};`)
177
- .join('\n') + '\n';
178
- }
215
+ const fmt = emitFormatDecls(jsFn._formatClosures, opts && opts.formatMode, 'const');
216
+ const formatDecls = fmt.decls;
179
217
 
180
218
  const validBody = errCore
181
219
  ? 'return _fn(data) ? VALID : { valid: false, errors: errFn(data, true).errors }'
182
220
  : 'return _fn(data) ? VALID : ABORT';
183
221
 
222
+ const names = fmt.exportsSetFormats ? 'validate, isValid, setFormats' : 'validate, isValid';
184
223
  const exports = format === 'esm'
185
- ? `export { validate, isValid };\nexport default { validate, isValid };\n`
186
- : `module.exports = { validate, isValid };\nmodule.exports.default = module.exports;\n`;
224
+ ? `export { ${names} };\nexport default { ${names} };\n`
225
+ : `module.exports = { ${names} };\nmodule.exports.default = module.exports;\n`;
187
226
 
188
227
  return `// Auto-generated by ata-validator — do not edit.
189
228
  // Schema is embedded; runtime has zero dependency on ata-validator.
@@ -240,6 +279,7 @@ function bundleStandalone(Validator, schemas, opts) {
240
279
  const format = (opts && opts.format) || 'cjs';
241
280
  const R = 'Object.freeze({valid:true,errors:Object.freeze([])})';
242
281
  let bundleUsesSafeRe = false;
282
+ let bundleInjects = false;
243
283
  const fns = schemas.map((schema) => {
244
284
  const v = new Validator(schema, bundleOpts);
245
285
  v._ensureCompiled();
@@ -255,12 +295,13 @@ function bundleStandalone(Validator, schemas, opts) {
255
295
  jsErrFn && jsErrFn._errSource
256
296
  ? jsErrFn._errSource
257
297
  : "return{valid:false,errors:[{code:'error',path:'',message:'validation failed'}]}";
258
- // Serialize custom format closures so the bundle has no runtime dep on ata.
298
+ // Custom format closures: embedded, or bound to the bundle-level
299
+ // registry when opts.formats is 'inject'.
259
300
  let preamble = '';
260
301
  if (jsFn._formatClosures) {
261
- preamble = jsFn._formatClosures
262
- .map(({ name, fn }) => `var ${name}=${fn.toString()};`)
263
- .join('\n');
302
+ const f = emitFormatDecls(jsFn._formatClosures, opts && opts.formatMode, 'var');
303
+ preamble = f.decls.replace(/^var __formats = [^\n]*\n|^function setFormats[^\n]*\n/gm, '');
304
+ if (f.exportsSetFormats) bundleInjects = true;
264
305
  }
265
306
  // Include hoisted anyOf/oneOf branch helpers (e.g. `_af1_b0`) so the
266
307
  // bundle output is self-contained. `toStandalone` emits this same source
@@ -277,10 +318,15 @@ function bundleStandalone(Validator, schemas, opts) {
277
318
  });
278
319
  const arr = `[${fns.join(',')}]`;
279
320
  const safeEmbed = bundleUsesSafeRe ? getSafeRegexEmbed() + '\n' : '';
321
+ const registry = bundleInjects
322
+ ? `var __formats=Object.create(null);\nfunction setFormats(map){for(var k in map)__formats[k]=map[k]}\n`
323
+ : '';
280
324
  if (format === 'esm') {
281
- return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}const R=${R};\nconst validators=${arr};\nexport default validators;\nexport { validators };\n`;
325
+ const extra = bundleInjects ? 'export { validators, setFormats };' : 'export { validators };';
326
+ return `// Auto-generated by ata-validator — do not edit\n${safeEmbed}${registry}const R=${R};\nconst validators=${arr};\nexport default validators;\n${extra}\n`;
282
327
  }
283
- return `'use strict';\n${safeEmbed}var R=${R};\nmodule.exports=[${fns.join(',')}];\n`;
328
+ const attach = bundleInjects ? 'module.exports.setFormats=setFormats;\n' : '';
329
+ return `'use strict';\n${safeEmbed}${registry}var R=${R};\nmodule.exports=[${fns.join(',')}];\n${attach}`;
284
330
  }
285
331
 
286
332
  // Compact bundle: deduplicated code. Shared template functions + per-schema params.
@@ -355,14 +401,17 @@ function bundleCompact(Validator, schemas, opts) {
355
401
  // bodies. Collect them across all schemas (deduped by name) and embed via
356
402
  // Function#toString so the bundle stays self-contained.
357
403
  const fmtSeen = new Set();
404
+ const fmtAll = [];
358
405
  for (const e of entries) {
359
406
  if (!e || !e.fmt) continue;
360
- for (const { name, fn } of e.fmt) {
361
- if (fmtSeen.has(name)) continue;
362
- fmtSeen.add(name);
363
- out += `${declKW} ${name}=${fn.toString()};\n`;
407
+ for (const entry of e.fmt) {
408
+ if (fmtSeen.has(entry.name)) continue;
409
+ fmtSeen.add(entry.name);
410
+ fmtAll.push(entry);
364
411
  }
365
412
  }
413
+ const fmtOut = emitFormatDecls(fmtAll, opts && opts.formatMode, declKW);
414
+ out += fmtOut.decls;
366
415
 
367
416
  // Shared hybrid factories
368
417
  out += `${declKW} H=[\n`;
@@ -385,9 +434,10 @@ function bundleCompact(Validator, schemas, opts) {
385
434
  })
386
435
  .join(',');
387
436
  if (isEsm) {
388
- out += `const validators=[${arrBody}];\nexport default validators;\nexport { validators };\n`;
437
+ out += `const validators=[${arrBody}];\nexport default validators;\nexport { validators${fmtOut.exportsSetFormats ? ', setFormats' : ''} };\n`;
389
438
  } else {
390
439
  out += `module.exports=[${arrBody}];\n`;
440
+ if (fmtOut.exportsSetFormats) out += 'module.exports.setFormats=setFormats;\n';
391
441
  }
392
442
 
393
443
  return out;