@crewhaus/tool-schema 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,605 @@
1
+ /**
2
+ * A JSON Schema Draft-07 validator, written out in full here so the package
3
+ * has no runtime dependency and so its behaviour is auditable in one file.
4
+ *
5
+ * ## What is enforced
6
+ *
7
+ * | Group | Keywords |
8
+ * |---|---|
9
+ * | any | `type`, `enum`, `const` |
10
+ * | number | `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf` |
11
+ * | string | `minLength`, `maxLength`, `pattern`, `format` (opt-in) |
12
+ * | array | `items` (schema or tuple), `additionalItems`, `contains`, `minItems`, `maxItems`, `uniqueItems` |
13
+ * | object | `properties`, `patternProperties`, `additionalProperties`, `propertyNames`, `required`, `minProperties`, `maxProperties` |
14
+ * | logic | `allOf`, `anyOf`, `oneOf`, `not`, `if`/`then`/`else` |
15
+ * | reference | `$ref` to a local JSON Pointer, including `#/$defs/...` and `#/definitions/...` |
16
+ *
17
+ * Boolean schemas (`true` accepts everything, `false` rejects everything) are
18
+ * supported wherever a schema is expected.
19
+ *
20
+ * ## What is NOT enforced
21
+ *
22
+ * - **Annotations**, which carry no constraint and are skipped silently:
23
+ * `$id`, `$schema`, `$comment`, `title`, `description`, `default`,
24
+ * `examples`, `readOnly`, `writeOnly`, `deprecated`, `contentMediaType`,
25
+ * `contentEncoding`, `$defs`, `definitions`.
26
+ * - **`format`**, unless `assertFormat` is set. Draft-07 makes `format` an
27
+ * annotation by default and this validator follows that; the opt-in uses
28
+ * the checks in `./formats`, whose subsets are documented there.
29
+ * - **`dependencies` / `dependentRequired` / `dependentSchemas`**,
30
+ * **`unevaluatedProperties` / `unevaluatedItems`**, the draft-04 boolean
31
+ * form of `exclusiveMinimum`/`exclusiveMaximum`, and any other keyword not
32
+ * in the table above. These are never silently dropped: every one
33
+ * encountered is listed in the result's `unsupportedKeywords`, so a caller
34
+ * can tell a clean pass from an unenforced one. Note *encountered*: the
35
+ * list is built while walking, so a keyword sitting in a subschema this
36
+ * value never reached (a property it does not carry, a `$defs` entry no
37
+ * `$ref` took) is not in it. Validate a value that exercises the branch to
38
+ * see its keywords.
39
+ * - **Remote and non-pointer `$ref`** (`http://...`, `other.json#/x`, and
40
+ * `$id`-relative resolution). A `$ref` this validator cannot resolve
41
+ * locally is reported as an error rather than treated as `true`.
42
+ *
43
+ * ## Two deliberate deviations, both in the reporting
44
+ *
45
+ * - When `type` fails, the keywords specific to that type are not also run,
46
+ * so a number arriving where an object belongs produces one error rather
47
+ * than one per property. The verdict is identical; only the error list is
48
+ * shorter. `allOf`/`anyOf`/`oneOf`/`not` still run, since those can fail
49
+ * independently of the type.
50
+ * - Errors stop accumulating at `maxErrors`, and the result says `truncated`.
51
+ *
52
+ * `pattern` is an unanchored ECMA-262 match, per the spec — `"pattern": "a"`
53
+ * matches `"banana"`.
54
+ */
55
+ import { checkFormat, isFormatName } from "./formats";
56
+ import { canonicalize, deepEqual, isPlainObject, joinPointer, matchesType, parsePointer, preview, resolveSegments, typeOf, } from "./value";
57
+ /** Keywords that constrain a value and are implemented here. */
58
+ const SUPPORTED = new Set([
59
+ "type",
60
+ "enum",
61
+ "const",
62
+ "minimum",
63
+ "maximum",
64
+ "exclusiveMinimum",
65
+ "exclusiveMaximum",
66
+ "multipleOf",
67
+ "minLength",
68
+ "maxLength",
69
+ "pattern",
70
+ "format",
71
+ "items",
72
+ "additionalItems",
73
+ "contains",
74
+ "minItems",
75
+ "maxItems",
76
+ "uniqueItems",
77
+ "properties",
78
+ "patternProperties",
79
+ "additionalProperties",
80
+ "propertyNames",
81
+ "required",
82
+ "minProperties",
83
+ "maxProperties",
84
+ "allOf",
85
+ "anyOf",
86
+ "oneOf",
87
+ "not",
88
+ "if",
89
+ "then",
90
+ "else",
91
+ "$ref",
92
+ ]);
93
+ /** Keywords that carry no constraint; skipping them changes nothing. */
94
+ const ANNOTATIONS = new Set([
95
+ "$id",
96
+ "$schema",
97
+ "$comment",
98
+ "title",
99
+ "description",
100
+ "default",
101
+ "examples",
102
+ "readOnly",
103
+ "writeOnly",
104
+ "deprecated",
105
+ "contentMediaType",
106
+ "contentEncoding",
107
+ "$defs",
108
+ "definitions",
109
+ ]);
110
+ /**
111
+ * Depth ceiling for the walk. It guards a schema that references itself
112
+ * through a value that never shrinks, and it also stops a value nested past
113
+ * this many levels — both are counted, so the message names either cause.
114
+ */
115
+ const MAX_DEPTH = 100;
116
+ function fail(ctx, path, keyword, schemaPath, message) {
117
+ if (ctx.errors.length >= ctx.opts.maxErrors) {
118
+ ctx.truncated = true;
119
+ return false;
120
+ }
121
+ ctx.errors.push({ path, keyword, message, schemaPath });
122
+ return false;
123
+ }
124
+ /** Does `schema` reject `value`? Runs in a scratch context so no errors leak. */
125
+ function branchErrors(value, schema, ctx, schemaPath) {
126
+ const scratch = {
127
+ root: ctx.root,
128
+ opts: { assertFormat: ctx.opts.assertFormat, maxErrors: 20 },
129
+ errors: [],
130
+ truncated: false,
131
+ unsupported: ctx.unsupported,
132
+ refStack: ctx.refStack,
133
+ depth: ctx.depth,
134
+ };
135
+ validateNode(value, schema, "", schemaPath, scratch);
136
+ return scratch.errors;
137
+ }
138
+ function summarizeBranch(errors) {
139
+ if (errors.length === 0)
140
+ return "no error";
141
+ const first = errors[0];
142
+ const where = first.path === "" ? "" : ` at ${first.path}`;
143
+ return `${first.message}${where}`;
144
+ }
145
+ function validateNumber(value, schema, path, sp, ctx) {
146
+ const { minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf } = schema;
147
+ if (typeof minimum === "number" && value < minimum) {
148
+ fail(ctx, path, "minimum", joinPointer(sp, "minimum"), `${value} is below the minimum of ${minimum}`);
149
+ }
150
+ if (typeof maximum === "number" && value > maximum) {
151
+ fail(ctx, path, "maximum", joinPointer(sp, "maximum"), `${value} is above the maximum of ${maximum}`);
152
+ }
153
+ if (typeof exclusiveMinimum === "number" && value <= exclusiveMinimum) {
154
+ fail(ctx, path, "exclusiveMinimum", joinPointer(sp, "exclusiveMinimum"), `${value} must be greater than ${exclusiveMinimum}`);
155
+ }
156
+ if (typeof exclusiveMaximum === "number" && value >= exclusiveMaximum) {
157
+ fail(ctx, path, "exclusiveMaximum", joinPointer(sp, "exclusiveMaximum"), `${value} must be less than ${exclusiveMaximum}`);
158
+ }
159
+ if (typeof multipleOf === "number" && multipleOf > 0) {
160
+ // Two integers divide exactly in floating point, so `%` is both correct
161
+ // and free of the tolerance below. Taking this path matters beyond
162
+ // tidiness: once the quotient passes 2^53 every double is an integer, so
163
+ // the tolerance test would call 1e20 a multiple of 7.
164
+ const exact = Number.isInteger(value) && Number.isInteger(multipleOf);
165
+ const quotient = value / multipleOf;
166
+ // Binary floating point makes an exact remainder test unreliable for
167
+ // fractions (0.3 / 0.1 is 2.9999...), so there the quotient is compared
168
+ // to its nearest integer within a tolerance.
169
+ const off = exact ? value % multipleOf !== 0 : Math.abs(quotient - Math.round(quotient)) > 1e-9;
170
+ if (off) {
171
+ fail(ctx, path, "multipleOf", joinPointer(sp, "multipleOf"), `${value} is not a multiple of ${multipleOf}`);
172
+ }
173
+ }
174
+ }
175
+ function validateString(value, schema, path, sp, ctx) {
176
+ const { minLength, maxLength, pattern, format } = schema;
177
+ // Length is counted in code points, as the spec requires, so an emoji
178
+ // counts as one character rather than as its two UTF-16 units.
179
+ const length = [...value].length;
180
+ if (typeof minLength === "number" && length < minLength) {
181
+ fail(ctx, path, "minLength", joinPointer(sp, "minLength"), `length ${length} is under the minimum of ${minLength}`);
182
+ }
183
+ if (typeof maxLength === "number" && length > maxLength) {
184
+ fail(ctx, path, "maxLength", joinPointer(sp, "maxLength"), `length ${length} is over the maximum of ${maxLength}`);
185
+ }
186
+ if (typeof pattern === "string") {
187
+ let re = null;
188
+ try {
189
+ re = new RegExp(pattern);
190
+ }
191
+ catch (err) {
192
+ fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `the schema's pattern is not a valid regular expression: ${err.message}`);
193
+ }
194
+ if (re !== null && !re.test(value)) {
195
+ fail(ctx, path, "pattern", joinPointer(sp, "pattern"), `${preview(value, 60)} does not match /${pattern}/`);
196
+ }
197
+ }
198
+ if (typeof format === "string" && ctx.opts.assertFormat) {
199
+ if (!isFormatName(format)) {
200
+ ctx.unsupported.add(`format:${format}`);
201
+ }
202
+ else {
203
+ const result = checkFormat(value, format);
204
+ if (!result.valid) {
205
+ fail(ctx, path, "format", joinPointer(sp, "format"), `${preview(value, 60)} is not a valid ${format}: ${result.reason}`);
206
+ }
207
+ }
208
+ }
209
+ }
210
+ function validateArray(value, schema, path, sp, ctx) {
211
+ const { items, additionalItems, contains, minItems, maxItems, uniqueItems } = schema;
212
+ if (typeof minItems === "number" && value.length < minItems) {
213
+ fail(ctx, path, "minItems", joinPointer(sp, "minItems"), `${value.length} items, fewer than ${minItems}`);
214
+ }
215
+ if (typeof maxItems === "number" && value.length > maxItems) {
216
+ fail(ctx, path, "maxItems", joinPointer(sp, "maxItems"), `${value.length} items, more than ${maxItems}`);
217
+ }
218
+ if (uniqueItems === true) {
219
+ const seen = new Map();
220
+ for (let i = 0; i < value.length; i++) {
221
+ const key = canonicalize(value[i]);
222
+ const previous = seen.get(key);
223
+ if (previous !== undefined) {
224
+ fail(ctx, path, "uniqueItems", joinPointer(sp, "uniqueItems"), `items ${previous} and ${i} are equal (${preview(value[i], 60)})`);
225
+ break;
226
+ }
227
+ seen.set(key, i);
228
+ }
229
+ }
230
+ if (Array.isArray(items)) {
231
+ // Tuple form: schema i applies to item i; the rest fall to additionalItems.
232
+ for (let i = 0; i < value.length; i++) {
233
+ const itemSchema = items[i];
234
+ if (itemSchema !== undefined) {
235
+ validateNode(value[i], itemSchema, joinPointer(path, i), joinPointer(joinPointer(sp, "items"), i), ctx);
236
+ }
237
+ else if (additionalItems === false) {
238
+ // Named here rather than left to the generic `false` schema, so the
239
+ // error's keyword matches the one the schema author wrote — the same
240
+ // courtesy `additionalProperties: false` already gets.
241
+ fail(ctx, joinPointer(path, i), "additionalItems", joinPointer(sp, "additionalItems"), `item ${i} is beyond the ${items.length} the tuple describes, and additionalItems is false`);
242
+ }
243
+ else if (additionalItems !== undefined) {
244
+ validateNode(value[i], additionalItems, joinPointer(path, i), joinPointer(sp, "additionalItems"), ctx);
245
+ }
246
+ }
247
+ }
248
+ else if (items !== undefined) {
249
+ for (let i = 0; i < value.length; i++) {
250
+ validateNode(value[i], items, joinPointer(path, i), joinPointer(sp, "items"), ctx);
251
+ }
252
+ }
253
+ if (contains !== undefined) {
254
+ const matched = value.some((item) => branchErrors(item, contains, ctx, joinPointer(sp, "contains")).length === 0);
255
+ if (!matched) {
256
+ fail(ctx, path, "contains", joinPointer(sp, "contains"), "no item matches the contains schema");
257
+ }
258
+ }
259
+ }
260
+ function validateObject(value, schema, path, sp, ctx) {
261
+ const { properties, patternProperties, additionalProperties, propertyNames, required, minProperties, maxProperties, } = schema;
262
+ const keys = Object.keys(value);
263
+ if (Array.isArray(required)) {
264
+ for (const key of required) {
265
+ if (typeof key === "string" && !Object.hasOwn(value, key)) {
266
+ fail(ctx, path, "required", joinPointer(sp, "required"), `required property "${key}" is missing`);
267
+ }
268
+ }
269
+ }
270
+ if (typeof minProperties === "number" && keys.length < minProperties) {
271
+ fail(ctx, path, "minProperties", joinPointer(sp, "minProperties"), `${keys.length} properties, fewer than ${minProperties}`);
272
+ }
273
+ if (typeof maxProperties === "number" && keys.length > maxProperties) {
274
+ fail(ctx, path, "maxProperties", joinPointer(sp, "maxProperties"), `${keys.length} properties, more than ${maxProperties}`);
275
+ }
276
+ if (propertyNames !== undefined) {
277
+ for (const key of keys) {
278
+ const errors = branchErrors(key, propertyNames, ctx, joinPointer(sp, "propertyNames"));
279
+ if (errors.length > 0) {
280
+ fail(ctx, joinPointer(path, key), "propertyNames", joinPointer(sp, "propertyNames"), `property name "${key}" is not allowed: ${summarizeBranch(errors)}`);
281
+ }
282
+ }
283
+ }
284
+ const patterns = [];
285
+ if (isPlainObject(patternProperties)) {
286
+ for (const source of Object.keys(patternProperties)) {
287
+ try {
288
+ patterns.push([source, new RegExp(source)]);
289
+ }
290
+ catch (err) {
291
+ fail(ctx, path, "patternProperties", joinPointer(joinPointer(sp, "patternProperties"), source), `the schema's pattern is not a valid regular expression: ${err.message}`);
292
+ }
293
+ }
294
+ }
295
+ for (const key of keys) {
296
+ let covered = false;
297
+ if (isPlainObject(properties) && Object.hasOwn(properties, key)) {
298
+ covered = true;
299
+ validateNode(value[key], properties[key], joinPointer(path, key), joinPointer(joinPointer(sp, "properties"), key), ctx);
300
+ }
301
+ for (const [source, re] of patterns) {
302
+ if (!re.test(key))
303
+ continue;
304
+ covered = true;
305
+ validateNode(value[key], patternProperties[source], joinPointer(path, key), joinPointer(joinPointer(sp, "patternProperties"), source), ctx);
306
+ }
307
+ if (covered || additionalProperties === undefined)
308
+ continue;
309
+ if (additionalProperties === false) {
310
+ fail(ctx, joinPointer(path, key), "additionalProperties", joinPointer(sp, "additionalProperties"), `property "${key}" is not allowed here`);
311
+ continue;
312
+ }
313
+ if (additionalProperties !== true) {
314
+ validateNode(value[key], additionalProperties, joinPointer(path, key), joinPointer(sp, "additionalProperties"), ctx);
315
+ }
316
+ }
317
+ }
318
+ function validateLogic(value, schema, path, sp, ctx) {
319
+ const { allOf, anyOf, oneOf, not } = schema;
320
+ if (Array.isArray(allOf)) {
321
+ for (let i = 0; i < allOf.length; i++) {
322
+ validateNode(value, allOf[i], path, joinPointer(joinPointer(sp, "allOf"), i), ctx);
323
+ }
324
+ }
325
+ if (Array.isArray(anyOf)) {
326
+ const reasons = [];
327
+ let anyPassed = false;
328
+ for (let i = 0; i < anyOf.length && !anyPassed; i++) {
329
+ const errors = branchErrors(value, anyOf[i], ctx, joinPointer(joinPointer(sp, "anyOf"), i));
330
+ if (errors.length === 0)
331
+ anyPassed = true;
332
+ else
333
+ reasons.push(`[${i}] ${summarizeBranch(errors)}`);
334
+ }
335
+ if (!anyPassed) {
336
+ fail(ctx, path, "anyOf", joinPointer(sp, "anyOf"), `matched none of the ${anyOf.length} alternatives — ${reasons.join("; ")}`);
337
+ }
338
+ }
339
+ if (Array.isArray(oneOf)) {
340
+ const passing = [];
341
+ const reasons = [];
342
+ for (let i = 0; i < oneOf.length; i++) {
343
+ const errors = branchErrors(value, oneOf[i], ctx, joinPointer(joinPointer(sp, "oneOf"), i));
344
+ if (errors.length === 0)
345
+ passing.push(i);
346
+ else
347
+ reasons.push(`[${i}] ${summarizeBranch(errors)}`);
348
+ }
349
+ if (passing.length === 0) {
350
+ fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched none of the ${oneOf.length} alternatives — ${reasons.join("; ")}`);
351
+ }
352
+ else if (passing.length > 1) {
353
+ fail(ctx, path, "oneOf", joinPointer(sp, "oneOf"), `matched ${passing.length} alternatives (${passing.join(", ")}) but oneOf allows exactly one`);
354
+ }
355
+ }
356
+ if (not !== undefined) {
357
+ if (branchErrors(value, not, ctx, joinPointer(sp, "not")).length === 0) {
358
+ fail(ctx, path, "not", joinPointer(sp, "not"), "matched a schema it must not match");
359
+ }
360
+ }
361
+ if (schema["if"] !== undefined) {
362
+ const conditionHolds = branchErrors(value, schema["if"], ctx, joinPointer(sp, "if")).length === 0;
363
+ const branch = conditionHolds ? schema["then"] : schema["else"];
364
+ if (branch !== undefined) {
365
+ validateNode(value, branch, path, joinPointer(sp, conditionHolds ? "then" : "else"), ctx);
366
+ }
367
+ }
368
+ }
369
+ /** Resolve a local `$ref`. Returns the schema, or a message saying why not. */
370
+ function resolveRef(ref, ctx) {
371
+ if (!ref.startsWith("#")) {
372
+ return {
373
+ error: `only local $ref is supported, and "${ref}" points elsewhere — inline the target or use #/$defs/...`,
374
+ };
375
+ }
376
+ let segments;
377
+ try {
378
+ segments = parsePointer(ref);
379
+ }
380
+ catch (err) {
381
+ return { error: `$ref "${ref}" is not a JSON Pointer: ${err.message}` };
382
+ }
383
+ const found = resolveSegments(ctx.root, segments);
384
+ if (!found.found)
385
+ return { error: `$ref "${ref}" does not resolve inside this schema` };
386
+ const target = found.value;
387
+ if (typeof target !== "boolean" && !isPlainObject(target)) {
388
+ return { error: `$ref "${ref}" resolves to a ${typeOf(target)}, which is not a schema` };
389
+ }
390
+ return { schema: target };
391
+ }
392
+ /** Validate one value against one schema, appending any failures to `ctx`. */
393
+ function validateNode(value, schema, path, sp, ctx) {
394
+ if (ctx.depth > MAX_DEPTH) {
395
+ fail(ctx, path, "depth", sp, `nesting passed ${MAX_DEPTH} levels — either the value is that deep or a $ref is cycling`);
396
+ return;
397
+ }
398
+ if (schema === true)
399
+ return;
400
+ if (schema === false) {
401
+ fail(ctx, path, "false", sp, "this position accepts no value at all");
402
+ return;
403
+ }
404
+ if (!isPlainObject(schema)) {
405
+ fail(ctx, path, "schema", sp, `expected a schema object or boolean, found ${typeOf(schema)}`);
406
+ return;
407
+ }
408
+ for (const keyword of Object.keys(schema)) {
409
+ if (!SUPPORTED.has(keyword) && !ANNOTATIONS.has(keyword))
410
+ ctx.unsupported.add(keyword);
411
+ }
412
+ // Draft-04 wrote exclusive bounds as booleans beside minimum/maximum; that
413
+ // form means something different and is not enforced here.
414
+ if (typeof schema["exclusiveMinimum"] === "boolean")
415
+ ctx.unsupported.add("exclusiveMinimum (boolean form)");
416
+ if (typeof schema["exclusiveMaximum"] === "boolean")
417
+ ctx.unsupported.add("exclusiveMaximum (boolean form)");
418
+ // Draft-07: a `$ref` replaces its sibling keywords entirely.
419
+ if (typeof schema["$ref"] === "string") {
420
+ const ref = schema["$ref"];
421
+ const marker = `${ref}@${path}`;
422
+ if (ctx.refStack.includes(marker)) {
423
+ fail(ctx, path, "$ref", joinPointer(sp, "$ref"), `$ref "${ref}" cycles at this position`);
424
+ return;
425
+ }
426
+ const resolved = resolveRef(ref, ctx);
427
+ if ("error" in resolved) {
428
+ fail(ctx, path, "$ref", joinPointer(sp, "$ref"), resolved.error);
429
+ return;
430
+ }
431
+ ctx.refStack.push(marker);
432
+ ctx.depth += 1;
433
+ validateNode(value, resolved.schema, path, ref, ctx);
434
+ ctx.depth -= 1;
435
+ ctx.refStack.pop();
436
+ return;
437
+ }
438
+ const actual = typeOf(value);
439
+ const declared = schema["type"];
440
+ if (typeof declared === "string" || Array.isArray(declared)) {
441
+ const allowed = (Array.isArray(declared) ? declared : [declared]).filter((t) => typeof t === "string");
442
+ if (allowed.length > 0 && !allowed.some((t) => matchesType(actual, t))) {
443
+ fail(ctx, path, "type", joinPointer(sp, "type"), `expected ${allowed.join(" or ")}, found ${actual} (${preview(value, 60)})`);
444
+ // A wrong type makes every type-specific keyword below noise.
445
+ validateLogic(value, schema, path, sp, ctx);
446
+ return;
447
+ }
448
+ }
449
+ if (Array.isArray(schema["enum"])) {
450
+ if (!schema["enum"].some((candidate) => deepEqual(candidate, value))) {
451
+ fail(ctx, path, "enum", joinPointer(sp, "enum"), `${preview(value, 60)} is not one of ${preview(schema["enum"], 120)}`);
452
+ }
453
+ }
454
+ if (Object.hasOwn(schema, "const") && !deepEqual(schema["const"], value)) {
455
+ fail(ctx, path, "const", joinPointer(sp, "const"), `expected ${preview(schema["const"], 60)}, found ${preview(value, 60)}`);
456
+ }
457
+ ctx.depth += 1;
458
+ if (typeof value === "number")
459
+ validateNumber(value, schema, path, sp, ctx);
460
+ else if (typeof value === "string")
461
+ validateString(value, schema, path, sp, ctx);
462
+ else if (Array.isArray(value))
463
+ validateArray(value, schema, path, sp, ctx);
464
+ else if (isPlainObject(value))
465
+ validateObject(value, schema, path, sp, ctx);
466
+ validateLogic(value, schema, path, sp, ctx);
467
+ ctx.depth -= 1;
468
+ }
469
+ /**
470
+ * Validate `value` against `schema`, returning every failure with a JSON
471
+ * Pointer path. See the module comment for the exact supported subset; the
472
+ * result's `unsupportedKeywords` names anything in the schema that this
473
+ * validator did not enforce, so a pass can be read honestly.
474
+ */
475
+ export function validateValue(value, schema, options = {}) {
476
+ const ctx = {
477
+ root: schema,
478
+ opts: { assertFormat: options.assertFormat ?? false, maxErrors: options.maxErrors ?? 100 },
479
+ errors: [],
480
+ truncated: false,
481
+ unsupported: new Set(),
482
+ refStack: [],
483
+ depth: 0,
484
+ };
485
+ validateNode(value, schema, "", "", ctx);
486
+ return {
487
+ valid: ctx.errors.length === 0,
488
+ errors: ctx.errors,
489
+ truncated: ctx.truncated,
490
+ unsupportedKeywords: [...ctx.unsupported].sort(),
491
+ };
492
+ }
493
+ /**
494
+ * A structural sanity check on the schema itself, run before validation so a
495
+ * typo like `{"type": "sting"}` is reported as a bad schema rather than as
496
+ * every value being wrong. Returns the problems found; an empty array means
497
+ * the schema is well formed as far as this validator is concerned.
498
+ *
499
+ * Every position where the spec expects a subschema is descended into,
500
+ * including `$defs` and `definitions` — a typo in a definition is invisible
501
+ * at the top level and rejects every value that reaches it through a `$ref`,
502
+ * which is exactly the failure this check exists to catch.
503
+ */
504
+ export function checkSchemaShape(schema, path = "", depth = 0) {
505
+ const problems = [];
506
+ if (typeof schema === "boolean")
507
+ return problems;
508
+ if (!isPlainObject(schema)) {
509
+ problems.push(`${path || "/"}: expected a schema object or boolean, found ${typeOf(schema)}`);
510
+ return problems;
511
+ }
512
+ // A schema is finite JSON, so this only guards against a hand-built cyclic
513
+ // object; it costs nothing and cannot be hit by a parsed document.
514
+ if (depth > MAX_DEPTH)
515
+ return problems;
516
+ const validTypes = ["null", "boolean", "object", "array", "number", "string", "integer"];
517
+ const declared = schema["type"];
518
+ const declaredList = Array.isArray(declared)
519
+ ? declared
520
+ : declared === undefined
521
+ ? []
522
+ : [declared];
523
+ for (const t of declaredList) {
524
+ if (typeof t !== "string" || !validTypes.includes(t)) {
525
+ problems.push(`${joinPointer(path, "type")}: "${String(t)}" is not a JSON Schema type`);
526
+ }
527
+ }
528
+ if (Object.hasOwn(schema, "enum") && !Array.isArray(schema["enum"])) {
529
+ problems.push(`${joinPointer(path, "enum")}: enum must be an array`);
530
+ }
531
+ if (Object.hasOwn(schema, "required")) {
532
+ const required = schema["required"];
533
+ if (!Array.isArray(required)) {
534
+ problems.push(`${joinPointer(path, "required")}: required must be an array of property names`);
535
+ }
536
+ else {
537
+ // A non-string entry is silently ignored during validation, so a
538
+ // `required: [{"name": "id"}]` would claim to require nothing.
539
+ required.forEach((name, i) => {
540
+ if (typeof name !== "string") {
541
+ problems.push(`${joinPointer(joinPointer(path, "required"), i)}: "${String(name)}" is not a property name`);
542
+ }
543
+ });
544
+ }
545
+ }
546
+ if (typeof schema["pattern"] === "string") {
547
+ try {
548
+ new RegExp(schema["pattern"]);
549
+ }
550
+ catch (err) {
551
+ problems.push(`${joinPointer(path, "pattern")}: ${err.message}`);
552
+ }
553
+ }
554
+ const descend = (child, childPath) => {
555
+ problems.push(...checkSchemaShape(child, childPath, depth + 1));
556
+ };
557
+ // Keyword values that are a map of name to subschema.
558
+ for (const keyword of ["properties", "patternProperties", "$defs", "definitions"]) {
559
+ const map = schema[keyword];
560
+ if (!isPlainObject(map))
561
+ continue;
562
+ if (keyword === "patternProperties") {
563
+ for (const source of Object.keys(map)) {
564
+ try {
565
+ new RegExp(source);
566
+ }
567
+ catch (err) {
568
+ problems.push(`${joinPointer(joinPointer(path, keyword), source)}: ${err.message}`);
569
+ }
570
+ }
571
+ }
572
+ for (const key of Object.keys(map)) {
573
+ descend(map[key], joinPointer(joinPointer(path, keyword), key));
574
+ }
575
+ }
576
+ // Keyword values that are a single subschema, or (for `items`) a tuple.
577
+ for (const keyword of [
578
+ "items",
579
+ "additionalItems",
580
+ "additionalProperties",
581
+ "contains",
582
+ "propertyNames",
583
+ "not",
584
+ "if",
585
+ "then",
586
+ "else",
587
+ ]) {
588
+ const child = schema[keyword];
589
+ if (child === undefined)
590
+ continue;
591
+ if (keyword === "items" && Array.isArray(child)) {
592
+ child.forEach((item, i) => descend(item, joinPointer(joinPointer(path, keyword), i)));
593
+ continue;
594
+ }
595
+ descend(child, joinPointer(path, keyword));
596
+ }
597
+ // Keyword values that are an array of subschemas.
598
+ for (const keyword of ["allOf", "anyOf", "oneOf"]) {
599
+ const branches = schema[keyword];
600
+ if (!Array.isArray(branches))
601
+ continue;
602
+ branches.forEach((branch, i) => descend(branch, joinPointer(joinPointer(path, keyword), i)));
603
+ }
604
+ return problems;
605
+ }