ata-validator 1.25.0 → 1.26.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,18 @@
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.26.0 - 2026-09-17
6
+
7
+ ### Added
8
+
9
+ - `toStandaloneModule(schema, { parse: true })` inlines local, acyclic `$ref`s before the clone proof runs, so schemas that keep their shapes in `$defs` and reference them, which is every schema a generator emits, now get a `parse()`. Only a node that is exactly a `$ref` plus annotations is inlined; a cycle, an external reference or a constraining sibling keyword leaves the reference in place and the clone declines as before. Defaults follow the runtime exactly: a `default` written next to the `$ref` fills, a `default` written inside the referenced definition does not, because `validate()` with `useDefaults` draws the same line and `parse()` must not be more generous than `validate()`. A new test holds `parse()` output equal to the runtime's `validate().data` on the same input.
10
+ - When `parse: true` is requested and the clone still cannot be proven, the decline is loud: `onWarning` fires with the reason, and the emitted module carries a NOTE comment saying it has no `parse` export. Previously the only way to notice was reading the export list.
11
+
12
+ ### Fixed
13
+
14
+ - The runtime compiler built `patternProperties` (and combined `additionalProperties`) child validators with `new Function`, which discarded the parent's helper scope: a child schema that needed a compiled pattern or format helper threw `ReferenceError` at validation time. Child checks are now generated inline in the parent validator and share its helper bindings. Reported and fixed by @jdalton in #46.
15
+ - The TypeScript declarations emitted next to a compiled module now declare the `schemaHash` export; 1.25.0 added it to every module but not to the generated `.d.mts`, so importing it by name failed type checking while working at runtime.
16
+
5
17
  ## 1.25.0 - 2026-09-17
6
18
 
7
19
  ### Fixed
package/README.md CHANGED
@@ -60,7 +60,7 @@ file.
60
60
  libraries maintained outside this project. On its validation page, valid data, the run of
61
61
  2026-09-13 against ata 1.14.1 puts ata first at 603 ns, with the next entry at 1.77 times
62
62
  that. The same site puts ata last on the download page, at 64.9 KB gzipped, because the entry
63
- it bundles is the runtime compiler; the module `ata build` emits for that schema is 3.9 KB
63
+ it bundles is the runtime compiler; the module `ata build` emits for that schema is 5.1 KB
64
64
  minified and gzipped, and a compiled entry for the harness is in preparation.
65
65
  - [Bowtie](https://bowtie.report/), the cross-implementation JSON Schema test harness. ata's
66
66
  harness runs Draft 2020-12 and draft 7 there; on the harness at ata 1.16.1 the official suite
@@ -71,17 +71,19 @@ file.
71
71
 
72
72
  | Dimension | Schema | ata-AOT | runtime validator | Difference |
73
73
  |---|---|---|---|---|
74
- | Bundle (gzipped) | simple | 1.0 KB | 52.7 KB | 50.5x smaller |
75
- | Bundle (gzipped) | complex | 4.8 KB | 52.7 KB | 11.0x smaller |
76
- | Bundle (gzipped) | nested | 2.3 KB | 52.7 KB | 23.1x smaller |
74
+ | Bundle (gzipped) | simple | 1.1 KB | 52.7 KB | 48.1x smaller |
75
+ | Bundle (gzipped) | complex | 7.6 KB | 52.7 KB | 7.0x smaller |
76
+ | Bundle (gzipped) | nested | 4.1 KB | 52.7 KB | 13.0x smaller |
77
77
  | Cold start | simple | 21 ms | 40 ms | 1.9x faster |
78
- | Throughput (1M ops) | simple | 258 Mops/s | 102 Mops/s | 2.5x faster |
79
- | Compile time | simple | 8 µs | 1.61 ms | 191x faster |
78
+ | Throughput (1M ops) | simple | 257 Mops/s | 114 Mops/s | 2.3x faster |
79
+ | Compile time | simple | 14 µs | 1.52 ms | 111x faster |
80
80
 
81
81
  The runtime column is the default validator most frameworks ship. Reproduce on your machine
82
- with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-08-13. Across three runs throughput moved between 258 and 278
83
- Mops/s and the compile ratio between 150x and 199x, so treat the last two rows as an order
84
- of magnitude rather than a constant.
82
+ with `npm run bench:aot-vs-ajv`. Numbers from one run on Apple M4 Pro, Node 25.2.1, 2026-09-17,
83
+ on ata-validator 1.25.0, whose emitted modules carry full error detail and a schema hash, which
84
+ is where the growth over earlier 1.x module sizes comes from. Across three runs throughput moved
85
+ between 257 and 297 Mops/s and the compile ratio between 104x and 112x, so treat the last two
86
+ rows as an order of magnitude rather than a constant.
85
87
 
86
88
  The wins are largest on bundle size and compile time because AOT moves work from runtime to
87
89
  build time. Throughput and cold start are also faster because the compiled validator is a
@@ -209,19 +211,19 @@ is the most common way to get a misleading number out of this library.
209
211
 
210
212
  | | compiled with `ata build` | runtime `new Validator(schema)` |
211
213
  |---|---|---|
212
- | In a bundle, gzipped | **1.9 KB** | 75.6 KB |
213
- | Time to a served request | **3.5 ms** | 10.7 ms |
214
+ | In a bundle, gzipped | **4.5 KB** | 87.0 KB |
215
+ | Time to a served request | **3.4 ms** | 10.6 ms |
214
216
  | Schema known when | build time | any time |
215
217
 
216
218
  The bundle row is a ten-field user schema built with
217
219
  `bun build --minify --target=browser`. The startup row is a Hono route on Bun 1.4, best
218
- of seven, against 3.6 ms for the same app doing no validation at all, so the compiled
220
+ of seven, against 3.7 ms for the same app doing no validation at all, so the compiled
219
221
  path costs nothing measurable to start. The runtime
220
222
  figure is what it is because a schema that arrives at run time can use any keyword, so
221
223
  the whole engine has to be there. The compiled module imports nothing and contains only
222
224
  the checks your schema asks for.
223
225
 
224
- **On a server, use whichever fits your schemas.** 76 KB of JavaScript on a Node or Bun
226
+ **On a server, use whichever fits your schemas.** 87 KB of JavaScript on a Node or Bun
225
227
  process is not a cost anyone notices, and the runtime API is the simpler thing to reach
226
228
  for. Speed is the same either way once warm.
227
229
 
@@ -450,7 +452,7 @@ const v = new Validator(schema, {
450
452
 
451
453
  ### Build-time compile (`ata compile`)
452
454
 
453
- The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. Typical output is about 2 KB gzipped for a ten-field schema, against 74 KB for the runtime bundled for the browser.
455
+ The `ata` CLI turns a JSON Schema file into a self-contained JavaScript module. No runtime dependency on `ata-validator`, so only the generated validator ships to the browser. Typical output is about 4.5 KB gzipped for a ten-field schema, full error detail included, against 87 KB for the runtime bundled for the browser.
454
456
 
455
457
  ```bash
456
458
  npx ata compile schemas/user.json -o src/generated/user.validator.mjs
package/lib/aot-impl.js CHANGED
@@ -199,6 +199,74 @@ function emitFormatDecls(closures, mode, declKW) {
199
199
  // Returns null when the clone cannot be proven exact, and the module then
200
200
  // simply has no parse(). Declining is recoverable; a sanitiser that quietly
201
201
  // drops a property the schema allows is not.
202
+ // Resolve a local JSON pointer ('#', '#/$defs/x', draft-7 '#/definitions/x',
203
+ // any '#/...' path) against the schema root. Returns null for anything else:
204
+ // external documents, anchors, URNs. Those stay with the runtime engines.
205
+ function resolveLocalPointer(root, ref) {
206
+ if (ref === '#') return root;
207
+ if (typeof ref !== 'string' || !ref.startsWith('#/')) return null;
208
+ let cur = root;
209
+ for (const raw of ref.slice(2).split('/')) {
210
+ let seg = raw.replace(/~1/g, '/').replace(/~0/g, '~');
211
+ if (cur === null || typeof cur !== 'object') return null;
212
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) {
213
+ try { seg = decodeURIComponent(seg); } catch (_) { return null; }
214
+ if (!Object.prototype.hasOwnProperty.call(cur, seg)) return null;
215
+ }
216
+ cur = cur[seg];
217
+ }
218
+ return cur;
219
+ }
220
+
221
+ // Inline local, acyclic $refs before the clone proof runs, so a generated
222
+ // schema ($defs + $ref, the shape every schema generator emits) can still get
223
+ // a parse(). Only a node that is exactly a $ref plus annotations is replaced;
224
+ // a $ref with a constraining sibling, a cycle, an unresolvable target or an
225
+ // external reference is left in place, and emitClone declines it as before.
226
+ // The walk never mutates its input and never descends into data positions
227
+ // (default, const, enum, examples), where an object holding a "$ref" key is
228
+ // a value, not a schema.
229
+ const _INLINE_ANNOTATIONS = new Set(['$ref', 'title', 'description', '$comment', 'examples', 'deprecated', 'readOnly', 'writeOnly', 'default']);
230
+ const _INLINE_DATA_KEYS = new Set(['default', 'const', 'enum', 'examples']);
231
+ function inlineRefsForClone(root) {
232
+ const state = { budget: 512 };
233
+ // `inRef` marks content brought in from behind a reference. The runtime's
234
+ // useDefaults fills a default written next to the $ref, and does not fill
235
+ // defaults written inside the referenced definition, so the inlined copy
236
+ // keeps the first and drops the second. parse() must stay exactly as
237
+ // generous as validate(); a parse that fills more is a disagreement, not a
238
+ // feature.
239
+ const walk = (node, active, inRef) => {
240
+ if (!node || typeof node !== 'object') return node;
241
+ if (Array.isArray(node)) return node.map((n) => walk(n, active, inRef));
242
+ let cur = node;
243
+ let entered = false;
244
+ let siblingDefault;
245
+ while (cur && typeof cur === 'object' && !Array.isArray(cur) && typeof cur.$ref === 'string') {
246
+ for (const k of Object.keys(cur)) if (!_INLINE_ANNOTATIONS.has(k)) return node;
247
+ if (active.has(cur.$ref) || state.budget-- <= 0) return node;
248
+ const target = resolveLocalPointer(root, cur.$ref);
249
+ if (!target || typeof target !== 'object' || Array.isArray(target)) return node;
250
+ if (!entered && !inRef && cur.default !== undefined) siblingDefault = cur.default;
251
+ active = new Set(active);
252
+ active.add(cur.$ref);
253
+ cur = target;
254
+ entered = true;
255
+ }
256
+ const strip = inRef || entered;
257
+ const out = {};
258
+ for (const k of Object.keys(cur)) {
259
+ if (strip && k === 'default') continue;
260
+ const v = cur[k];
261
+ if (_INLINE_DATA_KEYS.has(k) || k === '$defs' || k === 'definitions') { out[k] = v; continue; }
262
+ out[k] = walk(v, active, strip);
263
+ }
264
+ if (siblingDefault !== undefined) out.default = siblingDefault;
265
+ return out;
266
+ };
267
+ return walk(root, new Set(), false);
268
+ }
269
+
202
270
  function emitClone(node, access, depth) {
203
271
  if (!node || typeof node !== 'object') return null;
204
272
  if (depth > 12) return null;
@@ -422,11 +490,21 @@ function toStandaloneModule(validator, opts) {
422
490
  // schema declares. Off by default: it costs bytes in every emitted module,
423
491
  // and the reason to compile a schema ahead of time is usually to ship as
424
492
  // little as possible. Ask for it with { parse: true }.
425
- const cloneExpr = !(opts && opts.parse) ? null : emitClone(
426
- typeof validator._schemaObj === 'object' ? validator._schemaObj : null,
427
- 'data',
428
- 0,
429
- );
493
+ // Local acyclic $refs are inlined first, so the schemas generators emit
494
+ // ($defs + $ref everywhere) still get a parse(). When the clone is still
495
+ // not provable, the decline is loud: the caller asked for parse and is not
496
+ // getting it, and discovering that by reading the export list cost a user
497
+ // an afternoon. Same channel as the error-detail decline above.
498
+ let cloneExpr = null;
499
+ if (opts && opts.parse) {
500
+ const baseSchema = typeof validator._schemaObj === 'object' ? validator._schemaObj : null;
501
+ cloneExpr = baseSchema ? emitClone(inlineRefsForClone(baseSchema), 'data', 0) : null;
502
+ if (!cloneExpr && typeof opts.onWarning === 'function') {
503
+ opts.onWarning(
504
+ 'parse() could not be generated for this schema: the rebuild is only emitted where the allowed key set is provable, and a remaining $ref (cyclic, external, or carrying constraining siblings), patternProperties, or an additionalProperties schema makes it someone else\'s decision. The module ships without a parse export; validate and strip with the runtime Validator instead.'
505
+ );
506
+ }
507
+ }
430
508
  // Named _ataParse rather than parse: the emitted module also carries the
431
509
  // safe-regex prelude, which has a module-scope parse() of its own for
432
510
  // reading patterns. A second declaration of that name shadowed it and the
@@ -483,9 +561,12 @@ function validateJSON(text) {
483
561
  ? `export { ${names}${parseAlias} };\nexport default { ${names}${parseProp} };\n`
484
562
  : `module.exports = { ${names}${parseProp} };\nmodule.exports.default = module.exports;\n`;
485
563
 
486
- const degradedNote = (!abortEarly && !errCore)
564
+ let degradedNote = (!abortEarly && !errCore)
487
565
  ? '// NOTE: error detail was requested but could not be generated for this\n// schema; failures report the single ATA9000 abort-early error. The verdict\n// is exact. Validate failing documents with the runtime Validator for detail.\n'
488
566
  : '';
567
+ if (opts && opts.parse && !cloneExpr) {
568
+ degradedNote += '// NOTE: parse() was requested but could not be generated for this schema;\n// the module has no parse export. Validate and strip with the runtime Validator.\n';
569
+ }
489
570
  return `// Auto-generated by ata-validator — do not edit.
490
571
  // Schema is embedded; runtime has zero dependency on ata-validator.
491
572
  ${degradedNote}'use strict';
@@ -2246,15 +2246,13 @@ function genCode(schema, v, lines, ctx, knownType) {
2246
2246
  }
2247
2247
  }
2248
2248
 
2249
- // Build sub-schema validators as closure vars
2249
+ // Build sub-schema checks inline so they share the parent helper scope.
2250
+ const subChecks = []
2250
2251
  for (let i = 0; i < ppEntries.length; i++) {
2251
2252
  const [, sub] = ppEntries[i]
2252
2253
  const subLines = []
2253
2254
  genCode(sub, `_ppv`, subLines, ctx)
2254
- const fnBody = subLines.length === 0 ? `return true` : `${subLines.join(';')};return true`
2255
- const fnVar = `_ppf${pi}_${i}`
2256
- ctx.closureVars.push(fnVar)
2257
- ctx.closureVals.push(new Function('_ppv', fnBody))
2255
+ subChecks.push(subLines.join(';'))
2258
2256
  }
2259
2257
 
2260
2258
  const guard = isObj ? '' : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v}))`
@@ -2268,13 +2266,11 @@ function genCode(schema, v, lines, ctx, knownType) {
2268
2266
  ctx._ppHandledAdditional = true
2269
2267
  ctx._ppHandledPropertyNames = !!pn
2270
2268
  const propKeys = Object.keys(schema.properties || {})
2271
- let apFn = null
2269
+ let apCheck = null
2272
2270
  if (apSchema) {
2273
2271
  const apLines = []
2274
2272
  genCode(apSchema, '_apv', apLines, ctx)
2275
- apFn = `_apf${pi}`
2276
- ctx.closureVars.push(apFn)
2277
- ctx.closureVals.push(new Function('_apv', apLines.length === 0 ? 'return true' : `${apLines.join(';')};return true`))
2273
+ apCheck = apLines.join(';')
2278
2274
  }
2279
2275
  lines.push(`${guard}{for(const ${kVar} in ${v}){`)
2280
2276
  // propertyNames checks (merged into same loop)
@@ -2308,14 +2304,14 @@ function genCode(schema, v, lines, ctx, knownType) {
2308
2304
  if (ppEntries.length > 0) {
2309
2305
  lines.push(`let _pm${pi}=false`)
2310
2306
  for (let i = 0; i < ppEntries.length; i++) {
2311
- lines.push(`if(${matchers[i].check}){_pm${pi}=true;if(!_ppf${pi}_${i}(${v}[${kVar}]))return false}`)
2307
+ lines.push(`if(${matchers[i].check}){_pm${pi}=true;const _ppv=${v}[${kVar}];${subChecks[i]}}`)
2312
2308
  }
2313
2309
  }
2314
2310
  // A key that is neither declared nor matched is additional. switch on
2315
2311
  // the declared names (V8 compiles string cases to a jump table); no
2316
2312
  // switch at all when nothing is declared, since a switch with no case
2317
2313
  // clause is a syntax error.
2318
- const additional = apFn ? `if(!${apFn}(${v}[${kVar}]))return false` : `return false`
2314
+ const additional = apCheck !== null ? `const _apv=${v}[${kVar}];${apCheck}` : `return false`
2319
2315
  const notMatched = ppEntries.length > 0 ? `if(!_pm${pi}){${additional}}` : additional
2320
2316
  if (propKeys.length) {
2321
2317
  const switchCases = propKeys.map(k => `case ${JSON.stringify(k)}:`).join('')
@@ -2352,7 +2348,7 @@ function genCode(schema, v, lines, ctx, knownType) {
2352
2348
  }
2353
2349
  }
2354
2350
  for (let i = 0; i < ppEntries.length; i++) {
2355
- lines.push(`if(${matchers[i].check}&&!_ppf${pi}_${i}(${v}[${kVar}]))return false`)
2351
+ lines.push(`if(${matchers[i].check}){const _ppv=${v}[${kVar}];${subChecks[i]}}`)
2356
2352
  }
2357
2353
  lines.push(`}}`)
2358
2354
  }
package/lib/ts-gen.js CHANGED
@@ -238,7 +238,8 @@ export type Result = ValidResult | InvalidResult;
238
238
 
239
239
  export declare function isValid(data: unknown): data is ${rootName};
240
240
  export declare function validate(data: unknown): Result;
241
- declare const _default: { validate: typeof validate; isValid: typeof isValid };
241
+ export declare const schemaHash: string;
242
+ declare const _default: { validate: typeof validate; isValid: typeof isValid; schemaHash: typeof schemaHash };
242
243
  export default _default;
243
244
  `;
244
245
  }
package/lib/version.js CHANGED
@@ -7,4 +7,4 @@
7
7
  //
8
8
  // Kept in lockstep with package.json by `tests/test_version_sync.js`.
9
9
 
10
- module.exports = '1.25.0';
10
+ module.exports = '1.26.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.25.0",
3
+ "version": "1.26.0",
4
4
  "description": "JSON Schema validation that compiles for speed and still runs where code generation is blocked. Compiled and interpreted engines answer identically at 100% of the official suite. TypeScript inference, Standard Schema V1, and a build step that emits dependency-free modules.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -122,13 +122,13 @@
122
122
  "LICENSE"
123
123
  ],
124
124
  "optionalDependencies": {
125
- "@ata-validator/native-darwin-arm64": "1.25.0",
126
- "@ata-validator/native-darwin-x64": "1.25.0",
127
- "@ata-validator/native-linux-arm64-gnu": "1.25.0",
128
- "@ata-validator/native-linux-arm64-musl": "1.25.0",
129
- "@ata-validator/native-linux-x64-gnu": "1.25.0",
130
- "@ata-validator/native-linux-x64-musl": "1.25.0",
131
- "@ata-validator/native-win32-x64": "1.25.0"
125
+ "@ata-validator/native-darwin-arm64": "1.26.0",
126
+ "@ata-validator/native-darwin-x64": "1.26.0",
127
+ "@ata-validator/native-linux-arm64-gnu": "1.26.0",
128
+ "@ata-validator/native-linux-arm64-musl": "1.26.0",
129
+ "@ata-validator/native-linux-x64-gnu": "1.26.0",
130
+ "@ata-validator/native-linux-x64-musl": "1.26.0",
131
+ "@ata-validator/native-win32-x64": "1.26.0"
132
132
  },
133
133
  "peerDependencies": {
134
134
  "yaml": "^2.0.0"