bantamkit-mcp 0.35.7 → 0.35.9
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/README.md +24 -9
- package/assets/tools/memory_compact.json +1 -2
- package/assets/tools/memory_recall.json +1 -3
- package/assets/tools/shiftwork_clock_in.json +1 -1
- package/assets/tools/shiftwork_clock_out.json +1 -1
- package/assets/tools/validate_json.json +1 -1
- package/assets/tools/work_plan.json +14 -8
- package/dist/assets.js +28 -4
- package/dist/cli.js +83 -24
- package/dist/contract.d.ts +0 -9
- package/dist/contract.js +51 -12
- package/dist/eventlog.d.ts +22 -2
- package/dist/eventlog.js +30 -3
- package/dist/hookadapter.js +263 -43
- package/dist/hostinstall.d.ts +2 -2
- package/dist/hostinstall.js +318 -84
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/mcp/identity.js +27 -1
- package/dist/mcp/pyargs.d.ts +22 -6
- package/dist/mcp/pyargs.js +113 -39
- package/dist/mcp/server.js +177 -41
- package/dist/mcp/status.js +11 -4
- package/dist/mcp/transport.d.ts +51 -6
- package/dist/mcp/transport.js +217 -26
- package/dist/mcpreport.d.ts +12 -0
- package/dist/mcpreport.js +67 -15
- package/dist/memory/cli.js +18 -3
- package/dist/memory/component.d.ts +17 -0
- package/dist/memory/component.js +51 -7
- package/dist/memory/factfile.d.ts +21 -3
- package/dist/memory/factfile.js +351 -12
- package/dist/memory/layers.d.ts +19 -0
- package/dist/memory/layers.js +102 -2
- package/dist/memory/pyyaml.d.ts +6 -4
- package/dist/memory/pyyaml.js +6 -4
- package/dist/memory/store.d.ts +82 -7
- package/dist/memory/store.js +277 -29
- package/dist/npminstall.d.ts +18 -0
- package/dist/npminstall.js +26 -5
- package/dist/pyargparse.js +48 -2
- package/dist/pyjsonschema.d.ts +85 -4
- package/dist/pyjsonschema.js +1812 -181
- package/dist/selfupdate.d.ts +112 -32
- package/dist/selfupdate.js +221 -79
- package/dist/shiftwork.d.ts +46 -7
- package/dist/shiftwork.js +246 -42
- package/dist/tokenledger.js +20 -4
- package/dist/updatecheck.d.ts +15 -3
- package/dist/updatecheck.js +23 -8
- package/dist/workplan.d.ts +8 -3
- package/dist/workplan.js +62 -3
- package/package.json +2 -2
package/dist/pyjsonschema.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The `jsonschema` seam: `
|
|
2
|
+
* The `jsonschema` seam: `validator_for(schema)(schema).iter_errors` + `exceptions.best_match`.
|
|
3
3
|
*
|
|
4
4
|
* WHY THIS IS NOT AJV — measured, not assumed. Over the 288-case corpus in
|
|
5
5
|
* `tools/conformance/suites/validate.mjs`, ajv 8.20.0 and jsonschema 4.26.0 agree on
|
|
@@ -14,14 +14,41 @@
|
|
|
14
14
|
* costs a runtime dependency; the whole keyword vocabulary is 286 lines of Python
|
|
15
15
|
* (`jsonschema/_keywords.py`), which is smaller than the translation layer would be.
|
|
16
16
|
*
|
|
17
|
-
* WHAT IS IMPLEMENTED
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* `
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
17
|
+
* WHAT IS IMPLEMENTED (job65, #125/#126; before it this file was 2020-12 only and RAISED on
|
|
18
|
+
* `$ref`, `$dynamicRef`, `$recursiveRef`, `unevaluatedItems` and `unevaluatedProperties`).
|
|
19
|
+
*
|
|
20
|
+
* THE DIALECT IS READ FROM `$schema`, as `jsonschema.validators.validator_for` reads it:
|
|
21
|
+
* the URI is normalised the way `_META_SCHEMAS` normalises its keys —
|
|
22
|
+
* `urlsplit(uri).geturl()`, which lowercases the scheme and drops an EMPTY fragment or
|
|
23
|
+
* query, so `…/draft-07/schema#` and `…/draft-07/schema` are one key and `https://…/draft-07`
|
|
24
|
+
* or a trailing slash are not — and an unknown URI, or no `$schema`, is draft 2020-12 (the
|
|
25
|
+
* reference warns and uses the latest draft). Drafts 4, 6, 7, 2019-09 and 2020-12 each get
|
|
26
|
+
* their own keyword table and type checker (`1.0` is an `integer` from draft 6 on, not in 4),
|
|
27
|
+
* and from draft 7 back `$ref` silences its siblings (`_legacy_keywords.ignore_ref_siblings`).
|
|
28
|
+
* Every subschema re-dispatches on its OWN `$schema` with the enclosing dialect as the
|
|
29
|
+
* default, because `Validator.evolve` does. Draft 3 is the one dialect the two runtimes
|
|
30
|
+
* REFUSE, with one shared sentence (`contract.py`'s pre-scan says the same words).
|
|
31
|
+
*
|
|
32
|
+
* `$ref` RESOLVES INSIDE THE ONE RESOURCE A CALLER SCHEMA IS. `#`, a JSON pointer `#/…`
|
|
33
|
+
* (percent-decoded as a whole, then `~1`/`~0` per segment, `int()` for a list index — the
|
|
34
|
+
* order `referencing`'s `Resource.pointer` uses) and a plain-name `#anchor` (`$anchor`,
|
|
35
|
+
* plus `$dynamicAnchor` under 2020-12) all resolve against the root document. `$dynamicRef`
|
|
36
|
+
* is the same lookup because `_keywords.dynamicRef` IS `_validate_reference(ref)` in 4.26,
|
|
37
|
+
* and `$recursiveRef` is the root because `lookup_recursive_ref` can only land on the root
|
|
38
|
+
* when there is one resource. What is NOT here is a second resource: a reference with a
|
|
39
|
+
* scheme, host, path or query, or a schema that embeds a `$id` (`id` under draft 4) below
|
|
40
|
+
* its root, is refused BEFORE validation with one sentence both runtimes print — a
|
|
41
|
+
* `PyJsonSchemaRefusal`, which `contract.schemaError` turns into the `{valid: false,
|
|
42
|
+
* feedback}` answer rather than an exception, because a model handed that sentence can act
|
|
43
|
+
* on it and a model handed `isError` cannot. A pointer to nowhere or an unknown anchor is
|
|
44
|
+
* the same refusal, found lazily where Python's `PointerToNowhere`/`NoSuchAnchor` find it.
|
|
45
|
+
*
|
|
46
|
+
* `unevaluatedItems`/`unevaluatedProperties` carry BOTH walkers: `_utils.find_evaluated_*`
|
|
47
|
+
* for 2020-12 and `_legacy_keywords.find_evaluated_*` for 2019-09, which differ in more than
|
|
48
|
+
* `prefixItems` — the 2019 property walker reads the KEYS of an `additionalProperties`
|
|
49
|
+
* subschema as property names, and this port reproduces that because the sentence does.
|
|
50
|
+
* `format` is implemented as the no-op it is — `jsonschema.validate` passes no
|
|
51
|
+
* `format_checker`, so the keyword can never produce an error on this path.
|
|
25
52
|
*
|
|
26
53
|
* THE CALLER SCHEMA IS CHECKED FIRST, as it is in the reference. `jsonschema.validate` calls
|
|
27
54
|
* `cls.check_schema(schema)` before it looks at the instance, so an invalid schema raises
|
|
@@ -34,13 +61,17 @@
|
|
|
34
61
|
* `{"additionalProperties": [1]}` all answered `{"valid": true}`, and `{"type": 3}` answered
|
|
35
62
|
* `{"valid": false}` with a sentence about a schema that is not a schema.
|
|
36
63
|
*
|
|
37
|
-
* `checkSchema` below is the fix and it is NOT the metaschema —
|
|
38
|
-
* `$ref`
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
* `
|
|
42
|
-
*
|
|
64
|
+
* `checkSchema` below is the fix and it is NOT the metaschema — the metaschemas are several
|
|
65
|
+
* documents wired together with `$ref`/`$dynamicRef` across resources, which is the second
|
|
66
|
+
* resource this file does not implement. It is the keyword SHAPE table, applied recursively
|
|
67
|
+
* and gated per dialect, because a keyword the dialect does not know is DATA to its
|
|
68
|
+
* metaschema: `{"if": 3}` is a `SchemaError` under draft 7 and a valid schema under draft 4.
|
|
69
|
+
* Measured over 50 malformed shapes under 2020-12 and 121 shapes under each of the five
|
|
70
|
+
* dialects: the refusal bit matches the reference on every row the suite carries, and only
|
|
71
|
+
* the WORDS differ. Registered and NOT fixed here: `validate_json` letting an exception out
|
|
72
|
+
* on caller input is a `runtime-py` defect (invariant 8).
|
|
43
73
|
*/
|
|
74
|
+
import vm from 'node:vm';
|
|
44
75
|
import { cmpCodepoint } from './memory/pyfs.js';
|
|
45
76
|
import { reprValue } from './pyjson.js';
|
|
46
77
|
export class PyJsonSchemaUnsupported extends Error {
|
|
@@ -49,6 +80,97 @@ export class PyJsonSchemaUnsupported extends Error {
|
|
|
49
80
|
this.name = 'PyJsonSchemaUnsupported';
|
|
50
81
|
}
|
|
51
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* A schema the validator will not resolve, answered — not raised. `contract.schemaError`
|
|
85
|
+
* returns the message as the feedback sentence, so `validate_json` says `{"valid": false,
|
|
86
|
+
* "feedback": …}` where it used to say `isError` (#125). The Python side prints the same
|
|
87
|
+
* sentence from `contract._unsupported_schema` and from the `referencing` exceptions.
|
|
88
|
+
*/
|
|
89
|
+
export class PyJsonSchemaRefusal extends Error {
|
|
90
|
+
constructor(message) {
|
|
91
|
+
super(message);
|
|
92
|
+
this.name = 'PyJsonSchemaRefusal';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const DRAFT_BY_URI = new Map([
|
|
96
|
+
['http://json-schema.org/draft-03/schema', 3],
|
|
97
|
+
['http://json-schema.org/draft-04/schema', 4],
|
|
98
|
+
['http://json-schema.org/draft-06/schema', 6],
|
|
99
|
+
['http://json-schema.org/draft-07/schema', 7],
|
|
100
|
+
['https://json-schema.org/draft/2019-09/schema', 2019],
|
|
101
|
+
['https://json-schema.org/draft/2020-12/schema', 2020],
|
|
102
|
+
]);
|
|
103
|
+
/** `urllib.parse.uses_netloc`, which decides whether `urlunsplit` re-inserts `//`. */
|
|
104
|
+
const USES_NETLOC = new Set([
|
|
105
|
+
'', 'ftp', 'http', 'gopher', 'nntp', 'telnet', 'imap', 'wais', 'file', 'mms', 'https', 'shttp',
|
|
106
|
+
'snews', 'prospero', 'rtsp', 'rtsps', 'rtspu', 'rsync', 'svn', 'svn+ssh', 'sftp', 'nfs', 'git',
|
|
107
|
+
'git+ssh', 'ws', 'wss', 'itms-services',
|
|
108
|
+
]);
|
|
109
|
+
/**
|
|
110
|
+
* `urllib.parse.urlsplit(uri).geturl()` — `_utils.URIDict.normalize`, the function every
|
|
111
|
+
* `$schema` goes through before `_META_SCHEMAS` is consulted. 3.12 strips leading C0/space,
|
|
112
|
+
* removes every tab/CR/LF, lowercases the scheme, and `urlunsplit` drops an empty query and
|
|
113
|
+
* an empty fragment. Measured in `tools/conformance/suites/validate.mjs` (`dialect/…`).
|
|
114
|
+
*/
|
|
115
|
+
export function pyUrlNormalize(uri) {
|
|
116
|
+
let url = uri.replace(/^[\u0000- ]+/, '').replace(/[\t\r\n]/g, '');
|
|
117
|
+
let scheme = '';
|
|
118
|
+
const colon = url.indexOf(':');
|
|
119
|
+
if (colon > 0 && /^[A-Za-z][A-Za-z0-9+.-]*$/.test(url.slice(0, colon))) {
|
|
120
|
+
scheme = url.slice(0, colon).toLowerCase();
|
|
121
|
+
url = url.slice(colon + 1);
|
|
122
|
+
}
|
|
123
|
+
let netloc = '';
|
|
124
|
+
if (url.startsWith('//')) {
|
|
125
|
+
const rel = url.slice(2).search(/[/?#]/);
|
|
126
|
+
const stop = rel === -1 ? url.length : 2 + rel;
|
|
127
|
+
netloc = url.slice(2, stop);
|
|
128
|
+
url = url.slice(stop);
|
|
129
|
+
}
|
|
130
|
+
let fragment = '';
|
|
131
|
+
const hash = url.indexOf('#');
|
|
132
|
+
if (hash !== -1) {
|
|
133
|
+
fragment = url.slice(hash + 1);
|
|
134
|
+
url = url.slice(0, hash);
|
|
135
|
+
}
|
|
136
|
+
let query = '';
|
|
137
|
+
const q = url.indexOf('?');
|
|
138
|
+
if (q !== -1) {
|
|
139
|
+
query = url.slice(q + 1);
|
|
140
|
+
url = url.slice(0, q);
|
|
141
|
+
}
|
|
142
|
+
if (netloc || (scheme && USES_NETLOC.has(scheme) && !url.startsWith('//'))) {
|
|
143
|
+
if (url && !url.startsWith('/'))
|
|
144
|
+
url = `/${url}`;
|
|
145
|
+
url = `//${netloc}${url}`;
|
|
146
|
+
}
|
|
147
|
+
if (scheme)
|
|
148
|
+
url = `${scheme}:${url}`;
|
|
149
|
+
if (query)
|
|
150
|
+
url += `?${query}`;
|
|
151
|
+
if (fragment)
|
|
152
|
+
url += `#${fragment}`;
|
|
153
|
+
return url;
|
|
154
|
+
}
|
|
155
|
+
const draft3Sentence = (uri) => `the schema's $schema ${JSON.stringify(uri)} names draft 3, which this validator does not ` +
|
|
156
|
+
'support (draft-04, draft-06, draft-07, 2019-09 and 2020-12 are)';
|
|
157
|
+
/**
|
|
158
|
+
* `validator_for(schema, default=fallback)`: the dialect a (sub)schema is validated under.
|
|
159
|
+
* The root falls back to 2020-12 (`_LATEST_VERSION`); a subschema falls back to the
|
|
160
|
+
* enclosing dialect, which is what `Validator.evolve` passes as `default`.
|
|
161
|
+
*/
|
|
162
|
+
function dialectOf(schema, fallback) {
|
|
163
|
+
const v = get(schema, '$schema');
|
|
164
|
+
if (v === undefined)
|
|
165
|
+
return fallback;
|
|
166
|
+
// `schema["$schema"] not in _META_SCHEMAS` → `urlsplit(3)` → AttributeError. Both raise.
|
|
167
|
+
if (v.t !== 'str')
|
|
168
|
+
throw new PyJsonSchemaUnsupported(`$schema must be a string, got ${v.t}`);
|
|
169
|
+
const found = DRAFT_BY_URI.get(pyUrlNormalize(v.v));
|
|
170
|
+
if (found === 3)
|
|
171
|
+
throw new PyJsonSchemaRefusal(draft3Sentence(v.v));
|
|
172
|
+
return found ?? fallback;
|
|
173
|
+
}
|
|
52
174
|
export function absolutePath(error) {
|
|
53
175
|
return error.parent === null ? error.path : [...absolutePath(error.parent), ...error.path];
|
|
54
176
|
}
|
|
@@ -87,20 +209,23 @@ export function pyEqual(a, b) {
|
|
|
87
209
|
return false;
|
|
88
210
|
}
|
|
89
211
|
/**
|
|
90
|
-
*
|
|
212
|
+
* The draft type checkers.
|
|
91
213
|
*
|
|
92
214
|
* Two Python-isms carry: `bool` is not an `int`, so `true` is neither `integer` nor
|
|
93
215
|
* `number`; and draft 6 onward redefines `integer` to accept a float with no fractional
|
|
94
|
-
* part, so `1.0` IS an integer.
|
|
216
|
+
* part, so `1.0` IS an integer — under draft 6+ only. `draft4_type_checker` is
|
|
217
|
+
* `is_integer` alone, and `{"type": "integer"}` refuses `1.0` there.
|
|
95
218
|
*/
|
|
96
|
-
function isType(instance, type) {
|
|
219
|
+
function isType(instance, type, draft = 2020) {
|
|
97
220
|
switch (type) {
|
|
98
221
|
case 'array':
|
|
99
222
|
return instance.t === 'list';
|
|
100
223
|
case 'boolean':
|
|
101
224
|
return instance.t === 'bool';
|
|
102
225
|
case 'integer':
|
|
103
|
-
|
|
226
|
+
if (instance.t === 'int')
|
|
227
|
+
return true;
|
|
228
|
+
return draft !== 4 && instance.t === 'float' && Number.isInteger(instance.v);
|
|
104
229
|
case 'null':
|
|
105
230
|
return instance.t === 'null';
|
|
106
231
|
case 'number':
|
|
@@ -115,39 +240,53 @@ function isType(instance, type) {
|
|
|
115
240
|
throw new PyJsonSchemaUnsupported(`undefined type check ${JSON.stringify(type)}`);
|
|
116
241
|
}
|
|
117
242
|
}
|
|
118
|
-
|
|
243
|
+
const falseSchemaError = (instance, draft) => ({
|
|
244
|
+
message: `False schema does not allow ${reprValue(instance)}`,
|
|
245
|
+
validator: null,
|
|
246
|
+
instance,
|
|
247
|
+
schema: false,
|
|
248
|
+
path: [],
|
|
249
|
+
context: [],
|
|
250
|
+
parent: null,
|
|
251
|
+
draft,
|
|
252
|
+
});
|
|
253
|
+
/**
|
|
254
|
+
* `applicable_validators`: for 2019-09 and 2020-12 it is `methodcaller("items")` — plain
|
|
255
|
+
* dict order, which for a JSON-parsed schema is DOCUMENT order; from draft 7 back it is
|
|
256
|
+
* `_legacy_keywords.ignore_ref_siblings`, which returns `$ref` ALONE when the schema has
|
|
257
|
+
* one. That order decides which error comes first, and `best_match` breaks ties by taking
|
|
258
|
+
* the first maximum, so it is load-bearing.
|
|
259
|
+
*/
|
|
260
|
+
function applicable(schema, draft) {
|
|
261
|
+
if (draft === 4 || draft === 6 || draft === 7) {
|
|
262
|
+
const ref = schema.get('$ref');
|
|
263
|
+
// `schema.get("$ref") is not None` — a JSON null counts as absent.
|
|
264
|
+
if (ref !== undefined && ref.t !== 'null')
|
|
265
|
+
return [['$ref', ref]];
|
|
266
|
+
}
|
|
267
|
+
return schema;
|
|
268
|
+
}
|
|
269
|
+
function iterErrors(instance, schema, ctx) {
|
|
270
|
+
const c = ctx ?? rootCtx(schema);
|
|
119
271
|
if (schema === true)
|
|
120
272
|
return [];
|
|
121
|
-
if (schema === false)
|
|
122
|
-
return [
|
|
123
|
-
{
|
|
124
|
-
message: `False schema does not allow ${reprValue(instance)}`,
|
|
125
|
-
validator: null,
|
|
126
|
-
instance,
|
|
127
|
-
schema,
|
|
128
|
-
path: [],
|
|
129
|
-
context: [],
|
|
130
|
-
parent: null,
|
|
131
|
-
},
|
|
132
|
-
];
|
|
133
|
-
}
|
|
273
|
+
if (schema === false)
|
|
274
|
+
return [falseSchemaError(instance, c.draft)];
|
|
134
275
|
if (schema.t === 'bool')
|
|
135
|
-
return iterErrors(instance, schema.v);
|
|
276
|
+
return iterErrors(instance, schema.v, c);
|
|
136
277
|
if (schema.t !== 'dict') {
|
|
137
278
|
throw new PyJsonSchemaUnsupported(`a schema must be an object or a boolean, got ${schema.t}`);
|
|
138
279
|
}
|
|
280
|
+
// `Validator.evolve` re-dispatches on the subschema's own `$schema`, defaulting to the
|
|
281
|
+
// enclosing class.
|
|
282
|
+
const draft = dialectOf(schema, c.draft);
|
|
283
|
+
const here = draft === c.draft ? c : { ...c, draft };
|
|
284
|
+
const table = KEYWORDS_BY_DRAFT[draft];
|
|
139
285
|
const out = [];
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
const fn = KEYWORDS[keyword];
|
|
145
|
-
if (fn === undefined) {
|
|
146
|
-
if (UNSUPPORTED.has(keyword)) {
|
|
147
|
-
throw new PyJsonSchemaUnsupported(`${keyword} is not implemented by this port`);
|
|
148
|
-
}
|
|
149
|
-
continue; // an annotation (`title`, `$comment`, `description`, …) — jsonschema skips it too
|
|
150
|
-
}
|
|
286
|
+
for (const [keyword, value] of applicable(schema.v, draft)) {
|
|
287
|
+
const fn = table[keyword];
|
|
288
|
+
if (fn === undefined)
|
|
289
|
+
continue; // an annotation, or a keyword of another dialect — jsonschema skips it too
|
|
151
290
|
const emit = (message, extra = {}) => ({
|
|
152
291
|
message,
|
|
153
292
|
validator: extra.validator ?? keyword,
|
|
@@ -156,6 +295,7 @@ function iterErrors(instance, schema) {
|
|
|
156
295
|
path: [],
|
|
157
296
|
context: extra.context ?? [],
|
|
158
297
|
parent: null,
|
|
298
|
+
draft,
|
|
159
299
|
});
|
|
160
300
|
const descend = (childInstance, childSchema, path) => {
|
|
161
301
|
if (childSchema === undefined)
|
|
@@ -165,28 +305,30 @@ function iterErrors(instance, schema) {
|
|
|
165
305
|
// Measured against Python; a port that prepended here would name a field Python does
|
|
166
306
|
// not name.
|
|
167
307
|
if (childSchema.t === 'bool')
|
|
168
|
-
return iterErrors(childInstance, childSchema.v);
|
|
169
|
-
const errors = iterErrors(childInstance, childSchema);
|
|
308
|
+
return iterErrors(childInstance, childSchema.v, here);
|
|
309
|
+
const errors = iterErrors(childInstance, childSchema, here);
|
|
170
310
|
if (path !== undefined)
|
|
171
311
|
for (const e of errors)
|
|
172
312
|
e.path.unshift(path);
|
|
173
313
|
return errors;
|
|
174
314
|
};
|
|
175
|
-
out.push(...fn(value, instance, schema, emit, descend));
|
|
315
|
+
out.push(...fn(value, instance, schema, emit, descend, here));
|
|
176
316
|
}
|
|
177
317
|
return out;
|
|
178
318
|
}
|
|
179
|
-
const isValid = (instance, schema) => iterErrors(instance, schema).length === 0;
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
'
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
]);
|
|
319
|
+
const isValid = (instance, schema, ctx) => iterErrors(instance, schema, ctx).length === 0;
|
|
320
|
+
/** The context a bare `iterErrors(instance, schema)` call runs under: the schema is the root. */
|
|
321
|
+
function rootCtx(schema) {
|
|
322
|
+
const draft = dialectOf(schema, 2020);
|
|
323
|
+
const root = typeof schema === 'boolean' ? { t: 'bool', v: schema } : schema;
|
|
324
|
+
return { root, draft, rootDraft: draft };
|
|
325
|
+
}
|
|
187
326
|
/** `extras_msg`: `"'a', 'b'", "were"`. */
|
|
188
327
|
function extrasMsg(extras) {
|
|
189
|
-
return
|
|
328
|
+
return extrasMsgValues(extras.map((e) => ({ t: 'str', v: e })));
|
|
329
|
+
}
|
|
330
|
+
function extrasMsgValues(extras) {
|
|
331
|
+
return [extras.map(reprValue).join(', '), extras.length === 1 ? 'was' : 'were'];
|
|
190
332
|
}
|
|
191
333
|
/** `find_additional_properties`, in instance order. */
|
|
192
334
|
function findAdditionalProperties(instance, schema) {
|
|
@@ -204,30 +346,748 @@ function findAdditionalProperties(instance, schema) {
|
|
|
204
346
|
}
|
|
205
347
|
return out;
|
|
206
348
|
}
|
|
349
|
+
// ------------------------------------------------------------- Python `re`, translated
|
|
207
350
|
/**
|
|
208
|
-
* `re.search(pattern, string)
|
|
351
|
+
* `re.search(pattern, string)` — the reference's dialect, translated, bounded (#131, #132).
|
|
352
|
+
*
|
|
353
|
+
* THE DIALECT IS PYTHON'S. Until job65 this was `new RegExp(pattern, 'u')` with a silent
|
|
354
|
+
* fallback to a non-`u` RegExp, so the two runtimes disagreed on 5 of 5 of #131's probes:
|
|
355
|
+
* `\d`/`\w` are Unicode-wide in `re` and ASCII in JS, `$` matches before a final newline in
|
|
356
|
+
* `re`, `(?P<x>…)` refused here, and the fallback read `\Z` as a literal `Z`. JSON Schema
|
|
357
|
+
* recommends ECMA-262, but the product's contract is the reference's verdict, so the pattern
|
|
358
|
+
* is parsed the way `re._parser` parses it and re-emitted as a `v`-mode RegExp whose every
|
|
359
|
+
* construct means what it means in `re`:
|
|
360
|
+
*
|
|
361
|
+
* - `\d` → `\p{Nd}`; `\w` → `[\p{L}\p{N}_]` (`str.isalnum() or "_"`); `\s` → the 29
|
|
362
|
+
* codepoints `str.isspace()` names (JS's `\s` adds U+FEFF and drops U+001C-1F, U+0085);
|
|
363
|
+
* `\b`/`\B` are look-arounds over that `\w`, and `\B` never matches the empty string
|
|
364
|
+
* (`SRE_AT_UNI_NON_BOUNDARY`, CPython 3.11-3.13). Measured over every codepoint: the
|
|
365
|
+
* three classes are equal except for the codepoints Unicode 16 assigned after the
|
|
366
|
+
* reference's Unicode 15 (5,004 for `\w`, 80 for `\d`, 0 for `\s`).
|
|
367
|
+
* - `.` → `[^\n]` (JS also stops at `\r`, U+2028, U+2029); `$` → `(?=\n?$)`; `\A` → `^`;
|
|
368
|
+
* `\Z` → `$`; `(?P<n>…)` → a group; `{,n}` → `{0,n}`; a `{` that is not a repeat is a
|
|
369
|
+
* literal; every literal is emitted as `\u{…}`, so no JS syntax rule can reach it.
|
|
209
370
|
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
212
|
-
* in
|
|
213
|
-
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
371
|
+
* A pattern `re` itself refuses is a `PyReError` (what `re.error` is on the reference: the
|
|
372
|
+
* schema check raises it). Constructs with no faithful translation — inline flags,
|
|
373
|
+
* look-behind (fixed-width in `re`, any width in JS), backreferences (a JS backreference to
|
|
374
|
+
* a group that did not take part matches the empty string; `re`'s fails), atomic groups,
|
|
375
|
+
* possessive quantifiers, conditional groups and `\N{…}` (no Unicode name table here) — are
|
|
376
|
+
* parsed, so that a broken pattern is still an error, and then REFUSED on both runtimes with
|
|
377
|
+
* one sentence (`unsupportedPatternSentence`; `contract._pattern_unsupported` on the
|
|
378
|
+
* reference), never answered differently.
|
|
216
379
|
*/
|
|
217
|
-
|
|
218
|
-
|
|
380
|
+
export class PyReError extends Error {
|
|
381
|
+
constructor(message) {
|
|
382
|
+
super(message);
|
|
383
|
+
this.name = 'PyReError';
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
const RE_SPECIAL = new Set([...'.\\[{()*+?^$|']);
|
|
387
|
+
const RE_DIGITS = new Set([...'0123456789']);
|
|
388
|
+
const RE_OCT = new Set([...'01234567']);
|
|
389
|
+
const RE_HEX = new Set([...'0123456789abcdefABCDEF']);
|
|
390
|
+
const RE_WHITESPACE = new Set([...' \t\n\r\v\f']);
|
|
391
|
+
const RE_FLAGS = new Map([
|
|
392
|
+
['i', 2], ['L', 4], ['m', 8], ['s', 16], ['x', 64], ['a', 256], ['t', 1], ['u', 32],
|
|
393
|
+
]);
|
|
394
|
+
const FLAG_UNICODE = 32;
|
|
395
|
+
const FLAG_ASCII = 256;
|
|
396
|
+
const FLAG_VERBOSE = 64;
|
|
397
|
+
const TYPE_FLAGS = 4 | 32 | 256;
|
|
398
|
+
const GLOBAL_FLAGS = 1 | 128;
|
|
399
|
+
const MAXREPEAT = 4294967295n;
|
|
400
|
+
const MAXGROUPS = 1073741823n;
|
|
401
|
+
const u = (cp) => `\\u{${cp.toString(16)}}`;
|
|
402
|
+
const RE_WORD = '[\\p{L}\\p{N}_]';
|
|
403
|
+
const RE_NOT_WORD = '[^\\p{L}\\p{N}_]';
|
|
404
|
+
const RE_SPACE_ITEMS = `${u(9)}-${u(13)}${u(0x1c)}-${u(0x20)}${u(0x85)}${u(0xa0)}${u(0x1680)}${u(0x2000)}-${u(0x200a)}` +
|
|
405
|
+
`${u(0x2028)}${u(0x2029)}${u(0x202f)}${u(0x205f)}${u(0x3000)}`;
|
|
406
|
+
/** `\d \D \s \S \w \W` as a class member (a nested class where JS needs one). */
|
|
407
|
+
const RE_CATEGORY_IN_CLASS = {
|
|
408
|
+
'\\d': '\\p{Nd}',
|
|
409
|
+
'\\D': '\\P{Nd}',
|
|
410
|
+
'\\w': RE_WORD,
|
|
411
|
+
'\\W': RE_NOT_WORD,
|
|
412
|
+
'\\s': `[${RE_SPACE_ITEMS}]`,
|
|
413
|
+
'\\S': `[^${RE_SPACE_ITEMS}]`,
|
|
414
|
+
};
|
|
415
|
+
const RE_AT = {
|
|
416
|
+
'\\A': '^',
|
|
417
|
+
'\\Z': '$',
|
|
418
|
+
'\\b': `(?:(?<=${RE_WORD})(?!${RE_WORD})|(?<!${RE_WORD})(?=${RE_WORD}))`,
|
|
419
|
+
'\\B': `(?:(?<=${RE_WORD})(?=${RE_WORD})|(?<!${RE_WORD})(?!${RE_WORD})(?!(?<![\\s\\S])(?![\\s\\S])))`,
|
|
420
|
+
};
|
|
421
|
+
const RE_ESCAPES = {
|
|
422
|
+
'\\a': 7, '\\b': 8, '\\f': 12, '\\n': 10, '\\r': 13, '\\t': 9, '\\v': 11, '\\\\': 92,
|
|
423
|
+
};
|
|
424
|
+
const XID_START = /^[\p{XID_Start}_]$/u;
|
|
425
|
+
const XID_CONTINUE = /^\p{XID_Continue}$/u;
|
|
426
|
+
/** `re._parser.Tokenizer` over codepoints: an escape is one two-character token. */
|
|
427
|
+
class ReSource {
|
|
428
|
+
chars;
|
|
429
|
+
index = 0;
|
|
430
|
+
next = null;
|
|
431
|
+
constructor(pattern) {
|
|
432
|
+
this.chars = [...pattern];
|
|
433
|
+
this.advance();
|
|
434
|
+
}
|
|
435
|
+
advance() {
|
|
436
|
+
let index = this.index;
|
|
437
|
+
if (index >= this.chars.length) {
|
|
438
|
+
this.next = null;
|
|
439
|
+
return;
|
|
440
|
+
}
|
|
441
|
+
let char = this.chars[index];
|
|
442
|
+
if (char === '\\') {
|
|
443
|
+
index += 1;
|
|
444
|
+
if (index >= this.chars.length)
|
|
445
|
+
throw new PyReError('bad escape (end of pattern)');
|
|
446
|
+
char += this.chars[index];
|
|
447
|
+
}
|
|
448
|
+
this.index = index + 1;
|
|
449
|
+
this.next = char;
|
|
450
|
+
}
|
|
451
|
+
match(char) {
|
|
452
|
+
if (char === this.next) {
|
|
453
|
+
this.advance();
|
|
454
|
+
return true;
|
|
455
|
+
}
|
|
456
|
+
return false;
|
|
457
|
+
}
|
|
458
|
+
get() {
|
|
459
|
+
const self = this.next;
|
|
460
|
+
this.advance();
|
|
461
|
+
return self;
|
|
462
|
+
}
|
|
463
|
+
getwhile(n, charset) {
|
|
464
|
+
let result = '';
|
|
465
|
+
for (let i = 0; i < n; i += 1) {
|
|
466
|
+
const c = this.next;
|
|
467
|
+
if (c === null || !charset.has(c))
|
|
468
|
+
break;
|
|
469
|
+
result += c;
|
|
470
|
+
this.advance();
|
|
471
|
+
}
|
|
472
|
+
return result;
|
|
473
|
+
}
|
|
474
|
+
getuntil(terminator, name) {
|
|
475
|
+
let result = '';
|
|
476
|
+
for (;;) {
|
|
477
|
+
const c = this.next;
|
|
478
|
+
this.advance();
|
|
479
|
+
if (c === null) {
|
|
480
|
+
if (!result)
|
|
481
|
+
throw new PyReError(`missing ${name}`);
|
|
482
|
+
throw new PyReError(`missing ${terminator}, unterminated name`);
|
|
483
|
+
}
|
|
484
|
+
if (c === terminator) {
|
|
485
|
+
if (!result)
|
|
486
|
+
throw new PyReError(`missing ${name}`);
|
|
487
|
+
break;
|
|
488
|
+
}
|
|
489
|
+
result += c;
|
|
490
|
+
}
|
|
491
|
+
return result;
|
|
492
|
+
}
|
|
493
|
+
tell() {
|
|
494
|
+
return this.index - (this.next === null ? 0 : [...this.next].length);
|
|
495
|
+
}
|
|
496
|
+
seek(index) {
|
|
497
|
+
this.index = index;
|
|
498
|
+
this.advance();
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function checkGroupName(name) {
|
|
502
|
+
const cps = [...name];
|
|
503
|
+
if (!(XID_START.test(cps[0]) && cps.slice(1).every((c) => XID_CONTINUE.test(c)))) {
|
|
504
|
+
throw new PyReError(`bad character in group name ${JSON.stringify(name)}`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
function checkGroup(state, gid) {
|
|
508
|
+
return gid < state.groups && state.closed.has(gid);
|
|
509
|
+
}
|
|
510
|
+
function checkLookbehindGroup(state, gid) {
|
|
511
|
+
if (state.lookbehindgroups !== null) {
|
|
512
|
+
if (!checkGroup(state, gid))
|
|
513
|
+
throw new PyReError('cannot refer to an open group');
|
|
514
|
+
if (gid >= state.lookbehindgroups) {
|
|
515
|
+
throw new PyReError('cannot refer to group defined in the same lookbehind subpattern');
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
/** `_class_escape`: a literal codepoint, or a category's class-member source. */
|
|
520
|
+
function classEscape(source, escape) {
|
|
521
|
+
const known = RE_ESCAPES[escape];
|
|
522
|
+
if (known !== undefined)
|
|
523
|
+
return { lit: known };
|
|
524
|
+
const cat = RE_CATEGORY_IN_CLASS[escape];
|
|
525
|
+
if (cat !== undefined)
|
|
526
|
+
return { cat };
|
|
527
|
+
return { lit: escapeLiteral(source, escape, true, null) };
|
|
528
|
+
}
|
|
529
|
+
/** The literal arms `_escape` and `_class_escape` share: `\x \u \U \N`, octal, an identity escape. */
|
|
530
|
+
function escapeLiteral(source, escape, inClass, state) {
|
|
531
|
+
const c = escape.slice(1);
|
|
532
|
+
if (c === 'x' || c === 'u' || c === 'U') {
|
|
533
|
+
const width = c === 'x' ? 2 : c === 'u' ? 4 : 8;
|
|
534
|
+
const digits = source.getwhile(width, RE_HEX);
|
|
535
|
+
if (digits.length !== width)
|
|
536
|
+
throw new PyReError(`incomplete escape ${escape}${digits}`);
|
|
537
|
+
const cp = parseInt(digits, 16);
|
|
538
|
+
if (cp > 0x10ffff)
|
|
539
|
+
throw new PyReError(`bad escape ${escape}${digits}`);
|
|
540
|
+
return cp;
|
|
541
|
+
}
|
|
542
|
+
if (c === 'N') {
|
|
543
|
+
if (!source.match('{'))
|
|
544
|
+
throw new PyReError('missing {');
|
|
545
|
+
source.getuntil('}', 'character name');
|
|
546
|
+
// No Unicode name table here: parsed so a broken one is still an error, then refused.
|
|
547
|
+
if (state !== null)
|
|
548
|
+
state.unsupported = true;
|
|
549
|
+
else
|
|
550
|
+
NAMED_IN_CLASS.hit = true;
|
|
551
|
+
return 0x3f;
|
|
552
|
+
}
|
|
553
|
+
if (inClass ? RE_OCT.has(c) : c === '0') {
|
|
554
|
+
const digits = c + source.getwhile(2, RE_OCT);
|
|
555
|
+
const cp = parseInt(digits, 8);
|
|
556
|
+
if (cp > 0o377)
|
|
557
|
+
throw new PyReError(`octal escape value \\${digits} outside of range 0-0o377`);
|
|
558
|
+
return cp;
|
|
559
|
+
}
|
|
560
|
+
if (RE_DIGITS.has(c))
|
|
561
|
+
throw new PyReError(`bad escape ${escape}`);
|
|
562
|
+
if (/^[A-Za-z]$/.test(c))
|
|
563
|
+
throw new PyReError(`bad escape ${escape}`);
|
|
564
|
+
return c.codePointAt(0);
|
|
565
|
+
}
|
|
566
|
+
/** `\N{…}` inside a class has no state to mark; the class parser reads this flag back. */
|
|
567
|
+
const NAMED_IN_CLASS = { hit: false };
|
|
568
|
+
function parseClass(source, state) {
|
|
569
|
+
const items = [];
|
|
570
|
+
let count = 0;
|
|
571
|
+
const negate = source.match('^');
|
|
572
|
+
NAMED_IN_CLASS.hit = false;
|
|
573
|
+
for (;;) {
|
|
574
|
+
const self = source.get();
|
|
575
|
+
if (self === null)
|
|
576
|
+
throw new PyReError('unterminated character set');
|
|
577
|
+
if (self === ']' && count > 0)
|
|
578
|
+
break;
|
|
579
|
+
const code1 = self.startsWith('\\') ? classEscape(source, self) : { lit: self.codePointAt(0) };
|
|
580
|
+
if (source.match('-')) {
|
|
581
|
+
const that = source.get();
|
|
582
|
+
if (that === null)
|
|
583
|
+
throw new PyReError('unterminated character set');
|
|
584
|
+
if (that === ']') {
|
|
585
|
+
items.push('lit' in code1 ? u(code1.lit) : code1.cat, u(0x2d));
|
|
586
|
+
count += 2;
|
|
587
|
+
break;
|
|
588
|
+
}
|
|
589
|
+
const code2 = that.startsWith('\\') ? classEscape(source, that) : { lit: that.codePointAt(0) };
|
|
590
|
+
if (!('lit' in code1) || !('lit' in code2) || code2.lit < code1.lit) {
|
|
591
|
+
throw new PyReError(`bad character range ${self}-${that}`);
|
|
592
|
+
}
|
|
593
|
+
items.push(`${u(code1.lit)}-${u(code2.lit)}`);
|
|
594
|
+
}
|
|
595
|
+
else {
|
|
596
|
+
items.push('lit' in code1 ? u(code1.lit) : code1.cat);
|
|
597
|
+
}
|
|
598
|
+
count += 1;
|
|
599
|
+
}
|
|
600
|
+
if (NAMED_IN_CLASS.hit)
|
|
601
|
+
state.unsupported = true;
|
|
602
|
+
return `[${negate ? '^' : ''}${items.join('')}]`;
|
|
603
|
+
}
|
|
604
|
+
/** `_parse_flags`. Returns `null` for global flags (`(?…)`), else the scoped pair. */
|
|
605
|
+
function parseFlags(source, state, first) {
|
|
606
|
+
let char = first;
|
|
607
|
+
let add = 0;
|
|
608
|
+
let del = 0;
|
|
609
|
+
if (char !== '-') {
|
|
610
|
+
for (;;) {
|
|
611
|
+
const flag = RE_FLAGS.get(char);
|
|
612
|
+
if (char === 'L')
|
|
613
|
+
throw new PyReError("bad inline flag: cannot use 'L' flag with a str pattern");
|
|
614
|
+
add |= flag;
|
|
615
|
+
if (flag & TYPE_FLAGS && (add & TYPE_FLAGS) !== flag) {
|
|
616
|
+
throw new PyReError("bad inline flag: flags 'a', 'u' and 'L' are incompatible");
|
|
617
|
+
}
|
|
618
|
+
char = source.get();
|
|
619
|
+
if (char === null)
|
|
620
|
+
throw new PyReError('missing -, : or )');
|
|
621
|
+
if (char === ')' || char === '-' || char === ':')
|
|
622
|
+
break;
|
|
623
|
+
if (!RE_FLAGS.has(char))
|
|
624
|
+
throw new PyReError(/\p{L}/u.test(char) ? 'unknown flag' : 'missing -, : or )');
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
if (char === ')') {
|
|
628
|
+
state.flags |= add;
|
|
629
|
+
return null;
|
|
630
|
+
}
|
|
631
|
+
if (add & GLOBAL_FLAGS)
|
|
632
|
+
throw new PyReError('bad inline flag: cannot turn on global flag');
|
|
633
|
+
if (char === '-') {
|
|
634
|
+
char = source.get();
|
|
635
|
+
if (char === null)
|
|
636
|
+
throw new PyReError('missing flag');
|
|
637
|
+
if (!RE_FLAGS.has(char))
|
|
638
|
+
throw new PyReError(/\p{L}/u.test(char) ? 'unknown flag' : 'missing flag');
|
|
639
|
+
for (;;) {
|
|
640
|
+
const flag = RE_FLAGS.get(char);
|
|
641
|
+
if (flag & TYPE_FLAGS)
|
|
642
|
+
throw new PyReError("bad inline flag: cannot turn off flags 'a', 'u' and 'L'");
|
|
643
|
+
del |= flag;
|
|
644
|
+
char = source.get();
|
|
645
|
+
if (char === null)
|
|
646
|
+
throw new PyReError('missing :');
|
|
647
|
+
if (char === ':')
|
|
648
|
+
break;
|
|
649
|
+
if (!RE_FLAGS.has(char))
|
|
650
|
+
throw new PyReError(/\p{L}/u.test(char) ? 'unknown flag' : 'missing :');
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
if (del & GLOBAL_FLAGS)
|
|
654
|
+
throw new PyReError('bad inline flag: cannot turn off global flag');
|
|
655
|
+
if (add & del)
|
|
656
|
+
throw new PyReError('bad inline flag: flag turned on and off');
|
|
657
|
+
return [add, del];
|
|
658
|
+
}
|
|
659
|
+
/** `_parse_sub`: alternatives joined by `|`. */
|
|
660
|
+
function parseSub(source, state, verbose, nested) {
|
|
661
|
+
const branches = [];
|
|
662
|
+
for (;;) {
|
|
663
|
+
branches.push(parseSeq(source, state, verbose, nested, nested === 0 && branches.length === 0));
|
|
664
|
+
if (!source.match('|'))
|
|
665
|
+
break;
|
|
666
|
+
if (nested === 0)
|
|
667
|
+
verbose = (state.flags & FLAG_VERBOSE) !== 0;
|
|
668
|
+
}
|
|
669
|
+
return branches.length === 1 ? branches[0] : branches.join('|');
|
|
670
|
+
}
|
|
671
|
+
/** `_parse`: one branch. */
|
|
672
|
+
function parseSeq(source, state, verbose, nested, first) {
|
|
673
|
+
const items = [];
|
|
674
|
+
for (;;) {
|
|
675
|
+
const self = source.next;
|
|
676
|
+
if (self === null || self === '|' || self === ')')
|
|
677
|
+
break;
|
|
678
|
+
source.get();
|
|
679
|
+
if (verbose) {
|
|
680
|
+
if (RE_WHITESPACE.has(self))
|
|
681
|
+
continue;
|
|
682
|
+
if (self === '#') {
|
|
683
|
+
for (;;) {
|
|
684
|
+
const c = source.get();
|
|
685
|
+
if (c === null || c === '\n')
|
|
686
|
+
break;
|
|
687
|
+
}
|
|
688
|
+
continue;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
if (self.startsWith('\\')) {
|
|
692
|
+
items.push(parseEscape(source, self, state));
|
|
693
|
+
}
|
|
694
|
+
else if (!RE_SPECIAL.has(self)) {
|
|
695
|
+
items.push({ kind: 'atom', src: u(self.codePointAt(0)) });
|
|
696
|
+
}
|
|
697
|
+
else if (self === '[') {
|
|
698
|
+
items.push({ kind: 'atom', src: parseClass(source, state) });
|
|
699
|
+
}
|
|
700
|
+
else if (self === '*' || self === '+' || self === '?' || self === '{') {
|
|
701
|
+
const here = source.tell();
|
|
702
|
+
let quant;
|
|
703
|
+
if (self === '{') {
|
|
704
|
+
if (source.next === '}') {
|
|
705
|
+
items.push({ kind: 'atom', src: u(0x7b) });
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
let lo = '';
|
|
709
|
+
let hi = '';
|
|
710
|
+
while (source.next !== null && RE_DIGITS.has(source.next))
|
|
711
|
+
lo += source.get();
|
|
712
|
+
if (source.match(',')) {
|
|
713
|
+
while (source.next !== null && RE_DIGITS.has(source.next))
|
|
714
|
+
hi += source.get();
|
|
715
|
+
}
|
|
716
|
+
else {
|
|
717
|
+
hi = lo;
|
|
718
|
+
}
|
|
719
|
+
if (!source.match('}')) {
|
|
720
|
+
items.push({ kind: 'atom', src: u(0x7b) });
|
|
721
|
+
source.seek(here);
|
|
722
|
+
continue;
|
|
723
|
+
}
|
|
724
|
+
const min = lo ? BigInt(lo) : 0n;
|
|
725
|
+
const max = hi ? BigInt(hi) : MAXREPEAT;
|
|
726
|
+
if (lo && min >= MAXREPEAT)
|
|
727
|
+
throw new PyJsonSchemaUnsupported('OverflowError: the repetition number is too large');
|
|
728
|
+
if (hi && max >= MAXREPEAT)
|
|
729
|
+
throw new PyJsonSchemaUnsupported('OverflowError: the repetition number is too large');
|
|
730
|
+
if (hi && max < min)
|
|
731
|
+
throw new PyReError('min repeat greater than max repeat');
|
|
732
|
+
quant = max === MAXREPEAT ? `{${min},}` : min === max ? `{${min}}` : `{${min},${max}}`;
|
|
733
|
+
}
|
|
734
|
+
else {
|
|
735
|
+
quant = self;
|
|
736
|
+
}
|
|
737
|
+
const item = items[items.length - 1];
|
|
738
|
+
if (item === undefined || item.kind === 'at')
|
|
739
|
+
throw new PyReError('nothing to repeat');
|
|
740
|
+
if (item.kind === 'repeat')
|
|
741
|
+
throw new PyReError('multiple repeat');
|
|
742
|
+
if (source.match('?')) {
|
|
743
|
+
quant += '?';
|
|
744
|
+
}
|
|
745
|
+
else if (source.match('+')) {
|
|
746
|
+
state.unsupported = true; // possessive
|
|
747
|
+
}
|
|
748
|
+
items[items.length - 1] = { kind: 'repeat', src: `(?:${item.src})${quant}` };
|
|
749
|
+
}
|
|
750
|
+
else if (self === '.') {
|
|
751
|
+
items.push({ kind: 'atom', src: `[^${u(10)}]` });
|
|
752
|
+
}
|
|
753
|
+
else if (self === '(') {
|
|
754
|
+
const item = parseGroup(source, state, verbose, nested, first && items.length === 0);
|
|
755
|
+
if (item === 'global-flags') {
|
|
756
|
+
verbose = (state.flags & FLAG_VERBOSE) !== 0;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (item !== null)
|
|
760
|
+
items.push(item);
|
|
761
|
+
}
|
|
762
|
+
else if (self === '^') {
|
|
763
|
+
items.push({ kind: 'at', src: '^' });
|
|
764
|
+
}
|
|
765
|
+
else if (self === '$') {
|
|
766
|
+
items.push({ kind: 'at', src: `(?=${u(10)}?$)` });
|
|
767
|
+
}
|
|
768
|
+
else {
|
|
769
|
+
throw new PyReError(`unexpected ${self}`);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
return items.map((i) => i.src).join('');
|
|
773
|
+
}
|
|
774
|
+
/** `_escape`, outside a class. */
|
|
775
|
+
function parseEscape(source, escape, state) {
|
|
776
|
+
const at = RE_AT[escape];
|
|
777
|
+
if (at !== undefined)
|
|
778
|
+
return { kind: 'at', src: at };
|
|
779
|
+
const cat = RE_CATEGORY_IN_CLASS[escape];
|
|
780
|
+
if (cat !== undefined)
|
|
781
|
+
return { kind: 'atom', src: escape === '\\d' ? '\\p{Nd}' : escape === '\\D' ? '\\P{Nd}' : cat };
|
|
782
|
+
const known = RE_ESCAPES[escape];
|
|
783
|
+
if (known !== undefined)
|
|
784
|
+
return { kind: 'atom', src: u(known) };
|
|
785
|
+
const c = escape.slice(1);
|
|
786
|
+
if (c !== '0' && RE_DIGITS.has(c)) {
|
|
787
|
+
// An octal escape or a decimal group reference.
|
|
788
|
+
let digits = c;
|
|
789
|
+
if (source.next !== null && RE_DIGITS.has(source.next)) {
|
|
790
|
+
digits += source.get();
|
|
791
|
+
if (RE_OCT.has(digits[0]) && RE_OCT.has(digits[1]) && source.next !== null && RE_OCT.has(source.next)) {
|
|
792
|
+
digits += source.get();
|
|
793
|
+
const cp = parseInt(digits, 8);
|
|
794
|
+
if (cp > 0o377)
|
|
795
|
+
throw new PyReError(`octal escape value \\${digits} outside of range 0-0o377`);
|
|
796
|
+
return { kind: 'atom', src: u(cp) };
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
const group = Number(digits);
|
|
800
|
+
if (group < state.groups) {
|
|
801
|
+
if (!checkGroup(state, group))
|
|
802
|
+
throw new PyReError('cannot refer to an open group');
|
|
803
|
+
checkLookbehindGroup(state, group);
|
|
804
|
+
state.unsupported = true; // a backreference
|
|
805
|
+
return { kind: 'atom', src: '(?:)' };
|
|
806
|
+
}
|
|
807
|
+
throw new PyReError(`invalid group reference ${group}`);
|
|
808
|
+
}
|
|
809
|
+
return { kind: 'atom', src: u(escapeLiteral(source, escape, false, state)) };
|
|
810
|
+
}
|
|
811
|
+
/** `(` … — a group, an assertion, a comment, flags, or a Python extension. */
|
|
812
|
+
function parseGroup(source, state, verbose, nested, first) {
|
|
813
|
+
let capture = true;
|
|
814
|
+
let name = null;
|
|
815
|
+
let subVerbose = verbose;
|
|
816
|
+
let wrap = (body) => `(?:${body})`;
|
|
817
|
+
if (source.match('?')) {
|
|
818
|
+
const char = source.get();
|
|
819
|
+
if (char === null)
|
|
820
|
+
throw new PyReError('unexpected end of pattern');
|
|
821
|
+
if (char === 'P') {
|
|
822
|
+
if (source.match('<')) {
|
|
823
|
+
name = source.getuntil('>', 'group name');
|
|
824
|
+
checkGroupName(name);
|
|
825
|
+
}
|
|
826
|
+
else if (source.match('=')) {
|
|
827
|
+
const ref = source.getuntil(')', 'group name');
|
|
828
|
+
checkGroupName(ref);
|
|
829
|
+
const gid = state.groupdict.get(ref);
|
|
830
|
+
if (gid === undefined)
|
|
831
|
+
throw new PyReError(`unknown group name ${JSON.stringify(ref)}`);
|
|
832
|
+
if (!checkGroup(state, gid))
|
|
833
|
+
throw new PyReError('cannot refer to an open group');
|
|
834
|
+
checkLookbehindGroup(state, gid);
|
|
835
|
+
state.unsupported = true; // a named backreference
|
|
836
|
+
return { kind: 'atom', src: '(?:)' };
|
|
837
|
+
}
|
|
838
|
+
else {
|
|
839
|
+
const next = source.get();
|
|
840
|
+
if (next === null)
|
|
841
|
+
throw new PyReError('unexpected end of pattern');
|
|
842
|
+
throw new PyReError(`unknown extension ?P${next}`);
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
else if (char === ':') {
|
|
846
|
+
capture = false;
|
|
847
|
+
}
|
|
848
|
+
else if (char === '#') {
|
|
849
|
+
for (;;) {
|
|
850
|
+
if (source.next === null)
|
|
851
|
+
throw new PyReError('missing ), unterminated comment');
|
|
852
|
+
if (source.get() === ')')
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
return null;
|
|
856
|
+
}
|
|
857
|
+
else if (char === '=' || char === '!' || char === '<') {
|
|
858
|
+
let dir = 1;
|
|
859
|
+
let kind = char;
|
|
860
|
+
let saved = null;
|
|
861
|
+
if (char === '<') {
|
|
862
|
+
const next = source.get();
|
|
863
|
+
if (next === null)
|
|
864
|
+
throw new PyReError('unexpected end of pattern');
|
|
865
|
+
if (next !== '=' && next !== '!')
|
|
866
|
+
throw new PyReError(`unknown extension ?<${next}`);
|
|
867
|
+
kind = next;
|
|
868
|
+
dir = -1;
|
|
869
|
+
saved = state.lookbehindgroups;
|
|
870
|
+
if (saved === null)
|
|
871
|
+
state.lookbehindgroups = state.groups;
|
|
872
|
+
state.unsupported = true; // look-behind
|
|
873
|
+
}
|
|
874
|
+
const body = parseSub(source, state, verbose, nested + 1);
|
|
875
|
+
if (dir < 0 && saved === null)
|
|
876
|
+
state.lookbehindgroups = null;
|
|
877
|
+
if (!source.match(')'))
|
|
878
|
+
throw new PyReError('missing ), unterminated subpattern');
|
|
879
|
+
return { kind: 'atom', src: `(?${dir < 0 ? '<' : ''}${kind}${body})` };
|
|
880
|
+
}
|
|
881
|
+
else if (char === '(') {
|
|
882
|
+
// A conditional group: parsed for validity, then refused.
|
|
883
|
+
const condname = source.getuntil(')', 'group name');
|
|
884
|
+
if (/^\d+$/.test(condname)) {
|
|
885
|
+
const big = BigInt(condname);
|
|
886
|
+
if (big === 0n)
|
|
887
|
+
throw new PyReError('bad group number');
|
|
888
|
+
if (big >= MAXGROUPS)
|
|
889
|
+
throw new PyReError(`invalid group reference ${big}`);
|
|
890
|
+
const gid = Number(big);
|
|
891
|
+
// A forward reference is legal here; `parse` checks it against the final count.
|
|
892
|
+
state.grouprefs.push(gid);
|
|
893
|
+
checkLookbehindGroup(state, gid);
|
|
894
|
+
}
|
|
895
|
+
else {
|
|
896
|
+
checkGroupName(condname);
|
|
897
|
+
const gid = state.groupdict.get(condname);
|
|
898
|
+
if (gid === undefined)
|
|
899
|
+
throw new PyReError(`unknown group name ${JSON.stringify(condname)}`);
|
|
900
|
+
checkLookbehindGroup(state, gid);
|
|
901
|
+
}
|
|
902
|
+
const yes = parseSeq(source, state, verbose, nested + 1, false);
|
|
903
|
+
if (source.match('|')) {
|
|
904
|
+
parseSeq(source, state, verbose, nested + 1, false);
|
|
905
|
+
if (source.next === '|')
|
|
906
|
+
throw new PyReError('conditional backref with more than two branches');
|
|
907
|
+
}
|
|
908
|
+
if (!source.match(')'))
|
|
909
|
+
throw new PyReError('missing ), unterminated subpattern');
|
|
910
|
+
state.unsupported = true;
|
|
911
|
+
return { kind: 'atom', src: `(?:${yes})` };
|
|
912
|
+
}
|
|
913
|
+
else if (char === '>') {
|
|
914
|
+
capture = false;
|
|
915
|
+
state.unsupported = true; // an atomic group
|
|
916
|
+
}
|
|
917
|
+
else if (RE_FLAGS.has(char) || char === '-') {
|
|
918
|
+
const flags = parseFlags(source, state, char);
|
|
919
|
+
if (flags === null) {
|
|
920
|
+
if (!first)
|
|
921
|
+
throw new PyReError('global flags not at the start of the expression');
|
|
922
|
+
return 'global-flags';
|
|
923
|
+
}
|
|
924
|
+
const [add, del] = flags;
|
|
925
|
+
state.unsupported = true; // scoped inline flags
|
|
926
|
+
capture = false;
|
|
927
|
+
subVerbose = (verbose || (add & FLAG_VERBOSE) !== 0) && (del & FLAG_VERBOSE) === 0;
|
|
928
|
+
}
|
|
929
|
+
else {
|
|
930
|
+
throw new PyReError(`unknown extension ?${char}`);
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
let group = null;
|
|
934
|
+
if (capture) {
|
|
935
|
+
group = state.groups;
|
|
936
|
+
state.groups += 1;
|
|
937
|
+
if (name !== null) {
|
|
938
|
+
const ogid = state.groupdict.get(name);
|
|
939
|
+
if (ogid !== undefined) {
|
|
940
|
+
throw new PyReError(`redefinition of group name ${JSON.stringify(name)} as group ${group}; was group ${ogid}`);
|
|
941
|
+
}
|
|
942
|
+
state.groupdict.set(name, group);
|
|
943
|
+
}
|
|
944
|
+
wrap = (body) => `(${body})`;
|
|
945
|
+
}
|
|
946
|
+
const body = parseSub(source, state, subVerbose, nested + 1);
|
|
947
|
+
if (!source.match(')'))
|
|
948
|
+
throw new PyReError('missing ), unterminated subpattern');
|
|
949
|
+
if (group !== null)
|
|
950
|
+
state.closed.add(group);
|
|
951
|
+
return { kind: 'atom', src: wrap(body) };
|
|
952
|
+
}
|
|
953
|
+
/** A `\N` escape anywhere, an escaped backslash skipped — `contract._names_a_character`. */
|
|
954
|
+
function namesACharacter(pattern) {
|
|
955
|
+
for (let i = 0; i < pattern.length; i += 1) {
|
|
956
|
+
if (pattern[i] === '\\') {
|
|
957
|
+
if (pattern[i + 1] === 'N')
|
|
958
|
+
return true;
|
|
959
|
+
i += 1;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
/** `re._parser.parse(pattern)` + `fix_flags`, re-emitted for a `v`-mode RegExp. */
|
|
965
|
+
export function translatePyPattern(pattern) {
|
|
966
|
+
// `\N{…}` is refused before the parse, on both runtimes, whatever else the pattern holds:
|
|
967
|
+
// without a Unicode name table the port cannot tell a defined name from an undefined one.
|
|
968
|
+
if (namesACharacter(pattern))
|
|
969
|
+
return { source: null };
|
|
970
|
+
const source = new ReSource(pattern);
|
|
971
|
+
const state = {
|
|
972
|
+
flags: 0,
|
|
973
|
+
groups: 1,
|
|
974
|
+
groupdict: new Map(),
|
|
975
|
+
closed: new Set(),
|
|
976
|
+
lookbehindgroups: null,
|
|
977
|
+
grouprefs: [],
|
|
978
|
+
unsupported: false,
|
|
979
|
+
};
|
|
980
|
+
let body;
|
|
981
|
+
try {
|
|
982
|
+
body = parseSub(source, state, false, 0);
|
|
983
|
+
}
|
|
984
|
+
catch (e) {
|
|
985
|
+
// A pattern nested past the JS stack is past anything `re` would be asked to compile.
|
|
986
|
+
if (e instanceof RangeError)
|
|
987
|
+
throw new PyReError('pattern too deeply nested');
|
|
988
|
+
throw e;
|
|
989
|
+
}
|
|
990
|
+
if (source.next !== null)
|
|
991
|
+
throw new PyReError('unbalanced parenthesis');
|
|
992
|
+
for (const g of state.grouprefs)
|
|
993
|
+
if (g >= state.groups)
|
|
994
|
+
throw new PyReError(`invalid group reference ${g}`);
|
|
995
|
+
if (state.flags & FLAG_ASCII && state.flags & FLAG_UNICODE) {
|
|
996
|
+
throw new PyJsonSchemaUnsupported('ValueError: ASCII and UNICODE flags are incompatible');
|
|
997
|
+
}
|
|
998
|
+
// `(?u)` alone is the default and changes nothing; every other global flag is refused.
|
|
999
|
+
if (state.flags & ~FLAG_UNICODE)
|
|
1000
|
+
state.unsupported = true;
|
|
1001
|
+
return { source: state.unsupported ? null : body };
|
|
1002
|
+
}
|
|
1003
|
+
/** The sentence both runtimes answer for a pattern outside the shared dialect (#131). */
|
|
1004
|
+
export function unsupportedPatternSentence(pattern) {
|
|
1005
|
+
return (`the pattern ${clipText(reprValue({ t: 'str', v: pattern }))} is not supported: this validator ` +
|
|
1006
|
+
'refuses inline flags, look-behind, backreferences, atomic groups, possessive quantifiers, ' +
|
|
1007
|
+
'conditional groups and \\N{...} escapes');
|
|
1008
|
+
}
|
|
1009
|
+
/** The milliseconds a document's patterns may spend, in total, before the validation is refused (#132). */
|
|
1010
|
+
export const PATTERN_BUDGET_MS = 500;
|
|
1011
|
+
/** The sentence both runtimes answer when a document's patterns run past the budget (#132). */
|
|
1012
|
+
export function patternBudgetSentence(pattern) {
|
|
1013
|
+
return (`the pattern ${clipText(reprValue({ t: 'str', v: pattern }))} did not finish within the ` +
|
|
1014
|
+
`${PATTERN_BUDGET_MS} ms this validator allows a document's patterns; rewrite it so it cannot ` +
|
|
1015
|
+
'backtrack catastrophically, as (a+)+$ does');
|
|
1016
|
+
}
|
|
1017
|
+
/**
|
|
1018
|
+
* At most `limit` codepoints of `text`, then `…(N more)` (#153). Codepoints, not UTF-16
|
|
1019
|
+
* units, because `len()` on the reference counts codepoints.
|
|
1020
|
+
*/
|
|
1021
|
+
export function clipText(text, limit = 200) {
|
|
1022
|
+
const cps = [...text];
|
|
1023
|
+
if (cps.length <= limit)
|
|
1024
|
+
return text;
|
|
1025
|
+
return `${cps.slice(0, limit).join('')}…(${cps.length - limit} more)`;
|
|
1026
|
+
}
|
|
1027
|
+
const compiled = new Map();
|
|
1028
|
+
/** The `v`-mode RegExp for a supported pattern; `PyReError` for one `re` refuses. */
|
|
1029
|
+
function compilePyPattern(pattern) {
|
|
1030
|
+
let re = compiled.get(pattern);
|
|
1031
|
+
if (re !== undefined)
|
|
1032
|
+
return re;
|
|
1033
|
+
let translation;
|
|
219
1034
|
try {
|
|
220
|
-
|
|
1035
|
+
translation = translatePyPattern(pattern);
|
|
221
1036
|
}
|
|
222
|
-
catch {
|
|
223
|
-
|
|
224
|
-
|
|
1037
|
+
catch (e) {
|
|
1038
|
+
if (e instanceof PyReError) {
|
|
1039
|
+
throw new PyJsonSchemaUnsupported(`pattern ${JSON.stringify(pattern)} is not a regular expression: ${e.message}`);
|
|
225
1040
|
}
|
|
226
|
-
|
|
227
|
-
|
|
1041
|
+
throw e;
|
|
1042
|
+
}
|
|
1043
|
+
if (translation.source === null)
|
|
1044
|
+
throw new PyJsonSchemaRefusal(unsupportedPatternSentence(pattern));
|
|
1045
|
+
try {
|
|
1046
|
+
re = new RegExp(translation.source, 'v');
|
|
1047
|
+
}
|
|
1048
|
+
catch (e) {
|
|
1049
|
+
throw new PyJsonSchemaUnsupported(`pattern ${JSON.stringify(pattern)} translated to a RegExp V8 will not compile: ${e.message}`);
|
|
1050
|
+
}
|
|
1051
|
+
if (compiled.size > 256)
|
|
1052
|
+
compiled.clear();
|
|
1053
|
+
compiled.set(pattern, re);
|
|
1054
|
+
return re;
|
|
1055
|
+
}
|
|
1056
|
+
/**
|
|
1057
|
+
* The running validation's pattern clock. `validate` opens one; `reSearch` charges every
|
|
1058
|
+
* search to it and refuses — `PyJsonSchemaRefusal`, so the answer is `{valid: false,
|
|
1059
|
+
* feedback}` — once the total passes `PATTERN_BUDGET_MS`. A catastrophic search is stopped
|
|
1060
|
+
* mid-run by `vm`'s timeout, which interrupts the RegExp engine itself; the server's event
|
|
1061
|
+
* loop is blocked for at most the budget, so a `ping` sent behind it is answered within it.
|
|
1062
|
+
*/
|
|
1063
|
+
let patternClock = null;
|
|
1064
|
+
let regexContext = null;
|
|
1065
|
+
const regexScript = new vm.Script('r.test(s)');
|
|
1066
|
+
function reSearch(pattern, text) {
|
|
1067
|
+
const re = compilePyPattern(pattern);
|
|
1068
|
+
if (patternClock === null)
|
|
1069
|
+
return re.test(text);
|
|
1070
|
+
const remaining = PATTERN_BUDGET_MS - patternClock.spentMs;
|
|
1071
|
+
if (remaining <= 0)
|
|
1072
|
+
throw new PyJsonSchemaRefusal(patternBudgetSentence(pattern));
|
|
1073
|
+
regexContext ??= vm.createContext(Object.create(null));
|
|
1074
|
+
regexContext['r'] = re;
|
|
1075
|
+
regexContext['s'] = text;
|
|
1076
|
+
const started = performance.now();
|
|
1077
|
+
try {
|
|
1078
|
+
return regexScript.runInContext(regexContext, { timeout: Math.max(1, Math.ceil(remaining)) }) === true;
|
|
1079
|
+
}
|
|
1080
|
+
catch (e) {
|
|
1081
|
+
if (e.code === 'ERR_SCRIPT_EXECUTION_TIMEOUT') {
|
|
1082
|
+
throw new PyJsonSchemaRefusal(patternBudgetSentence(pattern));
|
|
228
1083
|
}
|
|
1084
|
+
throw e;
|
|
1085
|
+
}
|
|
1086
|
+
finally {
|
|
1087
|
+
patternClock.spentMs += performance.now() - started;
|
|
1088
|
+
regexContext['r'] = undefined;
|
|
1089
|
+
regexContext['s'] = undefined;
|
|
229
1090
|
}
|
|
230
|
-
return re.test(text);
|
|
231
1091
|
}
|
|
232
1092
|
const sortedByStr = (names) => [...names].sort(cmpCodepoint);
|
|
233
1093
|
/** Python truthiness for a decoded JSON value: `not aP`, `if uI`. */
|
|
@@ -249,11 +1109,445 @@ function pyTruthy(value) {
|
|
|
249
1109
|
return value.v.size > 0;
|
|
250
1110
|
}
|
|
251
1111
|
}
|
|
1112
|
+
// ------------------------------------------------------------------- the one resource
|
|
1113
|
+
const REF_KEYWORDS = ['$ref', '$dynamicRef', '$recursiveRef'];
|
|
1114
|
+
/** The keywords whose value is a schema, in ANY dialect — the walk the pre-scan takes. */
|
|
1115
|
+
const WALK_SUBSCHEMA = new Set([
|
|
1116
|
+
'additionalItems', 'additionalProperties', 'contains', 'else', 'if', 'items', 'not',
|
|
1117
|
+
'propertyNames', 'then', 'unevaluatedItems', 'unevaluatedProperties',
|
|
1118
|
+
]);
|
|
1119
|
+
/** …whose value is an object whose VALUES are schemas (or, for `dependencies`, string lists). */
|
|
1120
|
+
const WALK_MAP = new Set(['$defs', 'definitions', 'dependencies', 'dependentSchemas', 'patternProperties', 'properties']);
|
|
1121
|
+
/** …whose value is an array of schemas. */
|
|
1122
|
+
const WALK_LIST = new Set(['allOf', 'anyOf', 'oneOf', 'prefixItems']);
|
|
1123
|
+
/**
|
|
1124
|
+
* Every subschema of `schema`, root first, in document order, with its path in the form
|
|
1125
|
+
* the refusal sentence prints. Structural, not blind: a PROPERTY named `$ref` is not a
|
|
1126
|
+
* reference, and this walk never mistakes it for one. `contract.py`'s `_subschemas` is the
|
|
1127
|
+
* same walk in the same order, which is what makes the two sentences name the same place.
|
|
1128
|
+
*/
|
|
1129
|
+
function* subschemas(schema, path = '') {
|
|
1130
|
+
if (schema.t !== 'dict')
|
|
1131
|
+
return;
|
|
1132
|
+
yield [path, schema.v];
|
|
1133
|
+
for (const [k, v] of schema.v) {
|
|
1134
|
+
const at = path === '' ? k : `${path}/${k}`;
|
|
1135
|
+
if (k === 'items' && v.t === 'list') {
|
|
1136
|
+
for (let i = 0; i < v.v.length; i += 1)
|
|
1137
|
+
yield* subschemas(v.v[i], `${at}/${i}`);
|
|
1138
|
+
}
|
|
1139
|
+
else if (WALK_SUBSCHEMA.has(k)) {
|
|
1140
|
+
yield* subschemas(v, at);
|
|
1141
|
+
}
|
|
1142
|
+
else if (WALK_MAP.has(k) && v.t === 'dict') {
|
|
1143
|
+
for (const [n, x] of v.v)
|
|
1144
|
+
yield* subschemas(x, `${at}/${n}`);
|
|
1145
|
+
}
|
|
1146
|
+
else if (WALK_LIST.has(k) && v.t === 'list') {
|
|
1147
|
+
for (let i = 0; i < v.v.length; i += 1)
|
|
1148
|
+
yield* subschemas(v.v[i], `${at}/${i}`);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* The eager half of the one-resource rule (#125), run BEFORE `checkSchema` as the Python
|
|
1154
|
+
* pre-scan runs before `check_schema`. Three refusals, in document order, each one sentence
|
|
1155
|
+
* that `contract._unsupported_schema` prints byte for byte:
|
|
1156
|
+
*
|
|
1157
|
+
* - a subschema whose `$schema` names draft 3;
|
|
1158
|
+
* - a `$ref`/`$dynamicRef` that is not fragment-only (`$recursiveRef`'s value is never
|
|
1159
|
+
* read by the reference, so it is never judged here either);
|
|
1160
|
+
* - a `$id` (`id` under draft 4) below the root while the document holds any reference.
|
|
1161
|
+
*
|
|
1162
|
+
* Eager rather than on first use, deliberately: the reference resolves a `$ref` only when
|
|
1163
|
+
* validation reaches it, but it resolves it against a base URI this port does not model,
|
|
1164
|
+
* and the Python side cannot be made lazy about an absolute reference it CAN resolve.
|
|
1165
|
+
* Both runtimes refusing the same document is the property; refusing a branch the
|
|
1166
|
+
* instance never reached is the price, and it is the same trade `pattern` already takes.
|
|
1167
|
+
*/
|
|
1168
|
+
function refuseUnsupportedReferences(schema, draft) {
|
|
1169
|
+
const idKey = draft === 4 ? 'id' : '$id';
|
|
1170
|
+
const refs = [];
|
|
1171
|
+
const ids = [];
|
|
1172
|
+
for (const [path, sub] of subschemas(schema)) {
|
|
1173
|
+
if (path !== '') {
|
|
1174
|
+
const nested = sub.get('$schema');
|
|
1175
|
+
if (nested !== undefined && nested.t === 'str' && DRAFT_BY_URI.get(pyUrlNormalize(nested.v)) === 3) {
|
|
1176
|
+
throw new PyJsonSchemaRefusal(draft3Sentence(nested.v));
|
|
1177
|
+
}
|
|
1178
|
+
const id = sub.get(idKey);
|
|
1179
|
+
if (id !== undefined && id.t === 'str')
|
|
1180
|
+
ids.push(path);
|
|
1181
|
+
}
|
|
1182
|
+
for (const k of REF_KEYWORDS) {
|
|
1183
|
+
const v = sub.get(k);
|
|
1184
|
+
if (v !== undefined && v.t === 'str')
|
|
1185
|
+
refs.push([k, v.v]);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
for (const [k, v] of refs) {
|
|
1189
|
+
if (k !== '$recursiveRef' && v !== '' && !v.startsWith('#')) {
|
|
1190
|
+
throw new PyJsonSchemaRefusal(`the reference ${JSON.stringify(v)} cannot be resolved: only references into this ` +
|
|
1191
|
+
'schema ("#", "#/..." and "#name") are supported');
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
if (refs.length > 0 && ids.length > 0) {
|
|
1195
|
+
throw new PyJsonSchemaRefusal(`the schema declares "${idKey}" at ${ids[0]}; references are only resolved in a schema ` +
|
|
1196
|
+
'with a single resource');
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
/**
|
|
1200
|
+
* The eager half of #131, run after the one-resource scan as `contract._unsupported_schema`
|
|
1201
|
+
* runs it: every `pattern` and every `patternProperties` key, in document order, that `re`
|
|
1202
|
+
* parses but this port cannot translate faithfully is refused with one sentence. A pattern
|
|
1203
|
+
* `re` itself refuses is left to `checkSchema`, which raises where the reference raises.
|
|
1204
|
+
*/
|
|
1205
|
+
function refuseUnsupportedPatterns(schema) {
|
|
1206
|
+
for (const [, sub] of subschemas(schema)) {
|
|
1207
|
+
const candidates = [];
|
|
1208
|
+
const pattern = sub.get('pattern');
|
|
1209
|
+
if (pattern !== undefined && pattern.t === 'str')
|
|
1210
|
+
candidates.push(pattern.v);
|
|
1211
|
+
const pp = sub.get('patternProperties');
|
|
1212
|
+
if (pp !== undefined && pp.t === 'dict')
|
|
1213
|
+
candidates.push(...pp.v.keys());
|
|
1214
|
+
for (const candidate of candidates) {
|
|
1215
|
+
let translation;
|
|
1216
|
+
try {
|
|
1217
|
+
translation = translatePyPattern(candidate);
|
|
1218
|
+
}
|
|
1219
|
+
catch {
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
if (translation.source === null)
|
|
1223
|
+
throw new PyJsonSchemaRefusal(unsupportedPatternSentence(candidate));
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
/** `urllib.parse.unquote(s)`: `%XX` runs decoded as UTF-8 with replacement, a lone `%` kept. */
|
|
1228
|
+
function pyUnquote(text) {
|
|
1229
|
+
return text.replace(/(?:%[0-9A-Fa-f]{2})+/g, (run) => {
|
|
1230
|
+
const bytes = Uint8Array.from(run.split('%').slice(1), (h) => parseInt(h, 16));
|
|
1231
|
+
return new TextDecoder('utf-8').decode(bytes);
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
/** `int(segment)` for a list index: sign, digits, underscores between them, surrounding whitespace. */
|
|
1235
|
+
function pyInt(segment) {
|
|
1236
|
+
const m = /^\s*([+-]?)(\d+(?:_\d+)*)\s*$/.exec(segment);
|
|
1237
|
+
if (m === null) {
|
|
1238
|
+
throw new PyJsonSchemaUnsupported(`invalid literal for int() with base 10: ${JSON.stringify(segment)}`);
|
|
1239
|
+
}
|
|
1240
|
+
const n = Number(m[2].replace(/_/g, ''));
|
|
1241
|
+
return m[1] === '-' ? -n : n;
|
|
1242
|
+
}
|
|
1243
|
+
const nowhere = (pointer) => new PyJsonSchemaRefusal(`the reference ${JSON.stringify(`#${pointer}`)} points at nothing in this schema`);
|
|
1244
|
+
/**
|
|
1245
|
+
* `referencing.Resource.pointer`: the fragment is percent-decoded AS A WHOLE and then split
|
|
1246
|
+
* on `/`; a segment into a sequence goes through `int()` (so `-1` is the last element and
|
|
1247
|
+
* `a` is a `ValueError`, which escapes), a segment into an object has `~1` then `~0`
|
|
1248
|
+
* unescaped. A `str` is a `Sequence` in Python, so a pointer can index into one.
|
|
1249
|
+
*/
|
|
1250
|
+
function resolvePointer(pointer, root) {
|
|
1251
|
+
let contents = root;
|
|
1252
|
+
for (const raw of pyUnquote(pointer.slice(1)).split('/')) {
|
|
1253
|
+
if (contents.t === 'list' || contents.t === 'str') {
|
|
1254
|
+
const seq = contents.t === 'list' ? contents.v : [...contents.v].map((ch) => ({ t: 'str', v: ch }));
|
|
1255
|
+
const index = pyInt(raw);
|
|
1256
|
+
const i = index < 0 ? seq.length + index : index;
|
|
1257
|
+
if (i < 0 || i >= seq.length)
|
|
1258
|
+
throw nowhere(pointer);
|
|
1259
|
+
contents = seq[i];
|
|
1260
|
+
}
|
|
1261
|
+
else if (contents.t === 'dict') {
|
|
1262
|
+
const next = contents.v.get(raw.replace(/~1/g, '/').replace(/~0/g, '~'));
|
|
1263
|
+
if (next === undefined)
|
|
1264
|
+
throw nowhere(pointer);
|
|
1265
|
+
contents = next;
|
|
1266
|
+
}
|
|
1267
|
+
else {
|
|
1268
|
+
// `3["x"]` — a TypeError in Python, not a LookupError, so not a PointerToNowhere.
|
|
1269
|
+
throw new PyJsonSchemaUnsupported(`${contents.t} object is not subscriptable`);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
return contents;
|
|
1273
|
+
}
|
|
1274
|
+
/** `Resource.anchor(name)`: the subschema carrying `$anchor` (or, under 2020-12, `$dynamicAnchor`) `name`. */
|
|
1275
|
+
function resolveAnchor(name, ctx) {
|
|
1276
|
+
const keys = ctx.rootDraft === 2020 ? ['$anchor', '$dynamicAnchor'] : ctx.rootDraft === 2019 ? ['$anchor'] : [];
|
|
1277
|
+
// Under draft 7 and earlier an anchor is a fragment-only `$id` below the root, which the
|
|
1278
|
+
// eager scan refused with the document; nothing is left here to match.
|
|
1279
|
+
for (const [, sub] of subschemas(ctx.root)) {
|
|
1280
|
+
for (const k of keys) {
|
|
1281
|
+
const v = sub.get(k);
|
|
1282
|
+
if (v !== undefined && v.t === 'str' && v.v === name)
|
|
1283
|
+
return { t: 'dict', v: sub };
|
|
1284
|
+
}
|
|
1285
|
+
}
|
|
1286
|
+
throw new PyJsonSchemaRefusal(`the reference ${JSON.stringify(`#${name}`)} names no anchor in this schema`);
|
|
1287
|
+
}
|
|
1288
|
+
/** `Resolver.lookup(ref)` inside the one resource: `""` and `"#"` are the root. */
|
|
1289
|
+
function resolveRef(ref, ctx) {
|
|
1290
|
+
if (ref === '' || ref === '#')
|
|
1291
|
+
return ctx.root;
|
|
1292
|
+
const fragment = ref.slice(1);
|
|
1293
|
+
if (fragment.startsWith('/'))
|
|
1294
|
+
return resolvePointer(fragment, ctx.root);
|
|
1295
|
+
return resolveAnchor(fragment, ctx);
|
|
1296
|
+
}
|
|
1297
|
+
const refValue = (keyword, value) => {
|
|
1298
|
+
// `lookup(3)` → `urljoin("", 3)` → TypeError. Both raise.
|
|
1299
|
+
if (value.t !== 'str')
|
|
1300
|
+
throw new PyJsonSchemaUnsupported(`${keyword} must be a string, got ${value.t}`);
|
|
1301
|
+
return value.v;
|
|
1302
|
+
};
|
|
1303
|
+
// --------------------------------------------------------------- unevaluated*, two ways
|
|
1304
|
+
/**
|
|
1305
|
+
* `_utils.find_evaluated_property_keys_by_schema` (2020-12). A list, not a set, because the
|
|
1306
|
+
* reference's is — membership is all it is used for, so the duplicates do not matter.
|
|
1307
|
+
*/
|
|
1308
|
+
function evaluatedKeys2020(instance, schema, ctx) {
|
|
1309
|
+
if (typeof schema === 'boolean' || schema.t === 'bool')
|
|
1310
|
+
return [];
|
|
1311
|
+
const keys = [];
|
|
1312
|
+
const sub = (child) => evaluatedKeys2020(instance, child, ctx);
|
|
1313
|
+
for (const k of ['$ref', '$dynamicRef']) {
|
|
1314
|
+
const ref = get(schema, k);
|
|
1315
|
+
if (ref !== undefined && ref.t !== 'null')
|
|
1316
|
+
keys.push(...sub(resolveRef(refValue(k, ref), ctx)));
|
|
1317
|
+
}
|
|
1318
|
+
const properties = get(schema, 'properties');
|
|
1319
|
+
if (properties !== undefined && properties.t === 'dict') {
|
|
1320
|
+
for (const name of properties.v.keys())
|
|
1321
|
+
if (instance.has(name))
|
|
1322
|
+
keys.push(name);
|
|
1323
|
+
}
|
|
1324
|
+
for (const k of ['additionalProperties', 'unevaluatedProperties']) {
|
|
1325
|
+
const child = get(schema, k);
|
|
1326
|
+
if (child === undefined || child.t === 'null')
|
|
1327
|
+
continue;
|
|
1328
|
+
for (const [name, value] of instance)
|
|
1329
|
+
if (isValid(value, child, ctx))
|
|
1330
|
+
keys.push(name);
|
|
1331
|
+
}
|
|
1332
|
+
const pp = get(schema, 'patternProperties');
|
|
1333
|
+
if (pp !== undefined && pp.t === 'dict') {
|
|
1334
|
+
for (const name of instance.keys())
|
|
1335
|
+
for (const pattern of pp.v.keys())
|
|
1336
|
+
if (reSearch(pattern, name))
|
|
1337
|
+
keys.push(name);
|
|
1338
|
+
}
|
|
1339
|
+
const ds = get(schema, 'dependentSchemas');
|
|
1340
|
+
if (ds !== undefined && ds.t === 'dict') {
|
|
1341
|
+
for (const [name, child] of ds.v)
|
|
1342
|
+
if (instance.has(name))
|
|
1343
|
+
keys.push(...sub(child));
|
|
1344
|
+
}
|
|
1345
|
+
for (const k of ['allOf', 'oneOf', 'anyOf']) {
|
|
1346
|
+
const list = get(schema, k);
|
|
1347
|
+
if (list === undefined || list.t !== 'list')
|
|
1348
|
+
continue;
|
|
1349
|
+
for (const child of list.v)
|
|
1350
|
+
if (isValid({ t: 'dict', v: instance }, child, ctx))
|
|
1351
|
+
keys.push(...sub(child));
|
|
1352
|
+
}
|
|
1353
|
+
keys.push(...evaluatedByIf(schema, { t: 'dict', v: instance }, ctx, sub));
|
|
1354
|
+
return keys;
|
|
1355
|
+
}
|
|
1356
|
+
/** The `if`/`then`/`else` arm all four walkers share. */
|
|
1357
|
+
function evaluatedByIf(schema, instance, ctx, walk) {
|
|
1358
|
+
const cond = get(schema, 'if');
|
|
1359
|
+
if (cond === undefined)
|
|
1360
|
+
return [];
|
|
1361
|
+
if (isValid(instance, cond, ctx)) {
|
|
1362
|
+
const then = get(schema, 'then');
|
|
1363
|
+
return [...walk(cond), ...(then === undefined ? [] : walk(then))];
|
|
1364
|
+
}
|
|
1365
|
+
const otherwise = get(schema, 'else');
|
|
1366
|
+
return otherwise === undefined ? [] : walk(otherwise);
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* `_legacy_keywords.find_evaluated_property_keys_by_schema` (2019-09). Note the second
|
|
1370
|
+
* loop: for an OBJECT `additionalProperties`/`unevaluatedProperties` it reads the
|
|
1371
|
+
* subschema's KEYS (`type`, `minLength`, …) as property names. Reproduced, not corrected —
|
|
1372
|
+
* the sentence that comes out of it is the reference's sentence.
|
|
1373
|
+
*/
|
|
1374
|
+
function evaluatedKeys2019(instance, schema, ctx) {
|
|
1375
|
+
if (typeof schema === 'boolean' || schema.t === 'bool')
|
|
1376
|
+
return [];
|
|
1377
|
+
const keys = [];
|
|
1378
|
+
const sub = (child) => evaluatedKeys2019(instance, child, ctx);
|
|
1379
|
+
const ref = get(schema, '$ref');
|
|
1380
|
+
if (ref !== undefined && ref.t !== 'null')
|
|
1381
|
+
keys.push(...sub(resolveRef(refValue('$ref', ref), ctx)));
|
|
1382
|
+
if (get(schema, '$recursiveRef') !== undefined)
|
|
1383
|
+
keys.push(...sub(ctx.root));
|
|
1384
|
+
for (const k of ['properties', 'additionalProperties', 'unevaluatedProperties']) {
|
|
1385
|
+
const value = get(schema, k);
|
|
1386
|
+
if (value === undefined)
|
|
1387
|
+
continue;
|
|
1388
|
+
if (value.t === 'bool' && value.v)
|
|
1389
|
+
keys.push(...instance.keys());
|
|
1390
|
+
else if (value.t === 'dict')
|
|
1391
|
+
for (const name of value.v.keys())
|
|
1392
|
+
if (instance.has(name))
|
|
1393
|
+
keys.push(name);
|
|
1394
|
+
}
|
|
1395
|
+
const pp = get(schema, 'patternProperties');
|
|
1396
|
+
if (pp !== undefined && pp.t === 'dict') {
|
|
1397
|
+
for (const name of instance.keys())
|
|
1398
|
+
for (const pattern of pp.v.keys())
|
|
1399
|
+
if (reSearch(pattern, name))
|
|
1400
|
+
keys.push(name);
|
|
1401
|
+
}
|
|
1402
|
+
const ds = get(schema, 'dependentSchemas');
|
|
1403
|
+
if (ds !== undefined && ds.t === 'dict') {
|
|
1404
|
+
for (const [name, child] of ds.v)
|
|
1405
|
+
if (instance.has(name))
|
|
1406
|
+
keys.push(...sub(child));
|
|
1407
|
+
}
|
|
1408
|
+
for (const k of ['allOf', 'oneOf', 'anyOf']) {
|
|
1409
|
+
const list = get(schema, k);
|
|
1410
|
+
if (list === undefined || list.t !== 'list')
|
|
1411
|
+
continue;
|
|
1412
|
+
for (const child of list.v)
|
|
1413
|
+
if (isValid({ t: 'dict', v: instance }, child, ctx))
|
|
1414
|
+
keys.push(...sub(child));
|
|
1415
|
+
}
|
|
1416
|
+
keys.push(...evaluatedByIf(schema, { t: 'dict', v: instance }, ctx, sub));
|
|
1417
|
+
return keys;
|
|
1418
|
+
}
|
|
1419
|
+
const range = (n) => Array.from({ length: n }, (_, i) => i);
|
|
1420
|
+
/** `_utils.find_evaluated_item_indexes_by_schema` (2020-12). */
|
|
1421
|
+
function evaluatedIndexes2020(instance, schema, ctx) {
|
|
1422
|
+
if (typeof schema === 'boolean' || schema.t === 'bool')
|
|
1423
|
+
return [];
|
|
1424
|
+
if (get(schema, 'items') !== undefined)
|
|
1425
|
+
return range(instance.length);
|
|
1426
|
+
const idx = [];
|
|
1427
|
+
const sub = (child) => evaluatedIndexes2020(instance, child, ctx);
|
|
1428
|
+
for (const k of ['$ref', '$dynamicRef']) {
|
|
1429
|
+
const ref = get(schema, k);
|
|
1430
|
+
if (ref !== undefined && ref.t !== 'null')
|
|
1431
|
+
idx.push(...sub(resolveRef(refValue(k, ref), ctx)));
|
|
1432
|
+
}
|
|
1433
|
+
const prefix = get(schema, 'prefixItems');
|
|
1434
|
+
if (prefix !== undefined)
|
|
1435
|
+
idx.push(...range(pyLen(prefix)));
|
|
1436
|
+
const list = { t: 'list', v: instance };
|
|
1437
|
+
idx.push(...evaluatedByIf(schema, list, ctx, sub));
|
|
1438
|
+
for (const k of ['contains', 'unevaluatedItems']) {
|
|
1439
|
+
const child = get(schema, k);
|
|
1440
|
+
if (child === undefined)
|
|
1441
|
+
continue;
|
|
1442
|
+
instance.forEach((v, i) => {
|
|
1443
|
+
if (isValid(v, child, ctx))
|
|
1444
|
+
idx.push(i);
|
|
1445
|
+
});
|
|
1446
|
+
}
|
|
1447
|
+
for (const k of ['allOf', 'oneOf', 'anyOf']) {
|
|
1448
|
+
const children = get(schema, k);
|
|
1449
|
+
if (children === undefined || children.t !== 'list')
|
|
1450
|
+
continue;
|
|
1451
|
+
for (const child of children.v)
|
|
1452
|
+
if (isValid(list, child, ctx))
|
|
1453
|
+
idx.push(...sub(child));
|
|
1454
|
+
}
|
|
1455
|
+
return idx;
|
|
1456
|
+
}
|
|
1457
|
+
/** `_legacy_keywords.find_evaluated_item_indexes_by_schema` (2019-09). */
|
|
1458
|
+
function evaluatedIndexes2019(instance, schema, ctx) {
|
|
1459
|
+
if (typeof schema === 'boolean' || schema.t === 'bool')
|
|
1460
|
+
return [];
|
|
1461
|
+
const idx = [];
|
|
1462
|
+
const sub = (child) => evaluatedIndexes2019(instance, child, ctx);
|
|
1463
|
+
const ref = get(schema, '$ref');
|
|
1464
|
+
if (ref !== undefined && ref.t !== 'null')
|
|
1465
|
+
idx.push(...sub(resolveRef(refValue('$ref', ref), ctx)));
|
|
1466
|
+
if (get(schema, '$recursiveRef') !== undefined)
|
|
1467
|
+
idx.push(...sub(ctx.root));
|
|
1468
|
+
const items = get(schema, 'items');
|
|
1469
|
+
if (items !== undefined) {
|
|
1470
|
+
if (get(schema, 'additionalItems') !== undefined)
|
|
1471
|
+
return range(instance.length);
|
|
1472
|
+
if (items.t === 'dict')
|
|
1473
|
+
return range(instance.length);
|
|
1474
|
+
idx.push(...range(pyLen(items)));
|
|
1475
|
+
}
|
|
1476
|
+
const list = { t: 'list', v: instance };
|
|
1477
|
+
idx.push(...evaluatedByIf(schema, list, ctx, sub));
|
|
1478
|
+
for (const k of ['contains', 'unevaluatedItems']) {
|
|
1479
|
+
const child = get(schema, k);
|
|
1480
|
+
if (child === undefined)
|
|
1481
|
+
continue;
|
|
1482
|
+
instance.forEach((v, i) => {
|
|
1483
|
+
if (isValid(v, child, ctx))
|
|
1484
|
+
idx.push(i);
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
for (const k of ['allOf', 'oneOf', 'anyOf']) {
|
|
1488
|
+
const children = get(schema, k);
|
|
1489
|
+
if (children === undefined || children.t !== 'list')
|
|
1490
|
+
continue;
|
|
1491
|
+
for (const child of children.v)
|
|
1492
|
+
if (isValid(list, child, ctx))
|
|
1493
|
+
idx.push(...sub(child));
|
|
1494
|
+
}
|
|
1495
|
+
return idx;
|
|
1496
|
+
}
|
|
1497
|
+
/** `len(value)` where the reference calls it on a schema value: a list, a dict, a string — never a bool. */
|
|
1498
|
+
function pyLen(value) {
|
|
1499
|
+
if (value.t === 'list')
|
|
1500
|
+
return value.v.length;
|
|
1501
|
+
if (value.t === 'dict')
|
|
1502
|
+
return value.v.size;
|
|
1503
|
+
if (value.t === 'str')
|
|
1504
|
+
return cpLength(value.v);
|
|
1505
|
+
throw new PyJsonSchemaUnsupported(`object of type '${value.t}' has no len()`);
|
|
1506
|
+
}
|
|
1507
|
+
function unevaluatedItemsWith(walker) {
|
|
1508
|
+
return (_value, instance, schema, emit, _descend, ctx) => {
|
|
1509
|
+
if (instance.t !== 'list')
|
|
1510
|
+
return [];
|
|
1511
|
+
const evaluated = new Set(walker(instance.v, schema, ctx));
|
|
1512
|
+
const unevaluated = instance.v.filter((_, i) => !evaluated.has(i));
|
|
1513
|
+
if (unevaluated.length === 0)
|
|
1514
|
+
return [];
|
|
1515
|
+
const [joined, verb] = extrasMsgValues(unevaluated);
|
|
1516
|
+
return [emit(`Unevaluated items are not allowed (${joined} ${verb} unexpected)`)];
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
function unevaluatedPropertiesWith(walker) {
|
|
1520
|
+
return (value, instance, schema, emit, _descend, ctx) => {
|
|
1521
|
+
if (instance.t !== 'dict')
|
|
1522
|
+
return [];
|
|
1523
|
+
const evaluated = new Set(walker(instance.v, schema, ctx));
|
|
1524
|
+
const unevaluated = [];
|
|
1525
|
+
for (const [name, v] of instance.v) {
|
|
1526
|
+
if (evaluated.has(name))
|
|
1527
|
+
continue;
|
|
1528
|
+
// `for _ in validator.descend(...)`: appended ONCE PER ERROR, so a subschema that
|
|
1529
|
+
// fails a property twice names it twice. Reproduced.
|
|
1530
|
+
for (const _ of iterErrors(v, value, ctx))
|
|
1531
|
+
unevaluated.push(name);
|
|
1532
|
+
}
|
|
1533
|
+
if (unevaluated.length === 0)
|
|
1534
|
+
return [];
|
|
1535
|
+
if (value.t === 'bool' && !value.v) {
|
|
1536
|
+
const [joined, verb] = extrasMsg(sortedByStr(unevaluated));
|
|
1537
|
+
return [emit(`Unevaluated properties are not allowed (${joined} ${verb} unexpected)`)];
|
|
1538
|
+
}
|
|
1539
|
+
const [joined, verb] = extrasMsg(unevaluated);
|
|
1540
|
+
return [
|
|
1541
|
+
emit(`Unevaluated properties are not valid under the given schema (${joined} ${verb} unevaluated and invalid)`),
|
|
1542
|
+
];
|
|
1543
|
+
};
|
|
1544
|
+
}
|
|
1545
|
+
// ------------------------------------------------------------------------- the keywords
|
|
252
1546
|
const KEYWORDS = {
|
|
253
|
-
type(value, instance, _schema, emit) {
|
|
1547
|
+
type(value, instance, _schema, emit, _descend, ctx) {
|
|
254
1548
|
// `ensure_list`: a bare string becomes a one-element list, anything else passes through.
|
|
255
1549
|
const types = value.t === 'str' ? [value] : value.t === 'list' ? value.v : [value];
|
|
256
|
-
if (types.some((t) => t.t === 'str' && isType(instance, t.v)))
|
|
1550
|
+
if (types.some((t) => t.t === 'str' && isType(instance, t.v, ctx.draft)))
|
|
257
1551
|
return [];
|
|
258
1552
|
const reprs = types.map(reprValue).join(', ');
|
|
259
1553
|
return [emit(`${reprValue(instance)} is not of type ${reprs}`)];
|
|
@@ -341,6 +1635,7 @@ const KEYWORDS = {
|
|
|
341
1635
|
out.push(...descend(instance.v[i], value.v[i], i));
|
|
342
1636
|
return out;
|
|
343
1637
|
},
|
|
1638
|
+
/** `_keywords.items` (2020-12): everything past `prefixItems`. */
|
|
344
1639
|
items(value, instance, schema, emit, descend) {
|
|
345
1640
|
if (instance.t !== 'list')
|
|
346
1641
|
return [];
|
|
@@ -360,7 +1655,8 @@ const KEYWORDS = {
|
|
|
360
1655
|
out.push(...descend(instance.v[i], value, i));
|
|
361
1656
|
return out;
|
|
362
1657
|
},
|
|
363
|
-
contains(
|
|
1658
|
+
/** `_keywords.contains` (2019-09 and 2020-12): with `minContains`/`maxContains`. */
|
|
1659
|
+
contains(value, instance, schema, emit, _descend, ctx) {
|
|
364
1660
|
if (instance.t !== 'list')
|
|
365
1661
|
return [];
|
|
366
1662
|
const minRaw = get(schema, 'minContains');
|
|
@@ -369,7 +1665,7 @@ const KEYWORDS = {
|
|
|
369
1665
|
const maxContains = maxRaw === undefined ? instance.v.length : asNumber(maxRaw);
|
|
370
1666
|
let matches = 0;
|
|
371
1667
|
for (const each of instance.v) {
|
|
372
|
-
if (isValid(each, value)) {
|
|
1668
|
+
if (isValid(each, value, ctx)) {
|
|
373
1669
|
matches += 1;
|
|
374
1670
|
if (matches > maxContains) {
|
|
375
1671
|
return [
|
|
@@ -561,12 +1857,12 @@ const KEYWORDS = {
|
|
|
561
1857
|
out.push(...descend(instance, subschema));
|
|
562
1858
|
return out;
|
|
563
1859
|
},
|
|
564
|
-
anyOf(value, instance, _schema, emit) {
|
|
1860
|
+
anyOf(value, instance, _schema, emit, _descend, ctx) {
|
|
565
1861
|
if (value.t !== 'list')
|
|
566
1862
|
return [];
|
|
567
1863
|
const allErrors = [];
|
|
568
1864
|
for (const subschema of value.v) {
|
|
569
|
-
const errs = iterErrors(instance, subschema);
|
|
1865
|
+
const errs = iterErrors(instance, subschema, ctx);
|
|
570
1866
|
if (errs.length === 0)
|
|
571
1867
|
return [];
|
|
572
1868
|
allErrors.push(...errs);
|
|
@@ -578,7 +1874,7 @@ const KEYWORDS = {
|
|
|
578
1874
|
child.parent = error;
|
|
579
1875
|
return [error];
|
|
580
1876
|
},
|
|
581
|
-
oneOf(value, instance, _schema, emit) {
|
|
1877
|
+
oneOf(value, instance, _schema, emit, _descend, ctx) {
|
|
582
1878
|
if (value.t !== 'list')
|
|
583
1879
|
return [];
|
|
584
1880
|
const out = [];
|
|
@@ -586,7 +1882,7 @@ const KEYWORDS = {
|
|
|
586
1882
|
let firstValid = null;
|
|
587
1883
|
let index = 0;
|
|
588
1884
|
for (; index < value.v.length; index += 1) {
|
|
589
|
-
const errs = iterErrors(instance, value.v[index]);
|
|
1885
|
+
const errs = iterErrors(instance, value.v[index], ctx);
|
|
590
1886
|
if (errs.length === 0) {
|
|
591
1887
|
firstValid = value.v[index];
|
|
592
1888
|
break;
|
|
@@ -603,26 +1899,218 @@ const KEYWORDS = {
|
|
|
603
1899
|
}
|
|
604
1900
|
// `subschemas` is one shared iterator in Python: the comprehension below resumes where
|
|
605
1901
|
// the loop above broke, so the schemas already tried are never re-checked.
|
|
606
|
-
const moreValid = value.v.slice(index + 1).filter((each) => isValid(instance, each));
|
|
1902
|
+
const moreValid = value.v.slice(index + 1).filter((each) => isValid(instance, each, ctx));
|
|
607
1903
|
if (moreValid.length > 0) {
|
|
608
1904
|
const reprs = [...moreValid, firstValid].map(reprValue).join(', ');
|
|
609
1905
|
out.push(emit(`${reprValue(instance)} is valid under each of ${reprs}`));
|
|
610
1906
|
}
|
|
611
1907
|
return out;
|
|
612
1908
|
},
|
|
613
|
-
not(value, instance, _schema, emit) {
|
|
614
|
-
if (!isValid(instance, value))
|
|
1909
|
+
not(value, instance, _schema, emit, _descend, ctx) {
|
|
1910
|
+
if (!isValid(instance, value, ctx))
|
|
615
1911
|
return [];
|
|
616
1912
|
return [emit(`${reprValue(instance)} should not be valid under ${reprValue(value)}`)];
|
|
617
1913
|
},
|
|
618
|
-
if(value, instance, schema, _emit, descend) {
|
|
619
|
-
if (isValid(instance, value)) {
|
|
1914
|
+
if(value, instance, schema, _emit, descend, ctx) {
|
|
1915
|
+
if (isValid(instance, value, ctx)) {
|
|
620
1916
|
const then = get(schema, 'then');
|
|
621
1917
|
return then === undefined ? [] : descend(instance, then);
|
|
622
1918
|
}
|
|
623
1919
|
const otherwise = get(schema, 'else');
|
|
624
1920
|
return otherwise === undefined ? [] : descend(instance, otherwise);
|
|
625
1921
|
},
|
|
1922
|
+
/** `_keywords.ref`: `descend(instance, resolved.contents)` — no path, no `$ref` in the schema path. */
|
|
1923
|
+
$ref(value, instance, _schema, _emit, descend, ctx) {
|
|
1924
|
+
return descend(instance, resolveRef(refValue('$ref', value), ctx));
|
|
1925
|
+
},
|
|
1926
|
+
/** `_keywords.dynamicRef` is `_validate_reference(ref=dynamicRef)` — the same lookup. */
|
|
1927
|
+
$dynamicRef(value, instance, _schema, _emit, descend, ctx) {
|
|
1928
|
+
return descend(instance, resolveRef(refValue('$dynamicRef', value), ctx));
|
|
1929
|
+
},
|
|
1930
|
+
/** `_legacy_keywords.recursiveRef` never reads its value; in one resource it is the root. */
|
|
1931
|
+
$recursiveRef(_value, instance, _schema, _emit, descend, ctx) {
|
|
1932
|
+
return descend(instance, ctx.root);
|
|
1933
|
+
},
|
|
1934
|
+
unevaluatedItems: unevaluatedItemsWith(evaluatedIndexes2020),
|
|
1935
|
+
unevaluatedProperties: unevaluatedPropertiesWith(evaluatedKeys2020),
|
|
1936
|
+
};
|
|
1937
|
+
/** `_legacy_keywords`: the keywords whose shape or sentence is older than 2020-12. */
|
|
1938
|
+
const LEGACY = {
|
|
1939
|
+
/** `items_draft3_draft4`: an object applies to every item, anything else is zipped. */
|
|
1940
|
+
itemsDraft4(value, instance, _schema, _emit, descend) {
|
|
1941
|
+
if (instance.t !== 'list')
|
|
1942
|
+
return [];
|
|
1943
|
+
const out = [];
|
|
1944
|
+
if (value.t === 'dict') {
|
|
1945
|
+
instance.v.forEach((item, i) => out.push(...descend(item, value, i)));
|
|
1946
|
+
}
|
|
1947
|
+
else if (value.t === 'list') {
|
|
1948
|
+
const n = Math.min(instance.v.length, value.v.length);
|
|
1949
|
+
for (let i = 0; i < n; i += 1)
|
|
1950
|
+
out.push(...descend(instance.v[i], value.v[i], i));
|
|
1951
|
+
}
|
|
1952
|
+
return out;
|
|
1953
|
+
},
|
|
1954
|
+
/** `items_draft6_draft7_draft201909`: an array is zipped, anything else applies to every item. */
|
|
1955
|
+
itemsDraft6(value, instance, _schema, _emit, descend) {
|
|
1956
|
+
if (instance.t !== 'list')
|
|
1957
|
+
return [];
|
|
1958
|
+
const out = [];
|
|
1959
|
+
if (value.t === 'list') {
|
|
1960
|
+
const n = Math.min(instance.v.length, value.v.length);
|
|
1961
|
+
for (let i = 0; i < n; i += 1)
|
|
1962
|
+
out.push(...descend(instance.v[i], value.v[i], i));
|
|
1963
|
+
}
|
|
1964
|
+
else {
|
|
1965
|
+
instance.v.forEach((item, i) => out.push(...descend(item, value, i)));
|
|
1966
|
+
}
|
|
1967
|
+
return out;
|
|
1968
|
+
},
|
|
1969
|
+
additionalItems(value, instance, schema, emit, descend) {
|
|
1970
|
+
if (instance.t !== 'list')
|
|
1971
|
+
return [];
|
|
1972
|
+
// `schema.get("items", {})` is an object when absent, so the keyword applies only
|
|
1973
|
+
// beside a LIST `items`; `len(True)` beside a boolean one is a TypeError that escapes.
|
|
1974
|
+
const items = get(schema, 'items');
|
|
1975
|
+
if (items === undefined || items.t === 'dict')
|
|
1976
|
+
return [];
|
|
1977
|
+
const lenItems = pyLen(items);
|
|
1978
|
+
if (value.t === 'dict') {
|
|
1979
|
+
const out = [];
|
|
1980
|
+
for (let i = lenItems; i < instance.v.length; i += 1)
|
|
1981
|
+
out.push(...descend(instance.v[i], value, i));
|
|
1982
|
+
return out;
|
|
1983
|
+
}
|
|
1984
|
+
if (!pyTruthy(value) && instance.v.length > lenItems) {
|
|
1985
|
+
const [joined, verb] = extrasMsgValues(instance.v.slice(lenItems));
|
|
1986
|
+
return [emit(`Additional items are not allowed (${joined} ${verb} unexpected)`)];
|
|
1987
|
+
}
|
|
1988
|
+
return [];
|
|
1989
|
+
},
|
|
1990
|
+
/** `dependencies_draft4_draft6_draft7`: a list is `dependentRequired`, anything else a schema. */
|
|
1991
|
+
dependencies(value, instance, _schema, emit, descend) {
|
|
1992
|
+
if (instance.t !== 'dict' || value.t !== 'dict')
|
|
1993
|
+
return [];
|
|
1994
|
+
const out = [];
|
|
1995
|
+
for (const [property, dependency] of value.v) {
|
|
1996
|
+
if (!instance.v.has(property))
|
|
1997
|
+
continue;
|
|
1998
|
+
if (dependency.t === 'list') {
|
|
1999
|
+
for (const each of dependency.v) {
|
|
2000
|
+
const name = each.v;
|
|
2001
|
+
if (!instance.v.has(name)) {
|
|
2002
|
+
out.push(emit(`${reprValue(each)} is a dependency of ${reprValue({ t: 'str', v: property })}`));
|
|
2003
|
+
}
|
|
2004
|
+
}
|
|
2005
|
+
}
|
|
2006
|
+
else {
|
|
2007
|
+
out.push(...descend(instance, dependency));
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
return out;
|
|
2011
|
+
},
|
|
2012
|
+
/** `maximum_draft3_draft4`: a truthy `exclusiveMaximum` sibling makes the bound exclusive. */
|
|
2013
|
+
maximumDraft4(value, instance, schema, emit) {
|
|
2014
|
+
if (!isType(instance, 'number'))
|
|
2015
|
+
return [];
|
|
2016
|
+
const exclusive = get(schema, 'exclusiveMaximum');
|
|
2017
|
+
if (exclusive !== undefined && pyTruthy(exclusive)) {
|
|
2018
|
+
if (!numLess(instance, value)) {
|
|
2019
|
+
return [emit(`${reprValue(instance)} is greater than or equal to the maximum of ${reprValue(value)}`)];
|
|
2020
|
+
}
|
|
2021
|
+
return [];
|
|
2022
|
+
}
|
|
2023
|
+
if (numLess(value, instance)) {
|
|
2024
|
+
return [emit(`${reprValue(instance)} is greater than the maximum of ${reprValue(value)}`)];
|
|
2025
|
+
}
|
|
2026
|
+
return [];
|
|
2027
|
+
},
|
|
2028
|
+
minimumDraft4(value, instance, schema, emit) {
|
|
2029
|
+
if (!isType(instance, 'number'))
|
|
2030
|
+
return [];
|
|
2031
|
+
const exclusive = get(schema, 'exclusiveMinimum');
|
|
2032
|
+
if (exclusive !== undefined && pyTruthy(exclusive)) {
|
|
2033
|
+
if (!numLess(value, instance)) {
|
|
2034
|
+
return [emit(`${reprValue(instance)} is less than or equal to the minimum of ${reprValue(value)}`)];
|
|
2035
|
+
}
|
|
2036
|
+
return [];
|
|
2037
|
+
}
|
|
2038
|
+
if (numLess(instance, value)) {
|
|
2039
|
+
return [emit(`${reprValue(instance)} is less than the minimum of ${reprValue(value)}`)];
|
|
2040
|
+
}
|
|
2041
|
+
return [];
|
|
2042
|
+
},
|
|
2043
|
+
/** `contains_draft6_draft7`: no `minContains`, and a different sentence. */
|
|
2044
|
+
containsDraft6(value, instance, _schema, emit, _descend, ctx) {
|
|
2045
|
+
if (instance.t !== 'list')
|
|
2046
|
+
return [];
|
|
2047
|
+
if (instance.v.some((element) => isValid(element, value, ctx)))
|
|
2048
|
+
return [];
|
|
2049
|
+
return [emit(`None of ${reprValue(instance)} are valid under the given schema`)];
|
|
2050
|
+
},
|
|
2051
|
+
unevaluatedItems2019: unevaluatedItemsWith(evaluatedIndexes2019),
|
|
2052
|
+
unevaluatedProperties2019: unevaluatedPropertiesWith(evaluatedKeys2019),
|
|
2053
|
+
};
|
|
2054
|
+
const pick = (names) => Object.fromEntries(names.map((n) => [n, KEYWORDS[n]]));
|
|
2055
|
+
/** `Draft4Validator.VALIDATORS` … `Draft202012Validator.VALIDATORS`, name for name. */
|
|
2056
|
+
const KEYWORDS_BY_DRAFT = {
|
|
2057
|
+
4: {
|
|
2058
|
+
...pick([
|
|
2059
|
+
'$ref', 'additionalProperties', 'allOf', 'anyOf', 'enum', 'format', 'maxItems', 'maxLength',
|
|
2060
|
+
'maxProperties', 'minItems', 'minLength', 'minProperties', 'multipleOf', 'not', 'oneOf',
|
|
2061
|
+
'pattern', 'patternProperties', 'properties', 'required', 'type', 'uniqueItems',
|
|
2062
|
+
]),
|
|
2063
|
+
additionalItems: LEGACY['additionalItems'],
|
|
2064
|
+
dependencies: LEGACY['dependencies'],
|
|
2065
|
+
items: LEGACY['itemsDraft4'],
|
|
2066
|
+
maximum: LEGACY['maximumDraft4'],
|
|
2067
|
+
minimum: LEGACY['minimumDraft4'],
|
|
2068
|
+
},
|
|
2069
|
+
6: {
|
|
2070
|
+
...pick([
|
|
2071
|
+
'$ref', 'additionalProperties', 'allOf', 'anyOf', 'const', 'enum', 'exclusiveMaximum',
|
|
2072
|
+
'exclusiveMinimum', 'format', 'maxItems', 'maxLength', 'maxProperties', 'maximum', 'minItems',
|
|
2073
|
+
'minLength', 'minProperties', 'minimum', 'multipleOf', 'not', 'oneOf', 'pattern',
|
|
2074
|
+
'patternProperties', 'properties', 'propertyNames', 'required', 'type', 'uniqueItems',
|
|
2075
|
+
]),
|
|
2076
|
+
additionalItems: LEGACY['additionalItems'],
|
|
2077
|
+
contains: LEGACY['containsDraft6'],
|
|
2078
|
+
dependencies: LEGACY['dependencies'],
|
|
2079
|
+
items: LEGACY['itemsDraft6'],
|
|
2080
|
+
},
|
|
2081
|
+
7: {
|
|
2082
|
+
...pick([
|
|
2083
|
+
'$ref', 'additionalProperties', 'allOf', 'anyOf', 'const', 'enum', 'exclusiveMaximum',
|
|
2084
|
+
'exclusiveMinimum', 'format', 'if', 'maxItems', 'maxLength', 'maxProperties', 'maximum',
|
|
2085
|
+
'minItems', 'minLength', 'minProperties', 'minimum', 'multipleOf', 'not', 'oneOf', 'pattern',
|
|
2086
|
+
'patternProperties', 'properties', 'propertyNames', 'required', 'type', 'uniqueItems',
|
|
2087
|
+
]),
|
|
2088
|
+
additionalItems: LEGACY['additionalItems'],
|
|
2089
|
+
contains: LEGACY['containsDraft6'],
|
|
2090
|
+
dependencies: LEGACY['dependencies'],
|
|
2091
|
+
items: LEGACY['itemsDraft6'],
|
|
2092
|
+
},
|
|
2093
|
+
2019: {
|
|
2094
|
+
...pick([
|
|
2095
|
+
'$recursiveRef', '$ref', 'additionalProperties', 'allOf', 'anyOf', 'const', 'contains',
|
|
2096
|
+
'dependentRequired', 'dependentSchemas', 'enum', 'exclusiveMaximum', 'exclusiveMinimum',
|
|
2097
|
+
'format', 'if', 'maxItems', 'maxLength', 'maxProperties', 'maximum', 'minItems', 'minLength',
|
|
2098
|
+
'minProperties', 'minimum', 'multipleOf', 'not', 'oneOf', 'pattern', 'patternProperties',
|
|
2099
|
+
'properties', 'propertyNames', 'required', 'type', 'uniqueItems',
|
|
2100
|
+
]),
|
|
2101
|
+
additionalItems: LEGACY['additionalItems'],
|
|
2102
|
+
items: LEGACY['itemsDraft6'],
|
|
2103
|
+
unevaluatedItems: LEGACY['unevaluatedItems2019'],
|
|
2104
|
+
unevaluatedProperties: LEGACY['unevaluatedProperties2019'],
|
|
2105
|
+
},
|
|
2106
|
+
2020: pick([
|
|
2107
|
+
'$dynamicRef', '$ref', 'additionalProperties', 'allOf', 'anyOf', 'const', 'contains',
|
|
2108
|
+
'dependentRequired', 'dependentSchemas', 'enum', 'exclusiveMaximum', 'exclusiveMinimum',
|
|
2109
|
+
'format', 'if', 'items', 'maxItems', 'maxLength', 'maxProperties', 'maximum', 'minItems',
|
|
2110
|
+
'minLength', 'minProperties', 'minimum', 'multipleOf', 'not', 'oneOf', 'pattern',
|
|
2111
|
+
'patternProperties', 'prefixItems', 'properties', 'propertyNames', 'required', 'type',
|
|
2112
|
+
'unevaluatedItems', 'unevaluatedProperties', 'uniqueItems',
|
|
2113
|
+
]),
|
|
626
2114
|
};
|
|
627
2115
|
/** `a < b` over Python numbers, exact for two ints of any size. */
|
|
628
2116
|
function numLess(a, b) {
|
|
@@ -641,9 +2129,9 @@ function matchesType(error) {
|
|
|
641
2129
|
if (expected === undefined)
|
|
642
2130
|
return false;
|
|
643
2131
|
if (expected.t === 'str')
|
|
644
|
-
return isType(error.instance, expected.v);
|
|
2132
|
+
return isType(error.instance, expected.v, error.draft);
|
|
645
2133
|
if (expected.t === 'list') {
|
|
646
|
-
return expected.v.some((t) => isType(error.instance, t.v));
|
|
2134
|
+
return expected.v.some((t) => isType(error.instance, t.v, error.draft));
|
|
647
2135
|
}
|
|
648
2136
|
return false;
|
|
649
2137
|
}
|
|
@@ -732,10 +2220,10 @@ export function bestMatch(errors) {
|
|
|
732
2220
|
}
|
|
733
2221
|
// ------------------------------------------------------------------ the schema, checked
|
|
734
2222
|
/**
|
|
735
|
-
* `cls.check_schema(schema)`, reduced to the SHAPE of each keyword's value.
|
|
2223
|
+
* `cls.check_schema(schema)`, reduced to the SHAPE of each keyword's value, per dialect.
|
|
736
2224
|
*
|
|
737
2225
|
* WHY THIS EXISTS, and why it is not the metaschema. `jsonschema.validate` runs
|
|
738
|
-
* `check_schema` BEFORE it looks at the instance, so a caller schema the
|
|
2226
|
+
* `check_schema` BEFORE it looks at the instance, so a caller schema the metaschema
|
|
739
2227
|
* refuses never reaches a keyword. Without it, this port ran the keywords anyway and the
|
|
740
2228
|
* keywords are written to SKIP a value they do not recognise — which is correct behaviour
|
|
741
2229
|
* inside `iter_errors` and a wrong ANSWER at the tool boundary. N8 measured it on the wire:
|
|
@@ -748,96 +2236,175 @@ export function bestMatch(errors) {
|
|
|
748
2236
|
*
|
|
749
2237
|
* The last one is the worst of the five: a sentence about a schema that is not a schema.
|
|
750
2238
|
*
|
|
751
|
-
* WHAT IS CHECKED, AND THE RULING ON WHAT IS NOT. Running the real
|
|
752
|
-
* available to this port —
|
|
753
|
-
* `$ref
|
|
754
|
-
*
|
|
755
|
-
*
|
|
756
|
-
*
|
|
757
|
-
*
|
|
758
|
-
*
|
|
759
|
-
*
|
|
760
|
-
*
|
|
761
|
-
*
|
|
2239
|
+
* WHAT IS CHECKED, AND THE RULING ON WHAT IS NOT. Running the real metaschemas is not
|
|
2240
|
+
* available to this port — each is several documents wired together across resources with
|
|
2241
|
+
* `$ref`/`$dynamicRef`, and one resource is all this file resolves. What is here instead
|
|
2242
|
+
* is the SHAPE TABLE: for every keyword this port implements, the type the dialect's
|
|
2243
|
+
* metaschema demands of its value, applied recursively through the keywords whose values
|
|
2244
|
+
* hold subschemas, and GATED BY DIALECT — the metaschema of draft 4 knows no `if`, so
|
|
2245
|
+
* `{"if": 3}` is data there and a `SchemaError` from draft 7 on; draft 4 allows no boolean
|
|
2246
|
+
* subschema, requires `maximum` beside `exclusiveMaximum: true`, refuses an empty
|
|
2247
|
+
* `required`/`enum` and reads `2.0` as a non-integer. That is narrower than the metaschema
|
|
2248
|
+
* — it does not check `title`, `description`, `$comment` or `examples`, nor
|
|
2249
|
+
* `dependentRequired`'s uniqueness — and every gap is a schema Python refuses and this port
|
|
2250
|
+
* answers on. Each keyword listed below is MEASURED against the reference in
|
|
2251
|
+
* `tools/conformance/suites/validate.mjs`, under every dialect; the gaps are listed there
|
|
2252
|
+
* too, as rows deliberately absent from the grid rather than quietly ruled.
|
|
762
2253
|
*
|
|
763
|
-
* BOTH RUNTIMES
|
|
2254
|
+
* BOTH RUNTIMES REFUSE, AND THE SENTENCE DIFFERS. Python raises `SchemaError` with the
|
|
764
2255
|
* metaschema's own wording and it ESCAPES `validate_json` to the caller (a registered
|
|
765
2256
|
* `runtime-py` defect — a tool should not let an exception out on caller input; not fixed
|
|
766
2257
|
* here, invariant 8). This raises `PyJsonSchemaUnsupported`, which escapes the same way,
|
|
767
2258
|
* because agreeing that the tool raises is the behaviour and inventing an answer is not.
|
|
768
2259
|
*/
|
|
769
2260
|
const SIMPLE_TYPES = new Set(['array', 'boolean', 'integer', 'null', 'number', 'object', 'string']);
|
|
770
|
-
/** Keywords whose value is itself a schema. */
|
|
771
|
-
const SUBSCHEMA =
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
];
|
|
2261
|
+
/** Keywords whose value is itself a schema, and the first dialect that knows them. */
|
|
2262
|
+
const SUBSCHEMA = {
|
|
2263
|
+
additionalProperties: 4,
|
|
2264
|
+
contains: 6,
|
|
2265
|
+
else: 7,
|
|
2266
|
+
if: 7,
|
|
2267
|
+
not: 4,
|
|
2268
|
+
propertyNames: 6,
|
|
2269
|
+
then: 7,
|
|
2270
|
+
unevaluatedItems: 2019,
|
|
2271
|
+
unevaluatedProperties: 2019,
|
|
2272
|
+
};
|
|
783
2273
|
/** Keywords whose value is an object whose VALUES are schemas. */
|
|
784
|
-
const SUBSCHEMA_MAP =
|
|
785
|
-
/** Keywords whose value is
|
|
786
|
-
const SUBSCHEMA_LIST =
|
|
2274
|
+
const SUBSCHEMA_MAP = { $defs: 2019, definitions: 4, dependentSchemas: 2019, patternProperties: 4, properties: 4 };
|
|
2275
|
+
/** Keywords whose value is a non-empty array of schemas. */
|
|
2276
|
+
const SUBSCHEMA_LIST = { allOf: 4, anyOf: 4, oneOf: 4 };
|
|
787
2277
|
/** Keywords whose value is any JSON number. */
|
|
788
|
-
const NUMBER_KEYWORDS = ['
|
|
789
|
-
/** Keywords whose value is a non-negative integer. */
|
|
790
|
-
const COUNT_KEYWORDS =
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
2278
|
+
const NUMBER_KEYWORDS = ['maximum', 'minimum'];
|
|
2279
|
+
/** Keywords whose value is a non-negative integer, and the first dialect that knows them. */
|
|
2280
|
+
const COUNT_KEYWORDS = {
|
|
2281
|
+
maxContains: 2019,
|
|
2282
|
+
maxItems: 4,
|
|
2283
|
+
maxLength: 4,
|
|
2284
|
+
maxProperties: 4,
|
|
2285
|
+
minContains: 2019,
|
|
2286
|
+
minItems: 4,
|
|
2287
|
+
minLength: 4,
|
|
2288
|
+
minProperties: 4,
|
|
2289
|
+
};
|
|
2290
|
+
/** `$anchor`'s pattern changed between the two dialects that have it. */
|
|
2291
|
+
const ANCHOR_PATTERN = {
|
|
2292
|
+
2019: /^[A-Za-z][-A-Za-z0-9.:_]*$/,
|
|
2293
|
+
2020: /^[A-Za-z_][-A-Za-z0-9._]*$/,
|
|
2294
|
+
};
|
|
2295
|
+
const knows = (draft, since) => draft >= since;
|
|
800
2296
|
function refuse(where, expected, got) {
|
|
801
2297
|
const kind = typeof got === 'boolean' ? 'boolean' : got.t;
|
|
802
2298
|
throw new PyJsonSchemaUnsupported(`${where} must be ${expected}, got ${kind}`);
|
|
803
2299
|
}
|
|
804
|
-
|
|
805
|
-
|
|
2300
|
+
/** Draft 4's metaschema says `"type": "object"` for every subschema; draft 6 added the booleans. */
|
|
2301
|
+
const isSchemaValue = (v, draft) => v.t === 'dict' || (v.t === 'bool' && draft !== 4);
|
|
2302
|
+
const schemaWord = (draft) => (draft === 4 ? 'an object' : 'an object or a boolean');
|
|
2303
|
+
export function checkSchema(schema, where = 'the schema', draft) {
|
|
806
2304
|
if (typeof schema === 'boolean' || schema.t === 'bool')
|
|
807
2305
|
return;
|
|
2306
|
+
const d = draft ?? dialectOf(schema, 2020);
|
|
808
2307
|
if (schema.t !== 'dict')
|
|
809
2308
|
refuse(where, 'an object or a boolean', schema);
|
|
2309
|
+
const subschema = (v, at, boolOk = false) => {
|
|
2310
|
+
if (!(boolOk && v.t === 'bool') && !isSchemaValue(v, d))
|
|
2311
|
+
refuse(at, schemaWord(d), v);
|
|
2312
|
+
checkSchema(v, at, d);
|
|
2313
|
+
};
|
|
2314
|
+
const schemaList = (value, at, nonEmpty) => {
|
|
2315
|
+
if (value.t !== 'list')
|
|
2316
|
+
refuse(at, 'an array', value);
|
|
2317
|
+
if (nonEmpty && value.v.length === 0)
|
|
2318
|
+
throw new PyJsonSchemaUnsupported(`${at} must be a non-empty array`);
|
|
2319
|
+
value.v.forEach((v, i) => subschema(v, `${at}/${i}`));
|
|
2320
|
+
};
|
|
2321
|
+
const stringList = (value, at, nonEmpty) => {
|
|
2322
|
+
if (value.t !== 'list')
|
|
2323
|
+
refuse(at, 'an array', value);
|
|
2324
|
+
if (nonEmpty && value.v.length === 0)
|
|
2325
|
+
throw new PyJsonSchemaUnsupported(`${at} must be a non-empty array`);
|
|
2326
|
+
const seen = new Set();
|
|
2327
|
+
value.v.forEach((v, i) => {
|
|
2328
|
+
if (v.t !== 'str')
|
|
2329
|
+
refuse(`${at}/${i}`, 'a string', v);
|
|
2330
|
+
if (seen.has(v.v)) {
|
|
2331
|
+
throw new PyJsonSchemaUnsupported(`${at} must have unique items, got ${reprValue(v)} twice`);
|
|
2332
|
+
}
|
|
2333
|
+
seen.add(v.v);
|
|
2334
|
+
});
|
|
2335
|
+
};
|
|
2336
|
+
const string = (value, at) => {
|
|
2337
|
+
if (value.t !== 'str')
|
|
2338
|
+
refuse(at, 'a string', value);
|
|
2339
|
+
return value.v;
|
|
2340
|
+
};
|
|
2341
|
+
const anchor = (value, at) => {
|
|
2342
|
+
const pattern = ANCHOR_PATTERN[d === 2019 ? 2019 : 2020];
|
|
2343
|
+
if (!pattern.test(string(value, at))) {
|
|
2344
|
+
throw new PyJsonSchemaUnsupported(`${at} must match ${pattern.source}, got ${reprValue(value)}`);
|
|
2345
|
+
}
|
|
2346
|
+
};
|
|
810
2347
|
for (const [keyword, value] of schema.v) {
|
|
811
2348
|
const at = `${where}: ${keyword}`;
|
|
812
|
-
if (
|
|
813
|
-
// `allOf`/`anyOf`/`oneOf` carry `"minItems": 1` in
|
|
814
|
-
|
|
815
|
-
|
|
2349
|
+
if (keyword in SUBSCHEMA_LIST) {
|
|
2350
|
+
// `allOf`/`anyOf`/`oneOf` carry `"minItems": 1` in every dialect's metaschema.
|
|
2351
|
+
schemaList(value, at, true);
|
|
2352
|
+
}
|
|
2353
|
+
else if (keyword === 'prefixItems') {
|
|
2354
|
+
if (d === 2020)
|
|
2355
|
+
schemaList(value, at, false);
|
|
2356
|
+
}
|
|
2357
|
+
else if (keyword === 'items') {
|
|
2358
|
+
if (value.t === 'list') {
|
|
2359
|
+
// A tuple `items` is `schemaArray` (non-empty) through 2019-09 and not a schema at all in 2020-12.
|
|
2360
|
+
if (d === 2020)
|
|
2361
|
+
refuse(at, 'an object or a boolean', value);
|
|
2362
|
+
schemaList(value, at, true);
|
|
816
2363
|
}
|
|
2364
|
+
else
|
|
2365
|
+
subschema(value, at);
|
|
2366
|
+
}
|
|
2367
|
+
else if (keyword === 'additionalItems') {
|
|
2368
|
+
// Draft 4 allows no boolean subschema EXCEPT here and on `additionalProperties`, whose
|
|
2369
|
+
// metaschema entries are `anyOf: [{type: boolean}, {$ref: "#"}]`.
|
|
2370
|
+
if (d !== 2020)
|
|
2371
|
+
subschema(value, at, true);
|
|
817
2372
|
}
|
|
818
|
-
if (
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
2373
|
+
else if (keyword === 'additionalProperties') {
|
|
2374
|
+
subschema(value, at, true);
|
|
2375
|
+
}
|
|
2376
|
+
else if (keyword in SUBSCHEMA) {
|
|
2377
|
+
if (knows(d, SUBSCHEMA[keyword]))
|
|
2378
|
+
subschema(value, at);
|
|
2379
|
+
}
|
|
2380
|
+
else if (keyword in SUBSCHEMA_MAP) {
|
|
2381
|
+
if (!knows(d, SUBSCHEMA_MAP[keyword]))
|
|
2382
|
+
continue;
|
|
2383
|
+
if (value.t !== 'dict')
|
|
2384
|
+
refuse(at, 'an object', value);
|
|
2385
|
+
// From draft 6 the metaschema says `"propertyNames": {"format": "regex"}` here, so a key
|
|
2386
|
+
// `re` cannot compile is a SchemaError even when no property ever meets it (job65, #131;
|
|
2387
|
+
// until then this port compiled a key only on first use and answered `valid` otherwise).
|
|
2388
|
+
if (keyword === 'patternProperties' && d >= 6)
|
|
2389
|
+
for (const k of value.v.keys())
|
|
2390
|
+
compilePyPattern(k);
|
|
2391
|
+
for (const [k, v] of value.v)
|
|
2392
|
+
subschema(v, `${at}/${k}`);
|
|
822
2393
|
}
|
|
823
|
-
else if (
|
|
2394
|
+
else if (keyword === 'dependencies') {
|
|
2395
|
+
// Each value is a schema or a `stringArray` (non-empty under draft 4). Still a SHAPE in
|
|
2396
|
+
// the 2019-09 and 2020-12 metaschemas — deprecated there, but `{"dependencies": {"a": 3}}`
|
|
2397
|
+
// is a `SchemaError` under every dialect — even though it is no longer a keyword either
|
|
2398
|
+
// validator runs. Measured: `check_schema/gated` in `tools/conformance/suites/validate.mjs`.
|
|
824
2399
|
if (value.t !== 'dict')
|
|
825
2400
|
refuse(at, 'an object', value);
|
|
826
2401
|
for (const [k, v] of value.v) {
|
|
827
|
-
if (
|
|
828
|
-
|
|
829
|
-
|
|
2402
|
+
if (v.t === 'list')
|
|
2403
|
+
stringList(v, `${at}/${k}`, d === 4);
|
|
2404
|
+
else
|
|
2405
|
+
subschema(v, `${at}/${k}`);
|
|
830
2406
|
}
|
|
831
2407
|
}
|
|
832
|
-
else if (SUBSCHEMA_LIST.includes(keyword)) {
|
|
833
|
-
if (value.t !== 'list')
|
|
834
|
-
refuse(at, 'an array', value);
|
|
835
|
-
value.v.forEach((v, i) => {
|
|
836
|
-
if (!isSchemaValue(v))
|
|
837
|
-
refuse(`${at}/${i}`, 'an object or a boolean', v);
|
|
838
|
-
checkSchema(v, `${at}/${i}`);
|
|
839
|
-
});
|
|
840
|
-
}
|
|
841
2408
|
else if (keyword === 'type') {
|
|
842
2409
|
const each = (v, place) => {
|
|
843
2410
|
if (v.t !== 'str')
|
|
@@ -866,19 +2433,12 @@ export function checkSchema(schema, where = 'the schema') {
|
|
|
866
2433
|
each(value, at);
|
|
867
2434
|
}
|
|
868
2435
|
else if (keyword === 'required') {
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
const seen = new Set();
|
|
872
|
-
value.v.forEach((v, i) => {
|
|
873
|
-
if (v.t !== 'str')
|
|
874
|
-
refuse(`${at}/${i}`, 'a string', v);
|
|
875
|
-
if (seen.has(v.v)) {
|
|
876
|
-
throw new PyJsonSchemaUnsupported(`${at} must have unique items, got ${reprValue(v)} twice`);
|
|
877
|
-
}
|
|
878
|
-
seen.add(v.v);
|
|
879
|
-
});
|
|
2436
|
+
// Draft 4's `stringArray` carries `"minItems": 1`; later drafts default it to `[]`.
|
|
2437
|
+
stringList(value, at, d === 4);
|
|
880
2438
|
}
|
|
881
2439
|
else if (keyword === 'dependentRequired') {
|
|
2440
|
+
if (d < 2019)
|
|
2441
|
+
continue;
|
|
882
2442
|
if (value.t !== 'dict')
|
|
883
2443
|
refuse(at, 'an object', value);
|
|
884
2444
|
for (const [k, v] of value.v) {
|
|
@@ -893,6 +2453,16 @@ export function checkSchema(schema, where = 'the schema') {
|
|
|
893
2453
|
else if (keyword === 'enum') {
|
|
894
2454
|
if (value.t !== 'list')
|
|
895
2455
|
refuse(at, 'an array', value);
|
|
2456
|
+
if (d === 4) {
|
|
2457
|
+
// Draft 4: `"minItems": 1, "uniqueItems": true`; draft 6 on, any array.
|
|
2458
|
+
if (value.v.length === 0)
|
|
2459
|
+
throw new PyJsonSchemaUnsupported(`${at} must be a non-empty array`);
|
|
2460
|
+
value.v.forEach((v, i) => {
|
|
2461
|
+
if (value.v.slice(0, i).some((seen) => pyEqual(seen, v))) {
|
|
2462
|
+
throw new PyJsonSchemaUnsupported(`${at} must have unique items, got ${reprValue(v)} twice`);
|
|
2463
|
+
}
|
|
2464
|
+
});
|
|
2465
|
+
}
|
|
896
2466
|
}
|
|
897
2467
|
else if (keyword === 'uniqueItems') {
|
|
898
2468
|
if (value.t !== 'bool')
|
|
@@ -903,18 +2473,12 @@ export function checkSchema(schema, where = 'the schema') {
|
|
|
903
2473
|
refuse(at, 'a string', value);
|
|
904
2474
|
// `"format": "regex"` on `pattern` in the metaschema, and jsonschema's own format
|
|
905
2475
|
// checker compiles it — `{"pattern": "("}` is a SchemaError there, and was
|
|
906
|
-
// `{"valid": true}` here.
|
|
907
|
-
//
|
|
908
|
-
//
|
|
909
|
-
//
|
|
910
|
-
// (`(?P<x>…)`, `\A`, `\Z`), so a schema carrying one now refuses HERE even when the
|
|
911
|
-
// keyword would never have fired. Before, it refused only when it fired, and answered
|
|
912
|
-
// otherwise. Both are the same ruled divergence — see the `python re and JS RegExp`
|
|
913
|
-
// ruling — and a refusal in an unreached branch is a smaller error than `{"valid":
|
|
914
|
-
// true}` for a pattern that is not a pattern. Both directions are pinned as ruled
|
|
915
|
-
// cases in `tools/conformance/suites/validate.mjs`.
|
|
2476
|
+
// `{"valid": true}` here. Compiling it here puts the refusal where Python's is, before
|
|
2477
|
+
// the instance. Since job65 (#131) the compile is `re`'s grammar, not `RegExp`'s, so
|
|
2478
|
+
// `(?P<x>…)`, `\A` and `\Z` compile on both sides and the old ruled price — a
|
|
2479
|
+
// Python-only pattern refused here in a branch that never fired — is gone.
|
|
916
2480
|
if (keyword === 'pattern')
|
|
917
|
-
|
|
2481
|
+
compilePyPattern(value.v);
|
|
918
2482
|
}
|
|
919
2483
|
else if (keyword === 'multipleOf') {
|
|
920
2484
|
if (value.t !== 'int' && value.t !== 'float')
|
|
@@ -927,25 +2491,92 @@ export function checkSchema(schema, where = 'the schema') {
|
|
|
927
2491
|
if (value.t !== 'int' && value.t !== 'float')
|
|
928
2492
|
refuse(at, 'a number', value);
|
|
929
2493
|
}
|
|
930
|
-
else if (
|
|
931
|
-
|
|
2494
|
+
else if (keyword === 'exclusiveMaximum' || keyword === 'exclusiveMinimum') {
|
|
2495
|
+
if (d === 4) {
|
|
2496
|
+
// Draft 4: a boolean modifier, and the metaschema's `dependencies` demand the bound.
|
|
2497
|
+
if (value.t !== 'bool')
|
|
2498
|
+
refuse(at, 'a boolean', value);
|
|
2499
|
+
const bound = keyword === 'exclusiveMaximum' ? 'maximum' : 'minimum';
|
|
2500
|
+
if (!schema.v.has(bound)) {
|
|
2501
|
+
throw new PyJsonSchemaUnsupported(`${where}: '${bound}' is a dependency of '${keyword}'`);
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
else if (value.t !== 'int' && value.t !== 'float')
|
|
2505
|
+
refuse(at, 'a number', value);
|
|
2506
|
+
}
|
|
2507
|
+
else if (keyword in COUNT_KEYWORDS) {
|
|
2508
|
+
if (!knows(d, COUNT_KEYWORDS[keyword]))
|
|
2509
|
+
continue;
|
|
2510
|
+
// `draft4_type_checker` has no float-with-no-fraction clause: `2.0` is not an integer there.
|
|
2511
|
+
const integral = value.t === 'int' || (d !== 4 && value.t === 'float' && Number.isInteger(value.v));
|
|
932
2512
|
if (!integral)
|
|
933
2513
|
refuse(at, 'a non-negative integer', value);
|
|
934
2514
|
if (value.t === 'int' ? value.v < 0n : value.v < 0) {
|
|
935
2515
|
throw new PyJsonSchemaUnsupported(`${at} must be a non-negative integer, got ${reprValue(value)}`);
|
|
936
2516
|
}
|
|
937
2517
|
}
|
|
2518
|
+
else if (keyword === '$schema') {
|
|
2519
|
+
string(value, at);
|
|
2520
|
+
}
|
|
2521
|
+
else if (keyword === '$ref') {
|
|
2522
|
+
// Draft 4's metaschema has no `$ref` property; the lookup refuses a non-string lazily.
|
|
2523
|
+
if (d !== 4)
|
|
2524
|
+
string(value, at);
|
|
2525
|
+
}
|
|
2526
|
+
else if (keyword === '$dynamicRef') {
|
|
2527
|
+
if (d === 2020)
|
|
2528
|
+
string(value, at);
|
|
2529
|
+
}
|
|
2530
|
+
else if (keyword === '$recursiveRef') {
|
|
2531
|
+
if (d >= 2019)
|
|
2532
|
+
string(value, at);
|
|
2533
|
+
}
|
|
2534
|
+
else if (keyword === '$id') {
|
|
2535
|
+
if (d === 4)
|
|
2536
|
+
continue;
|
|
2537
|
+
const id = string(value, at);
|
|
2538
|
+
// 2019-09 and 2020-12: `"pattern": "^[^#]*#?$"` — an empty fragment at most.
|
|
2539
|
+
if (d >= 2019 && !/^[^#]*#?$/.test(id)) {
|
|
2540
|
+
throw new PyJsonSchemaUnsupported(`${at} must match ^[^#]*#?$, got ${reprValue(value)}`);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
else if (keyword === 'id') {
|
|
2544
|
+
if (d === 4)
|
|
2545
|
+
string(value, at);
|
|
2546
|
+
}
|
|
2547
|
+
else if (keyword === '$anchor') {
|
|
2548
|
+
if (d >= 2019)
|
|
2549
|
+
anchor(value, at);
|
|
2550
|
+
}
|
|
2551
|
+
else if (keyword === '$dynamicAnchor') {
|
|
2552
|
+
if (d === 2020)
|
|
2553
|
+
anchor(value, at);
|
|
2554
|
+
}
|
|
938
2555
|
}
|
|
939
2556
|
}
|
|
940
2557
|
/**
|
|
941
2558
|
* `jsonschema.validate(instance, schema)` reduced to what `contract.schema_error` needs:
|
|
942
2559
|
* the single error `best_match` chooses, or `null`.
|
|
943
2560
|
*
|
|
944
|
-
*
|
|
945
|
-
*
|
|
2561
|
+
* In the reference's order: `validator_for` (the dialect, which raises on a non-string
|
|
2562
|
+
* `$schema`), then the one-resource pre-scan `contract.py` runs before `validate`, then
|
|
2563
|
+
* `check_schema`, then the instance. A `PyJsonSchemaRefusal` out of here is an ANSWER —
|
|
2564
|
+
* `contract.schemaError` returns its sentence — and a `PyJsonSchemaUnsupported` is a raise.
|
|
946
2565
|
*/
|
|
947
2566
|
export function validate(instance, schema) {
|
|
948
|
-
|
|
949
|
-
|
|
2567
|
+
const draft = dialectOf(schema, 2020);
|
|
2568
|
+
if (schema.t === 'dict') {
|
|
2569
|
+
refuseUnsupportedReferences(schema, draft);
|
|
2570
|
+
refuseUnsupportedPatterns(schema);
|
|
2571
|
+
}
|
|
2572
|
+
checkSchema(schema, 'the schema', draft);
|
|
2573
|
+
const outer = patternClock;
|
|
2574
|
+
patternClock = { spentMs: 0 };
|
|
2575
|
+
try {
|
|
2576
|
+
return bestMatch(iterErrors(instance, schema, { root: schema, draft, rootDraft: draft }));
|
|
2577
|
+
}
|
|
2578
|
+
finally {
|
|
2579
|
+
patternClock = outer;
|
|
2580
|
+
}
|
|
950
2581
|
}
|
|
951
2582
|
export { iterErrors };
|