ata-validator 1.1.0 → 1.2.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.2.1 - 2026-07-18
6
+
7
+ ### Fixed
8
+
9
+ - Schemas the JS compiler cannot represent now validate on the engine that gets them right, instead of always preferring the native addon when it is installed. The native resolver mishandles several `$id` base-URI corners (URN bases, absolute-path references, empty JSON-pointer tokens, nested `$id` scopes) and silently skips regex patterns its engine cannot parse; all of these now route to the interpreted engine. Pure dynamic-ref schemas stay on the native path, which tracks `$dynamicRef` scopes more completely. Buffer and parallel APIs are unchanged.
10
+ - The JS compiler no longer compiles two shapes it got wrong: `unevaluatedItems` with `contains` in scope (contains-matched items were never credited as evaluated, rejecting valid arrays) and plain-anchor schemas that open nested `$id` scopes (same-named anchors in different base-URI scopes resolved to the wrong target). Both now go to the interpreted engine.
11
+ - Official draft 2020-12 suite: 1190 of 1190 applicable cases with the native accelerator (up from 1175), 1188 of 1190 (99.8%) pure-JS (up from 1184). Draft 7 suite gains four `ref.json` cases.
12
+
13
+ ## 1.2.0 - 2026-07-18
14
+
15
+ ### Added
16
+
17
+ - An interpreted engine (`lib/interpreter.js`) now backs schemas the JS compiler cannot represent when the native addon is absent (browser, edge workers, `ATA_NO_NATIVE=1`). These schemas validated only with the native engine before, and 1.1.0 made them throw a clear error in native-less environments; they now just validate. Full draft 2020-12 semantics: `$id`/`$anchor` resolution, `$dynamicRef` dynamic scoping, annotation tracking for `unevaluatedProperties`/`unevaluatedItems`. The pure-JS configuration now passes 1184 of 1190 applicable cases (99.5%) in the official test suite, up from 974, and the six remaining failures are shared with the native engine. Error results on the native-less path also carry full per-keyword detail now instead of a single generic message.
18
+
19
+ ### Changed
20
+
21
+ - Validation errors now follow the schema's keyword declaration order instead of a fixed required-first order: a schema declaring `properties` before `required` reports the property errors first, matching what schema authors read top to bottom and what the previous default validator emitted. Order within one keyword is unchanged (`required` errors still follow the array). Single-error and `abortEarly` results are untouched. With this change ata passes every applicable test in Fastify's validation suite (181 of 187; the remaining 6 test the incumbent validator's own extension API rather than validation behavior).
22
+
5
23
  ## 1.1.0 - 2026-07-18
6
24
 
7
25
  ### Added
package/README.md CHANGED
@@ -20,7 +20,7 @@ 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. Validation behavior is identical for the schema shapes the JS engine compiles, which covers typical API payload schemas. The buffer and parallel APIs, plus a small set of complex shapes (some `$dynamicRef`, cyclic `$ref`, and unusual keyword interactions), currently need the native engine and throw a clear error without it rather than validating incorrectly.
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 passes 99.8% of the official draft 2020-12 test suite. 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
25
  In your code:
26
26
 
@@ -396,6 +396,8 @@ const result = v['~standard'].validate(data);
396
396
 
397
397
  ### Fastify Plugin
398
398
 
399
+ Measured against Fastify's own schema and validation test files with ata swapped in as the default validator: 181 of 187 tests pass, and the remaining six assert the default validator's own extension API rather than validation behavior. Validation errors follow the schema's keyword declaration order, so error-message plugins and `transformErrors` hooks written against the default validator see the same order.
400
+
399
401
  ```bash
400
402
  npm install fastify-ata
401
403
  ```
@@ -464,7 +466,7 @@ Copy-paste recipes for the common frameworks. Most need 10-20 lines of glue. See
464
466
 
465
467
  ### Known limitations
466
468
 
467
- The 98.5% Draft 2020-12 pass rate excludes areas that are deliberate scope decisions for 1.x:
469
+ The Draft 2020-12 pass rate (1188 of 1190 applicable cases, 99.8%, in the pure-JS configuration; 1190 of 1190 with the native accelerator installed, which covers the two remaining `$dynamicRef` scope corners) excludes areas that are deliberate scope decisions for 1.x:
468
470
 
469
471
  - **Remote `$ref` over the network** is not fetched. Register cross-schema refs explicitly with `schemas: [...]` or `addSchema()`.
470
472
  - **`$vocabulary`** is not implemented; custom vocabularies are ignored rather than enforced.
package/index.js CHANGED
@@ -319,6 +319,47 @@ function resolveSchemaByPath(rootSchema, schemaPath) {
319
319
  return target;
320
320
  }
321
321
 
322
+ // Rank an error by walking its schemaPath through the schema object: at each
323
+ // level the segment's index among the node's declared keys. Comparing ranks
324
+ // lexicographically orders errors by keyword declaration order, which is the
325
+ // order AJV emits and what schema authors read top to bottom. Segments that
326
+ // cannot be resolved (cross-schema refs, normalized keys) end the walk; the
327
+ // stable sort then keeps such errors in engine emission order.
328
+ function schemaOrderRank(rootSchema, schemaPath) {
329
+ if (!schemaPath || typeof schemaPath !== 'string' || !schemaPath.startsWith('#')) return null;
330
+ const parts = schemaPath.slice(1).split('/').filter(Boolean).map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
331
+ const rank = [];
332
+ let node = rootSchema;
333
+ for (const seg of parts) {
334
+ if (node == null || typeof node !== 'object') break;
335
+ if (Array.isArray(node)) {
336
+ const idx = Number(seg);
337
+ if (!Number.isInteger(idx) || idx < 0 || idx >= node.length) break;
338
+ rank.push(idx);
339
+ node = node[idx];
340
+ } else {
341
+ const idx = Object.keys(node).indexOf(seg);
342
+ if (idx < 0) break;
343
+ rank.push(idx);
344
+ node = node[seg];
345
+ }
346
+ }
347
+ return rank;
348
+ }
349
+
350
+ function sortErrorsBySchemaOrder(rootSchema, errors) {
351
+ const ranked = errors.map((e, i) => ({ e, i, rank: schemaOrderRank(rootSchema, e.schemaPath) }));
352
+ ranked.sort((a, b) => {
353
+ if (!a.rank || !b.rank) return a.i - b.i;
354
+ const n = Math.min(a.rank.length, b.rank.length);
355
+ for (let k = 0; k < n; k++) {
356
+ if (a.rank[k] !== b.rank[k]) return a.rank[k] - b.rank[k];
357
+ }
358
+ return a.i - b.i;
359
+ });
360
+ return ranked.map((r) => r.e);
361
+ }
362
+
322
363
  function parsePointerPath(path) {
323
364
  if (!path) return [];
324
365
  return path
@@ -725,16 +766,32 @@ class Validator {
725
766
  // invalid path doesn't dereference a null _compiled.
726
767
  const hasUnevaluated = schemaObj && (schemaObj.unevaluatedProperties !== undefined || schemaObj.unevaluatedItems !== undefined || this._schemaStr.includes('unevaluatedProperties') || this._schemaStr.includes('unevaluatedItems'))
727
768
  const hasDynRef = this._schemaStr.includes('"$dynamicRef"') || this._schemaStr.includes('"$dynamicAnchor"')
728
- const jsOnlyFallback = (d) => ({
729
- valid: jsFn(d),
730
- errors: jsFn(d) ? [] : [{
731
- keyword: 'validation',
732
- instancePath: '',
733
- schemaPath: '',
734
- params: {},
735
- message: 'schema validation failed (detailed errors unavailable without native addon)'
736
- }]
737
- });
769
+ // Native-less error path: the interpreted engine re-validates failing
770
+ // data to produce full errors. If it disagrees with the codegen verdict
771
+ // (it should not), a generic error keeps the result consistent.
772
+ let _interp = null;
773
+ const jsOnlyFallback = (d) => {
774
+ if (jsFn(d)) return { valid: true, data: d, errors: [] };
775
+ if (!_interp) {
776
+ const { createInterpreter } = require('./lib/interpreter');
777
+ _interp = createInterpreter(schemaObj, {
778
+ schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
779
+ formats: this._userFormats,
780
+ });
781
+ }
782
+ const r = _interp.validate(d);
783
+ if (!r.valid) return r;
784
+ return {
785
+ valid: false,
786
+ errors: [{
787
+ keyword: 'validation',
788
+ instancePath: '',
789
+ schemaPath: '',
790
+ params: {},
791
+ message: 'schema validation failed'
792
+ }]
793
+ };
794
+ };
738
795
  const errFn =
739
796
  safeErrFn ||
740
797
  (hasUnevaluated
@@ -960,14 +1017,38 @@ class Validator {
960
1017
  };
961
1018
  }
962
1019
  } else if (native) {
963
- // Native-only path: no JS codegen, use native for everything
1020
+ // No JS codegen: buffer/parallel APIs always come from the native
1021
+ // engine, but the object-validation entry points go to whichever
1022
+ // engine is more correct for the schema shape. Pure dynamic-ref
1023
+ // schemas stay on the C++ path (full $dynamicRef scope tracking);
1024
+ // everything else uses the interpreted engine, which handles the
1025
+ // $id/URN base-URI resolution corners the native resolver gets wrong.
964
1026
  this._ensureNative();
965
- const _hasDynamic = this._schemaStr.includes('"$dynamicRef"') || this._schemaStr.includes('"$dynamicAnchor"') || this._schemaStr.includes('"$anchor"')
966
- // For schemas with dynamic refs/anchors, use validateJSON (C++ path with full support)
967
- // instead of validate (NAPI direct V8 path without anchor maps)
968
- const _validate = _hasDynamic
969
- ? (data) => this._compiled.validateJSON(JSON.stringify(data))
970
- : (data) => this._compiled.validate(data);
1027
+ const _hasDynRef = this._schemaStr.includes('"$dynamicRef"') || this._schemaStr.includes('"$dynamicAnchor"')
1028
+ const _hasUneval = this._schemaStr.includes('"unevaluatedProperties"') || this._schemaStr.includes('"unevaluatedItems"')
1029
+ let _validate;
1030
+ if (_hasDynRef && !_hasUneval) {
1031
+ // validateJSON is the C++ path with full anchor-map support; the NAPI
1032
+ // direct V8 `validate` path has no anchor maps.
1033
+ _validate = (data) => this._compiled.validateJSON(JSON.stringify(data));
1034
+ this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
1035
+ this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
1036
+ } else {
1037
+ const { createInterpreter } = require('./lib/interpreter');
1038
+ const interp = createInterpreter(schemaObj, {
1039
+ schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
1040
+ formats: this._userFormats,
1041
+ });
1042
+ _validate = (data) => interp.validate(data);
1043
+ this.validateJSON = (jsonStr) => {
1044
+ try {
1045
+ return _validate(JSON.parse(jsonStr));
1046
+ } catch (e) {
1047
+ return { valid: false, errors: [{ keyword: 'syntax', instancePath: '', schemaPath: '#', params: {}, message: e.message }] };
1048
+ }
1049
+ };
1050
+ this.isValidJSON = (jsonStr) => this.validateJSON(jsonStr).valid;
1051
+ }
971
1052
  this.validate = preprocess
972
1053
  ? (data) => {
973
1054
  preprocess(data);
@@ -975,8 +1056,6 @@ class Validator {
975
1056
  }
976
1057
  : _validate;
977
1058
  this.isValidObject = (data) => _validate(data).valid;
978
- this.validateJSON = (jsonStr) => this._compiled.validateJSON(jsonStr);
979
- this.isValidJSON = (jsonStr) => this._compiled.isValidJSON(jsonStr);
980
1059
  this.validateAndParse = (jsonStr) => this._compiled.validateAndParse(jsonStr);
981
1060
  {
982
1061
  const slot = this._fastSlot;
@@ -1009,17 +1088,42 @@ class Validator {
1009
1088
  };
1010
1089
  }
1011
1090
  } else {
1012
- // No JS codegen and no native engine. Leaving the lazy stubs in place
1013
- // makes them re-dispatch to themselves forever, so bind a clear error.
1014
- const unsupported = () => {
1015
- throw new Error(
1016
- 'This schema is not supported by the pure-JS engine and the native addon is unavailable in this environment'
1017
- );
1091
+ // No JS codegen and no native engine: fall back to the interpreted
1092
+ // engine. Slow but correct, and strictly better than the previous
1093
+ // behavior (the lazy stubs re-dispatched to themselves forever).
1094
+ const { createInterpreter } = require('./lib/interpreter');
1095
+ const interp = createInterpreter(schemaObj, {
1096
+ schemaMap: this._schemaMap.size > 0 ? this._schemaMap : null,
1097
+ formats: this._userFormats,
1098
+ });
1099
+ const run = preprocess
1100
+ ? (data) => { preprocess(data); return interp.validate(data); }
1101
+ : (data) => interp.validate(data);
1102
+ this.validate = run;
1103
+ this.isValidObject = (data) => run(data).valid;
1104
+ this.validateJSON = (jsonStr) => {
1105
+ try {
1106
+ return run(JSON.parse(jsonStr));
1107
+ } catch (e) {
1108
+ return { valid: false, errors: [{ keyword: 'syntax', instancePath: '', schemaPath: '#', params: {}, message: e.message }] };
1109
+ }
1110
+ };
1111
+ this.isValidJSON = (jsonStr) => this.validateJSON(jsonStr).valid;
1112
+ }
1113
+
1114
+ // Declaration-order errors: whichever engine produced them, multi-error
1115
+ // results are sorted by the schema's keyword declaration order before
1116
+ // enrichment. Single-error and abortEarly results pass through untouched.
1117
+ if (this.validate) {
1118
+ const inner = this.validate;
1119
+ const root = this._schemaObj;
1120
+ this.validate = (data) => {
1121
+ const result = inner(data);
1122
+ if (result && !result.valid && result.errors && result.errors.length > 1 && result !== ABORT_EARLY_RESULT) {
1123
+ return { valid: false, errors: sortErrorsBySchemaOrder(root, result.errors) };
1124
+ }
1125
+ return result;
1018
1126
  };
1019
- this.validate = unsupported;
1020
- this.isValidObject = unsupported;
1021
- this.validateJSON = unsupported;
1022
- this.isValidJSON = unsupported;
1023
1127
  }
1024
1128
 
1025
1129
  // richErrors enrichment: layered on top of whichever validate path was
@@ -0,0 +1,617 @@
1
+ 'use strict';
2
+
3
+ // Interpreted draft 2020-12 engine. This is the correctness fallback for
4
+ // schemas the JS codegen cannot represent when the native engine is absent
5
+ // (browser, edge workers, ATA_NO_NATIVE). It walks the schema directly with
6
+ // full annotation tracking (unevaluatedProperties/Items), $id/$anchor
7
+ // resolution, and $dynamicRef dynamic-scope semantics. It optimizes for
8
+ // correctness and clarity, not speed: hot paths belong to codegen and the
9
+ // native engine.
10
+
11
+ const { compileSafe } = require('./safe-regex');
12
+
13
+ const SCHEMA_KEYWORDS = {
14
+ single: ['additionalProperties', 'contains', 'propertyNames', 'if', 'then', 'else', 'not', 'items', 'unevaluatedItems', 'unevaluatedProperties'],
15
+ maps: ['$defs', 'definitions', 'properties', 'patternProperties', 'dependentSchemas'],
16
+ lists: ['allOf', 'anyOf', 'oneOf', 'prefixItems'],
17
+ };
18
+
19
+ const FALLBACK_BASE = 'ata://root';
20
+
21
+ function resolveUri(base, ref) {
22
+ try {
23
+ return new URL(ref, base || FALLBACK_BASE).href;
24
+ } catch {
25
+ return ref;
26
+ }
27
+ }
28
+
29
+ function splitFragment(uri) {
30
+ const hash = uri.indexOf('#');
31
+ if (hash < 0) return [uri, ''];
32
+ return [uri.slice(0, hash), decodeURIComponent(uri.slice(hash + 1))];
33
+ }
34
+
35
+ // ---------- resource index ----------
36
+
37
+ function indexSchemas(rootSchema, schemaMap) {
38
+ const state = {
39
+ resources: new Map(), // baseUri -> schema node of the resource root
40
+ anchors: new Map(), // baseUri -> Map(anchorName -> node)
41
+ dynamicAnchors: new Map(), // baseUri -> Map(anchorName -> node)
42
+ nodeBase: new Map(), // schema node -> baseUri of its enclosing resource
43
+ rootBase: FALLBACK_BASE,
44
+ };
45
+
46
+ const rootBase =
47
+ typeof rootSchema === 'object' && rootSchema !== null && typeof rootSchema.$id === 'string'
48
+ ? resolveUri(FALLBACK_BASE, splitFragment(rootSchema.$id)[0])
49
+ : FALLBACK_BASE;
50
+ state.rootBase = rootBase;
51
+ indexResource(rootSchema, rootBase, state);
52
+
53
+ if (schemaMap) {
54
+ for (const [id, schema] of schemaMap) {
55
+ const base = resolveUri(FALLBACK_BASE, splitFragment(id)[0]);
56
+ if (!state.resources.has(base)) indexResource(schema, base, state);
57
+ // Also register under the raw key so non-URI ids stay addressable.
58
+ if (!state.resources.has(id)) state.resources.set(id, schema);
59
+ }
60
+ }
61
+ return state;
62
+ }
63
+
64
+ function indexResource(node, baseUri, state) {
65
+ if (typeof node !== 'object' || node === null) return;
66
+ if (!state.resources.has(baseUri)) state.resources.set(baseUri, node);
67
+ if (!state.anchors.has(baseUri)) state.anchors.set(baseUri, new Map());
68
+ if (!state.dynamicAnchors.has(baseUri)) state.dynamicAnchors.set(baseUri, new Map());
69
+ walkSchema(node, baseUri, state, true);
70
+ }
71
+
72
+ function walkSchema(node, baseUri, state, isResourceRoot) {
73
+ if (typeof node !== 'object' || node === null) return;
74
+ if (state.nodeBase.has(node)) return; // cycle guard
75
+ state.nodeBase.set(node, baseUri);
76
+
77
+ if (!isResourceRoot && typeof node.$id === 'string') {
78
+ // Embedded resource: new base URI, indexed as its own resource.
79
+ const newBase = splitFragment(resolveUri(baseUri, node.$id))[0];
80
+ state.nodeBase.delete(node); // re-register under the new base
81
+ indexResource(node, newBase, state);
82
+ return;
83
+ }
84
+
85
+ if (typeof node.$anchor === 'string') state.anchors.get(baseUri).set(node.$anchor, node);
86
+ if (typeof node.$dynamicAnchor === 'string') {
87
+ state.dynamicAnchors.get(baseUri).set(node.$dynamicAnchor, node);
88
+ // A $dynamicAnchor is also addressable as a plain anchor.
89
+ state.anchors.get(baseUri).set(node.$dynamicAnchor, node);
90
+ }
91
+
92
+ for (const kw of SCHEMA_KEYWORDS.single) {
93
+ if (node[kw] !== undefined) walkSchema(node[kw], baseUri, state, false);
94
+ }
95
+ for (const kw of SCHEMA_KEYWORDS.maps) {
96
+ const map = node[kw];
97
+ if (map && typeof map === 'object' && !Array.isArray(map)) {
98
+ for (const key of Object.keys(map)) walkSchema(map[key], baseUri, state, false);
99
+ }
100
+ }
101
+ for (const kw of SCHEMA_KEYWORDS.lists) {
102
+ const list = node[kw];
103
+ if (Array.isArray(list)) for (const sub of list) walkSchema(sub, baseUri, state, false);
104
+ }
105
+ }
106
+
107
+ // ---------- ref resolution ----------
108
+
109
+ function walkPointer(root, pointer) {
110
+ if (pointer === '' || pointer === '/') return root;
111
+ const parts = pointer
112
+ .split('/')
113
+ .slice(1)
114
+ .map((s) => s.replace(/~1/g, '/').replace(/~0/g, '~'));
115
+ let node = root;
116
+ for (const part of parts) {
117
+ if (node === null || typeof node !== 'object') return undefined;
118
+ if (Array.isArray(node)) {
119
+ node = node[Number(part)];
120
+ } else if (part in node) {
121
+ node = node[part];
122
+ } else if (part === 'definitions' && node.$defs) {
123
+ // draft-07 normalization renames definitions to $defs; keep old
124
+ // pointers working (mirrors the 1.0.2 alias fix).
125
+ node = node.$defs;
126
+ } else if (part === '$defs' && node.definitions) {
127
+ node = node.definitions;
128
+ } else {
129
+ return undefined;
130
+ }
131
+ }
132
+ return node;
133
+ }
134
+
135
+ function resolveRef(ref, fromBase, state) {
136
+ const abs = resolveUri(fromBase, ref);
137
+ const [uri, fragment] = splitFragment(abs);
138
+ let resource = state.resources.get(uri);
139
+ let resourceBase = uri;
140
+ if (resource === undefined && (uri === FALLBACK_BASE || uri === '')) {
141
+ resource = state.resources.get(state.rootBase);
142
+ resourceBase = state.rootBase;
143
+ }
144
+ if (resource === undefined) {
145
+ // Raw-id registration (schemaMap keys that are not URIs).
146
+ const [rawUri, rawFragment] = splitFragment(ref);
147
+ if (state.resources.has(rawUri)) {
148
+ resource = state.resources.get(rawUri);
149
+ resourceBase = rawUri;
150
+ if (rawFragment === '') return { node: resource, base: resourceBase };
151
+ if (rawFragment.startsWith('/')) return { node: walkPointer(resource, rawFragment), base: resourceBase };
152
+ const anchored = state.anchors.get(resourceBase);
153
+ return { node: anchored ? anchored.get(rawFragment) : undefined, base: resourceBase };
154
+ }
155
+ return { node: undefined, base: resourceBase };
156
+ }
157
+ if (fragment === '') return { node: resource, base: resourceBase };
158
+ if (fragment.startsWith('/')) {
159
+ const node = walkPointer(resource, fragment);
160
+ // A pointer can land inside an embedded resource; its recorded base wins.
161
+ const base = node !== null && typeof node === 'object' && state.nodeBase.has(node) ? state.nodeBase.get(node) : resourceBase;
162
+ return { node, base };
163
+ }
164
+ const anchored = state.anchors.get(resourceBase);
165
+ return { node: anchored ? anchored.get(fragment) : undefined, base: resourceBase };
166
+ }
167
+
168
+ // ---------- value helpers ----------
169
+
170
+ function deepEqual(a, b) {
171
+ if (a === b) return true;
172
+ if (typeof a === 'number' && typeof b === 'number') return a === b;
173
+ if (a === null || b === null || typeof a !== 'object' || typeof b !== 'object') return false;
174
+ const aArr = Array.isArray(a);
175
+ if (aArr !== Array.isArray(b)) return false;
176
+ if (aArr) {
177
+ if (a.length !== b.length) return false;
178
+ for (let i = 0; i < a.length; i++) if (!deepEqual(a[i], b[i])) return false;
179
+ return true;
180
+ }
181
+ const ak = Object.keys(a);
182
+ const bk = Object.keys(b);
183
+ if (ak.length !== bk.length) return false;
184
+ for (const k of ak) {
185
+ if (!(k in b) || !deepEqual(a[k], b[k])) return false;
186
+ }
187
+ return true;
188
+ }
189
+
190
+ function codePointLength(s) {
191
+ let n = 0;
192
+ for (let i = 0; i < s.length; i++) {
193
+ const c = s.charCodeAt(i);
194
+ if (c >= 0xd800 && c <= 0xdbff && i + 1 < s.length) i++;
195
+ n++;
196
+ }
197
+ return n;
198
+ }
199
+
200
+ function typeMatches(type, d) {
201
+ switch (type) {
202
+ case 'string': return typeof d === 'string';
203
+ case 'number': return typeof d === 'number' && isFinite(d);
204
+ case 'integer': return typeof d === 'number' && Number.isInteger(d);
205
+ case 'boolean': return typeof d === 'boolean';
206
+ case 'null': return d === null;
207
+ case 'array': return Array.isArray(d);
208
+ case 'object': return typeof d === 'object' && d !== null && !Array.isArray(d);
209
+ default: return true;
210
+ }
211
+ }
212
+
213
+ function multipleOfOk(d, m) {
214
+ if (m === 0) return false;
215
+ const q = d / m;
216
+ if (Number.isInteger(q)) return true;
217
+ // Float remainder tolerance (0.0075 % 0.0001 style cases)
218
+ return Math.abs(q - Math.round(q)) < 1e-9;
219
+ }
220
+
221
+ const FORMAT_CHECKS = {
222
+ email: (s) => { const at = s.indexOf('@'); return at > 0 && at < s.length - 1 && s.indexOf('.', at) > at + 1; },
223
+ date: (s) => { if (s.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(s)) return false; const m = +s.slice(5, 7), d = +s.slice(8, 10); return m >= 1 && m <= 12 && d >= 1 && d <= 31; },
224
+ 'date-time': (s) => /^\d{4}-\d{2}-\d{2}[Tt]\d{2}:\d{2}:\d{2}(\.\d+)?([Zz]|[+-]\d{2}:\d{2})$/.test(s) && !isNaN(Date.parse(s)),
225
+ time: (s) => /^([01]\d|2[0-3]):[0-5]\d:[0-5]\d(\.\d+)?([Zz]|[+-]\d{2}:\d{2})?$/.test(s),
226
+ duration: (s) => /^P(?:\d+Y)?(?:\d+M)?(?:\d+W)?(?:\d+D)?(?:T(?:\d+H)?(?:\d+M)?(?:\d+(?:\.\d+)?S)?)?$/.test(s) && s !== 'P' && !s.endsWith('T'),
227
+ uuid: (s) => s.length === 36 && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s),
228
+ uri: (s) => /^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(s),
229
+ 'uri-reference': (s) => s === '' || !/\s/.test(s),
230
+ ipv4: (s) => { const p = s.split('.'); return p.length === 4 && p.every((n) => { const v = +n; return n !== '' && v >= 0 && v <= 255 && String(v) === n; }); },
231
+ ipv6: (s) => s !== '' && /^[0-9a-fA-F:.]+$/.test(s) && s.split(':').length >= 3 && s.split(':').length <= 8,
232
+ hostname: (s) => s.length > 0 && s.length <= 253 && /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/.test(s),
233
+ regex: (s) => { try { new RegExp(s, 'u'); return true; } catch { try { new RegExp(s); return true; } catch { return false; } } },
234
+ 'json-pointer': (s) => s === '' || (/^\//.test(s) && !/~(?![01])/.test(s)),
235
+ };
236
+
237
+ // ---------- evaluation ----------
238
+
239
+ const NO_ANNOTATIONS = { props: null, items: null };
240
+
241
+ function mergeAnnotations(target, from) {
242
+ if (from.props && from.props.size) {
243
+ if (!target.props) target.props = new Set();
244
+ for (const p of from.props) target.props.add(p);
245
+ }
246
+ if (from.items && from.items.size) {
247
+ if (!target.items) target.items = new Set();
248
+ for (const i of from.items) target.items.add(i);
249
+ }
250
+ }
251
+
252
+ class Interpreter {
253
+ constructor(rootSchema, options) {
254
+ const opts = options || {};
255
+ this.root = rootSchema;
256
+ this.state = indexSchemas(rootSchema, opts.schemaMap);
257
+ this.userFormats = opts.formats || null;
258
+ this.patternCache = new Map();
259
+ }
260
+
261
+ pattern(src) {
262
+ let re = this.patternCache.get(src);
263
+ if (!re) {
264
+ if (/\\[pP]\{/.test(src)) {
265
+ // Unicode property escapes need a real RegExp in unicode mode; the
266
+ // linear engine does not understand them.
267
+ try { re = new RegExp(src, 'u'); } catch { re = new RegExp(src); }
268
+ } else {
269
+ try {
270
+ re = compileSafe(src);
271
+ } catch {
272
+ try { re = new RegExp(src, 'u'); } catch { re = new RegExp(src); }
273
+ }
274
+ }
275
+ this.patternCache.set(src, re);
276
+ }
277
+ return re;
278
+ }
279
+
280
+ validate(data) {
281
+ const errors = [];
282
+ const dynScope = [this.state.rootBase];
283
+ const valid = this.eval(this.root, data, this.state.rootBase, dynScope, errors, '', '#', [], NO_ANNOTATIONS_SINK());
284
+ return valid ? { valid: true, data, errors: [] } : { valid: false, errors };
285
+ }
286
+
287
+ // Evaluates `schema` against `data`. Collected annotations for data are
288
+ // written into `sink` ({props, items}); errors append to `errors`.
289
+ // `stack` breaks infinite $ref recursion on identical (schema, data) pairs.
290
+ eval(schema, data, base, dynScope, errors, instancePath, schemaPath, stack, sink) {
291
+ if (schema === true) return true;
292
+ if (schema === false) {
293
+ errors.push(err('false schema', 'not', instancePath, schemaPath, {}, 'boolean schema is false'));
294
+ return false;
295
+ }
296
+ if (typeof schema !== 'object' || schema === null) return true;
297
+
298
+ for (const frame of stack) {
299
+ if (frame[0] === schema && frame[1] === data) return true; // fixed point
300
+ }
301
+ stack = stack.concat([[schema, data]]);
302
+
303
+ const nodeBase = this.state.nodeBase.get(schema);
304
+ if (nodeBase !== undefined && nodeBase !== base) base = nodeBase;
305
+ // Entering a schema resource extends the dynamic scope.
306
+ if (this.state.resources.get(base) === schema && dynScope[dynScope.length - 1] !== base) {
307
+ dynScope = dynScope.concat(base);
308
+ }
309
+
310
+ let valid = true;
311
+ const local = { props: null, items: null };
312
+
313
+ // $ref / $dynamicRef: in-place applicators, annotations flow through
314
+ if (typeof schema.$ref === 'string') {
315
+ const { node, base: refBase } = resolveRef(schema.$ref, base, this.state);
316
+ if (node === undefined) {
317
+ errors.push(err('$ref', '$ref', instancePath, schemaPath + '/$ref', { ref: schema.$ref }, `cannot resolve $ref ${schema.$ref}`));
318
+ valid = false;
319
+ } else {
320
+ const sub = { props: null, items: null };
321
+ if (!this.eval(node, data, refBase, dynScope, errors, instancePath, schemaPath + '/$ref', stack, sub)) valid = false;
322
+ else mergeAnnotations(local, sub);
323
+ }
324
+ }
325
+
326
+ if (typeof schema.$dynamicRef === 'string') {
327
+ const ref = schema.$dynamicRef;
328
+ let { node, base: refBase } = resolveRef(ref, base, this.state);
329
+ const [, fragment] = splitFragment(resolveUri(base, ref));
330
+ if (node !== undefined && fragment && !fragment.startsWith('/')) {
331
+ // Initial target must itself carry the matching $dynamicAnchor for
332
+ // dynamic resolution; otherwise it behaves like $ref.
333
+ const initialDyn = this.state.dynamicAnchors.get(refBase);
334
+ if (initialDyn && initialDyn.get(fragment) === node) {
335
+ for (const scopeBase of dynScope) {
336
+ const dyn = this.state.dynamicAnchors.get(scopeBase);
337
+ if (dyn && dyn.has(fragment)) {
338
+ node = dyn.get(fragment);
339
+ refBase = scopeBase;
340
+ break;
341
+ }
342
+ }
343
+ }
344
+ }
345
+ if (node === undefined) {
346
+ errors.push(err('$dynamicRef', '$dynamicRef', instancePath, schemaPath + '/$dynamicRef', { ref }, `cannot resolve $dynamicRef ${ref}`));
347
+ valid = false;
348
+ } else {
349
+ const sub = { props: null, items: null };
350
+ if (!this.eval(node, data, refBase, dynScope, errors, instancePath, schemaPath + '/$dynamicRef', stack, sub)) valid = false;
351
+ else mergeAnnotations(local, sub);
352
+ }
353
+ }
354
+
355
+ // type / enum / const
356
+ if (schema.type !== undefined) {
357
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
358
+ if (!types.some((t) => typeMatches(t, data))) {
359
+ errors.push(err('type', 'type', instancePath, schemaPath + '/type', { type: schema.type }, `must be ${types.join(' or ')}`));
360
+ valid = false;
361
+ }
362
+ }
363
+ if (schema.enum !== undefined) {
364
+ if (!schema.enum.some((v) => deepEqual(v, data))) {
365
+ errors.push(err('enum', 'enum', instancePath, schemaPath + '/enum', { allowedValues: schema.enum }, 'must be equal to one of the allowed values'));
366
+ valid = false;
367
+ }
368
+ }
369
+ if (schema.const !== undefined) {
370
+ if (!deepEqual(schema.const, data)) {
371
+ errors.push(err('const', 'const', instancePath, schemaPath + '/const', { allowedValue: schema.const }, 'must be equal to constant'));
372
+ valid = false;
373
+ }
374
+ }
375
+
376
+ // numbers
377
+ if (typeof data === 'number') {
378
+ if (typeof schema.minimum === 'number' && !(data >= schema.minimum)) { errors.push(err('minimum', 'minimum', instancePath, schemaPath + '/minimum', { comparison: '>=', limit: schema.minimum }, `must be >= ${schema.minimum}`)); valid = false; }
379
+ if (typeof schema.maximum === 'number' && !(data <= schema.maximum)) { errors.push(err('maximum', 'maximum', instancePath, schemaPath + '/maximum', { comparison: '<=', limit: schema.maximum }, `must be <= ${schema.maximum}`)); valid = false; }
380
+ if (typeof schema.exclusiveMinimum === 'number' && !(data > schema.exclusiveMinimum)) { errors.push(err('exclusiveMinimum', 'exclusiveMinimum', instancePath, schemaPath + '/exclusiveMinimum', { comparison: '>', limit: schema.exclusiveMinimum }, `must be > ${schema.exclusiveMinimum}`)); valid = false; }
381
+ if (typeof schema.exclusiveMaximum === 'number' && !(data < schema.exclusiveMaximum)) { errors.push(err('exclusiveMaximum', 'exclusiveMaximum', instancePath, schemaPath + '/exclusiveMaximum', { comparison: '<', limit: schema.exclusiveMaximum }, `must be < ${schema.exclusiveMaximum}`)); valid = false; }
382
+ if (typeof schema.multipleOf === 'number' && !multipleOfOk(data, schema.multipleOf)) { errors.push(err('multipleOf', 'multipleOf', instancePath, schemaPath + '/multipleOf', { multipleOf: schema.multipleOf }, `must be multiple of ${schema.multipleOf}`)); valid = false; }
383
+ }
384
+
385
+ // strings
386
+ if (typeof data === 'string') {
387
+ if (schema.minLength !== undefined && codePointLength(data) < schema.minLength) { errors.push(err('minLength', 'minLength', instancePath, schemaPath + '/minLength', { limit: schema.minLength }, `must NOT have fewer than ${schema.minLength} characters`)); valid = false; }
388
+ if (schema.maxLength !== undefined && codePointLength(data) > schema.maxLength) { errors.push(err('maxLength', 'maxLength', instancePath, schemaPath + '/maxLength', { limit: schema.maxLength }, `must NOT have more than ${schema.maxLength} characters`)); valid = false; }
389
+ if (schema.pattern !== undefined && !this.pattern(schema.pattern).test(data)) { errors.push(err('pattern', 'pattern', instancePath, schemaPath + '/pattern', { pattern: schema.pattern }, `must match pattern "${schema.pattern}"`)); valid = false; }
390
+ if (schema.format !== undefined) {
391
+ const fc = (this.userFormats && typeof this.userFormats[schema.format] === 'function') ? this.userFormats[schema.format] : FORMAT_CHECKS[schema.format];
392
+ if (fc && !fc(data)) { errors.push(err('format', 'format', instancePath, schemaPath + '/format', { format: schema.format }, `must match format "${schema.format}"`)); valid = false; }
393
+ }
394
+ }
395
+
396
+ // arrays
397
+ if (Array.isArray(data)) {
398
+ if (schema.minItems !== undefined && data.length < schema.minItems) { errors.push(err('minItems', 'minItems', instancePath, schemaPath + '/minItems', { limit: schema.minItems }, `must NOT have fewer than ${schema.minItems} items`)); valid = false; }
399
+ if (schema.maxItems !== undefined && data.length > schema.maxItems) { errors.push(err('maxItems', 'maxItems', instancePath, schemaPath + '/maxItems', { limit: schema.maxItems }, `must NOT have more than ${schema.maxItems} items`)); valid = false; }
400
+ if (schema.uniqueItems === true) {
401
+ outer: for (let i = 0; i < data.length; i++) {
402
+ for (let j = i + 1; j < data.length; j++) {
403
+ if (deepEqual(data[i], data[j])) {
404
+ errors.push(err('uniqueItems', 'uniqueItems', instancePath, schemaPath + '/uniqueItems', { i, j }, 'must NOT have duplicate items'));
405
+ valid = false;
406
+ break outer;
407
+ }
408
+ }
409
+ }
410
+ }
411
+ const prefix = Array.isArray(schema.prefixItems) ? schema.prefixItems : null;
412
+ if (prefix) {
413
+ for (let i = 0; i < Math.min(prefix.length, data.length); i++) {
414
+ const sub = { props: null, items: null };
415
+ if (!this.eval(prefix[i], data[i], base, dynScope, errors, instancePath + '/' + i, schemaPath + '/prefixItems/' + i, stack, sub)) valid = false;
416
+ if (!local.items) local.items = new Set();
417
+ local.items.add(i);
418
+ }
419
+ }
420
+ if (schema.items !== undefined) {
421
+ const start = prefix ? prefix.length : 0;
422
+ for (let i = start; i < data.length; i++) {
423
+ const sub = { props: null, items: null };
424
+ if (!this.eval(schema.items, data[i], base, dynScope, errors, instancePath + '/' + i, schemaPath + '/items', stack, sub)) valid = false;
425
+ if (!local.items) local.items = new Set();
426
+ local.items.add(i);
427
+ }
428
+ }
429
+ if (schema.contains !== undefined) {
430
+ const matched = [];
431
+ for (let i = 0; i < data.length; i++) {
432
+ const scratch = [];
433
+ const sub = { props: null, items: null };
434
+ if (this.eval(schema.contains, data[i], base, dynScope, scratch, instancePath + '/' + i, schemaPath + '/contains', stack, sub)) matched.push(i);
435
+ }
436
+ const minC = schema.minContains !== undefined ? schema.minContains : 1;
437
+ if (matched.length < minC) { errors.push(err('contains', 'contains', instancePath, schemaPath + '/contains', { minContains: minC }, `must contain at least ${minC} valid item(s)`)); valid = false; }
438
+ if (schema.maxContains !== undefined && matched.length > schema.maxContains) { errors.push(err('maxContains', 'maxContains', instancePath, schemaPath + '/maxContains', { limit: schema.maxContains }, `must NOT contain more than ${schema.maxContains} valid item(s)`)); valid = false; }
439
+ if (matched.length) {
440
+ if (!local.items) local.items = new Set();
441
+ for (const i of matched) local.items.add(i);
442
+ }
443
+ }
444
+ }
445
+
446
+ // objects
447
+ if (typeof data === 'object' && data !== null && !Array.isArray(data)) {
448
+ const keys = Object.keys(data);
449
+ if (Array.isArray(schema.required)) {
450
+ for (const key of schema.required) {
451
+ if (!Object.prototype.hasOwnProperty.call(data, key)) { errors.push(err('required', 'required', instancePath, schemaPath + '/required', { missingProperty: key }, `must have required property '${key}'`)); valid = false; }
452
+ }
453
+ }
454
+ if (schema.minProperties !== undefined && keys.length < schema.minProperties) { errors.push(err('minProperties', 'minProperties', instancePath, schemaPath + '/minProperties', { limit: schema.minProperties }, `must NOT have fewer than ${schema.minProperties} properties`)); valid = false; }
455
+ if (schema.maxProperties !== undefined && keys.length > schema.maxProperties) { errors.push(err('maxProperties', 'maxProperties', instancePath, schemaPath + '/maxProperties', { limit: schema.maxProperties }, `must NOT have more than ${schema.maxProperties} properties`)); valid = false; }
456
+ if (schema.dependentRequired && typeof schema.dependentRequired === 'object') {
457
+ for (const [key, deps] of Object.entries(schema.dependentRequired)) {
458
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
459
+ for (const dep of deps) {
460
+ if (!Object.prototype.hasOwnProperty.call(data, dep)) { errors.push(err('required', 'required', instancePath, schemaPath + '/dependentRequired', { missingProperty: dep }, `must have required property '${dep}'`)); valid = false; }
461
+ }
462
+ }
463
+ }
464
+ }
465
+ if (schema.propertyNames !== undefined) {
466
+ for (const key of keys) {
467
+ if (!this.eval(schema.propertyNames, key, base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/propertyNames', stack, { props: null, items: null })) valid = false;
468
+ }
469
+ }
470
+ const props = schema.properties && typeof schema.properties === 'object' ? schema.properties : null;
471
+ const patterns = schema.patternProperties && typeof schema.patternProperties === 'object' ? Object.keys(schema.patternProperties) : null;
472
+ for (const key of keys) {
473
+ let evaluated = false;
474
+ if (props && Object.prototype.hasOwnProperty.call(props, key)) {
475
+ const sub = { props: null, items: null };
476
+ if (!this.eval(props[key], data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/properties/' + escapePointer(key), stack, sub)) valid = false;
477
+ evaluated = true;
478
+ }
479
+ if (patterns) {
480
+ for (const src of patterns) {
481
+ if (this.pattern(src).test(key)) {
482
+ const sub = { props: null, items: null };
483
+ if (!this.eval(schema.patternProperties[src], data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/patternProperties/' + escapePointer(src), stack, sub)) valid = false;
484
+ evaluated = true;
485
+ }
486
+ }
487
+ }
488
+ if (!evaluated && schema.additionalProperties !== undefined) {
489
+ const sub = { props: null, items: null };
490
+ if (!this.eval(schema.additionalProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/additionalProperties', stack, sub)) valid = false;
491
+ evaluated = true;
492
+ }
493
+ if (evaluated) {
494
+ if (!local.props) local.props = new Set();
495
+ local.props.add(key);
496
+ }
497
+ }
498
+ if (schema.dependentSchemas && typeof schema.dependentSchemas === 'object') {
499
+ for (const [key, dep] of Object.entries(schema.dependentSchemas)) {
500
+ if (Object.prototype.hasOwnProperty.call(data, key)) {
501
+ const sub = { props: null, items: null };
502
+ if (!this.eval(dep, data, base, dynScope, errors, instancePath, schemaPath + '/dependentSchemas/' + escapePointer(key), stack, sub)) valid = false;
503
+ else mergeAnnotations(local, sub);
504
+ }
505
+ }
506
+ }
507
+ }
508
+
509
+ // in-place applicators
510
+ if (Array.isArray(schema.allOf)) {
511
+ for (let i = 0; i < schema.allOf.length; i++) {
512
+ const sub = { props: null, items: null };
513
+ if (!this.eval(schema.allOf[i], data, base, dynScope, errors, instancePath, schemaPath + '/allOf/' + i, stack, sub)) valid = false;
514
+ else mergeAnnotations(local, sub);
515
+ }
516
+ }
517
+ if (Array.isArray(schema.anyOf)) {
518
+ let any = false;
519
+ const scratch = [];
520
+ for (let i = 0; i < schema.anyOf.length; i++) {
521
+ const sub = { props: null, items: null };
522
+ if (this.eval(schema.anyOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/anyOf/' + i, stack, sub)) {
523
+ any = true;
524
+ mergeAnnotations(local, sub);
525
+ }
526
+ }
527
+ if (!any) {
528
+ for (const e of scratch) errors.push(e);
529
+ errors.push(err('anyOf', 'anyOf', instancePath, schemaPath + '/anyOf', {}, 'must match a schema in anyOf'));
530
+ valid = false;
531
+ }
532
+ }
533
+ if (Array.isArray(schema.oneOf)) {
534
+ let count = 0;
535
+ let winner = null;
536
+ const scratch = [];
537
+ for (let i = 0; i < schema.oneOf.length; i++) {
538
+ const sub = { props: null, items: null };
539
+ if (this.eval(schema.oneOf[i], data, base, dynScope, scratch, instancePath, schemaPath + '/oneOf/' + i, stack, sub)) {
540
+ count++;
541
+ winner = sub;
542
+ }
543
+ }
544
+ if (count === 1) mergeAnnotations(local, winner);
545
+ else {
546
+ if (count === 0) for (const e of scratch) errors.push(e);
547
+ errors.push(err('oneOf', 'oneOf', instancePath, schemaPath + '/oneOf', { passingSchemas: count }, 'must match exactly one schema in oneOf'));
548
+ valid = false;
549
+ }
550
+ }
551
+ if (schema.not !== undefined) {
552
+ const scratch = [];
553
+ if (this.eval(schema.not, data, base, dynScope, scratch, instancePath, schemaPath + '/not', stack, { props: null, items: null })) {
554
+ errors.push(err('not', 'not', instancePath, schemaPath + '/not', {}, 'must NOT be valid'));
555
+ valid = false;
556
+ }
557
+ }
558
+ if (schema.if !== undefined) {
559
+ const ifSub = { props: null, items: null };
560
+ const ifScratch = [];
561
+ const ifOk = this.eval(schema.if, data, base, dynScope, ifScratch, instancePath, schemaPath + '/if', stack, ifSub);
562
+ if (ifOk) {
563
+ mergeAnnotations(local, ifSub);
564
+ if (schema.then !== undefined) {
565
+ const sub = { props: null, items: null };
566
+ if (!this.eval(schema.then, data, base, dynScope, errors, instancePath, schemaPath + '/then', stack, sub)) valid = false;
567
+ else mergeAnnotations(local, sub);
568
+ }
569
+ } else if (schema.else !== undefined) {
570
+ const sub = { props: null, items: null };
571
+ if (!this.eval(schema.else, data, base, dynScope, errors, instancePath, schemaPath + '/else', stack, sub)) valid = false;
572
+ else mergeAnnotations(local, sub);
573
+ }
574
+ }
575
+
576
+ // unevaluated*: run last, against annotations from everything above
577
+ if (schema.unevaluatedProperties !== undefined && typeof data === 'object' && data !== null && !Array.isArray(data)) {
578
+ for (const key of Object.keys(data)) {
579
+ if (local.props && local.props.has(key)) continue;
580
+ const sub = { props: null, items: null };
581
+ if (!this.eval(schema.unevaluatedProperties, data[key], base, dynScope, errors, instancePath + '/' + escapePointer(key), schemaPath + '/unevaluatedProperties', stack, sub)) valid = false;
582
+ if (!local.props) local.props = new Set();
583
+ local.props.add(key);
584
+ }
585
+ }
586
+ if (schema.unevaluatedItems !== undefined && Array.isArray(data)) {
587
+ for (let i = 0; i < data.length; i++) {
588
+ if (local.items && local.items.has(i)) continue;
589
+ const sub = { props: null, items: null };
590
+ if (!this.eval(schema.unevaluatedItems, data[i], base, dynScope, errors, instancePath + '/' + i, schemaPath + '/unevaluatedItems', stack, sub)) valid = false;
591
+ if (!local.items) local.items = new Set();
592
+ local.items.add(i);
593
+ }
594
+ }
595
+
596
+ if (valid) mergeAnnotations(sink, local);
597
+ return valid;
598
+ }
599
+ }
600
+
601
+ function NO_ANNOTATIONS_SINK() {
602
+ return { props: null, items: null };
603
+ }
604
+
605
+ function escapePointer(s) {
606
+ return String(s).replace(/~/g, '~0').replace(/\//g, '~1');
607
+ }
608
+
609
+ function err(code, keyword, instancePath, schemaPath, params, message) {
610
+ return { keyword, instancePath, schemaPath, params, message };
611
+ }
612
+
613
+ function createInterpreter(schema, options) {
614
+ return new Interpreter(schema, options);
615
+ }
616
+
617
+ module.exports = { createInterpreter };
@@ -99,7 +99,12 @@ function compileToJS(schema, defs, schemaMap) {
99
99
  // Exception: $dynamicRef/$anchor are handled by the interpretive path even though codegen can't
100
100
  if (!defs && !codegenSafe(schema, schemaMap)) {
101
101
  const str = JSON.stringify(schema)
102
- if (!str.includes('"$dynamicRef"') && !str.includes('"$dynamicAnchor"') && !str.includes('"$anchor"')) return null
102
+ const hasDynamic = str.includes('"$dynamicRef"') || str.includes('"$dynamicAnchor"')
103
+ if (!hasDynamic && !str.includes('"$anchor"')) return null
104
+ // The flat anchor map resolves "#name" without regard to $id base-URI
105
+ // scopes. When a plain-anchor schema opens nested $id scopes, same-named
106
+ // anchors can shadow each other, so those schemas must not use codegen.
107
+ if (!hasDynamic && hasNestedIdScope(schema)) return null
103
108
  }
104
109
 
105
110
  // Collect $defs early so sub-schemas can resolve $ref
@@ -723,6 +728,23 @@ function subtreeRefersToLocalDef(node, defNames) {
723
728
  return false
724
729
  }
725
730
 
731
+ // Returns true if any sub-schema below the root declares a non-fragment $id,
732
+ // i.e. opens a new base-URI scope. Fragment-only ids ('#name') are draft-07
733
+ // anchors, not scope changes.
734
+ function hasNestedIdScope(node, isRoot = true) {
735
+ if (typeof node !== 'object' || node === null) return false
736
+ if (!isRoot && typeof node.$id === 'string' && !node.$id.startsWith('#')) return true
737
+ for (const val of Object.values(node)) {
738
+ if (typeof val !== 'object' || val === null) continue
739
+ if (Array.isArray(val)) {
740
+ for (const item of val) { if (hasNestedIdScope(item, false)) return true }
741
+ } else if (hasNestedIdScope(val, false)) {
742
+ return true
743
+ }
744
+ }
745
+ return false
746
+ }
747
+
726
748
  // Recursively check if a schema can be safely compiled to JS codegen.
727
749
  // Returns false if any sub-schema contains features codegen gets wrong.
728
750
  function codegenSafe(schema, schemaMap) {
@@ -822,6 +844,10 @@ function codegenSafe(schema, schemaMap) {
822
844
  }
823
845
  // unevaluatedItems: allow boolean and schema values
824
846
  if (schema.unevaluatedItems !== undefined) {
847
+ // contains marks matched items as evaluated; the generated evaluated-item
848
+ // tracking never credits contains matches, so any contains in scope of an
849
+ // unevaluatedItems keyword must go to the interpreted engine.
850
+ if (JSON.stringify(schema).includes('"contains"')) return false
825
851
  if (typeof schema.unevaluatedItems === 'object' && schema.unevaluatedItems !== null) {
826
852
  if (!codegenSafe(schema.unevaluatedItems, schemaMap)) return false
827
853
  }
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.1.0';
10
+ module.exports = '1.2.1';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ata-validator",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
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.",
5
5
  "main": "index.js",
6
6
  "module": "index.mjs",
@@ -44,7 +44,7 @@
44
44
  "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",
45
45
  "build": "cmake-js build --target ata",
46
46
  "rebuild": "cmake-js rebuild --target ata",
47
- "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.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_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_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_defs_pointer_alias.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_nullable.js && node tests/test_validate_and_parse.js && node tests/test_validate_data.js && node tests/test_enrich_error.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_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",
47
+ "test": "node test.js && node tests/test_removed_aot_methods.js && node tests/test_no_native.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_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_additional_props_errors.js && node tests/test_id_anchor_refs.js && node tests/test_engine_routing.js && node tests/test_defs_pointer_alias.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_order.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_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_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",
48
48
  "bench:size": "node benchmark/bench_aot_size.mjs",
49
49
  "test:suite": "node tests/run_suite.js",
50
50
  "test:compat": "node tests/test_compat.js",
@@ -112,12 +112,12 @@
112
112
  "LICENSE"
113
113
  ],
114
114
  "optionalDependencies": {
115
- "@ata-validator/native-darwin-arm64": "1.1.0",
116
- "@ata-validator/native-linux-x64-gnu": "1.1.0",
117
- "@ata-validator/native-linux-arm64-gnu": "1.1.0",
118
- "@ata-validator/native-linux-x64-musl": "1.1.0",
119
- "@ata-validator/native-linux-arm64-musl": "1.1.0",
120
- "@ata-validator/native-win32-x64": "1.1.0"
115
+ "@ata-validator/native-darwin-arm64": "1.2.1",
116
+ "@ata-validator/native-linux-x64-gnu": "1.2.1",
117
+ "@ata-validator/native-linux-arm64-gnu": "1.2.1",
118
+ "@ata-validator/native-linux-x64-musl": "1.2.1",
119
+ "@ata-validator/native-linux-arm64-musl": "1.2.1",
120
+ "@ata-validator/native-win32-x64": "1.2.1"
121
121
  },
122
122
  "peerDependencies": {
123
123
  "yaml": "^2.0.0"