@jarenjs/validate 0.8.4 → 0.34.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.
Files changed (53) hide show
  1. package/ARCHITECTURE.md +1131 -0
  2. package/LICENSE +21 -0
  3. package/README.md +796 -2
  4. package/dist/types/array.d.ts +2 -0
  5. package/dist/types/bigint.d.ts +1 -0
  6. package/dist/types/combine.d.ts +1 -0
  7. package/dist/types/condition.d.ts +1 -0
  8. package/dist/types/content.d.ts +3 -0
  9. package/dist/types/data.d.ts +7 -0
  10. package/dist/types/dollar-data.d.ts +11 -0
  11. package/dist/types/dynamic-ref.d.ts +44 -0
  12. package/dist/types/enum.d.ts +1 -0
  13. package/dist/types/format.d.ts +21 -0
  14. package/dist/types/index.d.ts +972 -0
  15. package/dist/types/messages.d.ts +142 -0
  16. package/dist/types/normalize.d.ts +107 -0
  17. package/dist/types/number.d.ts +1 -0
  18. package/dist/types/object.d.ts +3 -0
  19. package/dist/types/query-keyword.d.ts +19 -0
  20. package/dist/types/query.d.ts +29 -0
  21. package/dist/types/schema.d.ts +1 -0
  22. package/dist/types/string.d.ts +1 -0
  23. package/dist/types/tools.d.ts +109 -0
  24. package/dist/types/traverse.d.ts +32 -0
  25. package/dist/types/unevaluated.d.ts +12 -0
  26. package/docs/ERROR-MESSAGES.md +251 -0
  27. package/package.json +37 -7
  28. package/src/array.js +610 -0
  29. package/src/bigint.js +108 -0
  30. package/src/combine.js +276 -0
  31. package/src/condition.js +129 -0
  32. package/src/content.js +83 -0
  33. package/src/data.js +101 -0
  34. package/src/dollar-data.js +212 -0
  35. package/src/dynamic-ref.js +121 -0
  36. package/src/enum.js +147 -0
  37. package/src/format.js +108 -0
  38. package/src/index.js +1896 -0
  39. package/src/messages.js +497 -0
  40. package/src/normalize.js +585 -0
  41. package/src/number.js +169 -0
  42. package/src/object.js +848 -0
  43. package/src/query-keyword.js +99 -0
  44. package/src/query.js +85 -0
  45. package/src/schema.js +690 -0
  46. package/src/string.js +164 -0
  47. package/src/tools.js +397 -0
  48. package/src/traverse.js +442 -0
  49. package/src/unevaluated.js +173 -0
  50. package/dist/index.js +0 -1998
  51. package/dist/index.js.map +0 -7
  52. package/dist/index.min.js +0 -2
  53. package/dist/index.min.js.map +0 -7
package/src/index.js ADDED
@@ -0,0 +1,1896 @@
1
+ //@ts-check
2
+
3
+ import {
4
+ isObjectClass,
5
+ isStringType,
6
+ } from '@jarenjs/core';
7
+
8
+ import {
9
+ compileSchemaObject,
10
+ } from './schema.js';
11
+
12
+ import {
13
+ storeSchemaIdsInMap,
14
+ restoreSchemaRefsInMap,
15
+ resolveRefSchemaDeep,
16
+ encodeJsonPointerPath,
17
+ TraverseOptions,
18
+ createJsonPointer,
19
+ } from './traverse.js';
20
+
21
+ import {
22
+ isBoolOrObjectClass,
23
+ hasSchemaRef,
24
+ hasUnevaluatedPropertiesCoverage,
25
+ hasUnevaluatedItemsCoverage,
26
+ EvalLog,
27
+ } from './tools.js';
28
+ import { wrapUnevaluated } from './unevaluated.js';
29
+ import { registerFormatCompiler, registerFormatCompilers } from './format.js';
30
+ import { mergeMap } from '@jarenjs/core/object';
31
+ import { hasRecursiveAnchor, getDynamicAnchorName, collectDynamicAnchors, collectDynamicAnchorsDeep } from './dynamic-ref.js';
32
+
33
+ export {
34
+ registerFormatCompilers
35
+ } from './format.js';
36
+
37
+ import {
38
+ convertInternalErrors,
39
+ } from './messages.js';
40
+
41
+ export {
42
+ ValidationError,
43
+ messagesEn,
44
+ compileMessageTemplate,
45
+ compileMessageCatalog,
46
+ renderErrorMessage,
47
+ localizeErrors,
48
+ } from './messages.js';
49
+
50
+
51
+ export { TraverseOptions };
52
+
53
+ /**
54
+ * Ajv-style $data reference object.
55
+ * The value is a Relative JSON Pointer that resolves from the current data location.
56
+ * Format: `<non-negative-integer>("#"|<json-pointer>)`
57
+ * - "0" - The current value itself
58
+ * - "0#" - The property name/index of the current value
59
+ * - "0/foo" - The "foo" property of the current value
60
+ * - "1" - The parent value
61
+ * - "1/foo" - The "foo" property of the parent value
62
+ * @see https://github.com/ajv-validator/ajv/tree/master/spec/extras/%24data
63
+ * @typedef {Object} DollarDataRef
64
+ * @property {string} $data - Relative JSON Pointer resolved from the current data location
65
+ */
66
+
67
+ /**
68
+ * Data keyword schema for referencing instance data (json-everything style).
69
+ * Allows constraints to reference values from other parts of the instance;
70
+ * every property value is a (relative) JSON Pointer to the constraint's value.
71
+ * @see https://docs.json-everything.net/schema/examples/data-ref/
72
+ * @typedef {Object} DataKeywordSchema
73
+ * @property {string} [minimum] - JSON Pointer to the minimum value
74
+ * @property {string} [maximum] - JSON Pointer to the maximum value
75
+ * @property {string} [exclusiveMinimum] - JSON Pointer to the exclusive minimum value
76
+ * @property {string} [exclusiveMaximum] - JSON Pointer to the exclusive maximum value
77
+ * @property {string} [multipleOf] - JSON Pointer to the multipleOf value
78
+ * @property {string} [minLength] - JSON Pointer to the minLength value
79
+ * @property {string} [maxLength] - JSON Pointer to the maxLength value
80
+ * @property {string} [pattern] - JSON Pointer to the pattern string
81
+ * @property {string} [format] - JSON Pointer to the format name
82
+ * @property {string} [enum] - JSON Pointer to an array of valid values
83
+ * @property {string} [const] - JSON Pointer to the constant value
84
+ * @property {string} [minItems] - JSON Pointer to the minItems value
85
+ * @property {string} [maxItems] - JSON Pointer to the maxItems value
86
+ * @property {string} [minProperties] - JSON Pointer to the minProperties value
87
+ * @property {string} [maxProperties] - JSON Pointer to the maxProperties value
88
+ */
89
+
90
+ /**
91
+ * The standard JSON Schema keywords understood by Jaren
92
+ * (draft-06 through draft 2020-12). See {@link JSONSchema} for the full
93
+ * schema object type that also permits custom keywords.
94
+ * @typedef {Object} JSONSchemaKeywords
95
+ * @property {string} [$id] - Schema resource identifier (URI)
96
+ * @property {string} [$schema] - Meta-schema URI declaring the draft dialect
97
+ * @property {string} [$ref] - Reference to another schema (URI reference)
98
+ * @property {string} [$anchor] - Plain-name fragment identifier (2019-09+)
99
+ * @property {string} [$dynamicRef] - Dynamic reference (2020-12)
100
+ * @property {string} [$dynamicAnchor] - Dynamic anchor (2020-12)
101
+ * @property {Record<string, boolean>} [$vocabulary] - Vocabulary declarations of a meta-schema
102
+ * @property {string} [$comment] - Comment for schema maintainers; not used in validation
103
+ * @property {Record<string, JSONSchema>} [$defs] - Reusable subschema definitions (2019-09+)
104
+ * @property {Record<string, JSONSchema>} [definitions] - Reusable subschema definitions (draft-07 and earlier)
105
+ * @property {string | string[]} [type] - Expected JSON type(s): 'null', 'boolean', 'object', 'array', 'number', 'string' or 'integer'
106
+ * @property {unknown[] | DollarDataRef} [enum] - Exhaustive list of valid values
107
+ * @property {unknown | DollarDataRef} [const] - Single valid value
108
+ * @property {number | DollarDataRef} [minLength] - Minimum string length (in graphemes by default)
109
+ * @property {number | DollarDataRef} [maxLength] - Maximum string length (in graphemes by default)
110
+ * @property {string | DollarDataRef} [pattern] - ECMA-262 regular expression the string must match
111
+ * @property {string} [contentEncoding] - Encoding of a string-embedded document (e.g. 'base64')
112
+ * @property {string} [contentMediaType] - Media type of a string-embedded document
113
+ * @property {JSONSchema} [contentSchema] - Schema for the decoded string-embedded document
114
+ * @property {number | DollarDataRef} [multipleOf] - Number must be a multiple of this value
115
+ * @property {number | DollarDataRef} [minimum] - Inclusive lower bound
116
+ * @property {number | DollarDataRef} [maximum] - Inclusive upper bound
117
+ * @property {number | boolean | DollarDataRef} [exclusiveMinimum] - Exclusive lower bound (boolean form in draft-04 style schemas)
118
+ * @property {number | boolean | DollarDataRef} [exclusiveMaximum] - Exclusive upper bound (boolean form in draft-04 style schemas)
119
+ * @property {Record<string, JSONSchema>} [properties] - Schemas for named object members
120
+ * @property {Record<string, JSONSchema>} [patternProperties] - Schemas for members whose name matches a regular expression
121
+ * @property {boolean | JSONSchema} [additionalProperties] - Schema for members not matched by properties/patternProperties
122
+ * @property {boolean | JSONSchema} [unevaluatedProperties] - Schema for members not evaluated by any subschema (2019-09+)
123
+ * @property {string[] | DollarDataRef} [required] - Member names that must be present
124
+ * @property {JSONSchema} [propertyNames] - Schema every member name must validate against
125
+ * @property {number | DollarDataRef} [minProperties] - Minimum number of members
126
+ * @property {number | DollarDataRef} [maxProperties] - Maximum number of members
127
+ * @property {JSONSchema | JSONSchema[]} [items] - Schema for array elements (array form is the draft-07 tuple syntax)
128
+ * @property {JSONSchema[]} [prefixItems] - Tuple element schemas (2020-12)
129
+ * @property {boolean | JSONSchema} [additionalItems] - Schema for elements beyond the tuple prefix (draft-07 and earlier)
130
+ * @property {boolean | JSONSchema} [unevaluatedItems] - Schema for elements not evaluated by any subschema (2019-09+)
131
+ * @property {JSONSchema} [contains] - At least one element must validate against this schema
132
+ * @property {number | DollarDataRef} [minItems] - Minimum number of elements
133
+ * @property {number | DollarDataRef} [maxItems] - Maximum number of elements
134
+ * @property {boolean | DollarDataRef} [uniqueItems] - Whether all elements must be unique
135
+ * @property {number} [minContains] - Minimum number of elements matching 'contains' (2019-09+)
136
+ * @property {number} [maxContains] - Maximum number of elements matching 'contains' (2019-09+)
137
+ * @property {JSONSchema[]} [allOf] - Value must validate against all of these schemas
138
+ * @property {JSONSchema[]} [anyOf] - Value must validate against at least one of these schemas
139
+ * @property {JSONSchema[]} [oneOf] - Value must validate against exactly one of these schemas
140
+ * @property {JSONSchema} [not] - Value must NOT validate against this schema
141
+ * @property {JSONSchema} [if] - Condition schema selecting between 'then' and 'else'
142
+ * @property {JSONSchema} [then] - Applied when 'if' validates
143
+ * @property {JSONSchema} [else] - Applied when 'if' does not validate
144
+ * @property {Record<string, JSONSchema>} [dependentSchemas] - Schemas applied when a member is present (2019-09+)
145
+ * @property {Record<string, string[]>} [dependentRequired] - Members required when a member is present (2019-09+)
146
+ * @property {string} [title] - Short descriptive title
147
+ * @property {string} [description] - Explanation of the schema's purpose
148
+ * @property {unknown} [default] - Default value annotation
149
+ * @property {unknown[]} [examples] - Example values annotation
150
+ * @property {boolean} [readOnly] - Value is managed by the receiving authority
151
+ * @property {boolean} [writeOnly] - Value is never returned by the receiving authority
152
+ * @property {boolean} [deprecated] - Value is deprecated
153
+ * @property {string | DollarDataRef} [format] - Named semantic format (e.g. 'email', 'uri', 'date-time')
154
+ * @property {string} [formatMinimum] - Format-aware inclusive lower bound (non-standard, Ajv-style)
155
+ * @property {string} [formatMaximum] - Format-aware inclusive upper bound (non-standard, Ajv-style)
156
+ * @property {string} [formatExclusiveMinimum] - Format-aware exclusive lower bound (non-standard, Ajv-style)
157
+ * @property {string} [formatExclusiveMaximum] - Format-aware exclusive upper bound (non-standard, Ajv-style)
158
+ * @property {DataKeywordSchema} [data] - Data keyword referencing instance data (json-everything style)
159
+ */
160
+
161
+ /**
162
+ * Represents a JSON Schema object.
163
+ * Covers the standard keywords of drafts 06, 07, 2019-09 and 2020-12
164
+ * (see {@link JSONSchemaKeywords}) while remaining open for custom
165
+ * keywords: any property outside the standard set is permitted.
166
+ * Note that a complete schema is `JSONSchema | boolean` - the boolean
167
+ * forms accept everything (`true`) or nothing (`false`).
168
+ * @typedef {JSONSchemaKeywords & Record<string, unknown>} JSONSchema
169
+ */
170
+
171
+ /**
172
+ * A format compiler function.
173
+ * Called once per schema location at compile time with the compiling
174
+ * ValidationObject and the schema that declares the format; returns the
175
+ * format validator that is invoked for each instance value, or undefined
176
+ * when the format does not apply to the schema location. Compilers are
177
+ * only invoked for schemas whose `format` member is a plain string.
178
+ * @typedef {(schemaObj: ValidationObject, jsonSchema: JSONSchema & {format?: string}) => ((data: unknown, dataPath?: string) => boolean) | undefined} FormatCompiler
179
+ */
180
+
181
+ export const DEFAULT_SCHEMA_DRAFT = 'http://json-schema.org/draft-06/schema#'
182
+
183
+ /**
184
+ * Detects the JSON Schema draft version from the schema's $schema property
185
+ * @param {object} schema - The JSON schema
186
+ * @returns {number} - The draft version (6, 7, 2019, or 2020)
187
+ */
188
+ export function detectSchemaDraft(schema) {
189
+ if (!schema || typeof schema !== 'object') return 7; // default to draft7
190
+ const schemaUrl = schema.$schema || '';
191
+ if (schemaUrl.includes('2020-12')) return 2020;
192
+ if (schemaUrl.includes('2019-09')) return 2019;
193
+ if (schemaUrl.includes('draft-07') || schemaUrl.includes('draft/07')) return 7;
194
+ if (schemaUrl.includes('draft-06') || schemaUrl.includes('draft/06')) return 6;
195
+ return 7; // default to draft7 behavior
196
+ }
197
+
198
+ const isBrowser = typeof window !== 'undefined';
199
+
200
+ const performance = (() => isBrowser
201
+ // eslint-disable-next-line no-undef
202
+ ? window.performance
203
+ : {
204
+ now: function performanceNow(start) {
205
+ // @ts-ignore
206
+ const ps = process;
207
+ if (!start) return ps.hrtime();
208
+ const end = ps.hrtime(start);
209
+ return Math.round((end[0] * 1000) + (end[1] / 1000000));
210
+ },
211
+ })();
212
+
213
+ class InternalValidationError {
214
+ constructor(obj, key, expected, dataKey, value, rest) {
215
+ this.timeStamp = performance.now();
216
+ this.object = obj;
217
+ this.key = key;
218
+ this.expected = expected;
219
+ this.dataKey = dataKey;
220
+ this.value = value;
221
+ this.rest = rest;
222
+ }
223
+ }
224
+
225
+ /**
226
+ * ValidationOptions configures the behavior of the validation process.
227
+ * @class
228
+ */
229
+ export class ValidationOptions {
230
+ /**
231
+ * Creates validation options.
232
+ * @param {boolean} [skipErrors=true] - Whether to stop at first error or continue
233
+ * @param {boolean} [useGrapheme=true] - Whether to use grapheme cluster counting for strings
234
+ * @param {boolean} [collectErrors=false] - Whether to collect all errors or just return boolean
235
+ * @param {boolean|null} [contentValidation=null] - Whether to validate contentEncoding/contentMediaType (null = auto based on draft)
236
+ * @param {number} [draftVersion=7] - The JSON Schema draft version (6, 7, 2019, or 2020)
237
+ * @param {boolean} [vocabValidation=true] - Whether the validation vocabulary is enabled (false when the schema's metaschema omits it via $vocabulary)
238
+ * @param {boolean|null} [formatAssertion=null] - Whether format asserts (null = auto: asserts below draft 2020-12, annotation-only from 2020-12 on)
239
+ * @param {boolean} [messages=true] - Whether collected errors carry rendered message text; false skips rendering (message: '', params/msgid still set)
240
+ * @param {'error'|'ignore'} [unknownFormats='ignore'] - What to do when an ASSERTING `format` names something no compiler is registered for: 'ignore' (the default, and what the specification requires) accepts it as an annotation; 'error' throws at COMPILE time. Never affects instance validation, and never applies where format is annotation-only anyway.
241
+ */
242
+ constructor(
243
+ skipErrors = true,
244
+ useGrapheme = true,
245
+ collectErrors = false,
246
+ contentValidation = null,
247
+ draftVersion = 7,
248
+ vocabValidation = true,
249
+ formatAssertion = null,
250
+ messages = true,
251
+ unknownFormats = 'ignore'
252
+ ) {
253
+ /** @type {boolean} Whether to stop at first error or continue */
254
+ this.skipErrors = skipErrors;
255
+ /** @type {boolean} Whether to use grapheme cluster counting for string length */
256
+ this.useGrapheme = useGrapheme;
257
+ /** @type {boolean} Whether to collect and return detailed errors */
258
+ this.collectErrors = collectErrors;
259
+ /** @type {boolean|null} Whether to validate contentEncoding/contentMediaType (null = auto based on draft) */
260
+ this.contentValidation = contentValidation;
261
+ /** @type {number} The JSON Schema draft version (6, 7, 2019, or 2020) */
262
+ this.draftVersion = draftVersion;
263
+ /** @type {boolean} Whether validation vocabulary keywords (type, minimum, ...) are asserted */
264
+ this.vocabValidation = vocabValidation;
265
+ /** @type {boolean|null} Whether the format keyword asserts (null = auto by draft) */
266
+ this.formatAssertion = formatAssertion;
267
+ /** @type {boolean} Whether collected errors carry rendered message text */
268
+ this.messages = messages;
269
+ /** @type {'error'|'ignore'} What an asserting `format` with no registered compiler does */
270
+ this.unknownFormats = unknownFormats;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * ValidationRoot manages the compilation and validation context for a schema.
276
+ * It holds references to all schemas, formats, options, and compiled ValidationObjects.
277
+ * @class
278
+ */
279
+ export class ValidationRoot {
280
+ /**
281
+ * Creates a ValidationObject and stores it in the root's object map.
282
+ * @param {ValidationRoot} self - The ValidationRoot instance
283
+ * @param {string} path - The URI path for this schema object
284
+ * @param {object|boolean} schema - The JSON schema
285
+ * @param {string} baseUri - The base URI for resolving relative refs
286
+ * @returns {ValidationObject} The created ValidationObject
287
+ */
288
+ static #createObject(self, path, schema, baseUri, parentDeclaredDraft = null) {
289
+ const objects = self.#objects;
290
+ if (objects.has(path)) {
291
+ const p = objects.get(path);
292
+ if (p != null)
293
+ throw new Error(`Object at '${path}' is already created`);
294
+ }
295
+
296
+ const obj = new ValidationObject(self, path, schema, baseUri, parentDeclaredDraft);
297
+ objects.set(path, obj);
298
+ return obj;
299
+ }
300
+
301
+ /** @type {string|null} The root schema origin/URI */
302
+ #rootOrigin = null;
303
+ /** @type {Map|null} Map of schema paths to schema objects */
304
+ #schemas = null;
305
+ /** @type {object|null} Registered format validators */
306
+ #formats = null;
307
+ /** @type {ValidationOptions|null} Validation options */
308
+ #options = null;
309
+ /** @type {TraverseOptions|null} Schema traversal options */
310
+ #traverse = null;
311
+ /** @type {Map|null} Map of paths to ValidationObjects */
312
+ #objects = null;
313
+ /** @type {Array} Array of validation errors */
314
+ #errors = null;
315
+ /** @type {ValidationObject|null} The root schema's ValidationObject */
316
+ #firstSchema = null;
317
+ /** @type {Map<string, Function[]>} Map of anchor names to stacks of validator functions */
318
+ #dynamicAnchors = null;
319
+ /** @type {string|null} Anchor name to register for the root schema on each validation, or null when not needed */
320
+ #rootAnchorName = null;
321
+ /** @type {function|null} Cached compiled validator of the root schema */
322
+ #rootValidator = null;
323
+ /** @type {Array<{name: string, schema: object, validator: (function|null)}>} $dynamicAnchors of the root resource (excluding the root's own), registered on each validation */
324
+ #rootDynamicAnchors = [];
325
+ /** @type {boolean} Whether any schema in this compilation contains a $data reference */
326
+ #usesDollarData = false;
327
+ /** @type {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
328
+ #usesUnevaluated = false;
329
+ /** @type {EvalLog} Log of evaluated properties/items for unevaluated* support */
330
+ #evalLog = new EvalLog();
331
+ /** @type {object|null} The JarenValidator instance this compilation belongs to, or null when constructed standalone */
332
+ #owner = null;
333
+ /** @type {Map<string, object>|null} Compiled 'errorMessage' specs by schema path; null when the schema set has none */
334
+ #errorMessages = null;
335
+
336
+ /** Keywords whose value is a map of arbitrary names to schemas; those
337
+ * names must not be mistaken for keywords (e.g. a metaschema declaring
338
+ * a property named 'unevaluatedProperties'). */
339
+ static #SCAN_MAP_KEYWORDS = new Set([
340
+ 'properties', 'patternProperties', 'dependentSchemas',
341
+ '$defs', 'definitions',
342
+ ]);
343
+
344
+ /**
345
+ * Whether an unevaluatedProperties/unevaluatedItems occurrence can force
346
+ * runtime annotation tracking. Two shapes never can (in skipErrors mode):
347
+ * the literal `true` form asserts nothing and only produces annotations,
348
+ * which matter only when a checking occurrence elsewhere consumes them;
349
+ * and a check whose sibling keywords already evaluate every property/item
350
+ * (see hasUnevaluatedPropertiesCoverage/hasUnevaluatedItemsCoverage) is
351
+ * unreachable, because reaching it means those siblings passed. When no
352
+ * occurrence forces tracking, the evaluation log has no consumers and
353
+ * annotation logging is skipped entirely.
354
+ * @param {object} node - The schema object holding the keyword
355
+ * @param {string} key - 'unevaluatedProperties' or 'unevaluatedItems'
356
+ * @returns {boolean} True when this occurrence requires annotation tracking
357
+ */
358
+ static #unevaluatedForcesTracking(node, key) {
359
+ if (node[key] === true) return false;
360
+ return key === 'unevaluatedProperties'
361
+ ? !hasUnevaluatedPropertiesCoverage(node)
362
+ : !hasUnevaluatedItemsCoverage(node);
363
+ }
364
+
365
+ /**
366
+ * Recursively scans a schema (sub)tree for keys that require special
367
+ * runtime support: '$data' references and 'unevaluatedProperties'/
368
+ * 'unevaluatedItems'. Keys inside name->schema maps (properties, $defs,
369
+ * ...) are property/definition names and are not treated as keywords.
370
+ * @param {any} node - The schema node to scan
371
+ * @param {Set<object>} seen - Cycle guard
372
+ * @param {{dollarData: boolean, unevaluated: boolean}} flags - Output flags
373
+ * @param {boolean} [isSchema=true] - Whether node's keys are schema keywords
374
+ */
375
+ static #scanSchemaFeatures(node, seen, flags, isSchema = true) {
376
+ if (node == null || typeof node !== 'object') return;
377
+ if (flags.dollarData && flags.unevaluated) return;
378
+ if (seen.has(node)) return;
379
+ seen.add(node);
380
+ if (Array.isArray(node)) {
381
+ for (let i = 0; i < node.length; ++i) {
382
+ ValidationRoot.#scanSchemaFeatures(node[i], seen, flags, isSchema);
383
+ }
384
+ return;
385
+ }
386
+ const keys = Object.keys(node);
387
+ for (let i = 0; i < keys.length; ++i) {
388
+ const key = keys[i];
389
+ if (isSchema) {
390
+ // The json-everything 'data' keyword resolves relative pointers
391
+ // against the data path at validation time, just like '$data'.
392
+ if (key === '$data'
393
+ || (key === 'data' && node[key] !== null && typeof node[key] === 'object')) flags.dollarData = true;
394
+ else if (key === '$query') {
395
+ // The '$query' keyword binds the instance path to its 'path'
396
+ // external at validation time, so it consumes data paths like
397
+ // '$data'. Its value is a query document, not a schema - the
398
+ // keys inside (operators, embedded schema literals) must not
399
+ // register as keywords of this compilation.
400
+ flags.dollarData = true;
401
+ continue;
402
+ }
403
+ else if (key === 'errorMessage') {
404
+ // 'errorMessage' is report-time metadata; its value is a message
405
+ // spec whose map form may spell keys like '$query' that must not
406
+ // register as keywords of this compilation.
407
+ continue;
408
+ }
409
+ else if (key === 'unevaluatedProperties' || key === 'unevaluatedItems') {
410
+ if (!flags.canElide || ValidationRoot.#unevaluatedForcesTracking(node, key))
411
+ flags.unevaluated = true;
412
+ }
413
+ if (ValidationRoot.#SCAN_MAP_KEYWORDS.has(key)) {
414
+ // The value is a name->schema map: its keys are names, its values schemas.
415
+ ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, false);
416
+ continue;
417
+ }
418
+ }
419
+ ValidationRoot.#scanSchemaFeatures(node[key], seen, flags, true);
420
+ }
421
+ }
422
+
423
+ /**
424
+ * Creates a new ValidationRoot.
425
+ * @param {string} origin - The root schema origin/URI
426
+ * @param {Map} schemas - Map of schema paths to schema objects
427
+ * @param {Record<string, FormatCompiler>} formats - Registered format validators
428
+ * @param {ValidationOptions} [opts] - Validation options
429
+ * @param {TraverseOptions} [traverse] - Schema traversal options
430
+ * @param {object|null} [owner] - The owning JarenValidator instance; extension
431
+ * keywords ('$query') compile embedded schema literals against it so their
432
+ * `$ref`s resolve to the owner's `addSchema` registrations
433
+ */
434
+ constructor(origin, schemas, formats, opts = new ValidationOptions(), traverse = new TraverseOptions, owner = null) {
435
+ const schema = schemas.get(origin);
436
+ this.#rootOrigin = origin;
437
+ this.#schemas = schemas;
438
+ this.#formats = formats;
439
+
440
+ this.#options = opts;
441
+ this.#traverse = traverse;
442
+ this.#owner = owner;
443
+
444
+ this.#objects = new Map();
445
+ this.#errors = [];
446
+ this.#dynamicAnchors = new Map();
447
+
448
+ // Detect $data references and unevaluated* keywords once, so fast paths
449
+ // can skip path building / annotation logging when nothing consumes them.
450
+ // Must run before validators are compiled below.
451
+ // Elision of unreachable unevaluated* checks relies on validators
452
+ // short-circuiting at the first failure, so it only holds in
453
+ // skipErrors mode (see #unevaluatedForcesTracking).
454
+ const flags = { dollarData: false, unevaluated: false, canElide: opts.skipErrors === true };
455
+ const seen = new Set();
456
+ for (const value of schemas.values()) {
457
+ ValidationRoot.#scanSchemaFeatures(value, seen, flags);
458
+ if (flags.dollarData && flags.unevaluated) break;
459
+ }
460
+ this.#usesDollarData = flags.dollarData;
461
+ this.#usesUnevaluated = flags.unevaluated;
462
+
463
+ // For the root schema, baseUri is the origin
464
+ this.#firstSchema = ValidationRoot.#createObject(this, origin, schema, origin);
465
+
466
+ // Precompute per-validation constants so validate() stays allocation-free.
467
+ // The root schema never changes after compilation.
468
+ const rootSchema = this.#firstSchema.schema;
469
+ const hasRecAnchor = isObjectClass(rootSchema) && hasRecursiveAnchor(rootSchema);
470
+ const dynAnchorName = isObjectClass(rootSchema) ? getDynamicAnchorName(rootSchema) : null;
471
+ this.#rootAnchorName = (hasRecAnchor || dynAnchorName) ? (dynAnchorName || '') : null;
472
+ this.#rootValidator = this.#firstSchema.validate;
473
+
474
+ // Entering the root resource brings ALL of its $dynamicAnchors into the
475
+ // dynamic scope (the root's own anchor is handled via #rootAnchorName).
476
+ this.#rootDynamicAnchors = collectDynamicAnchorsDeep(rootSchema)
477
+ .filter(anchor => anchor.schema !== rootSchema);
478
+ }
479
+
480
+ /** @returns {TraverseOptions} Schema traversal options */
481
+ get traverse() { return this.#traverse; }
482
+
483
+ /** @returns {ValidationOptions} Validation options */
484
+ get options() { return this.#options; }
485
+
486
+ /** @returns {object} Registered format validators */
487
+ get formats() { return this.#formats; }
488
+
489
+ /** @returns {Array} Array of validation errors */
490
+ get errors() { return this.#errors; }
491
+
492
+ /** @returns {boolean} Whether any schema in this compilation contains a $data reference */
493
+ get usesDollarData() { return this.#usesDollarData; }
494
+
495
+ /** @returns {boolean} Whether any schema in this compilation contains unevaluatedProperties/unevaluatedItems */
496
+ get usesUnevaluated() { return this.#usesUnevaluated; }
497
+
498
+ /** @returns {EvalLog} The evaluation log for unevaluated* annotation tracking */
499
+ get evalLog() { return this.#evalLog; }
500
+
501
+ /** @returns {object|null} The owning JarenValidator instance, or null when constructed standalone */
502
+ get owner() { return this.#owner; }
503
+
504
+ /** @returns {Map<string, object>|null} Compiled 'errorMessage' specs by schema path, or null when the schema set has none */
505
+ get errorMessages() { return this.#errorMessages; }
506
+
507
+ /**
508
+ * Register a compiled 'errorMessage' spec for a schema location.
509
+ * Called at schema compile time (see compileSchemaObject); the registry
510
+ * is only consulted at report time, over the already-failed set.
511
+ * @param {string} path - The schema path (ValidationObject.path)
512
+ * @param {object} spec - The compiled spec (see messages.js compileErrorMessageSpec)
513
+ */
514
+ registerErrorMessage(path, spec) {
515
+ if (this.#errorMessages === null) this.#errorMessages = new Map();
516
+ this.#errorMessages.set(path, spec);
517
+ }
518
+
519
+ /**
520
+ * Creates a new ValidationObject for the given path and schema.
521
+ * @param {string} path - The URI path for this schema object
522
+ * @param {object|boolean} schema - The JSON schema
523
+ * @param {string} baseUri - The base URI for resolving relative refs
524
+ * @returns {ValidationObject} The created ValidationObject
525
+ */
526
+ createObject(path, schema, baseUri, parentDeclaredDraft = null) {
527
+ return ValidationRoot.#createObject(this, path, schema, baseUri, parentDeclaredDraft);
528
+ }
529
+
530
+ /**
531
+ * Checks if an object exists at the given path without creating it.
532
+ * @param {string} path - The URI path to check
533
+ * @returns {ValidationObject|null|undefined} The existing object, null if marked unresolved, or undefined if not known
534
+ */
535
+ unresolvedObject(path) {
536
+ const objects = this.#objects;
537
+ if (objects.has(path))
538
+ return objects.get(path);
539
+
540
+ objects.set(path, null);
541
+ return null;
542
+ }
543
+
544
+ /**
545
+ * Gets the raw schema object by its URI/ID directly from the schemas map.
546
+ * This performs a direct lookup without following references.
547
+ * @param {string} uri - The schema URI to look up
548
+ * @returns {object|undefined} The raw schema object or undefined
549
+ */
550
+ getSchemaByUri(uri) {
551
+ return this.#schemas.get(uri);
552
+ }
553
+
554
+ /**
555
+ * Resolves a $ref to a ValidationObject, creating it if necessary.
556
+ * @param {string} ref - The reference URI to resolve
557
+ * @param {string} path - The current path (for error messages)
558
+ * @param {object} schema - The schema containing the $ref
559
+ * @returns {ValidationObject} The resolved ValidationObject
560
+ */
561
+ resolveObject(ref, path, schema) {
562
+ // Fast path - check if already compiled first
563
+ const objects = this.#objects;
564
+ const cached = objects.get(ref);
565
+ if (cached != null) return cached;
566
+
567
+ // Resolve ref chain and check final ID cache
568
+ const schemas = this.#schemas;
569
+ const traverse = this.#traverse;
570
+ const { id, schema: root } = resolveRefSchemaDeep(schemas, path, schema, traverse);
571
+
572
+ // Check if final ID is already compiled
573
+ const finalCached = objects.get(id);
574
+ if (finalCached != null) return finalCached;
575
+
576
+ // Create and cache the validation object
577
+ return ValidationRoot.#createObject(this, id, root);
578
+ }
579
+
580
+ /**
581
+ * Adds an error to the validation errors list.
582
+ * @param {InternalValidationError} error - The error to add
583
+ * @returns {boolean} Always returns false for convenience in validators
584
+ */
585
+ addError(error /*:InternalValidationError*/) {
586
+ this.#errors.push(error);
587
+ return false;
588
+ }
589
+
590
+ /**
591
+ * A checkpoint in the collected-error list.
592
+ *
593
+ * A SPECULATIVE applicator - an `anyOf` branch, an `if` condition, the
594
+ * subschema of a `not`, a `contains` candidate - runs a validator whose
595
+ * failure may be entirely expected. Those failures still call `addError`,
596
+ * so without a checkpoint they leak into the caller's issue list and blame
597
+ * a document for not matching a branch it was never required to match.
598
+ * Marking before the probe and rolling back after is the same discipline
599
+ * `EvalLog` already uses for annotations.
600
+ * @returns {number} The mark to pass to {@link rollbackErrors}
601
+ */
602
+ errorMark() {
603
+ return this.#errors.length;
604
+ }
605
+
606
+ /**
607
+ * Discard every error collected since `mark`.
608
+ * @param {number} mark - A value from {@link errorMark}
609
+ */
610
+ rollbackErrors(mark) {
611
+ if (this.#errors.length > mark) this.#errors.length = mark;
612
+ }
613
+
614
+ /**
615
+ * Validates data against the root schema.
616
+ * @param {unknown} data - The data to validate
617
+ * @returns {boolean} True if valid, false otherwise
618
+ */
619
+ validate(data /*:unknown*/) {
620
+ // only clear errors array when we need to collect errors
621
+ // When skipErrors=true (default), we don't use the errors array
622
+ if (!this.#options.skipErrors) {
623
+ this.#errors = [];
624
+ }
625
+ // Push/pop pairs are balanced (try/finally), so the anchors map is
626
+ // normally empty here already; clear defensively without reallocating.
627
+ if (this.#dynamicAnchors.size !== 0) {
628
+ this.#dynamicAnchors.clear();
629
+ }
630
+ // Clear evaluation annotations from the previous validation
631
+ if (this.#usesUnevaluated) {
632
+ this.#evalLog.reset();
633
+ }
634
+
635
+ const rootValidator = this.#rootValidator;
636
+ const anchorName = this.#rootAnchorName;
637
+ const rootAnchors = this.#rootDynamicAnchors;
638
+ if (anchorName !== null || rootAnchors.length !== 0) {
639
+ if (anchorName !== null)
640
+ this.pushDynamicAnchorValidator(anchorName, rootValidator);
641
+ for (let i = 0; i < rootAnchors.length; ++i) {
642
+ const anchor = rootAnchors[i];
643
+ if (anchor.validator === null)
644
+ anchor.validator = this.getOrCreateValidator(anchor.schema, this.#rootOrigin, this.#rootOrigin);
645
+ this.pushDynamicAnchorValidator(anchor.name, anchor.validator);
646
+ }
647
+ try {
648
+ // call compiled validator with dataRoot as third argument
649
+ return rootValidator(data, '', data);
650
+ } finally {
651
+ for (let i = rootAnchors.length - 1; i >= 0; --i) {
652
+ this.popDynamicAnchorValidator(rootAnchors[i].name);
653
+ }
654
+ if (anchorName !== null)
655
+ this.popDynamicAnchorValidator(anchorName);
656
+ }
657
+ }
658
+
659
+ // call compiled validator
660
+ // Pass dataRoot as the third argument for data keyword support
661
+ return rootValidator(data, '', data);
662
+ }
663
+
664
+ /**
665
+ * Returns the fastest repeated-validation entry point for this root.
666
+ * Error collection and root-level dynamic anchors need the per-call
667
+ * bookkeeping of validate(); without them the compiled root validator
668
+ * only needs the annotation log cleared (when tracking is on) and can
669
+ * otherwise be invoked directly. Dynamic anchors pushed during
670
+ * validation are balanced by try/finally, so the anchor map needs no
671
+ * per-call clearing here.
672
+ * @returns {(data: unknown) => boolean} The validation entry point
673
+ */
674
+ createValidateFn() {
675
+ if (!this.#options.skipErrors || this.#options.collectErrors
676
+ || this.#rootAnchorName !== null
677
+ || this.#rootDynamicAnchors.length !== 0) {
678
+ return (data) => this.validate(data);
679
+ }
680
+
681
+ const rootValidator = this.#rootValidator;
682
+ if (this.#usesUnevaluated) {
683
+ const evalLog = this.#evalLog;
684
+ return function validateRootTracked(data) {
685
+ evalLog.reset();
686
+ return rootValidator(data, '', data);
687
+ };
688
+ }
689
+ return function validateRoot(data) {
690
+ return rootValidator(data, '', data);
691
+ };
692
+ }
693
+
694
+ /**
695
+ * Get the stored validator for a dynamic anchor.
696
+ * Used by $dynamicRef for runtime resolution.
697
+ * Per draft 2020-12, $dynamicRef resolves to the FIRST (outermost)
698
+ * resource in the dynamic scope that defines the anchor.
699
+ * @param {string} anchorName - The anchor name
700
+ * @returns {Function|null} The validator function or null if not set
701
+ */
702
+ getDynamicAnchorValidator(anchorName) {
703
+ const stack = this.#dynamicAnchors.get(anchorName);
704
+ if (!stack || stack.length === 0) return null;
705
+ return stack[0];
706
+ }
707
+
708
+ /**
709
+ * Get the outermost validator for a recursive anchor.
710
+ * Used by $recursiveRef for runtime resolution.
711
+ * Returns the bottom of the stack (first/outermost registered validator).
712
+ * @param {string} anchorName - The anchor name (empty string for $recursiveRef)
713
+ * @returns {Function|null} The validator function or null if not set
714
+ */
715
+ getOutermostDynamicAnchorValidator(anchorName) {
716
+ const stack = this.#dynamicAnchors.get(anchorName);
717
+ if (!stack || stack.length === 0) return null;
718
+ return stack[0];
719
+ }
720
+
721
+ /**
722
+ * Push a validator onto the stack for a dynamic anchor.
723
+ * Called when entering a schema with $recursiveAnchor or $dynamicAnchor.
724
+ * @param {string} anchorName - The anchor name
725
+ * @param {Function} validator - The validator function
726
+ */
727
+ pushDynamicAnchorValidator(anchorName, validator) {
728
+ if (!this.#dynamicAnchors.has(anchorName)) {
729
+ this.#dynamicAnchors.set(anchorName, []);
730
+ }
731
+ this.#dynamicAnchors.get(anchorName).push(validator);
732
+ }
733
+
734
+ /**
735
+ * Pop a validator from the stack for a dynamic anchor.
736
+ * Called when exiting a schema with $recursiveAnchor or $dynamicAnchor.
737
+ * @param {string} anchorName - The anchor name
738
+ */
739
+ popDynamicAnchorValidator(anchorName) {
740
+ const stack = this.#dynamicAnchors.get(anchorName);
741
+ if (stack && stack.length > 0) {
742
+ stack.pop();
743
+ }
744
+ }
745
+
746
+ /**
747
+ * Get or create a validator for a given schema.
748
+ * This is used when we need a validator for a schema at validation time
749
+ * (e.g., for dynamic anchors collected from $defs).
750
+ * @param {object} schema - The schema to create a validator for
751
+ * @param {string} basePath - The base path for the schema
752
+ * @param {string} baseUri - The base URI for the schema
753
+ * @returns {Function} The validator function
754
+ */
755
+ getOrCreateValidator(schema, basePath, baseUri) {
756
+ // Create a unique path for this schema based on its content
757
+ // We use a simple JSON stringify for now, but this could be improved
758
+ const path = basePath + '/$def-anchor/' + JSON.stringify(schema).slice(0, 50);
759
+
760
+ // Check if we already have an object for this path
761
+ let obj = this.#objects.get(path);
762
+ if (obj != null) {
763
+ return obj.validate;
764
+ }
765
+
766
+ // Create a new validation object for this schema
767
+ obj = ValidationRoot.#createObject(this, path, schema, baseUri);
768
+ return obj.validate;
769
+ }
770
+ }
771
+
772
+ /**
773
+ * ValidationObject represents a single schema location with its compiled validator.
774
+ * It handles the compilation of schema validation logic and provides methods for
775
+ * creating child validators and error handlers.
776
+ * @class
777
+ */
778
+ export class ValidationObject {
779
+ /**
780
+ * Compiles a validator function for the given schema.
781
+ * This is the main entry point for schema compilation. It handles:
782
+ * - Simple schemas (type-only, required-only) via fast paths
783
+ * - Schemas with $ref by resolving to target validators
784
+ * - Complex schemas by delegating to compileSchemaObject
785
+ * @param {ValidationObject} self - The validation object that is compiling this validator
786
+ * @param {string} path - The path to this schema object (its URI identifier)
787
+ * @param {any} schema - The schema object to compile
788
+ * @param {string} baseUri - The base URI for resolving $ref (parent's base, before any sibling $id)
789
+ * @returns {function(any, any):boolean} A function that validates data against the compiled schema and returns a boolean.
790
+ */
791
+ static compileValidator(self, path, schema, baseUri) {
792
+ if (!hasSchemaRef(schema))
793
+ return compileSchemaObject(self, schema);
794
+
795
+ const root = self.#root;
796
+
797
+ // In draft 2019-09+, $ref can have sibling keywords that are applied together.
798
+ // In draft 7 and earlier, $ref overrides siblings.
799
+ //
800
+ // The draft that decides this is the one declared by the schema RESOURCE
801
+ // holding the `$ref`, not the one the root document happens to use. A
802
+ // 2020-12 resource embedded in a draft-07 document has to assert its
803
+ // siblings, and a draft-07 resource inside a 2020-12 document must not —
804
+ // reading the root's draft got both backwards.
805
+ const draftVersion = self.declaredDraft ?? root.options.draftVersion ?? 7;
806
+
807
+ // Base URI for resolving $ref:
808
+ // - Draft 7 and earlier: $ref replaces the schema entirely, so a sibling
809
+ // $id does not change the base URI - resolve against the parent's base.
810
+ // - Draft 2019-09+: $id establishes the base URI for the schema object it
811
+ // appears in, INCLUDING a sibling $ref (self.baseUri accounts for $id).
812
+ const refBase = draftVersion >= 2019
813
+ ? (self.baseUri || baseUri || path)
814
+ : (baseUri || path);
815
+ const { id: ref } = createJsonPointer(schema.$ref, refBase, root.traverse);
816
+ // Only check for sibling validators in draft 2019-09+
817
+ const siblingValidator = draftVersion >= 2019 ? compileSchemaObject(self, schema) : null;
818
+
819
+ // Collect dynamic anchors from the current schema's $defs/definitions.
820
+ // When following a $ref, dynamic anchors defined in the source schema's $defs
821
+ // should be in scope for $dynamicRef in the target schema.
822
+ // This handles cases like: root has $defs.foo with $dynamicAnchor, root.$ref points to list,
823
+ // and list.items has $dynamicRef that should resolve to root.$defs.foo's anchor.
824
+ const sourceDynamicAnchors = draftVersion >= 2020 ? collectDynamicAnchors(schema) : [];
825
+
826
+ // Since refs are now pre-compiled at compile time,
827
+ // we should always find the target immediately
828
+ const resolved = root.unresolvedObject(ref);
829
+ if (resolved != null) {
830
+ const refValidator = resolved.validate;
831
+ const resolvedSchema = resolved.schema;
832
+
833
+ // Check if the target schema has a dynamic anchor ($recursiveAnchor or $dynamicAnchor)
834
+ // If so, we need to wrap the call to register the anchor at validation time
835
+ const hasRecAnchor = isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema);
836
+ const dynAnchorName = isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null;
837
+
838
+ // Entering the target resource brings ALL of its $dynamicAnchors into
839
+ // the dynamic scope (the target root's own anchor is pushed separately
840
+ // as refValidator below). Source $defs anchors are kept for schemas
841
+ // whose resource entry point is the $ref itself.
842
+ const enterAnchors = sourceDynamicAnchors.slice();
843
+ const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
844
+ for (let i = 0; i < targetDeepAnchors.length; i++) {
845
+ const anchor = targetDeepAnchors[i];
846
+ if (anchor.schema === resolvedSchema) continue;
847
+ anchor.base = resolved.baseUri;
848
+ enterAnchors.push(anchor);
849
+ }
850
+
851
+ if (hasRecAnchor || dynAnchorName || enterAnchors.length > 0) {
852
+ const anchorName = dynAnchorName || ''; // empty string for $recursiveAnchor
853
+
854
+ // Wrap the ref validator to register dynamic anchor at call time (validation time)
855
+ if (siblingValidator) {
856
+ // unevaluated* keywords must see annotations produced by the $ref
857
+ // target, so the wrapper goes around the combined validator.
858
+ return wrapUnevaluated(self, schema, function validateRefWithDynamicAnchorAndSiblings(data, dataPath, dataRoot) {
859
+ // Register the entered resource's dynamic anchors first
860
+ for (let i = 0; i < enterAnchors.length; i++) {
861
+ const anchor = enterAnchors[i];
862
+ if (anchor.validator == null)
863
+ anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
864
+ root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
865
+ }
866
+ // Then register target's dynamic anchor if any
867
+ if (hasRecAnchor || dynAnchorName) {
868
+ root.pushDynamicAnchorValidator(anchorName, refValidator);
869
+ }
870
+ try {
871
+ return combineRefAndSiblings(refValidator, siblingValidator, self.options.skipErrors,
872
+ data, dataPath, dataRoot);
873
+ } finally {
874
+ // Pop in reverse order
875
+ if (hasRecAnchor || dynAnchorName) {
876
+ root.popDynamicAnchorValidator(anchorName);
877
+ }
878
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
879
+ root.popDynamicAnchorValidator(enterAnchors[i].name);
880
+ }
881
+ }
882
+ });
883
+ } else {
884
+ return function validateRefWithDynamicAnchor(data, dataPath, dataRoot) {
885
+ // Register the entered resource's dynamic anchors first
886
+ for (let i = 0; i < enterAnchors.length; i++) {
887
+ const anchor = enterAnchors[i];
888
+ if (anchor.validator == null)
889
+ anchor.validator = root.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
890
+ root.pushDynamicAnchorValidator(anchor.name, anchor.validator);
891
+ }
892
+ // Then register target's dynamic anchor if any
893
+ if (hasRecAnchor || dynAnchorName) {
894
+ root.pushDynamicAnchorValidator(anchorName, refValidator);
895
+ }
896
+ try {
897
+ return refValidator(data, dataPath, dataRoot);
898
+ } finally {
899
+ // Pop in reverse order
900
+ if (hasRecAnchor || dynAnchorName) {
901
+ root.popDynamicAnchorValidator(anchorName);
902
+ }
903
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
904
+ root.popDynamicAnchorValidator(enterAnchors[i].name);
905
+ }
906
+ }
907
+ };
908
+ }
909
+ }
910
+
911
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
912
+ if (siblingValidator) {
913
+ // unevaluated* keywords must see annotations produced by the $ref
914
+ // target, so the wrapper goes around the combined validator.
915
+ return wrapUnevaluated(self, schema, function validateRefWithSiblings(data, dataPath, dataRoot) {
916
+ return combineRefAndSiblings(refValidator, siblingValidator, self.options.skipErrors,
917
+ data, dataPath, dataRoot);
918
+ });
919
+ }
920
+
921
+ self.#validator = refValidator;
922
+ return refValidator;
923
+ }
924
+
925
+ // Fallback for edge cases (e.g., recursive refs that weren't pre-compiled)
926
+ // Pre-bind values to avoid closure overhead in hot path
927
+ const rootRef = root;
928
+ const boundRef = ref;
929
+ const boundRefBase = refBase;
930
+ const boundSchema = schema;
931
+ const boundSiblingValidator = siblingValidator;
932
+ const boundSourceAnchors = sourceDynamicAnchors;
933
+
934
+ // Memoized state of the first resolution - the target never changes.
935
+ let resolvedState = null;
936
+
937
+ return wrapUnevaluated(self, schema, function resolveSchemaCompiler(data, dataPath, dataRoot) {
938
+ if (resolvedState === null) {
939
+ const obj = rootRef.resolveObject(boundRef, boundRefBase, boundSchema);
940
+ const resolvedSchema = obj.schema;
941
+
942
+ // Entering the target resource brings ALL of its $dynamicAnchors into
943
+ // the dynamic scope, exactly like the pre-compiled path above.
944
+ const enterAnchors = boundSourceAnchors.slice();
945
+ const targetDeepAnchors = collectDynamicAnchorsDeep(resolvedSchema);
946
+ for (let i = 0; i < targetDeepAnchors.length; i++) {
947
+ const anchor = targetDeepAnchors[i];
948
+ if (anchor.schema === resolvedSchema) continue;
949
+ anchor.base = obj.baseUri;
950
+ enterAnchors.push(anchor);
951
+ }
952
+
953
+ resolvedState = {
954
+ refValidator: obj.validate,
955
+ enterAnchors,
956
+ hasRecAnchor: isObjectClass(resolvedSchema) && hasRecursiveAnchor(resolvedSchema),
957
+ dynAnchorName: isObjectClass(resolvedSchema) ? getDynamicAnchorName(resolvedSchema) : null,
958
+ };
959
+ }
960
+ const { refValidator, enterAnchors, hasRecAnchor, dynAnchorName } = resolvedState;
961
+
962
+ for (let i = 0; i < enterAnchors.length; i++) {
963
+ const anchor = enterAnchors[i];
964
+ if (anchor.validator == null)
965
+ anchor.validator = rootRef.getOrCreateValidator(anchor.schema, path, anchor.base || baseUri);
966
+ rootRef.pushDynamicAnchorValidator(anchor.name, anchor.validator);
967
+ }
968
+
969
+ try {
970
+ if (hasRecAnchor || dynAnchorName) {
971
+ const anchorName = dynAnchorName || '';
972
+ rootRef.pushDynamicAnchorValidator(anchorName, refValidator);
973
+ try {
974
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
975
+ if (boundSiblingValidator) {
976
+ return combineRefAndSiblings(refValidator, boundSiblingValidator,
977
+ self.options.skipErrors, data, dataPath, dataRoot);
978
+ }
979
+ return refValidator(data, dataPath, dataRoot);
980
+ } finally {
981
+ rootRef.popDynamicAnchorValidator(anchorName);
982
+ }
983
+ }
984
+
985
+ // If there are sibling validators (draft 2019-09+), combine them with the ref validator
986
+ if (boundSiblingValidator) {
987
+ return combineRefAndSiblings(refValidator, boundSiblingValidator,
988
+ self.options.skipErrors, data, dataPath, dataRoot);
989
+ }
990
+
991
+ // Cache the validator directly (skipping this resolver) only when
992
+ // this ref registers no dynamic anchors - otherwise later calls
993
+ // would lose the registrations.
994
+ if (enterAnchors.length === 0) {
995
+ self.#validator = refValidator;
996
+ }
997
+ return refValidator(data, dataPath, dataRoot);
998
+ } finally {
999
+ // Pop anchors in reverse order
1000
+ for (let i = enterAnchors.length - 1; i >= 0; i--) {
1001
+ rootRef.popDynamicAnchorValidator(enterAnchors[i].name);
1002
+ }
1003
+ }
1004
+ });
1005
+ }
1006
+
1007
+ /** @type {ValidationRoot} The root validation context */
1008
+ #root = null;
1009
+ /** @type {string} The URI path identifying this schema object */
1010
+ #path = null;
1011
+ /** @type {ValidationObject[]} Child validation objects created by this object */
1012
+ #members = null;
1013
+ /** @type {any} The schema object being validated */
1014
+ #schema = null;
1015
+ /** @type {function|null} The compiled validator function */
1016
+ #validator = null;
1017
+ /** @type {string} The effective base URI for child $ref resolution */
1018
+ #effectiveBaseUri = null;
1019
+ /** @type {number|null} Draft version declared by this schema's document ($schema), inherited by subschemas; null when never declared */
1020
+ #declaredDraft = null;
1021
+
1022
+ /**
1023
+ * Creates a new ValidationObject.
1024
+ * @param {ValidationRoot} root - The root validation context
1025
+ * @param {string} path - The URI path identifying this schema object
1026
+ * @param {any} schema - The schema object to compile
1027
+ * @param {string} baseUri - The base URI for resolving $ref
1028
+ * @param {number|null} [parentDeclaredDraft] - The declared draft inherited from the parent schema object
1029
+ */
1030
+ constructor(root, path, schema, baseUri, parentDeclaredDraft = null) {
1031
+ this.#root = root;
1032
+ this.#path = path;
1033
+ this.#members = [];
1034
+ this.#schema = schema;
1035
+ this.#validator = null;
1036
+
1037
+ // A document that declares its own $schema is processed per that draft
1038
+ // (cross-draft references); subschemas inherit the document's draft.
1039
+ this.#declaredDraft = (isObjectClass(schema) && isStringType(schema.$schema))
1040
+ ? detectSchemaDraft(schema)
1041
+ : parentDeclaredDraft;
1042
+
1043
+ // Calculate the effective base URI for this schema.
1044
+ // The effective base is what children should use for resolving relative $refs.
1045
+ // If this schema has an $id, it becomes the new base for children.
1046
+ if (isObjectClass(schema) && schema.$id) {
1047
+ // This schema has its own $id - resolve it against the parent's baseUri
1048
+ // to get the absolute base for children
1049
+ const { id: resolvedId } = createJsonPointer(schema.$id, baseUri);
1050
+ this.#effectiveBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
1051
+ } else {
1052
+ // No $id - inherit parent's base
1053
+ this.#effectiveBaseUri = baseUri;
1054
+ }
1055
+
1056
+ this.#validator = ValidationObject.compileValidator(this, path, schema, baseUri);
1057
+ }
1058
+
1059
+ /** @returns {string} The URI path identifying this schema object */
1060
+ get path() {
1061
+ return this.#path;
1062
+ }
1063
+
1064
+ /** @returns {string} The effective base URI for resolving relative $refs */
1065
+ get baseUri() {
1066
+ return this.#effectiveBaseUri;
1067
+ }
1068
+
1069
+ /** @returns {function} The compiled validator function */
1070
+ get validate() {
1071
+ return this.#validator;
1072
+ }
1073
+
1074
+ /** @returns {ValidationOptions} The validation options */
1075
+ get options() {
1076
+ return this.#root.options;
1077
+ }
1078
+
1079
+ /** @returns {object} The registered format validators */
1080
+ get formats() {
1081
+ return this.#root.formats;
1082
+ }
1083
+
1084
+ /** @returns {ValidationRoot} The root validation context */
1085
+ get root() {
1086
+ return this.#root;
1087
+ }
1088
+
1089
+ /** @returns {object} The schema object */
1090
+ get schema() {
1091
+ return this.#schema;
1092
+ }
1093
+
1094
+ /** @returns {number|null} Draft version declared by this schema's document via $schema, or null when never declared */
1095
+ get declaredDraft() {
1096
+ return this.#declaredDraft;
1097
+ }
1098
+
1099
+ /**
1100
+ * Creates an error handler function for validation failures.
1101
+ * @param {any} expected - The expected value that failed validation
1102
+ * @param {string | string[]} key - The keyword or keywords that failed
1103
+ * @returns {(data: unknown, ...meta: any[]) => boolean} A function that adds an error and returns false
1104
+ */
1105
+ createErrorHandler(expected, key) {
1106
+ const self = this;
1107
+
1108
+ // when skipErrors is true, we don't need to create error objects
1109
+ // Just return false immediately to avoid the overhead of error creation
1110
+ if (self.#root.options.skipErrors) {
1111
+ if (!Array.isArray(key)) {
1112
+ return function addNormalErrorFast(_data, ..._meta) {
1113
+ // Just return false without creating error object
1114
+ return false;
1115
+ };
1116
+ }
1117
+ else {
1118
+ return function addKeyedErrorFast(_dataKey, _data, ..._meta) {
1119
+ // Just return false without creating error object
1120
+ return false;
1121
+ };
1122
+ }
1123
+ }
1124
+
1125
+ if (!Array.isArray(key)) {
1126
+ return function addNormalError(data, ...meta) {
1127
+ const error = new InternalValidationError(self, key, expected, null, data, meta);
1128
+ return self.#root.addError(error);
1129
+ };
1130
+ }
1131
+ else {
1132
+ return function addKeyedError(dataKey, data, ...meta) {
1133
+ const error = new InternalValidationError(self, key, expected, dataKey, data, meta);
1134
+ return self.#root.addError(error);
1135
+ };
1136
+ }
1137
+ }
1138
+
1139
+ /**
1140
+ * Creates a validator function for a child schema.
1141
+ * This is used when compiling nested schemas (e.g., array items, object properties).
1142
+ * @param {JSONSchema | boolean} schema - The child schema to compile
1143
+ * @param {string} key - The property key where the schema is located
1144
+ * @param {number} [index] - Optional array index for tuple items
1145
+ * @returns {function|undefined} The compiled validator function, or undefined if schema is invalid
1146
+ */
1147
+ createValidator(schema, key, index) {
1148
+ if (!isBoolOrObjectClass(schema))
1149
+ return undefined;
1150
+
1151
+ const root = this.#root;
1152
+ // Use the effective base URI for resolving relative $refs
1153
+ // This is either: (a) the resolved $id of this schema, or (b) the inherited base from parent
1154
+ let basePath = this.#effectiveBaseUri;
1155
+ // Strip trailing '#' for URL resolution - a base URL ending with '#' breaks relative ref resolution
1156
+ if (basePath && basePath.endsWith('#')) {
1157
+ basePath = basePath.slice(0, -1);
1158
+ }
1159
+
1160
+ // Check if schema has $id - this affects how we calculate the path
1161
+ const hasId = isObjectClass(schema) && schema.$id;
1162
+
1163
+ // If schema has $id, resolve it against basePath to get the new base URI
1164
+ // Otherwise, use the current path
1165
+ const id = hasId
1166
+ ? createJsonPointer(schema.$id, basePath || this.#path).id
1167
+ : this.#path;
1168
+
1169
+ // If schema has $id, use the resolved ID as the path (it defines the schema's location)
1170
+ // Otherwise, append key/index to create a JSON pointer path
1171
+ const path = hasId
1172
+ ? id
1173
+ : index == null
1174
+ ? encodeJsonPointerPath(id, key)
1175
+ : encodeJsonPointerPath(id, key, String(index));
1176
+
1177
+
1178
+
1179
+ // Pass basePath as the baseUri for the child object.
1180
+ // This ensures that $ref in the child will be resolved against basePath,
1181
+ // not against any sibling $id that the child might have.
1182
+ // The child inherits this document's declared draft version.
1183
+ const child = root.createObject(path, schema, basePath, this.#declaredDraft);
1184
+ this.#members.push(child);
1185
+
1186
+ return child.#validator;
1187
+ }
1188
+ }
1189
+
1190
+ /**
1191
+ * Run a `$ref` and its sibling keywords, which are INDEPENDENT of each other:
1192
+ * a document can fail the referenced schema and its siblings for unrelated
1193
+ * reasons, and reporting only the first is the same short-circuit that used to
1194
+ * hide half of every issue list. Boolean mode keeps the early exit.
1195
+ * @param {Function} refValidator
1196
+ * @param {Function} siblingValidator
1197
+ * @param {boolean} stopAtFirst
1198
+ * @param {any} data
1199
+ * @param {string} dataPath
1200
+ * @param {any} dataRoot
1201
+ * @returns {boolean}
1202
+ */
1203
+ function combineRefAndSiblings(refValidator, siblingValidator, stopAtFirst, data, dataPath, dataRoot) {
1204
+ if (stopAtFirst)
1205
+ return refValidator(data, dataPath, dataRoot) && siblingValidator(data, dataPath, dataRoot);
1206
+ const target = refValidator(data, dataPath, dataRoot);
1207
+ return siblingValidator(data, dataPath, dataRoot) && target;
1208
+ }
1209
+
1210
+ /**
1211
+ * ValidatorOptions configures the JarenValidator instance.
1212
+ * Can be created with positional arguments or an options object.
1213
+ * @class
1214
+ * @example
1215
+ * // Positional arguments
1216
+ * const options = new ValidatorOptions(formats, schemas, validation, traverse);
1217
+ *
1218
+ * // Options object (recommended)
1219
+ * const options = new ValidatorOptions({
1220
+ * formats: { custom: validator },
1221
+ * collectErrors: true,
1222
+ * useGrapheme: false
1223
+ * });
1224
+ */
1225
+ export class ValidatorOptions {
1226
+ /**
1227
+ * Creates validator options.
1228
+ * @param {object|object[]} [formats={}] - Format validators or options object
1229
+ * @param {object[]} [schemas=[]] - Initial schemas to register
1230
+ * @param {ValidationOptions} [validation] - Validation behavior options
1231
+ * @param {TraverseOptions} [traverse] - Schema traversal options
1232
+ */
1233
+ constructor(
1234
+ formats = {},
1235
+ schemas = [],
1236
+ validation = new ValidationOptions(),
1237
+ traverse = new TraverseOptions(),
1238
+ ) {
1239
+ // Support object destructuring: new ValidatorOptions({ collectErrors: true })
1240
+ if (formats && typeof formats === 'object' && !Array.isArray(formats) &&
1241
+ !(formats instanceof Map)) {
1242
+ const opts = formats;
1243
+ /** @type {object} Registered format validators */
1244
+ this.formats = opts.formats || {};
1245
+ /** @type {object[]} Initial schemas to register */
1246
+ this.schemas = opts.schemas || [];
1247
+ // If collectErrors is passed directly, create ValidationOptions with it
1248
+ if (opts.collectErrors != null || opts.skipErrors != null || opts.useGrapheme != null || opts.contentValidation != null || opts.draftVersion != null || opts.formatAssertion != null || opts.messages != null || opts.unknownFormats != null) {
1249
+ const collectErrors = opts.collectErrors ?? false;
1250
+ this.validation = new ValidationOptions(
1251
+ // collecting errors implies actually recording them
1252
+ opts.skipErrors ?? !collectErrors,
1253
+ opts.useGrapheme ?? true,
1254
+ collectErrors,
1255
+ // null, not false: an unset option must stay unset so `compile`
1256
+ // can apply the per-draft default. Coercing it here would make
1257
+ // any options object silently disable content assertion.
1258
+ opts.contentValidation ?? null,
1259
+ opts.draftVersion ?? 7,
1260
+ true,
1261
+ opts.formatAssertion ?? null,
1262
+ opts.messages ?? true,
1263
+ opts.unknownFormats ?? 'ignore'
1264
+ );
1265
+ } else {
1266
+ /** @type {ValidationOptions} Validation behavior options */
1267
+ this.validation = opts.validation || new ValidationOptions();
1268
+ }
1269
+ /** @type {TraverseOptions} Schema traversal options */
1270
+ this.traverse = opts.traverse || new TraverseOptions();
1271
+ } else {
1272
+ this.formats = formats;
1273
+ this.schemas = schemas;
1274
+ this.validation = validation;
1275
+ this.traverse = traverse;
1276
+ }
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * The object a compiled validator returns when `collectErrors` is enabled.
1282
+ * @typedef {{ valid: boolean, errors: import("./messages.js").ValidationError[] }} ValidationResultObject
1283
+ */
1284
+
1285
+ /**
1286
+ * A compiled validator in the default boolean mode. It is a type guard, so
1287
+ * `T` is whatever the caller asserts the schema describes; with no `T` it
1288
+ * behaves as an ordinary boolean predicate.
1289
+ * @template T
1290
+ * @typedef {(data: unknown) => data is T} CompiledPredicate
1291
+ */
1292
+
1293
+ /**
1294
+ * A compiled validator in collect-errors mode.
1295
+ * @typedef {(data: unknown) => ValidationResultObject} CompiledCollector
1296
+ */
1297
+
1298
+ /**
1299
+ * The plain-object form accepted by the JarenValidator constructor, mixing
1300
+ * validator-level settings with the ValidationOptions fields.
1301
+ * @template {boolean} [TCollect=false]
1302
+ * @typedef {object} ValidatorInit
1303
+ * @property {Record<string, FormatCompiler>} [formats] - Format compilers to register
1304
+ * @property {(JSONSchema | boolean)[]} [schemas] - Schemas to register
1305
+ * @property {ValidationOptions} [validation] - Validation behavior options
1306
+ * @property {TraverseOptions} [traverse] - Schema traversal options
1307
+ * @property {TCollect} [collectErrors] - Return `{ valid, errors }` instead of a boolean
1308
+ * @property {boolean} [skipErrors] - Stop at the first failure (defaults to `!collectErrors`)
1309
+ * @property {boolean} [useGrapheme] - Count grapheme clusters for string length
1310
+ * @property {boolean} [contentValidation] - Assert contentEncoding/contentMediaType
1311
+ * @property {number} [draftVersion] - The JSON Schema draft version
1312
+ * @property {boolean} [formatAssertion] - Assert the format keyword
1313
+ * @property {boolean} [messages] - Render English message text on collected errors
1314
+ * @property {'error'|'ignore'} [unknownFormats] - What an ASSERTING `format` with no registered compiler does: 'ignore' (default, per spec) accepts it as an annotation, 'error' throws at compile time
1315
+ */
1316
+
1317
+ /**
1318
+ * JarenValidator is the main entry point for JSON Schema validation.
1319
+ * It manages schema registration, format registration, and compilation.
1320
+ *
1321
+ * The `collectErrors` option decides what a compiled validator returns, and
1322
+ * it is carried in the type parameter so the two shapes never have to be
1323
+ * distinguished at runtime.
1324
+ * @template {boolean} [TCollect=false]
1325
+ * @class
1326
+ * @example
1327
+ * const validator = new JarenValidator();
1328
+ * validator.addSchema({ $id: 'http://example.com/schema', type: 'object' });
1329
+ * const validate = validator.compile({ $ref: 'http://example.com/schema' });
1330
+ * const valid = validate({ foo: 'bar' }); // true
1331
+ */
1332
+ export class JarenValidator {
1333
+ /** @type {object} Registered format validators */
1334
+ #formats = {}
1335
+ /** @type {Map} Map of schema URIs to schema objects */
1336
+ #schemas = new Map();
1337
+ /** @type {Map} Map of meta-schema URIs to compiled validators */
1338
+ #metaSchemas = new Map();
1339
+ /** @type {ValidatorOptions} Validator options */
1340
+ #options = new ValidatorOptions();
1341
+
1342
+ /**
1343
+ * Creates a new JarenValidator instance.
1344
+ * @param {ValidatorOptions | ValidatorInit<TCollect>} [options] - Validator options including formats, schemas, validation options, and traverse options
1345
+ */
1346
+ constructor(options = new ValidatorOptions()) {
1347
+ // Accept a plain options object ({ skipErrors, collectErrors, ... })
1348
+ // as well as a ValidatorOptions instance.
1349
+ if (!(options instanceof ValidatorOptions)) {
1350
+ options = new ValidatorOptions(options);
1351
+ }
1352
+ this.#formats = options.formats || {};
1353
+ this.#schemas = new Map();
1354
+ this.#metaSchemas = new Map();
1355
+ this.#options = options;
1356
+ }
1357
+
1358
+ /**
1359
+ * Adds a format validator.
1360
+ * @param {string} name - The format name (e.g., 'email', 'uri', 'date-time')
1361
+ * @param {FormatCompiler} formatCompiler - A function that compiles format validators
1362
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1363
+ * @example
1364
+ * validator.addFormat('custom', (schemaObj, schema) => {
1365
+ * return (data) => data.startsWith('custom:');
1366
+ * });
1367
+ */
1368
+ addFormat(name, formatCompiler) {
1369
+ registerFormatCompiler(
1370
+ this.#formats,
1371
+ name,
1372
+ formatCompiler);
1373
+ return this;
1374
+ }
1375
+
1376
+ /**
1377
+ * Adds multiple format validators at once.
1378
+ * @param {Record<string, FormatCompiler>} formatCompilers - Object mapping format names to compiler functions
1379
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1380
+ */
1381
+ addFormats(formatCompilers) {
1382
+ registerFormatCompilers(
1383
+ this.#formats,
1384
+ formatCompilers);
1385
+ return this;
1386
+ }
1387
+
1388
+ /**
1389
+ *
1390
+ * @param {boolean | object} schema
1391
+ * @param {object[] | undefined} schemas
1392
+ * @param {TraverseOptions} opts
1393
+ * @returns {{origin:string, map: Map}}
1394
+ */
1395
+ static #traverseSchema(schema, schemas = undefined, fromMap = undefined, opts = new TraverseOptions()) {
1396
+ // initialize schema map for all ids and refs
1397
+ const schemaMap = new Map(fromMap);
1398
+ const origin = storeSchemaIdsInMap(
1399
+ schemaMap,
1400
+ opts.origin,
1401
+ schema,
1402
+ opts);
1403
+
1404
+ // Then add the other reference schemas
1405
+ if (Array.isArray(schemas) && schemas.length > 0) {
1406
+ schemas.forEach(ref => storeSchemaIdsInMap(
1407
+ schemaMap,
1408
+ origin,
1409
+ ref,
1410
+ opts));
1411
+ }
1412
+
1413
+ // make sure all schemas are connected
1414
+ restoreSchemaRefsInMap(schemaMap, opts);
1415
+
1416
+ return { origin: origin, map: schemaMap };
1417
+ }
1418
+
1419
+ /**
1420
+ * Adds schema(s) to the validator instance.
1421
+ * This method does not compile schemas - it only registers them for reference.
1422
+ * Dependencies can be added in any order, and circular dependencies are supported.
1423
+ * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The schema(s) to add
1424
+ * @param {string} [key] - Optional key/URI to register the schema under
1425
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1426
+ * @example
1427
+ * // Add a single schema
1428
+ * validator.addSchema({ $id: 'http://example.com/user', type: 'object' });
1429
+ *
1430
+ * // Add multiple schemas
1431
+ * validator.addSchema([schema1, schema2]);
1432
+ *
1433
+ * // Add with explicit key
1434
+ * validator.addSchema({ type: 'string' }, 'http://example.com/name');
1435
+ */
1436
+ addSchema(schema, key = undefined) {
1437
+ if (Array.isArray(schema)) {
1438
+ schema.forEach((s, index) => this.addSchema(s, key ? `${key}[${index}]` : undefined));
1439
+ }
1440
+ else if (typeof schema === 'object') {
1441
+ const schemaKey = key || schema.$id;
1442
+ if (schemaKey) {
1443
+ this.#schemas.set(schemaKey, schema);
1444
+ // CHECK: Also store with alternate key (with/without #) for absolute URIs
1445
+ if (!schemaKey.startsWith('#')) {
1446
+ const altKey = schemaKey.endsWith('#') ? schemaKey.slice(0, -1) : schemaKey + '#';
1447
+ if (!this.#schemas.has(altKey)) {
1448
+ this.#schemas.set(altKey, schema);
1449
+ }
1450
+ }
1451
+
1452
+ // Also traverse the schema to find and store all internal $id anchors
1453
+ // This is important for remote schemas that may have location-independent identifiers
1454
+ // We use a wrapper that skips already-existing keys instead of throwing
1455
+ this.#traverseAndStoreIds(schemaKey, schema);
1456
+ }
1457
+ }
1458
+ return this;
1459
+ }
1460
+
1461
+ /**
1462
+ * Traverse a schema and store all $id anchors in the schemas map.
1463
+ * This is a wrapper around storeSchemaIdsInMap that skips already-existing keys.
1464
+ * Anchors are scoped to their document context (anchorsGlobal: false) to prevent
1465
+ * conflicts between different schemas that may use the same anchor names.
1466
+ * @param {string} baseUri - The base URI for the schema
1467
+ * @param {object} schema - The schema to traverse
1468
+ */
1469
+ #traverseAndStoreIds(baseUri, schema) {
1470
+ const traverseOpts = this.#options.traverse;
1471
+
1472
+ // Create options with anchorsGlobal: false to scope anchors to their document.
1473
+ // This prevents conflicts when multiple schemas use the same anchor names (e.g., '#foo').
1474
+ // Anchors will be stored as 'baseUri#anchor' instead of just '#anchor'.
1475
+ const scopedOpts = new TraverseOptions(
1476
+ traverseOpts.origin,
1477
+ traverseOpts.mergeSchemas,
1478
+ false, // anchorsGlobal: false - scope anchors to document
1479
+ traverseOpts.anchorsAllowed,
1480
+ traverseOpts.skipErrors
1481
+ );
1482
+
1483
+ // Use a wrapper map to collect new entries, then merge them
1484
+ const newSchemas = new Map();
1485
+ try {
1486
+ storeSchemaIdsInMap(newSchemas, baseUri, schema, scopedOpts);
1487
+ } catch (_e) {
1488
+ // Ignore errors for already-existing schemas at the root level
1489
+ }
1490
+
1491
+ // Merge new entries into the main schemas map, skipping existing keys
1492
+ for (const [id, value] of newSchemas.entries()) {
1493
+ if (!this.#schemas.has(id)) {
1494
+ this.#schemas.set(id, value);
1495
+ }
1496
+ }
1497
+ }
1498
+
1499
+ /**
1500
+ *
1501
+ * @param {JarenValidator} self
1502
+ * @param {string} origin
1503
+ * @param {Map} schemas
1504
+ * @returns {(data) => boolean | {valid: boolean, errors: import("./messages.js").ValidationError[]}}
1505
+ */
1506
+ static #compileSchema(self, origin, schemas, validation = self.#options.validation) {
1507
+ const root = new ValidationRoot(
1508
+ origin,
1509
+ schemas,
1510
+ self.#formats,
1511
+ validation,
1512
+ self.#options.traverse,
1513
+ self);
1514
+
1515
+ const collectErrors = self.#options.validation?.collectErrors || false;
1516
+
1517
+ function jarenValidateSchema(data) {
1518
+ const valid = root.validate(data);
1519
+ if (collectErrors) {
1520
+ return {
1521
+ valid,
1522
+ errors: valid ? [] : convertInternalErrors(root.errors)
1523
+ };
1524
+ }
1525
+ return valid;
1526
+ }
1527
+
1528
+ return jarenValidateSchema;
1529
+ }
1530
+
1531
+ /**
1532
+ * Compile schema using a pre-created ValidationRoot (with pre-compiled refs).
1533
+ * @param {JarenValidator} self
1534
+ * @param {string} origin
1535
+ * @param {Map} schemas
1536
+ * @param {ValidationRoot} root - Pre-created root with pre-compiled refs
1537
+ * @returns {(data) => boolean | {valid: boolean, errors: import("./messages.js").ValidationError[]}}
1538
+ */
1539
+ static #compileSchemaWithRoot(self, origin, schemas, root) {
1540
+ const collectErrors = self.#options.validation?.collectErrors || false;
1541
+
1542
+ if (!collectErrors) {
1543
+ const jarenValidateSchema = root.createValidateFn();
1544
+ Object.defineProperty(jarenValidateSchema, "errors", {
1545
+ get: function () { return root.errors }
1546
+ })
1547
+ return jarenValidateSchema;
1548
+ }
1549
+
1550
+ function jarenValidateSchema(data) {
1551
+ const valid = root.validate(data);
1552
+ return {
1553
+ valid,
1554
+ errors: valid ? [] : convertInternalErrors(root.errors)
1555
+ };
1556
+ }
1557
+
1558
+ Object.defineProperty(jarenValidateSchema, "errors", {
1559
+ get: function () { return root.errors }
1560
+ })
1561
+
1562
+ return jarenValidateSchema;
1563
+ }
1564
+
1565
+ static normalizeUriKey(key) {
1566
+ return key;
1567
+ }
1568
+
1569
+ /**
1570
+ * Adds meta-schema(s) that can be used to validate schemas.
1571
+ * Meta-schemas are schemas that describe the structure of valid JSON schemas.
1572
+ * @param {JSONSchema | boolean | (JSONSchema | boolean)[]} schema - The meta-schema(s) to add
1573
+ * @param {string} [key] - Optional key/URI for the meta-schema
1574
+ * @returns {this} This validator instance for chaining (the polymorphic `this` keeps the collectErrors type parameter across a chain)
1575
+ * @example
1576
+ * validator.addMetaSchema(draft7MetaSchema, 'http://json-schema.org/draft-07/schema');
1577
+ */
1578
+ addMetaSchema(schema, key = undefined) {
1579
+ key = JarenValidator.normalizeUriKey(key)
1580
+ // A meta-schema is infrastructure, not something the caller authored:
1581
+ // every JSON Schema meta-schema declares `format: "uri-reference"` on
1582
+ // `$id`/`$ref`, and nobody registers formats in order to check that a
1583
+ // SCHEMA is well-formed. The unknownFormats guard protects an author
1584
+ // from a keyword of their own that silently checks nothing, so it does
1585
+ // not apply here — otherwise merely registering draft-07 would throw.
1586
+ const validation = JarenValidator.#withOption(this.#options.validation,
1587
+ 'unknownFormats', 'ignore');
1588
+ if (Array.isArray(schema)) {
1589
+ const first = schema.shift();
1590
+ const { origin, map } = JarenValidator.#traverseSchema(first, schema, undefined, new TraverseOptions(key));
1591
+ const compiled = JarenValidator.#compileSchema(this, origin, map, validation);
1592
+ this.#metaSchemas.set(origin, compiled);
1593
+ mergeMap(this.#schemas, map);
1594
+ }
1595
+ else if (isBoolOrObjectClass(schema)) {
1596
+ const { origin, map } = JarenValidator.#traverseSchema(schema, undefined, undefined, new TraverseOptions(key));
1597
+ const compiled = JarenValidator.#compileSchema(this, origin, map, validation);
1598
+ this.#metaSchemas.set(origin, compiled);
1599
+ mergeMap(this.#schemas, map);
1600
+ }
1601
+ return this;
1602
+ }
1603
+
1604
+ /** A ValidationOptions with one member replaced, leaving the original
1605
+ * untouched (the validator's own options must not drift). */
1606
+ static #withOption(validation, name, value) {
1607
+ const next = new ValidationOptions();
1608
+ Object.assign(next, validation ?? new ValidationOptions());
1609
+ next[name] = value;
1610
+ return next;
1611
+ }
1612
+
1613
+ /**
1614
+ * Retrieves a registered schema by its key/URI.
1615
+ * @param {string} key - The schema URI/key
1616
+ * @returns {JSONSchema | boolean | null} The registered schema, or null if not found
1617
+ */
1618
+ getSchema(key) {
1619
+ key = JarenValidator.normalizeUriKey(key)
1620
+ return this.#schemas.get(key) || null;
1621
+ }
1622
+
1623
+ /**
1624
+ * Validates a schema against a registered meta-schema.
1625
+ * This is used to ensure schemas are valid according to the JSON Schema specification.
1626
+ * @param {JSONSchema | boolean} schema - The schema to validate
1627
+ * @returns {boolean} True if the schema is valid
1628
+ * @example
1629
+ * validator.addMetaSchema(draft7MetaSchema);
1630
+ * const isValid = validator.validateSchema({ type: 'string' }); // true
1631
+ */
1632
+ validateSchema(schema) {
1633
+ // if no meta schema is present, just return true;
1634
+ if (this.#metaSchemas.size == 0)
1635
+ return true;
1636
+
1637
+ const schemaId = JarenValidator.normalizeUriKey(schema.$schema || DEFAULT_SCHEMA_DRAFT);
1638
+ const metaSchema = this.#metaSchemas.get(schemaId);
1639
+ // if the metaSchema is not present, we fail the validation
1640
+ if (!metaSchema)
1641
+ return false;
1642
+
1643
+ // otherwise, validate the schema
1644
+ return metaSchema(schema);
1645
+ }
1646
+
1647
+ /**
1648
+ * Pre-compile refs to eliminate validation-time overhead.
1649
+ * Creates ValidationObjects for all refs in the schemas map during compile time.
1650
+ * @param {ValidationRoot} root - The validation root
1651
+ * @param {Map} schemas - The schemas map
1652
+ * @param {string} origin - The origin schema ID
1653
+ */
1654
+ static #precompileRefs(root, schemas, origin) {
1655
+ // Pre-create validation objects for all refs in the schemas map
1656
+ // This moves ref resolution from validation time to compile time
1657
+
1658
+ // First pass: Create objects for schemas with $id (canonical paths)
1659
+ // These establish the base URIs for their descendants
1660
+ for (const [id, schema] of schemas.entries()) {
1661
+ // Skip if already compiled
1662
+ if (root.unresolvedObject(id) !== null) continue;
1663
+
1664
+ // Skip null placeholders
1665
+ if (schema == null) continue;
1666
+
1667
+ // Only process schemas with $id that are stored under their $id path
1668
+ // (not JSON pointer paths)
1669
+ if (!isObjectClass(schema) || !schema.$id) continue;
1670
+
1671
+ // Check if this id matches the resolved $id
1672
+ const { id: resolvedId } = createJsonPointer(schema.$id, origin);
1673
+ if (id === resolvedId || id === resolvedId + '#') {
1674
+ // This is a canonical $id path - create with origin as base
1675
+ try {
1676
+ root.createObject(id, schema, origin);
1677
+ } catch (_e) {
1678
+ // May fail if dependencies not resolved yet
1679
+ }
1680
+ }
1681
+ }
1682
+
1683
+ // Second pass: Create objects for remaining schemas
1684
+ // This includes:
1685
+ // 1. JSON pointer paths (e.g., #/definitions/x) - calculate baseUri by finding nearest ancestor $id
1686
+ // 2. Canonical $id paths with relative $ids that weren't matched in first pass
1687
+ for (const [id, schema] of schemas.entries()) {
1688
+ // Skip if already compiled
1689
+ if (root.unresolvedObject(id) !== null) continue;
1690
+
1691
+ // Skip null placeholders
1692
+ if (schema == null) continue;
1693
+
1694
+ // Skip schemas without $id that aren't refs - they're subschemas
1695
+ // that will be reached through traversal from a parent
1696
+ const hasId = isObjectClass(schema) && schema.$id;
1697
+ const isJsonPointerPath = id.includes('#/');
1698
+ const isCanonicalPath = !isJsonPointerPath && id.endsWith('#');
1699
+
1700
+ if (!hasId && !isJsonPointerPath) continue;
1701
+
1702
+ // Calculate baseUri for this schema
1703
+ let baseUri = origin;
1704
+
1705
+ if (isJsonPointerPath) {
1706
+ // JSON pointer path - traverse from root to find nearest $id ancestor
1707
+ const hashIndex = id.indexOf('#/');
1708
+ const baseDoc = id.substring(0, hashIndex);
1709
+ const pointer = id.substring(hashIndex + 1);
1710
+ const pointerParts = pointer.split('/').filter(p => p);
1711
+
1712
+ const rootId = baseDoc + '#';
1713
+ const rootSchema = schemas.get(rootId);
1714
+
1715
+ // Check if the root schema has an $id that matches the baseDoc.
1716
+ // If so, the baseDoc is already the resolved $id and we shouldn't
1717
+ // apply $id resolution during traversal (that would double-resolve).
1718
+ let rootIdMatchesBaseDoc = false;
1719
+ if (rootSchema && isObjectClass(rootSchema) && rootSchema.$id) {
1720
+ const { id: resolvedRootId } = createJsonPointer(rootSchema.$id, origin);
1721
+ const resolvedRootBase = resolvedRootId.endsWith('#') ? resolvedRootId.slice(0, -1) : resolvedRootId;
1722
+ if (baseDoc === resolvedRootBase) {
1723
+ rootIdMatchesBaseDoc = true;
1724
+ }
1725
+ }
1726
+
1727
+ let currentSchema = rootSchema;
1728
+ let currentBaseUri = baseDoc;
1729
+
1730
+ // Traverse and find the nearest $id ancestor
1731
+ for (let i = 0; i < pointerParts.length && currentSchema; i++) {
1732
+ const part = pointerParts[i];
1733
+
1734
+ // Check if current schema has $id (before moving to child)
1735
+ if (isObjectClass(currentSchema) && currentSchema.$id) {
1736
+ const isRootSchema = (i === 0);
1737
+ const shouldApplyId = !isRootSchema || !rootIdMatchesBaseDoc;
1738
+
1739
+ if (shouldApplyId) {
1740
+ const { id: resolvedId } = createJsonPointer(currentSchema.$id, currentBaseUri);
1741
+ currentBaseUri = resolvedId.endsWith('#') ? resolvedId.slice(0, -1) : resolvedId;
1742
+ }
1743
+ }
1744
+
1745
+ // Move to next level - handle both direct properties and definitions/$defs
1746
+ const nextSchema = currentSchema[part] ||
1747
+ currentSchema.$defs?.[part] ||
1748
+ currentSchema.definitions?.[part];
1749
+ currentSchema = nextSchema;
1750
+ }
1751
+
1752
+ baseUri = currentBaseUri;
1753
+ } else if (hasId && isCanonicalPath) {
1754
+ // Canonical $id path that wasn't handled in first pass
1755
+ // This happens when the $id is relative and resolves differently
1756
+ // than against the origin. We need to find the correct base URI.
1757
+
1758
+ // Find any schema in the map that has this schema as a descendant
1759
+ // and use its $id as the base
1760
+ for (const [candidateId, candidateSchema] of schemas.entries()) {
1761
+ if (!candidateSchema || candidateSchema === schema) continue;
1762
+
1763
+ // Check if candidate is an ancestor by checking if our id starts with candidate's path
1764
+ if (isJsonPointerPath && id.startsWith(candidateId.replace('#', '#/') + '/')) {
1765
+ // This is a descendant of a JSON pointer path - skip for now
1766
+ continue;
1767
+ }
1768
+
1769
+ // If candidate has an $id, it could be our base
1770
+ if (isObjectClass(candidateSchema) && candidateSchema.$id) {
1771
+ // Try resolving our $id against this candidate's resolved $id
1772
+ const { id: candidateResolvedId } = createJsonPointer(candidateSchema.$id, origin);
1773
+ const candidateBase = candidateResolvedId.endsWith('#') ? candidateResolvedId.slice(0, -1) : candidateResolvedId;
1774
+
1775
+ try {
1776
+ const { id: testResolvedId } = createJsonPointer(schema.$id, candidateBase);
1777
+ const testResolvedIdWithHash = testResolvedId.endsWith('#') ? testResolvedId : testResolvedId + '#';
1778
+
1779
+ if (id === testResolvedIdWithHash || id === testResolvedId) {
1780
+ baseUri = candidateBase;
1781
+ break;
1782
+ }
1783
+ } catch (_e) {
1784
+ // Invalid URL, skip this candidate
1785
+ }
1786
+ }
1787
+ }
1788
+ }
1789
+
1790
+ try {
1791
+ root.createObject(id, schema, baseUri);
1792
+ } catch (_e) {
1793
+ // Ref may not be resolvable yet, that's ok
1794
+ }
1795
+ }
1796
+ }
1797
+
1798
+ /**
1799
+ * Compiles a schema into a validation function.
1800
+ * This is the main method for creating validators. It resolves all $ref references,
1801
+ * compiles the schema structure, and returns a function that validates data.
1802
+ * The return type follows the instance's `collectErrors` setting: a type
1803
+ * guard over `unknown` by default, or a function producing
1804
+ * `{ valid, errors }` when errors are collected. Jaren does not infer `T`
1805
+ * from the schema — the caller asserts what the schema describes, which is
1806
+ * what a checked contract wrapper wants; pair it with a schema-to-type
1807
+ * generator if you need the shape derived mechanically.
1808
+ * @template [T=unknown]
1809
+ * @param {JSONSchema | boolean} schema - The schema to compile
1810
+ * @param {(JSONSchema | boolean)[]} [schemas] - Additional schemas to reference during compilation
1811
+ * @returns {TCollect extends true ? CompiledCollector : CompiledPredicate<T>} A validation function
1812
+ * @example
1813
+ * const validate = validator.compile({
1814
+ * type: 'object',
1815
+ * properties: {
1816
+ * name: { type: 'string' }
1817
+ * }
1818
+ * });
1819
+ *
1820
+ * const valid = validate({ name: 'John' }); // true
1821
+ * const invalid = validate({ name: 123 }); // false
1822
+ *
1823
+ * // Narrowing to a caller-asserted type
1824
+ * const isUser = validator.compile<{ name: string }>(userSchema);
1825
+ * if (isUser(input)) input.name; // input is { name: string } here
1826
+ *
1827
+ * // With error collection
1828
+ * const collecting = new JarenValidator({ collectErrors: true });
1829
+ * const result = collecting.compile(schema)({ name: 123 });
1830
+ * // result = { valid: false, errors: [...] }
1831
+ */
1832
+ compile(schema, schemas = undefined) {
1833
+ const { origin, map } = JarenValidator.#traverseSchema(schema, schemas, this.#schemas, this.#options.traverse);
1834
+
1835
+ // Detect draft version from schema and update validation options
1836
+ const draftVersion = detectSchemaDraft(schema);
1837
+ const existingValidation = this.#options.validation || new ValidationOptions();
1838
+ // For draft7, contentValidation defaults to true; for 2019-09+, defaults to false
1839
+ const contentValidationDefault = draftVersion < 2019;
1840
+
1841
+ // When the schema declares a custom metaschema via $schema, its
1842
+ // $vocabulary decides which keyword vocabularies are asserted. A
1843
+ // metaschema that omits the validation vocabulary turns keywords like
1844
+ // 'type' and 'minimum' into annotations that assert nothing.
1845
+ let vocabValidation = true;
1846
+ // In draft 2020-12 the format keyword is annotation-only unless the
1847
+ // metaschema opts into the format-assertion vocabulary (or the user
1848
+ // sets the formatAssertion option explicitly).
1849
+ let formatAssertion = existingValidation.formatAssertion ?? null;
1850
+ if (isObjectClass(schema) && isStringType(schema.$schema)) {
1851
+ const metaKey = JarenValidator.normalizeUriKey(schema.$schema);
1852
+ const metaSchema = map.get(metaKey)
1853
+ || map.get(metaKey.endsWith('#') ? metaKey.slice(0, -1) : metaKey + '#');
1854
+ if (isObjectClass(metaSchema) && isObjectClass(metaSchema.$vocabulary)) {
1855
+ vocabValidation = Object.keys(metaSchema.$vocabulary)
1856
+ .some(uri => uri.includes('/vocab/validation'));
1857
+ if (formatAssertion == null
1858
+ && Object.keys(metaSchema.$vocabulary).some(uri => uri.includes('/vocab/format-assertion'))) {
1859
+ formatAssertion = true;
1860
+ }
1861
+ }
1862
+ }
1863
+ if (formatAssertion == null) {
1864
+ formatAssertion = draftVersion < 2020;
1865
+ }
1866
+
1867
+ const validationOptions = new ValidationOptions(
1868
+ existingValidation.skipErrors ?? true,
1869
+ existingValidation.useGrapheme ?? true,
1870
+ existingValidation.collectErrors ?? false,
1871
+ existingValidation.contentValidation ?? contentValidationDefault,
1872
+ draftVersion,
1873
+ vocabValidation,
1874
+ formatAssertion,
1875
+ existingValidation.messages ?? true,
1876
+ existingValidation.unknownFormats ?? 'ignore'
1877
+ );
1878
+
1879
+ // Pre-compile all refs before returning the validator
1880
+ // This ensures all ref chains are resolved at compile time
1881
+ const root = new ValidationRoot(
1882
+ origin,
1883
+ map,
1884
+ this.#formats,
1885
+ validationOptions,
1886
+ this.#options.traverse,
1887
+ this
1888
+ );
1889
+
1890
+ // Pre-create validation objects for all refs
1891
+ JarenValidator.#precompileRefs(root, map, origin);
1892
+
1893
+ // Re-compile with the pre-populated root
1894
+ return JarenValidator.#compileSchemaWithRoot(this, origin, map, root);
1895
+ }
1896
+ }