bantamkit-mcp 0.35.6 → 0.35.8

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.
Files changed (44) hide show
  1. package/README.md +11 -7
  2. package/assets/tools/memory_compact.json +1 -2
  3. package/assets/tools/memory_recall.json +1 -3
  4. package/assets/tools/shiftwork_clock_in.json +1 -1
  5. package/assets/tools/shiftwork_clock_out.json +1 -1
  6. package/assets/tools/validate_json.json +1 -1
  7. package/assets/tools/work_plan.json +14 -8
  8. package/dist/cli.js +28 -0
  9. package/dist/contract.d.ts +0 -9
  10. package/dist/contract.js +51 -12
  11. package/dist/eventlog.d.ts +14 -1
  12. package/dist/eventlog.js +23 -3
  13. package/dist/hookadapter.js +260 -45
  14. package/dist/hostinstall.js +250 -79
  15. package/dist/index.d.ts +2 -2
  16. package/dist/index.js +1 -1
  17. package/dist/mcp/pyargs.d.ts +22 -6
  18. package/dist/mcp/pyargs.js +113 -39
  19. package/dist/mcp/server.js +18 -27
  20. package/dist/mcp/status.js +11 -4
  21. package/dist/mcpreport.d.ts +12 -0
  22. package/dist/mcpreport.js +67 -15
  23. package/dist/memory/cli.js +18 -3
  24. package/dist/memory/component.d.ts +17 -0
  25. package/dist/memory/component.js +51 -7
  26. package/dist/memory/factfile.d.ts +21 -3
  27. package/dist/memory/factfile.js +351 -12
  28. package/dist/memory/layers.d.ts +19 -0
  29. package/dist/memory/layers.js +102 -2
  30. package/dist/memory/pyyaml.d.ts +6 -4
  31. package/dist/memory/pyyaml.js +6 -4
  32. package/dist/memory/store.d.ts +82 -7
  33. package/dist/memory/store.js +277 -29
  34. package/dist/npminstall.d.ts +18 -0
  35. package/dist/npminstall.js +26 -5
  36. package/dist/pyjsonschema.d.ts +85 -4
  37. package/dist/pyjsonschema.js +1812 -181
  38. package/dist/selfupdate.d.ts +112 -32
  39. package/dist/selfupdate.js +221 -79
  40. package/dist/shiftwork.d.ts +46 -7
  41. package/dist/shiftwork.js +246 -42
  42. package/dist/workplan.d.ts +8 -3
  43. package/dist/workplan.js +62 -3
  44. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The `jsonschema` seam: `Draft202012Validator.iter_errors` + `exceptions.best_match`.
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. Draft 2020-12, which is the checkpoint schema's dialect and also
18
- * jsonschema's default for a schema with no `$schema` — the shape `validate_json` receives.
19
- * 30 of the 36 keywords in `Draft202012Validator.VALIDATORS`. The other six RAISE rather
20
- * than answer plausibly: `$ref` and `$dynamicRef` need a reference resolver,
21
- * `unevaluatedItems`/`unevaluatedProperties` need annotation collection, and neither appears
22
- * anywhere in the asset pack (measured: `assets/**\/*.json`). `format` is implemented as the
23
- * no-op it is — `jsonschema.validate` passes no `format_checker`, so the keyword can never
24
- * produce an error on this path.
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 — running that would need
38
- * `$ref` and `$dynamicRef`, which is the vocabulary this file refuses to implement. It is
39
- * the keyword SHAPE table, applied recursively. Measured over 50 malformed schemas: 50 of 50
40
- * now refuse on both sides, and only the WORDS differ. Each is a ruled case in
41
- * `tools/conformance/suites/validate.mjs`. Registered and NOT fixed here: `validate_json`
42
- * letting an exception out on caller input is a `runtime-py` defect (invariant 8).
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
- * `draft202012_type_checker`.
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
- return instance.t === 'int' || (instance.t === 'float' && Number.isInteger(instance.v));
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
- function iterErrors(instance, schema) {
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
- // `applicable_validators` for draft 2020-12 is `methodcaller("items")` — plain dict order,
141
- // which for a JSON-parsed schema is DOCUMENT order. That order decides which error comes
142
- // first, and `best_match` breaks ties by taking the first maximum, so it is load-bearing.
143
- for (const [keyword, value] of schema.v) {
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
- const UNSUPPORTED = new Set([
181
- '$ref',
182
- '$dynamicRef',
183
- '$recursiveRef',
184
- 'unevaluatedItems',
185
- 'unevaluatedProperties',
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 [extras.map((e) => reprValue({ t: 'str', v: e })).join(', '), extras.length === 1 ? 'was' : 'were'];
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
- * RULING. Python's `re` and JS's `RegExp` are different languages past the common core:
211
- * `\d` is Unicode-wide in Python and ASCII-only in JS, `$` matches before a trailing newline
212
- * in Python and not in JS, and `(?P<x>…)`/`\Z`/`\A` are Python-only syntax. The asset pack
213
- * holds exactly one `pattern` (`assets/tools/memory_save.json`), which is inside the common
214
- * core; a caller-supplied schema is not guaranteed to be. Carried as a ruled case rather
215
- * than fixed, because fixing it means porting `sre_compile`.
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
- function reSearch(pattern, text) {
218
- let re;
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
- re = new RegExp(pattern, 'u');
1035
+ translation = translatePyPattern(pattern);
221
1036
  }
222
- catch {
223
- try {
224
- re = new RegExp(pattern);
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
- catch (e) {
227
- throw new PyJsonSchemaUnsupported(`pattern ${JSON.stringify(pattern)} is not a JS regular expression: ${e.message}`);
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(value, instance, schema, emit) {
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 2020-12 metaschema
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 metaschema is not
752
- * available to this port — the 2020-12 metaschema is eight documents wired together with
753
- * `$ref` and `$dynamicRef`, which is exactly the vocabulary this file refuses to implement,
754
- * so `check_schema` would have to be more capable than `validate`. What is here instead is
755
- * the SHAPE TABLE: for every keyword this port implements, the type the metaschema demands
756
- * of its value, applied recursively through the keywords whose values hold subschemas. That
757
- * is narrower than the metaschema — it does not enforce `uniqueItems` on `required`, the
758
- * `minLength: 1` on `allOf`, or that a `pattern` compiles — and every gap is a schema Python
759
- * refuses and this port answers on. Each keyword listed below is MEASURED against the
760
- * reference in `tools/conformance/suites/validate.mjs`; the gaps are listed there too, as
761
- * cases that are deliberately absent rather than quietly ruled.
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 NOW REFUSE, AND THE SENTENCE DIFFERS. Python raises `SchemaError` with the
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
- 'additionalProperties',
773
- 'contains',
774
- 'else',
775
- 'if',
776
- 'items',
777
- 'not',
778
- 'propertyNames',
779
- 'then',
780
- 'unevaluatedItems',
781
- 'unevaluatedProperties',
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 = ['$defs', 'definitions', 'dependentSchemas', 'patternProperties', 'properties'];
785
- /** Keywords whose value is an array of schemas. */
786
- const SUBSCHEMA_LIST = ['allOf', 'anyOf', 'oneOf', 'prefixItems'];
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 = ['exclusiveMaximum', 'exclusiveMinimum', 'maximum', 'minimum'];
789
- /** Keywords whose value is a non-negative integer. */
790
- const COUNT_KEYWORDS = [
791
- 'maxContains',
792
- 'maxItems',
793
- 'maxLength',
794
- 'maxProperties',
795
- 'minContains',
796
- 'minItems',
797
- 'minLength',
798
- 'minProperties',
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
- const isSchemaValue = (v) => v.t === 'dict' || v.t === 'bool';
805
- export function checkSchema(schema, where = 'the schema') {
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 (SUBSCHEMA_LIST.includes(keyword) && keyword !== 'prefixItems') {
813
- // `allOf`/`anyOf`/`oneOf` carry `"minItems": 1` in the applicator vocabulary.
814
- if (value.t === 'list' && value.v.length === 0) {
815
- throw new PyJsonSchemaUnsupported(`${at} must be a non-empty array`);
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 (SUBSCHEMA.includes(keyword)) {
819
- if (!isSchemaValue(value))
820
- refuse(at, 'an object or a boolean', value);
821
- checkSchema(value, at);
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 (SUBSCHEMA_MAP.includes(keyword)) {
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 (!isSchemaValue(v))
828
- refuse(`${at}/${k}`, 'an object or a boolean', v);
829
- checkSchema(v, `${at}/${k}`);
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
- if (value.t !== 'list')
870
- refuse(at, 'an array', value);
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. `reSearch` already refuses a pattern it cannot compile;
907
- // this only moves the refusal to where Python's is, which is before the instance.
908
- //
909
- // THE PRICE, measured and accepted: `re` accepts patterns `RegExp` does not
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
- reSearch(value.v, '');
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 (COUNT_KEYWORDS.includes(keyword)) {
931
- const integral = value.t === 'int' || (value.t === 'float' && Number.isInteger(value.v));
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
- * `check_schema` runs FIRST, as it does in the reference. See `checkSchema` for what that
945
- * means here and for the five wrong answers it replaced.
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
- checkSchema(schema);
949
- return bestMatch(iterErrors(instance, schema));
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 };