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 +18 -0
- package/README.md +4 -2
- package/index.js +133 -29
- package/lib/interpreter.js +617 -0
- package/lib/js-compiler.js +27 -1
- package/lib/version.js +1 -1
- package/package.json +8 -8
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.
|
|
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
|
|
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
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
//
|
|
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
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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
|
|
1013
|
-
//
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
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 };
|
package/lib/js-compiler.js
CHANGED
|
@@ -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
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ata-validator",
|
|
3
|
-
"version": "1.1
|
|
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
|
|
116
|
-
"@ata-validator/native-linux-x64-gnu": "1.1
|
|
117
|
-
"@ata-validator/native-linux-arm64-gnu": "1.1
|
|
118
|
-
"@ata-validator/native-linux-x64-musl": "1.1
|
|
119
|
-
"@ata-validator/native-linux-arm64-musl": "1.1
|
|
120
|
-
"@ata-validator/native-win32-x64": "1.1
|
|
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"
|