ata-validator 1.0.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 };
@@ -702,6 +702,27 @@ function canResolveDynamicRefs(target, callingSchema, schemaMap) {
702
702
  return refs.every(r => anchors.has(r))
703
703
  }
704
704
 
705
+ // Returns true if the node's subtree contains a local $ref pointing to one of
706
+ // the given def names (i.e., '#/$defs/Name' or '#/definitions/Name' where Name
707
+ // is in defNames). Used to detect whether a $defs body cycles back to a sibling.
708
+ function subtreeRefersToLocalDef(node, defNames) {
709
+ if (typeof node !== 'object' || node === null) return false
710
+ if (node.$ref) {
711
+ const m = /^#\/(?:\$defs|definitions)\/([^/]+)$/.exec(node.$ref)
712
+ if (m && defNames.has(m[1])) return true
713
+ }
714
+ for (const val of Object.values(node)) {
715
+ if (typeof val === 'object' && val !== null) {
716
+ if (Array.isArray(val)) {
717
+ for (const item of val) { if (subtreeRefersToLocalDef(item, defNames)) return true }
718
+ } else {
719
+ if (subtreeRefersToLocalDef(val, defNames)) return true
720
+ }
721
+ }
722
+ }
723
+ return false
724
+ }
725
+
705
726
  // Recursively check if a schema can be safely compiled to JS codegen.
706
727
  // Returns false if any sub-schema contains features codegen gets wrong.
707
728
  function codegenSafe(schema, schemaMap) {
@@ -809,6 +830,17 @@ function codegenSafe(schema, schemaMap) {
809
830
  // Check $defs: targets must be safe, names must be simple, no nested $ref chains
810
831
  const defs = schema.$defs || schema.definitions
811
832
  if (defs) {
833
+ const defNames = new Set(Object.keys(defs))
834
+ // Detect cyclic local refs: if any def's own body references back to a sibling
835
+ // def, the schema contains a cycle. The JS codegen returns (() => true) on cycle
836
+ // (permissive), so cyclic schemas must route to the interpreted engine instead.
837
+ // We check only the def bodies, not the top-level schema, so that a plain
838
+ // { $ref: '#/$defs/Name', $defs: { Name: { type: 'string' } } } (no cycle)
839
+ // still compiles.
840
+ const hasCycle = Object.values(defs).some(
841
+ (def) => typeof def === 'object' && def !== null && subtreeRefersToLocalDef(def, defNames)
842
+ )
843
+ if (hasCycle) return false
812
844
  for (const [name, def] of Object.entries(defs)) {
813
845
  if (/[~/"']/.test(name)) return false // special chars in def name
814
846
  if (typeof def === 'boolean') return false
@@ -1,7 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  // Browser stub: there is no native addon in the browser. Swapped in via the
4
- // package.json `browser` field so bundlers never resolve `pkg-prebuilds`.
4
+ // package.json `browser` field so bundlers skip native platform probing.
5
5
 
6
6
  module.exports = function loadNative() {
7
7
  return null;