ata-validator 1.22.0 → 1.22.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,24 @@
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.22.1 - 2026-09-16
6
+
7
+ ### Fixed
8
+
9
+ - `validateJSON()` did not return on a document that ends inside an array. `validateJSON('[')` was one byte, any schema, the default configuration, and it never came back. On a rejection the error carries a caret, so a map from pointer to position is built from the raw text; its array loop asked for the next element without checking that the input had ended, `walk` returned without moving, and nothing else could close the array. `'[}'` hung the same way, because a character that begins no value leaves the position where it was. The object loop escaped only by accident: an unterminated string throws, and `lib/data-position-cache.js` catches throws. It cannot catch a loop. Both container loops now stop at the end of the input, and the array loop stops when an iteration does not advance, so a truncated document still yields a map of what was there.
10
+ - A regression here is an infinite loop, which an ordinary test cannot report because it never reaches a failure. `tests/test_malformed_json_termination.js` runs its corpus in a child process under a watchdog, so a hang becomes a timeout and a red test. The corpus is every prefix of ten documents plus characters inserted where they cannot begin a value: 1793 inputs, 7172 verdicts. It was confirmed to fail before this change and pass after.
11
+ - `tests/fuzz_positions.js` had never fed malformed text to this walker; it fuzzes schema positions with `JSON.stringify` output, which is always well formed. Nothing else changes: on the same five-field schema, `validate()` on a valid document, `validate()` on an invalid one with the errors read, and `validateJSON()` on both are all within measurement noise of 1.22.0.
12
+
13
+ - `unevaluatedProperties: false` reported the boolean schema underneath the keyword instead of the keyword itself: `keyword: 'not'`, empty `params`, and an `instancePath` pointing at the offending member rather than the object holding it. Nothing in that error said which key was unexpected, so a caller branching on `params.unevaluatedProperty` to flag unknown keys saw nothing at all. It now reports `unevaluatedProperties` on the object, with `params.unevaluatedProperty` naming the key, one error per key. `unevaluatedItems: false` had the same shape and now reports one error for the array, with `params.limit` at the first unevaluated position. Both engines were changed together and `tests/test_unevaluated_error_shape.js` holds them to it. Reported by a config linter reading `err.params.unevaluatedProperty` through `ata-validator/compat`.
14
+ - `verbose: true` attached only `parentSchema`. It now also attaches `data`, the value the error points at, and `schema`, the failing keyword's own value, which is the whole set the option is supposed to carry. Without `data` a caller had to walk the document by the instance path itself. The default error shape is unchanged: the two fields appear only when `verbose` is on, including on the errors `ata-validator/compat` synthesises for an `if` wrapper.
15
+
16
+ ### Changed
17
+
18
+ - `additionalProperties: false` no longer compiles to a comparison per declared name. It was one comparison per name per key of the document, which is quadratic in the number of properties: a 1000-property schema spent 75 µs on a document where the same schema without the keyword took 3, and doubling the properties quadrupled that. Above 128 declared names the generated code now reads a name lookup built once per compiled function; below that the comparison chain stays, because it allocates nothing and the two are the same within noise there.
19
+ - Cross-process medians, one variant per process, properties of type string: 250 names 8.76 µs to 5.49, 500 names 26.29 to 12.47. With no per-property work, so the membership test is all that is measured: 1000 names 77.3 µs to 18.1, 2000 names 273.6 to 38.9.
20
+ - Measured and not taken: `for...in` over the document instead of an indexed loop over `Object.keys` was about a quarter slower at every size, even though it allocates no key array.
21
+ - `tests/test_additional_properties_scaling.js` guards this structurally, on the emitted source: the lookup is present above the threshold, absent below it, and declared outside the function that runs per call. It asserts no timing, because a wall-clock ratio was not a guard here: the quadratic shape measured 7.7x for four times the properties on the machine the test was written on, where a CI runner measured 8.3x for the fixed code. The comparison chain short-circuits as soon as a key matches, so the ratio never separated the two. Found by a config linter whose schema is generated from a project's environment variables, so its property count is whatever the project has.
22
+
5
23
  ## 1.22.0 - 2026-09-14
6
24
 
7
25
  ### Fixed
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # ata-validator
2
2
 
3
- JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile your schemas to per-schema ESM modules with no validator dependency. `Validator<T>` composes with TypeBox, Zod-from-JSON-Schema, Valibot, or hand-written types. Runtime API available for dynamic schemas. Draft 2020-12, draft 7 and the [JSON Schema v1 dialect](#dialects).
3
+ JSON Schema validation that compiles for speed and still runs where code generation is blocked. The compiled and interpreted engines answer identically, at 100% of the official suite in both modes, across Draft 2020-12, draft 7 and the [JSON Schema v1 dialect](#dialects). First-class TypeScript inference, and `ata build` emits a standalone module that imports nothing.
4
4
 
5
5
  [![npm](https://img.shields.io/npm/v/ata-validator)](https://www.npmjs.com/package/ata-validator)
6
6
  [![License](https://img.shields.io/npm/l/ata-validator)](LICENSE)
@@ -97,6 +97,55 @@ run on the interpreted engine. That engine compiles each schema into a tree of c
97
97
  on a six-field object schema a passing payload costs about 136 ns against 8 ns for the same
98
98
  shape on the compiled path, both measured warm in one run on the same machine.
99
99
 
100
+ ## Schemas and models
101
+
102
+ If you ask a model for structured output and validate the result, the schema is
103
+ part of the prompt and the validation error is part of the retry. Two functions
104
+ produce those strings.
105
+
106
+ ```js
107
+ import { describeSchema, toRetryMessage, Validator } from 'ata-validator'
108
+
109
+ describeSchema(schema)
110
+ // output: object
111
+ // status: one of "AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"
112
+ // region_code: one of "R11", "R24", "R37", "R52"
113
+ // amount: integer, at least 1
114
+ // no other fields
115
+
116
+ const r = new Validator(schema).validate(fromTheModel)
117
+ if (!r.valid) toRetryMessage(r.errors)
118
+ // /status: expected one of ["AWAITING_CLEARANCE", "PART_SETTLED", "CLOSED_OUT"], found "partial"
119
+ // /region_code: expected one of ["R11", "R24", "R37", "R52"], found "HH"
120
+ // /amount: expected ≥1, found 0
121
+ // /: unknown property "extra"
122
+ ```
123
+
124
+ Whether this is worth anything depends on the schema, and the measurement that
125
+ says so is
126
+ [public](https://github.com/mertcanaltin/retry-message-experiment), with the
127
+ harness and the raw results.
128
+
129
+ On constraints a model can work out from the document, a pattern, a date format,
130
+ a currency in uppercase, it changes nothing: 40 of 40 documents recovered on one
131
+ retry with the conventional error text, and 40 of 40 with the detailed one.
132
+
133
+ On constraints it cannot work out, an `enum` of internal codes while the
134
+ document says "paid in part" and "Hamburg office", the conventional text
135
+ recovered 0 of 30 and the detailed text 30 of 30. The model does not give up
136
+ when it is not told the values, it invents plausible ones, so no number of
137
+ retries closes it.
138
+
139
+ Stating the constraints up front does the same job earlier. Across four prompts
140
+ on the same fixtures, first attempts that validate: nothing 0 of 30, a lean
141
+ field list 0 of 30, a careful hand-written description 30 of 30 on the inferable
142
+ fixture and 0 of 30 on the opaque one, and `describeSchema` 30 and 23. A person
143
+ writing prose matches the generated description wherever prose works. It is the
144
+ enumerated values a person leaves out.
145
+
146
+ One model, Claude Haiku 4.5, one schema shape, 30 to 40 documents per arm, no
147
+ temperature control. Read it as a finding, not a law.
148
+
100
149
  ## Error messages
101
150
 
102
151
  ata's error output is compiler-grade: each error carries a stable code, an inline source frame pointing at the schema file, and another pointing at the offending bytes in the request payload. Renderers ship in three styles:
@@ -153,6 +202,33 @@ Schemas without an `errorMessage` keyword pay nothing: the override pass is only
153
202
 
154
203
  For consumers who built log dashboards on the v0.14 error shape, `new Validator(schema, { richErrors: false })` returns the legacy shape exactly. For high-throughput paths, `abortEarly: true` continues to short-circuit; the returned error carries `code: 'ATA9000'` and no enrichment.
155
204
 
205
+ ## Which path, and where
206
+
207
+ There are two ways to run ata and they suit different places. Measuring the wrong one
208
+ is the most common way to get a misleading number out of this library.
209
+
210
+ | | compiled with `ata build` | runtime `new Validator(schema)` |
211
+ |---|---|---|
212
+ | In a bundle, gzipped | **1.9 KB** | 75.6 KB |
213
+ | Time to a served request | **3.5 ms** | 10.7 ms |
214
+ | Schema known when | build time | any time |
215
+
216
+ The bundle row is a ten-field user schema built with
217
+ `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
219
+ path costs nothing measurable to start. The runtime
220
+ figure is what it is because a schema that arrives at run time can use any keyword, so
221
+ the whole engine has to be there. The compiled module imports nothing and contains only
222
+ the checks your schema asks for.
223
+
224
+ **On a server, use whichever fits your schemas.** 76 KB of JavaScript on a Node or Bun
225
+ process is not a cost anyone notices, and the runtime API is the simpler thing to reach
226
+ for. Speed is the same either way once warm.
227
+
228
+ **In a browser, on an edge runtime, or anywhere cold starts are charged, compile.** This
229
+ is where the difference is the whole story, and it is also where `new Function` is often
230
+ blocked outright, which the compiled module does not need.
231
+
156
232
  ## When to use the runtime API instead
157
233
 
158
234
  `ata build` is for schemas you know at build time. If your schemas are user-supplied at runtime (form builders, no-code platforms, dynamic API ingestion), use the runtime API:
@@ -374,7 +450,7 @@ const v = new Validator(schema, {
374
450
 
375
451
  ### Build-time compile (`ata compile`)
376
452
 
377
- 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 ~1 KB gzipped compared to ~65 KB for the full runtime bundled for the browser.
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.
378
454
 
379
455
  ```bash
380
456
  npx ata compile schemas/user.json -o src/generated/user.validator.mjs
@@ -404,7 +480,7 @@ CLI options:
404
480
  | `-o, --output <file>` | `<schema>.validator.mjs` | Output path |
405
481
  | `-f, --format <fmt>` | `esm` | `esm` or `cjs` |
406
482
  | `--name <TypeName>` | from filename | Root type name in the `.d.ts` |
407
- | `--abort-early` | off | Generate the stub-error variant (~0.5 KB gzipped) |
483
+ | `--abort-early` | off | Generate the stub-error variant, about a third of the source |
408
484
  | `--no-types` | off | Skip the `.d.mts` / `.d.cts` output |
409
485
 
410
486
  For a project with many schemas, `ata build <glob>` compiles them all in one command:
@@ -415,13 +491,19 @@ npx ata build 'schemas/*.json' --out-dir build/validators --check
415
491
 
416
492
  Run with `--watch` during development for incremental rebuilds.
417
493
 
418
- Typical bundle sizes (10-field user schema, gzipped):
494
+ Bundle sizes for a 10-field user schema, minified and gzipped, measured with
495
+ `bun build --minify --target=browser`:
419
496
 
420
- | Variant | Size | Notes |
497
+ | What the app imports | Size | Notes |
421
498
  |---|---|---|
422
- | `ata-validator` runtime | ~27 KB | Full compiler + all keywords |
423
- | `ata compile` (standard) | **~1.1 KB** | Validator + detailed error collector |
424
- | `ata compile --abort-early` | **~0.5 KB** | Validator + stub errors only |
499
+ | `Validator` from `ata-validator` | 74.1 KB | The compiler ships with it, because a runtime schema can use any keyword |
500
+ | `isValid` from the compiled module | **1.9 KB** | Nothing else is reachable, so the error collector is dropped |
501
+ | `validate` from the compiled module | **3.6 KB** | Adds the detailed error collector |
502
+
503
+ `--abort-early` makes the generated source about three times smaller, and after
504
+ bundling it makes no difference: importing only `isValid` already leaves the error
505
+ collector unreachable, and a bundler drops it. Use the flag to cut the file on disk,
506
+ not to cut what ships.
425
507
 
426
508
  Programmatic API if you prefer to script it:
427
509
 
package/compat.js CHANGED
@@ -1,5 +1,7 @@
1
1
  'use strict';
2
2
 
3
+ const { resolvePointer } = require('./lib/pointer.js');
4
+
3
5
  // Drop-in for the default validator's class. `const Ajv = require('ata-validator/compat')`
4
6
  // and the rest of the file stays as it was: `new Ajv(opts)`, `compile`,
5
7
  // `validate`, `addSchema`, `getSchema`, `removeSchema`, `addFormat`,
@@ -174,7 +176,16 @@ class Ata {
174
176
  validate.errors = null;
175
177
  return true;
176
178
  }
177
- const errors = shaper === null ? result.errors : shaper(result.errors, data);
179
+ let errors = shaper === null ? result.errors : shaper(result.errors, data);
180
+ // Errors the shaper synthesises, the `if` wrapper among them, are built
181
+ // from the group rather than carried up from the validator, so verbose
182
+ // mode has to fill in the value they point at here or they would be the
183
+ // only ones missing it.
184
+ if (this.opts.verbose) {
185
+ errors = errors.map((e) => (e && !('data' in e)
186
+ ? { ...e, data: resolvePointer(data, e.instancePath || '', undefined) }
187
+ : e));
188
+ }
178
189
  validate.errors = shaper === null && !allErrors ? errors.slice(0, 1) : errors;
179
190
  return false;
180
191
  };
package/index.d.ts CHANGED
@@ -72,6 +72,37 @@ export function renderPretty(errors: RichValidationError[], opts?: PrettyOptions
72
72
  export function renderCompact(errors: RichValidationError[], opts?: CompactOptions): string;
73
73
  export function renderJSON(errors: RichValidationError[], opts?: JSONRenderOptions): string;
74
74
 
75
+ /** Options for {@link describeSchema}. */
76
+ export interface DescribeSchemaOptions {
77
+ /** What to call the top level in the description. Defaults to `output`. */
78
+ name?: string;
79
+ }
80
+
81
+ /**
82
+ * The schema's constraints as prose, for the prompt that asks a model to
83
+ * produce a document. Values a model cannot infer, an `enum` of internal codes
84
+ * most of all, are the ones worth stating: measured on one fixture, naming them
85
+ * took first-attempt validity from 0 of 30 to 23 of 30, where a careful
86
+ * hand-written description scored 0 on exactly those fields.
87
+ * https://github.com/mertcanaltin/retry-message-experiment
88
+ */
89
+ export function describeSchema(schema: object, opts?: DescribeSchemaOptions): string;
90
+
91
+ /** Options for {@link toRetryMessage}. */
92
+ export interface RetryMessageOptions {
93
+ /** Most errors to include. Defaults to 20, because a model does not act on a long list. */
94
+ limit?: number;
95
+ }
96
+
97
+ /**
98
+ * The errors as a string to send back to a model, naming what was expected and
99
+ * what arrived. On constraints a model can infer from context this changes
100
+ * nothing; where a constraint is an internal vocabulary it was the difference
101
+ * between 0 of 30 and 30 of 30 recovered on one retry.
102
+ * https://github.com/mertcanaltin/retry-message-experiment
103
+ */
104
+ export function toRetryMessage(errors: RichValidationError[], opts?: RetryMessageOptions): string;
105
+
75
106
  /** A user-supplied format checker. Receives the candidate value, returns true if valid. */
76
107
  export type FormatChecker = (value: string) => boolean;
77
108
 
package/index.js CHANGED
@@ -1233,19 +1233,36 @@ class Validator {
1233
1233
  }
1234
1234
  : (data) => (jsFn(data) ? VALID_RESULT : errFn(data));
1235
1235
  }
1236
- // Verbose mode: populate parentSchema on each error.
1237
- // Errors may be frozen, so clone them with the extra field.
1236
+ // Verbose mode: populate parentSchema, schema and data on each error, the
1237
+ // three fields the default error shape carries under the same option.
1238
+ // `data` is the value the error points at; without it a caller has to
1239
+ // walk the document by the instance path itself, which is what one
1240
+ // migration ended up writing by hand. Errors may be frozen, so clone
1241
+ // them with the extra fields.
1238
1242
  if (this._verbose) {
1239
1243
  const inner = this.validate;
1240
1244
  const root = this._schemaObj;
1245
+ const { resolvePointer } = require('./lib/pointer.js');
1241
1246
  this.validate = (data) => {
1242
1247
  const result = inner(data);
1243
1248
  if (result && !result.valid && result.errors) {
1244
- const enriched = result.errors.map((err) =>
1245
- err && err.parentSchema === undefined
1246
- ? { ...err, parentSchema: resolveSchemaByPath(root, err.schemaPath) }
1247
- : err
1248
- );
1249
+ const enriched = result.errors.map((err) => {
1250
+ if (!err || err.parentSchema !== undefined) return err;
1251
+ const parentSchema = resolveSchemaByPath(root, err.schemaPath);
1252
+ // The last segment of the schema path is the keyword that
1253
+ // failed, so its value on the parent is that keyword's schema.
1254
+ const sp = typeof err.schemaPath === 'string' ? err.schemaPath : '';
1255
+ const last = sp.slice(sp.lastIndexOf('/') + 1).replace(/~1/g, '/').replace(/~0/g, '~');
1256
+ const keywordSchema = (parentSchema !== null && typeof parentSchema === 'object' && last)
1257
+ ? parentSchema[last]
1258
+ : undefined;
1259
+ return {
1260
+ ...err,
1261
+ parentSchema,
1262
+ schema: keywordSchema,
1263
+ data: resolvePointer(data, err.instancePath, undefined),
1264
+ };
1265
+ });
1249
1266
  return { valid: false, errors: enriched };
1250
1267
  }
1251
1268
  return result;
@@ -63,6 +63,10 @@ function buildDataPositionMap (input) {
63
63
  i++;
64
64
  while (true) {
65
65
  skipWs();
66
+ // The input is not required to be valid JSON: this map is built to
67
+ // put a caret on a syntax error, so a document that stops in the
68
+ // middle of a container is the normal case, not an impossible one.
69
+ if (i >= n) break;
66
70
  if (text.charCodeAt(i) === 0x7d) { i++; break; }
67
71
  if (text.charCodeAt(i) === 0x2c) { i++; continue; }
68
72
  skipWs();
@@ -88,9 +92,15 @@ function buildDataPositionMap (input) {
88
92
  let idx = 0;
89
93
  while (true) {
90
94
  skipWs();
95
+ if (i >= n) break;
91
96
  if (text.charCodeAt(i) === 0x5d) { i++; break; }
92
97
  if (text.charCodeAt(i) === 0x2c) { i++; continue; }
98
+ const before = i;
93
99
  walk(path.concat([String(idx)]));
100
+ // A character that begins no value at all, a stray `}` for instance,
101
+ // leaves the position where it was, and a loop that does not advance
102
+ // does not end. `validateJSON('[')` hung here on one byte.
103
+ if (i === before) break;
94
104
  idx++;
95
105
  }
96
106
  } else if (ch === 0x22) {
@@ -219,6 +219,12 @@ function enrich (rawErr, opts) {
219
219
  parentSchema: rawErr.parentSchema,
220
220
  };
221
221
 
222
+ // Verbose mode's two other fields. Carried only when the raw error has them,
223
+ // so the default error shape does not grow two undefined keys for everyone
224
+ // who never asked for verbose.
225
+ if ('data' in rawErr) out.data = rawErr.data;
226
+ if ('schema' in rawErr) out.schema = rawErr.schema;
227
+
222
228
  // oneOf/anyOf collapse: preserve the nested branch errors so the pretty
223
229
  // renderer can surface the closest variant's diagnostics.
224
230
  if (rawErr.branchErrors) out.branchErrors = rawErr.branchErrors;
@@ -461,6 +461,11 @@ class Plan {
461
461
 
462
462
  this.unevaluatedProperties = schema.unevaluatedProperties !== undefined ? child(schema.unevaluatedProperties) : undefined;
463
463
  this.unevaluatedItems = schema.unevaluatedItems !== undefined ? child(schema.unevaluatedItems) : undefined;
464
+ // A `false` here rejects the member outright, and the error belongs to this
465
+ // keyword on this object, not to a boolean schema that happens to sit
466
+ // under it. Recorded at compile time so the check below is one test.
467
+ this.unevaluatedPropertiesFalse = schema.unevaluatedProperties === false;
468
+ this.unevaluatedItemsFalse = schema.unevaluatedItems === false;
464
469
  this.hasUnevaluated = this.unevaluatedProperties !== undefined || this.unevaluatedItems !== undefined;
465
470
 
466
471
  // Custom keywords present on this node, in schema order. Each op holds
@@ -1183,7 +1188,14 @@ class Interpreter {
1183
1188
  if (P.unevaluatedProperties !== undefined && bits === T_OBJECT) {
1184
1189
  for (const key of Object.keys(data)) {
1185
1190
  if (local.props && local.props.has(key)) continue;
1186
- if (!this.eval(P.unevaluatedProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack, DISCARD)) valid = false;
1191
+ if (P.unevaluatedPropertiesFalse) {
1192
+ // The name is what is wrong, so the error names it and sits on the
1193
+ // object holding it. Descending instead reported a false boolean
1194
+ // schema at the member's own path, which says nothing about which
1195
+ // key was unexpected and left `params` empty.
1196
+ valid = false;
1197
+ if (errors !== NOERRORS) errors.push(err('unevaluatedProperties', 'unevaluatedProperties', instancePath, schemaPath + '/unevaluatedProperties', { unevaluatedProperty: key }, 'must NOT have unevaluated properties'));
1198
+ } else if (!this.eval(P.unevaluatedProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack, DISCARD)) valid = false;
1187
1199
  if (!local.props) local.props = new Set();
1188
1200
  local.props.add(key);
1189
1201
  }
@@ -1191,6 +1203,13 @@ class Interpreter {
1191
1203
  if (P.unevaluatedItems !== undefined && bits === T_ARRAY) {
1192
1204
  for (let i = 0; i < data.length; i++) {
1193
1205
  if (local.items && local.items.has(i)) continue;
1206
+ if (P.unevaluatedItemsFalse) {
1207
+ // One error for the array, not one per trailing element: what the
1208
+ // array got wrong is its length past the last evaluated position.
1209
+ valid = false;
1210
+ if (errors !== NOERRORS) errors.push(err('unevaluatedItems', 'unevaluatedItems', instancePath, schemaPath + '/unevaluatedItems', { limit: i }, 'must NOT have more than ' + i + ' items'));
1211
+ break;
1212
+ }
1194
1213
  if (!this.eval(P.unevaluatedItems, data[i], base, dynScope, errors, instancePath + '/' + i, schemaPath + '/unevaluatedItems', stack, DISCARD)) valid = false;
1195
1214
  if (!local.items) local.items = new Set();
1196
1215
  local.items.add(i);
@@ -35,6 +35,46 @@ function hoistOnce(ctx, key, code) {
35
35
  else if (ctx.helperCode) ctx.helperCode.push(code)
36
36
  }
37
37
 
38
+ // Above this many declared names, asking whether a key is one of them by
39
+ // testing it against each name in turn costs a comparison per name per key,
40
+ // which is quadratic in the number of properties: a 1000-property schema with
41
+ // `additionalProperties: false` spent 75 us where the same schema without the
42
+ // keyword took 3, and doubling the properties quadrupled that. Below this many
43
+ // the two are the same within noise and the chain allocates nothing, so it
44
+ // stays. Cross-process medians, one variant per process, properties of type
45
+ // string: 3 to 100 names are a tie; 250 names 8.76 us against 5.49, 500 26.29
46
+ // against 12.47, and with no per-property work 2000 names 273 against 49.
47
+ const AP_LOOKUP_MIN = 128
48
+
49
+ // A name set built once per compiled function rather than once per call, as
50
+ // source text so the standalone output carries it the same way. A null
51
+ // prototype means a key called `toString` is not a member by accident, and a
52
+ // plain property read measured faster than `Set#has` at every size tried.
53
+ function emitNameLookup (ctx, names) {
54
+ const id = `_apn${ctx.varCounter++}`
55
+ const list = names.map((n) => JSON.stringify(n)).join(',')
56
+ const decl = `const ${id}=Object.create(null);for(const _apx of [${list}])${id}[_apx]=1`
57
+ if (ctx.preamble) ctx.preamble.push(decl)
58
+ else if (ctx.helperCode) ctx.helperCode.push(decl)
59
+ else return null
60
+ return id
61
+ }
62
+
63
+ // "every own key of `v` is one of `names`", as a `return false` check. A
64
+ // hoisted lookup above the crossover, the comparison chain below it.
65
+ function apMembershipCheck (ctx, names, v) {
66
+ if (names.length >= AP_LOOKUP_MIN) {
67
+ const id = emitNameLookup(ctx, names)
68
+ if (id !== null) {
69
+ // An indexed loop over `Object.keys`, not `for...in`: it allocates the key
70
+ // array but measured faster at every size tried, by about a quarter.
71
+ const i = ctx.varCounter++
72
+ return `var _apk${i}=Object.keys(${v});for(var _api${i}=0;_api${i}<_apk${i}.length;_api${i}++)if(${id}[_apk${i}[_api${i}]]===undefined)return false`
73
+ }
74
+ }
75
+ return `for(var _k in ${v})if(${names.map((k) => `_k!==${JSON.stringify(k)}`).join('&&')})return false`
76
+ }
77
+
38
78
  function emitDeq(ctx) {
39
79
  hoistOnce(ctx, '_deqHoisted', DEQ_HELPER)
40
80
  return '_deq'
@@ -2147,7 +2187,7 @@ function genCode(schema, v, lines, ctx, knownType) {
2147
2187
  ? (propCount <= 15
2148
2188
  ? `var _n=0;for(var _k in ${v})_n++;if(_n!==${propCount})return false`
2149
2189
  : `if(Object.keys(${v}).length!==${propCount})return false`)
2150
- : `for(var _k in ${v})if(${Object.keys(schema.properties).map(k => `_k!==${JSON.stringify(k)}`).join('&&')})return false`
2190
+ : apMembershipCheck(ctx, Object.keys(schema.properties), v)
2151
2191
  _deferOrInline(ctx, lines, v, isObj ? inner : `if(typeof ${v}==='object'&&${v}!==null&&!Array.isArray(${v})){${inner}}`)
2152
2192
  }
2153
2193
 
@@ -894,6 +894,27 @@ function install(deps) {
894
894
  // unevaluated*: run last, against the annotations of everything above.
895
895
  if (P.unevaluatedProperties !== undefined) {
896
896
  const fn = child(P.unevaluatedProperties);
897
+ if (fn === FALSE_PAIR) {
898
+ // `false` rejects the member outright, and what is wrong is the name.
899
+ // Running the boolean schema against the value instead reported a
900
+ // false schema at the member's own path with empty params, which does
901
+ // not say which key was unexpected. The error belongs to this keyword,
902
+ // on the object holding the key, and it carries the key.
903
+ steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
904
+ if (dataBits(data) !== T_OBJECT) return true;
905
+ let ok = true;
906
+ const keys = keysOf(rec, data);
907
+ for (let k = 0; k < keys.length; k++) {
908
+ const key = keys[k];
909
+ if (hasProp(rec, key)) continue;
910
+ ok = false;
911
+ if (errors === NOERRORS) return false;
912
+ errors.push(err('unevaluatedProperties', 'unevaluatedProperties', instancePath, schemaPath + '/unevaluatedProperties', { unevaluatedProperty: key }, 'must NOT have unevaluated properties'));
913
+ addProp(rec, key);
914
+ }
915
+ return ok;
916
+ });
917
+ } else {
897
918
  steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
898
919
  if (dataBits(data) !== T_OBJECT) return true;
899
920
  let ok = true;
@@ -909,6 +930,7 @@ function install(deps) {
909
930
  }
910
931
  return ok;
911
932
  });
933
+ }
912
934
  if (fn === FALSE_PAIR) {
913
935
  // Every key must already be evaluated; on success there is nothing
914
936
  // new to record.
@@ -936,6 +958,21 @@ function install(deps) {
936
958
  }
937
959
  if (P.unevaluatedItems !== undefined) {
938
960
  const fn = child(P.unevaluatedItems);
961
+ if (fn === FALSE_PAIR) {
962
+ // One error for the array rather than one per trailing element: what
963
+ // the array got wrong is its length past the last evaluated position.
964
+ steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
965
+ if (dataBits(data) !== T_ARRAY) return true;
966
+ for (let i = 0; i < data.length; i++) {
967
+ if (hasItem(rec, i)) continue;
968
+ if (errors !== NOERRORS) errors.push(err('unevaluatedItems', 'unevaluatedItems', instancePath, schemaPath + '/unevaluatedItems', { limit: i }, 'must NOT have more than ' + i + ' items'));
969
+ if (data.length > rec.n) rec.n = data.length;
970
+ return false;
971
+ }
972
+ if (data.length > rec.n) rec.n = data.length;
973
+ return true;
974
+ });
975
+ } else {
939
976
  steps.push((data, errors, instancePath, schemaPath, stack, rec) => {
940
977
  if (dataBits(data) !== T_ARRAY) return true;
941
978
  let ok = true;
@@ -949,6 +986,7 @@ function install(deps) {
949
986
  if (data.length > rec.n) rec.n = data.length;
950
987
  return ok;
951
988
  });
989
+ }
952
990
  if (fn === FALSE_PAIR) {
953
991
  vsteps.push((data, stack, rec) => {
954
992
  if (dataBits(data) !== T_ARRAY) return true;
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.22.0';
10
+ module.exports = '1.22.1';
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.22.0",
4
- "description": "JSON Schema validation with first-class TypeScript and zero runtime cost. AOT compile to per-schema ESM modules with zero validator dependency. Generic Validator<T> for TypeBox/Zod/Valibot composition. Optional runtime API. Standard Schema V1 compatible.",
3
+ "version": "1.22.1",
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",
7
7
  "types": "index.d.ts",
@@ -51,7 +51,7 @@
51
51
  "release:check": "node scripts/regen-safe-regex-source.js && node tests/test_pack_purity.js && node scripts/check-doc-coverage.js && node tests/test_error_codes_lock.js && node tests/test_safe_regex_source_sync.js && node tests/test_version_sync.js",
52
52
  "build": "cmake-js build --target ata",
53
53
  "rebuild": "cmake-js rebuild --target ata",
54
- "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_buffer_reject_cost.js && node tests/test_nan_verdict.js && node tests/test_remove_additional_nested.js && node tests/test_exclusive_bounds.js && node tests/test_engine_differential.js && node tests/test_error_shape_differential.js && node tests/test_output_format.js && node tests/test_retry_message.js && node tests/test_describe_schema.js && node tests/test_aot_parse.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_compile_cache_order.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_uri_helper_parity.js && node tests/test_formats_single_pass.js && node tests/test_format_single_error.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_hybrid_agreement.js && node tests/test_codegen_edge_shapes.js && node tests/test_ajv_errors.js && node tests/test_custom_keywords.js && node tests/test_aot_external_checks.js && node tests/test_ajv_parity.js && node tests/test_user_format_error_path.js && node tests/test_unevaluated_error_path.js && node tests/test_pattern_properties_errors.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_error_order_ordinal.js && node tests/test_rejection_shape.js && node tests/test_lazy_normalization.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_lazy_instance.js && node tests/test_cyclic_input.js && node tests/test_native_error_codes.js && node tests/test_verdict_preprocess.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_additive_fields.js && node tests/test_diagnostic_source.js && node tests/test_diagnose.js && node tests/test_correlate.js && node tests/test_diagnostics_score.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
54
+ "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.js && node tests/test_no_eval.js && node tests/test_property_dependencies.js && node tests/test_v1_dialect.js && node tests/test_buffer_path_parity.js && node tests/test_buffer_gate.js && node tests/test_buffer_reject_cost.js && node tests/test_nan_verdict.js && node tests/test_remove_additional_nested.js && node tests/test_exclusive_bounds.js && node tests/test_engine_differential.js && node tests/test_error_shape_differential.js && node tests/test_output_format.js && node tests/test_retry_message.js && node tests/test_describe_schema.js && node tests/test_aot_parse.js && node tests/test_draft7_semantics.js && node tests/test_metaschema_ref.js && node tests/test_pure_js_unsupported.js && node tests/test_native_load_order.js && node tests/test_pack_purity.js && node tests/test_make_native_package.js && node tests/test_browser_nofs.js && node tests/test_browser_imports_guard.js && node tests/test_version_sync.js && node tests/test_native_loaded.js && node tests/test_safe_regex_source_sync.js && node tests/test_t_builder.js && node tests/test_async_refine.js && node tests/test_safe_regex.js && node tests/test_safe_regex_integration.js && node tests/test_aot_build.js && node tests/test_aot_differential.js && node tests/test_aot_cli_build.js && node tests/test_aot_cli_smoke.js && node tests/test_bundle_standalone.js && node tests/test_standalone_anyof.js && node tests/test_standalone_formats.js && node tests/test_aot_format_mode.js && node tests/test_aot_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_compile_cache_order.js && node tests/test_engine_diagnostic.js && node tests/test_format_engine_parity.js && node tests/test_uri_helper_parity.js && node tests/test_formats_single_pass.js && node tests/test_format_single_error.js && node tests/test_defs_pointer_alias.js && node tests/test_cross_doc_root_ref.js && node tests/test_codegen_entrypoint_agreement.js && node tests/test_hybrid_agreement.js && node tests/test_codegen_edge_shapes.js && node tests/test_ajv_errors.js && node tests/test_custom_keywords.js && node tests/test_aot_external_checks.js && node tests/test_ajv_parity.js && node tests/test_user_format_error_path.js && node tests/test_unevaluated_error_path.js && node tests/test_unevaluated_error_shape.js && node tests/test_pattern_properties_errors.js && node tests/test_additional_properties_scaling.js && node tests/test_no_input_mutation.js && node tests/test_typed_validator_runner.js && node tests/test_define_schema.js && node tests/test_error_codes_lock.js && node tests/test_error_code_lookup.js && node tests/test_error_order.js && node tests/test_error_order_ordinal.js && node tests/test_rejection_shape.js && node tests/test_lazy_normalization.js && node tests/test_value_equality.js && node tests/test_vocabulary.js && node tests/test_schema_scan.js && node tests/test_lazy_errors.js && node tests/test_lazy_instance.js && node tests/test_cyclic_input.js && node tests/test_native_error_codes.js && node tests/test_verdict_preprocess.js && node tests/test_plan_compiler.js && node tests/test_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.js && node tests/test_enrich_received.js && node tests/test_rich_errors_optout.js && node tests/test_error_messages.js && node tests/test_source_positions.js && node tests/fuzz_positions.js && node tests/test_malformed_json_termination.js && node tests/test_data_positions.js && node tests/test_render_shared.js && node tests/test_renderers.js && node tests/test_additive_fields.js && node tests/test_diagnostic_source.js && node tests/test_diagnose.js && node tests/test_correlate.js && node tests/test_diagnostics_score.js && node tests/test_runtime_error_dx.js && node tests/test_aot_error_dx.js && node tests/test_abort_early.js && node tests/test_branch_collapse.js && node tests/test_suggestions.js && node tests/test_cli_validate.js && node tests/test_cli_version.js && node benchmark/bench_aot_size.mjs",
55
55
  "bench:size": "node benchmark/bench_aot_size.mjs",
56
56
  "test:suite": "node tests/run_suite.js && node tests/run_suite.js draft7 && node tests/run_suite.js v1",
57
57
  "test:compat": "node tests/test_compat.js",
@@ -122,13 +122,13 @@
122
122
  "LICENSE"
123
123
  ],
124
124
  "optionalDependencies": {
125
- "@ata-validator/native-darwin-arm64": "1.22.0",
126
- "@ata-validator/native-darwin-x64": "1.22.0",
127
- "@ata-validator/native-linux-arm64-gnu": "1.22.0",
128
- "@ata-validator/native-linux-arm64-musl": "1.22.0",
129
- "@ata-validator/native-linux-x64-gnu": "1.22.0",
130
- "@ata-validator/native-linux-x64-musl": "1.22.0",
131
- "@ata-validator/native-win32-x64": "1.22.0"
125
+ "@ata-validator/native-darwin-arm64": "1.22.1",
126
+ "@ata-validator/native-darwin-x64": "1.22.1",
127
+ "@ata-validator/native-linux-arm64-gnu": "1.22.1",
128
+ "@ata-validator/native-linux-arm64-musl": "1.22.1",
129
+ "@ata-validator/native-linux-x64-gnu": "1.22.1",
130
+ "@ata-validator/native-linux-x64-musl": "1.22.1",
131
+ "@ata-validator/native-win32-x64": "1.22.1"
132
132
  },
133
133
  "peerDependencies": {
134
134
  "yaml": "^2.0.0"