@apidevtools/json-schema-ref-parser 15.2.2 → 15.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -14,8 +14,17 @@ function bundle(parser, options) {
14
14
  // Build an inventory of all $ref pointers in the JSON Schema
15
15
  const inventory = [];
16
16
  crawl(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
17
+ // Get the root schema's $id (if any) for qualifying refs inside sub-schemas with their own $id
18
+ const rootId = parser.schema && typeof parser.schema === "object" && "$id" in parser.schema
19
+ ? parser.schema.$id
20
+ : undefined;
17
21
  // Remap all $ref pointers
18
- remap(inventory, options);
22
+ remap(inventory, options, rootId);
23
+ // Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
24
+ const bundleOptions = (options.bundle || {});
25
+ if (bundleOptions.optimizeInternalRefs !== false) {
26
+ fixRefsThroughRefs(inventory, parser.schema);
27
+ }
19
28
  }
20
29
  /**
21
30
  * Recursively crawls the given value, and inventories all JSON references.
@@ -155,7 +164,7 @@ function inventory$Ref($refParent, $refKey, path, pathFromRoot, indirections, in
155
164
  *
156
165
  * @param inventory
157
166
  */
158
- function remap(inventory, options) {
167
+ function remap(inventory, options, rootId) {
159
168
  // Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
160
169
  inventory.sort((a, b) => {
161
170
  if (a.file !== b.file) {
@@ -200,17 +209,35 @@ function remap(inventory, options) {
200
209
  let file, hash, pathFromRoot;
201
210
  for (const entry of inventory) {
202
211
  // console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
212
+ const bundleOpts = (options.bundle || {});
203
213
  if (!entry.external) {
204
- // This $ref already resolves to the main JSON Schema file
205
- entry.$ref.$ref = entry.hash;
214
+ // This $ref already resolves to the main JSON Schema file.
215
+ // When optimizeInternalRefs is false, preserve the original internal ref path
216
+ // instead of rewriting it to the fully resolved hash.
217
+ if (bundleOpts.optimizeInternalRefs !== false) {
218
+ entry.$ref.$ref = entry.hash;
219
+ }
206
220
  }
207
221
  else if (entry.file === file && entry.hash === hash) {
208
222
  // This $ref points to the same value as the previous $ref, so remap it to the same path
209
- entry.$ref.$ref = pathFromRoot;
223
+ if (rootId && isInsideIdScope(inventory, entry)) {
224
+ // This entry is inside a sub-schema with its own $id, so a bare root-relative JSON Pointer
225
+ // would be resolved relative to that $id, not the document root. Qualify with the root $id.
226
+ entry.$ref.$ref = rootId + pathFromRoot;
227
+ }
228
+ else {
229
+ entry.$ref.$ref = pathFromRoot;
230
+ }
210
231
  }
211
232
  else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
212
233
  // This $ref points to a sub-value of the previous $ref, so remap it beneath that path
213
- entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
234
+ const subPath = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
235
+ if (rootId && isInsideIdScope(inventory, entry)) {
236
+ entry.$ref.$ref = rootId + subPath;
237
+ }
238
+ else {
239
+ entry.$ref.$ref = subPath;
240
+ }
214
241
  }
215
242
  else {
216
243
  // We've moved to a new file or new hash
@@ -261,4 +288,101 @@ function removeFromInventory(inventory, entry) {
261
288
  const index = inventory.indexOf(entry);
262
289
  inventory.splice(index, 1);
263
290
  }
291
+ /**
292
+ * After remapping, some $ref paths may traverse through other $ref nodes.
293
+ * JSON pointer resolution does not follow $ref indirection, so these paths are invalid.
294
+ * This function detects and fixes such paths by following any intermediate $refs
295
+ * to compute a valid direct path.
296
+ */
297
+ function fixRefsThroughRefs(inventory, schema) {
298
+ for (const entry of inventory) {
299
+ if (!entry.$ref || typeof entry.$ref !== "object" || !("$ref" in entry.$ref)) {
300
+ continue;
301
+ }
302
+ const refValue = entry.$ref.$ref;
303
+ if (typeof refValue !== "string" || !refValue.startsWith("#/")) {
304
+ continue;
305
+ }
306
+ const fixedPath = resolvePathThroughRefs(schema, refValue);
307
+ if (fixedPath !== refValue) {
308
+ entry.$ref.$ref = fixedPath;
309
+ }
310
+ }
311
+ }
312
+ /**
313
+ * Walks a JSON pointer path through the schema. If any intermediate value
314
+ * is a $ref, follows it and adjusts the path accordingly.
315
+ * Returns the corrected path that doesn't traverse through any $ref.
316
+ */
317
+ function resolvePathThroughRefs(schema, refPath) {
318
+ if (!refPath.startsWith("#/")) {
319
+ return refPath;
320
+ }
321
+ const segments = refPath.slice(2).split("/");
322
+ let current = schema;
323
+ const resolvedSegments = [];
324
+ for (const seg of segments) {
325
+ if (current === null || current === undefined || typeof current !== "object") {
326
+ // Can't walk further, return original path
327
+ return refPath;
328
+ }
329
+ // If the current value is a $ref, follow it
330
+ if ("$ref" in current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
331
+ // Follow the $ref and restart the path from its target
332
+ const targetSegments = current.$ref.slice(2).split("/");
333
+ resolvedSegments.length = 0;
334
+ resolvedSegments.push(...targetSegments);
335
+ current = walkPath(schema, current.$ref);
336
+ if (current === null || current === undefined || typeof current !== "object") {
337
+ return refPath;
338
+ }
339
+ }
340
+ const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
341
+ const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
342
+ current = current[idx];
343
+ resolvedSegments.push(seg);
344
+ }
345
+ const result = "#/" + resolvedSegments.join("/");
346
+ return result;
347
+ }
348
+ /**
349
+ * Walks a JSON pointer path through a schema object, returning the value at that path.
350
+ */
351
+ function walkPath(schema, path) {
352
+ if (!path.startsWith("#/")) {
353
+ return undefined;
354
+ }
355
+ const segments = path.slice(2).split("/");
356
+ let current = schema;
357
+ for (const seg of segments) {
358
+ if (current === null || current === undefined || typeof current !== "object") {
359
+ return undefined;
360
+ }
361
+ const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
362
+ const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
363
+ current = current[idx];
364
+ }
365
+ return current;
366
+ }
367
+ /**
368
+ * Checks whether the given inventory entry is located inside a sub-schema that has its own $id.
369
+ * If so, root-relative JSON Pointer $refs placed at this location would be resolved against
370
+ * the $id base URI rather than the document root, making them invalid.
371
+ */
372
+ function isInsideIdScope(inventory, entry) {
373
+ for (const other of inventory) {
374
+ // Skip root-level entries
375
+ if (other.pathFromRoot === "#" || other.pathFromRoot === "#/") {
376
+ continue;
377
+ }
378
+ // Check if the other entry is an ancestor of the current entry
379
+ if (entry.pathFromRoot.startsWith(other.pathFromRoot + "/")) {
380
+ // Check if the ancestor's resolved value has a $id
381
+ if (other.value && typeof other.value === "object" && "$id" in other.value) {
382
+ return true;
383
+ }
384
+ }
385
+ }
386
+ return false;
387
+ }
264
388
  export default bundle;
@@ -13,7 +13,7 @@ export default dereference;
13
13
  function dereference(parser, options) {
14
14
  const start = Date.now();
15
15
  // console.log('Dereferencing $ref pointers in %s', parser.$refs._root$Ref.path);
16
- const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, "#", new Set(), new Set(), new Map(), parser.$refs, options, start);
16
+ const dereferenced = crawl(parser.schema, parser.$refs._root$Ref.path, "#", new Set(), new Set(), new Map(), parser.$refs, options, start, 0);
17
17
  parser.$refs.circular = dereferenced.circular;
18
18
  parser.schema = dereferenced.value;
19
19
  }
@@ -29,9 +29,10 @@ function dereference(parser, options) {
29
29
  * @param $refs
30
30
  * @param options
31
31
  * @param startTime - The time when the dereferencing started
32
+ * @param depth - The current recursion depth
32
33
  * @returns
33
34
  */
34
- function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime) {
35
+ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth) {
35
36
  let dereferenced;
36
37
  const result = {
37
38
  value: obj,
@@ -39,13 +40,19 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
39
40
  };
40
41
  checkDereferenceTimeout(startTime, options);
41
42
  const derefOptions = (options.dereference || {});
43
+ const maxDepth = derefOptions.maxDepth ?? 500;
44
+ if (depth > maxDepth) {
45
+ throw new RangeError(`Maximum dereference depth (${maxDepth}) exceeded at ${pathFromRoot}. ` +
46
+ `This likely indicates an extremely deep or recursive schema. ` +
47
+ `You can increase this limit with the dereference.maxDepth option.`);
48
+ }
42
49
  const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);
43
50
  if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) {
44
51
  if (obj && typeof obj === "object" && !ArrayBuffer.isView(obj) && !isExcludedPath(pathFromRoot)) {
45
52
  parents.add(obj);
46
53
  processedObjects.add(obj);
47
54
  if ($Ref.isAllowed$Ref(obj, options)) {
48
- dereferenced = dereference$Ref(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
55
+ dereferenced = dereference$Ref(obj, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
49
56
  result.circular = dereferenced.circular;
50
57
  result.value = dereferenced.value;
51
58
  }
@@ -58,9 +65,9 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
58
65
  continue;
59
66
  }
60
67
  const value = obj[key];
61
- let circular = false;
68
+ let circular;
62
69
  if ($Ref.isAllowed$Ref(value, options)) {
63
- dereferenced = dereference$Ref(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
70
+ dereferenced = dereference$Ref(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth);
64
71
  circular = dereferenced.circular;
65
72
  // Avoid pointless mutations; breaks frozen objects to no profit
66
73
  if (obj[key] !== dereferenced.value) {
@@ -76,7 +83,13 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
76
83
  });
77
84
  }
78
85
  }
79
- obj[key] = dereferenced.value;
86
+ // Clone the dereferenced value if cloneReferences is enabled and this is not a
87
+ // circular reference. This prevents mutations to one location from affecting others.
88
+ let assignedValue = dereferenced.value;
89
+ if (derefOptions?.cloneReferences && !circular && assignedValue && typeof assignedValue === "object") {
90
+ assignedValue = structuredClone(assignedValue);
91
+ }
92
+ obj[key] = assignedValue;
80
93
  // If we have data to preserve and our dereferenced object is still an object then
81
94
  // we need copy back our preserved data into our dereferenced schema.
82
95
  if (derefOptions?.preservedProperties) {
@@ -91,7 +104,7 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
91
104
  }
92
105
  else {
93
106
  if (!parents.has(value)) {
94
- dereferenced = crawl(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
107
+ dereferenced = crawl(value, keyPath, keyPathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
95
108
  circular = dereferenced.circular;
96
109
  // Avoid pointless mutations; breaks frozen objects to no profit
97
110
  if (obj[key] !== dereferenced.value) {
@@ -124,7 +137,7 @@ function crawl(obj, path, pathFromRoot, parents, processedObjects, dereferencedC
124
137
  * @param options
125
138
  * @returns
126
139
  */
127
- function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime) {
140
+ function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth) {
128
141
  const isExternalRef = $Ref.isExternal$Ref($ref);
129
142
  const shouldResolveOnCwd = isExternalRef && options?.dereference?.externalReferenceResolution === "root";
130
143
  const $refPath = url.resolve(shouldResolveOnCwd ? url.cwd() : path, $ref.$ref);
@@ -197,7 +210,7 @@ function dereference$Ref($ref, path, pathFromRoot, parents, processedObjects, de
197
210
  // Crawl the dereferenced value (unless it's circular)
198
211
  if (!circular) {
199
212
  // Determine if the dereferenced value is circular
200
- const dereferenced = crawl(dereferencedValue, pointer.path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime);
213
+ const dereferenced = crawl(dereferencedValue, pointer.path, pathFromRoot, parents, processedObjects, dereferencedCache, $refs, options, startTime, depth + 1);
201
214
  circular = dereferenced.circular;
202
215
  dereferencedValue = dereferenced.value;
203
216
  }
@@ -18,6 +18,14 @@ export interface BundleOptions {
18
18
  * @argument {string} parentPropName - The prop name of the parent object whose value was processed
19
19
  */
20
20
  onBundle?(path: string, value: JSONSchemaObject, parent?: JSONSchemaObject, parentPropName?: string): void;
21
+ /**
22
+ * Whether to optimize internal `$ref` paths by following intermediate `$ref` chains and
23
+ * rewriting them to point directly to the final target. When `false`, intermediate `$ref`
24
+ * indirections are preserved as-is.
25
+ *
26
+ * Default: `true`
27
+ */
28
+ optimizeInternalRefs?: boolean;
21
29
  }
22
30
  export interface DereferenceOptions {
23
31
  /**
@@ -70,6 +78,28 @@ export interface DereferenceOptions {
70
78
  * Default: `true`
71
79
  */
72
80
  mergeKeys?: boolean;
81
+ /**
82
+ * The maximum recursion depth for dereferencing nested schemas.
83
+ * If the schema nesting exceeds this depth, a RangeError will be thrown
84
+ * with a descriptive message instead of crashing with a stack overflow.
85
+ *
86
+ * Default: 500
87
+ */
88
+ maxDepth?: number;
89
+ /**
90
+ * Whether to create independent clones of each `$ref` target value instead of
91
+ * reusing the same JS object reference. When `false` (the default), multiple
92
+ * `$ref` pointers that resolve to the same value will all share the same object
93
+ * in memory, so modifying one will affect all others. When `true`, each `$ref`
94
+ * replacement gets its own deep copy, preventing unintended side effects from
95
+ * post-dereference mutations.
96
+ *
97
+ * Note: circular references are never cloned — they always maintain reference
98
+ * equality to correctly represent the circular structure.
99
+ *
100
+ * Default: `false`
101
+ */
102
+ cloneReferences?: boolean;
73
103
  }
74
104
  /**
75
105
  * Options that determine how JSON schemas are parsed, resolved, and dereferenced.
package/dist/lib/parse.js CHANGED
@@ -67,7 +67,7 @@ async function readFile(file, options, $refs) {
67
67
  }
68
68
  else if (!err || !("error" in err)) {
69
69
  // Throw a generic, friendly error.
70
- throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`);
70
+ throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`, { cause: err });
71
71
  }
72
72
  // Throw the original error, if it's one of our own (user-friendly) errors.
73
73
  else if (err.error instanceof ResolverError) {
@@ -118,7 +118,7 @@ async function parseFile(file, options, $refs) {
118
118
  throw err;
119
119
  }
120
120
  else if (!err || !("error" in err)) {
121
- throw new SyntaxError(`Unable to parse ${file.url}`);
121
+ throw new SyntaxError(`Unable to parse ${file.url}`, { cause: err });
122
122
  }
123
123
  else if (err.error instanceof ParserError) {
124
124
  throw err.error;
@@ -69,10 +69,24 @@ class Pointer {
69
69
  // Crawl the object, one token at a time
70
70
  this.value = unwrapOrThrow(obj);
71
71
  for (let i = 0; i < tokens.length; i++) {
72
+ // During token walking, if the current value is an extended $ref (has sibling keys
73
+ // alongside $ref, as allowed by JSON Schema 2019-09+), and resolveIf$Ref marks it
74
+ // as circular because the $ref resolves to the same path we're walking, we should
75
+ // reset the circular flag and continue walking the object's own properties.
76
+ // This prevents false circular detection when e.g. a root schema has both
77
+ // $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
78
+ const wasCircular = this.circular;
79
+ const isExtendedRef = $Ref.isExtended$Ref(this.value);
72
80
  if (resolveIf$Ref(this, options, pathFromRoot)) {
73
81
  // The $ref path has changed, so append the remaining tokens to the path
74
82
  this.path = Pointer.join(this.path, tokens.slice(i));
75
83
  }
84
+ else if (!wasCircular && this.circular && isExtendedRef) {
85
+ // resolveIf$Ref set circular=true on an extended $ref during token walking.
86
+ // Since we still have tokens to process, the object should be walked by its
87
+ // properties, not treated as a circular self-reference.
88
+ this.circular = false;
89
+ }
76
90
  const token = tokens[i];
77
91
  if (this.value[token] === undefined || (this.value[token] === null && i === tokens.length - 1)) {
78
92
  // one final case is if the entry itself includes slashes, and was parsed out as a token - we can join the remaining tokens and try again
package/lib/bundle.ts CHANGED
@@ -39,8 +39,20 @@ function bundle<S extends object = JSONSchema, O extends ParserOptions<S> = Pars
39
39
  const inventory: InventoryEntry[] = [];
40
40
  crawl<S, O>(parser, "schema", parser.$refs._root$Ref.path + "#", "#", 0, inventory, parser.$refs, options);
41
41
 
42
+ // Get the root schema's $id (if any) for qualifying refs inside sub-schemas with their own $id
43
+ const rootId =
44
+ parser.schema && typeof parser.schema === "object" && "$id" in (parser.schema as any)
45
+ ? (parser.schema as any).$id
46
+ : undefined;
47
+
42
48
  // Remap all $ref pointers
43
- remap<S, O>(inventory, options);
49
+ remap<S, O>(inventory, options, rootId);
50
+
51
+ // Fix any $ref paths that traverse through other $refs (which is invalid per JSON Schema spec)
52
+ const bundleOptions = (options.bundle || {}) as BundleOptions;
53
+ if (bundleOptions.optimizeInternalRefs !== false) {
54
+ fixRefsThroughRefs(inventory, parser.schema as any);
55
+ }
44
56
  }
45
57
 
46
58
  /**
@@ -206,6 +218,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
206
218
  function remap<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
207
219
  inventory: InventoryEntry[],
208
220
  options: O,
221
+ rootId?: string,
209
222
  ) {
210
223
  // Group & sort all the $ref pointers, so they're in the order that we need to dereference/remap them
211
224
  inventory.sort((a: InventoryEntry, b: InventoryEntry) => {
@@ -253,15 +266,31 @@ function remap<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
253
266
  for (const entry of inventory) {
254
267
  // console.log('Re-mapping $ref pointer "%s" at %s', entry.$ref.$ref, entry.pathFromRoot);
255
268
 
269
+ const bundleOpts = (options.bundle || {}) as BundleOptions;
256
270
  if (!entry.external) {
257
- // This $ref already resolves to the main JSON Schema file
258
- entry.$ref.$ref = entry.hash;
271
+ // This $ref already resolves to the main JSON Schema file.
272
+ // When optimizeInternalRefs is false, preserve the original internal ref path
273
+ // instead of rewriting it to the fully resolved hash.
274
+ if (bundleOpts.optimizeInternalRefs !== false) {
275
+ entry.$ref.$ref = entry.hash;
276
+ }
259
277
  } else if (entry.file === file && entry.hash === hash) {
260
278
  // This $ref points to the same value as the previous $ref, so remap it to the same path
261
- entry.$ref.$ref = pathFromRoot;
279
+ if (rootId && isInsideIdScope(inventory, entry)) {
280
+ // This entry is inside a sub-schema with its own $id, so a bare root-relative JSON Pointer
281
+ // would be resolved relative to that $id, not the document root. Qualify with the root $id.
282
+ entry.$ref.$ref = rootId + pathFromRoot;
283
+ } else {
284
+ entry.$ref.$ref = pathFromRoot;
285
+ }
262
286
  } else if (entry.file === file && entry.hash.indexOf(hash + "/") === 0) {
263
287
  // This $ref points to a sub-value of the previous $ref, so remap it beneath that path
264
- entry.$ref.$ref = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
288
+ const subPath = Pointer.join(pathFromRoot, Pointer.parse(entry.hash.replace(hash, "#")));
289
+ if (rootId && isInsideIdScope(inventory, entry)) {
290
+ entry.$ref.$ref = rootId + subPath;
291
+ } else {
292
+ entry.$ref.$ref = subPath;
293
+ }
265
294
  } else {
266
295
  // We've moved to a new file or new hash
267
296
  file = entry.file;
@@ -316,4 +345,116 @@ function removeFromInventory(inventory: InventoryEntry[], entry: any) {
316
345
  const index = inventory.indexOf(entry);
317
346
  inventory.splice(index, 1);
318
347
  }
348
+
349
+ /**
350
+ * After remapping, some $ref paths may traverse through other $ref nodes.
351
+ * JSON pointer resolution does not follow $ref indirection, so these paths are invalid.
352
+ * This function detects and fixes such paths by following any intermediate $refs
353
+ * to compute a valid direct path.
354
+ */
355
+ function fixRefsThroughRefs(inventory: InventoryEntry[], schema: any) {
356
+ for (const entry of inventory) {
357
+ if (!entry.$ref || typeof entry.$ref !== "object" || !("$ref" in entry.$ref)) {
358
+ continue;
359
+ }
360
+
361
+ const refValue = entry.$ref.$ref;
362
+ if (typeof refValue !== "string" || !refValue.startsWith("#/")) {
363
+ continue;
364
+ }
365
+
366
+ const fixedPath = resolvePathThroughRefs(schema, refValue);
367
+ if (fixedPath !== refValue) {
368
+ entry.$ref.$ref = fixedPath;
369
+ }
370
+ }
371
+ }
372
+
373
+ /**
374
+ * Walks a JSON pointer path through the schema. If any intermediate value
375
+ * is a $ref, follows it and adjusts the path accordingly.
376
+ * Returns the corrected path that doesn't traverse through any $ref.
377
+ */
378
+ function resolvePathThroughRefs(schema: any, refPath: string): string {
379
+ if (!refPath.startsWith("#/")) {
380
+ return refPath;
381
+ }
382
+
383
+ const segments = refPath.slice(2).split("/");
384
+ let current = schema;
385
+ const resolvedSegments: string[] = [];
386
+
387
+ for (const seg of segments) {
388
+ if (current === null || current === undefined || typeof current !== "object") {
389
+ // Can't walk further, return original path
390
+ return refPath;
391
+ }
392
+
393
+ // If the current value is a $ref, follow it
394
+ if ("$ref" in current && typeof current.$ref === "string" && current.$ref.startsWith("#/")) {
395
+ // Follow the $ref and restart the path from its target
396
+ const targetSegments = current.$ref.slice(2).split("/");
397
+ resolvedSegments.length = 0;
398
+ resolvedSegments.push(...targetSegments);
399
+ current = walkPath(schema, current.$ref);
400
+ if (current === null || current === undefined || typeof current !== "object") {
401
+ return refPath;
402
+ }
403
+ }
404
+
405
+ const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
406
+ const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
407
+ current = current[idx];
408
+ resolvedSegments.push(seg);
409
+ }
410
+
411
+ const result = "#/" + resolvedSegments.join("/");
412
+ return result;
413
+ }
414
+
415
+ /**
416
+ * Walks a JSON pointer path through a schema object, returning the value at that path.
417
+ */
418
+ function walkPath(schema: any, path: string): any {
419
+ if (!path.startsWith("#/")) {
420
+ return undefined;
421
+ }
422
+
423
+ const segments = path.slice(2).split("/");
424
+ let current = schema;
425
+
426
+ for (const seg of segments) {
427
+ if (current === null || current === undefined || typeof current !== "object") {
428
+ return undefined;
429
+ }
430
+ const decoded = seg.replace(/~1/g, "/").replace(/~0/g, "~");
431
+ const idx = Array.isArray(current) ? parseInt(decoded) : decoded;
432
+ current = current[idx];
433
+ }
434
+
435
+ return current;
436
+ }
437
+
438
+ /**
439
+ * Checks whether the given inventory entry is located inside a sub-schema that has its own $id.
440
+ * If so, root-relative JSON Pointer $refs placed at this location would be resolved against
441
+ * the $id base URI rather than the document root, making them invalid.
442
+ */
443
+ function isInsideIdScope(inventory: InventoryEntry[], entry: InventoryEntry): boolean {
444
+ for (const other of inventory) {
445
+ // Skip root-level entries
446
+ if (other.pathFromRoot === "#" || other.pathFromRoot === "#/") {
447
+ continue;
448
+ }
449
+ // Check if the other entry is an ancestor of the current entry
450
+ if (entry.pathFromRoot.startsWith(other.pathFromRoot + "/")) {
451
+ // Check if the ancestor's resolved value has a $id
452
+ if (other.value && typeof other.value === "object" && "$id" in other.value) {
453
+ return true;
454
+ }
455
+ }
456
+ }
457
+ return false;
458
+ }
459
+
319
460
  export default bundle;
@@ -31,6 +31,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
31
31
  parser.$refs,
32
32
  options,
33
33
  start,
34
+ 0,
34
35
  );
35
36
  parser.$refs.circular = dereferenced.circular;
36
37
  parser.schema = dereferenced.value;
@@ -48,6 +49,7 @@ function dereference<S extends object = JSONSchema, O extends ParserOptions<S> =
48
49
  * @param $refs
49
50
  * @param options
50
51
  * @param startTime - The time when the dereferencing started
52
+ * @param depth - The current recursion depth
51
53
  * @returns
52
54
  */
53
55
  function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
@@ -60,6 +62,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
60
62
  $refs: $Refs<S, O>,
61
63
  options: O,
62
64
  startTime: number,
65
+ depth: number,
63
66
  ) {
64
67
  let dereferenced;
65
68
  const result = {
@@ -70,6 +73,14 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
70
73
  checkDereferenceTimeout<S, O>(startTime, options);
71
74
 
72
75
  const derefOptions = (options.dereference || {}) as DereferenceOptions;
76
+ const maxDepth = derefOptions.maxDepth ?? 500;
77
+ if (depth > maxDepth) {
78
+ throw new RangeError(
79
+ `Maximum dereference depth (${maxDepth}) exceeded at ${pathFromRoot}. ` +
80
+ `This likely indicates an extremely deep or recursive schema. ` +
81
+ `You can increase this limit with the dereference.maxDepth option.`,
82
+ );
83
+ }
73
84
  const isExcludedPath = derefOptions.excludedPathMatcher || (() => false);
74
85
 
75
86
  if (derefOptions?.circular === "ignore" || !processedObjects.has(obj)) {
@@ -88,6 +99,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
88
99
  $refs,
89
100
  options,
90
101
  startTime,
102
+ depth,
91
103
  );
92
104
  result.circular = dereferenced.circular;
93
105
  result.value = dereferenced.value;
@@ -103,7 +115,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
103
115
  }
104
116
 
105
117
  const value = obj[key];
106
- let circular = false;
118
+ let circular;
107
119
 
108
120
  if ($Ref.isAllowed$Ref(value, options)) {
109
121
  dereferenced = dereference$Ref(
@@ -116,6 +128,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
116
128
  $refs,
117
129
  options,
118
130
  startTime,
131
+ depth,
119
132
  );
120
133
  circular = dereferenced.circular;
121
134
  // Avoid pointless mutations; breaks frozen objects to no profit
@@ -133,7 +146,14 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
133
146
  }
134
147
  }
135
148
 
136
- obj[key] = dereferenced.value;
149
+ // Clone the dereferenced value if cloneReferences is enabled and this is not a
150
+ // circular reference. This prevents mutations to one location from affecting others.
151
+ let assignedValue = dereferenced.value;
152
+ if (derefOptions?.cloneReferences && !circular && assignedValue && typeof assignedValue === "object") {
153
+ assignedValue = structuredClone(assignedValue);
154
+ }
155
+
156
+ obj[key] = assignedValue;
137
157
 
138
158
  // If we have data to preserve and our dereferenced object is still an object then
139
159
  // we need copy back our preserved data into our dereferenced schema.
@@ -159,6 +179,7 @@ function crawl<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
159
179
  $refs,
160
180
  options,
161
181
  startTime,
182
+ depth + 1,
162
183
  );
163
184
  circular = dereferenced.circular;
164
185
  // Avoid pointless mutations; breaks frozen objects to no profit
@@ -205,6 +226,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
205
226
  $refs: $Refs<S, O>,
206
227
  options: O,
207
228
  startTime: number,
229
+ depth: number,
208
230
  ) {
209
231
  const isExternalRef = $Ref.isExternal$Ref($ref);
210
232
  const shouldResolveOnCwd = isExternalRef && options?.dereference?.externalReferenceResolution === "root";
@@ -295,6 +317,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
295
317
  $refs,
296
318
  options,
297
319
  startTime,
320
+ depth + 1,
298
321
  );
299
322
  circular = dereferenced.circular;
300
323
  dereferencedValue = dereferenced.value;
package/lib/options.ts CHANGED
@@ -30,6 +30,15 @@ export interface BundleOptions {
30
30
  * @argument {string} parentPropName - The prop name of the parent object whose value was processed
31
31
  */
32
32
  onBundle?(path: string, value: JSONSchemaObject, parent?: JSONSchemaObject, parentPropName?: string): void;
33
+
34
+ /**
35
+ * Whether to optimize internal `$ref` paths by following intermediate `$ref` chains and
36
+ * rewriting them to point directly to the final target. When `false`, intermediate `$ref`
37
+ * indirections are preserved as-is.
38
+ *
39
+ * Default: `true`
40
+ */
41
+ optimizeInternalRefs?: boolean;
33
42
  }
34
43
 
35
44
  export interface DereferenceOptions {
@@ -89,6 +98,30 @@ export interface DereferenceOptions {
89
98
  * Default: `true`
90
99
  */
91
100
  mergeKeys?: boolean;
101
+
102
+ /**
103
+ * The maximum recursion depth for dereferencing nested schemas.
104
+ * If the schema nesting exceeds this depth, a RangeError will be thrown
105
+ * with a descriptive message instead of crashing with a stack overflow.
106
+ *
107
+ * Default: 500
108
+ */
109
+ maxDepth?: number;
110
+
111
+ /**
112
+ * Whether to create independent clones of each `$ref` target value instead of
113
+ * reusing the same JS object reference. When `false` (the default), multiple
114
+ * `$ref` pointers that resolve to the same value will all share the same object
115
+ * in memory, so modifying one will affect all others. When `true`, each `$ref`
116
+ * replacement gets its own deep copy, preventing unintended side effects from
117
+ * post-dereference mutations.
118
+ *
119
+ * Note: circular references are never cloned — they always maintain reference
120
+ * equality to correctly represent the circular structure.
121
+ *
122
+ * Default: `false`
123
+ */
124
+ cloneReferences?: boolean;
92
125
  }
93
126
 
94
127
  /**
package/lib/parse.ts CHANGED
@@ -91,7 +91,7 @@ async function readFile<S extends object = JSONSchema, O extends ParserOptions<S
91
91
  throw new UnmatchedResolverError(file.url);
92
92
  } else if (!err || !("error" in err)) {
93
93
  // Throw a generic, friendly error.
94
- throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`);
94
+ throw new SyntaxError(`Unable to resolve $ref pointer "${file.url}"`, { cause: err });
95
95
  }
96
96
  // Throw the original error, if it's one of our own (user-friendly) errors.
97
97
  else if (err.error instanceof ResolverError) {
@@ -143,7 +143,7 @@ async function parseFile<S extends object = JSONSchema, O extends ParserOptions<
143
143
  } else if (err && err.message && err.message.startsWith("Error parsing")) {
144
144
  throw err;
145
145
  } else if (!err || !("error" in err)) {
146
- throw new SyntaxError(`Unable to parse ${file.url}`);
146
+ throw new SyntaxError(`Unable to parse ${file.url}`, { cause: err });
147
147
  } else if (err.error instanceof ParserError) {
148
148
  throw err.error;
149
149
  } else {
package/lib/pointer.ts CHANGED
@@ -89,9 +89,22 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
89
89
  this.value = unwrapOrThrow(obj);
90
90
 
91
91
  for (let i = 0; i < tokens.length; i++) {
92
+ // During token walking, if the current value is an extended $ref (has sibling keys
93
+ // alongside $ref, as allowed by JSON Schema 2019-09+), and resolveIf$Ref marks it
94
+ // as circular because the $ref resolves to the same path we're walking, we should
95
+ // reset the circular flag and continue walking the object's own properties.
96
+ // This prevents false circular detection when e.g. a root schema has both
97
+ // $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
98
+ const wasCircular = this.circular;
99
+ const isExtendedRef = $Ref.isExtended$Ref(this.value);
92
100
  if (resolveIf$Ref(this, options, pathFromRoot)) {
93
101
  // The $ref path has changed, so append the remaining tokens to the path
94
102
  this.path = Pointer.join(this.path, tokens.slice(i));
103
+ } else if (!wasCircular && this.circular && isExtendedRef) {
104
+ // resolveIf$Ref set circular=true on an extended $ref during token walking.
105
+ // Since we still have tokens to process, the object should be walked by its
106
+ // properties, not treated as a circular self-reference.
107
+ this.circular = false;
95
108
  }
96
109
 
97
110
  const token = tokens[i];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apidevtools/json-schema-ref-parser",
3
- "version": "15.2.2",
3
+ "version": "15.3.1",
4
4
  "description": "Parse, Resolve, and Dereference JSON Schema $ref pointers",
5
5
  "type": "module",
6
6
  "types": "dist/lib/index.d.ts",
@@ -75,28 +75,28 @@
75
75
  "js-yaml": "^4.1.1"
76
76
  },
77
77
  "devDependencies": {
78
- "@eslint/compat": "^2.0.1",
79
- "@eslint/js": "^9.39.2",
78
+ "@eslint/compat": "^2.0.2",
79
+ "@eslint/js": "^10.0.1",
80
80
  "@types/eslint": "^9.6.1",
81
81
  "@types/js-yaml": "^4.0.9",
82
82
  "@types/json-schema": "^7.0.15",
83
83
  "@types/node": "^25",
84
- "@typescript-eslint/eslint-plugin": "^8.53.1",
85
- "@typescript-eslint/parser": "^8.53.1",
84
+ "@typescript-eslint/eslint-plugin": "^8.56.1",
85
+ "@typescript-eslint/parser": "^8.56.1",
86
86
  "@vitest/coverage-v8": "^4.0.18",
87
87
  "cross-env": "^10.1.0",
88
- "eslint": "^9.39.2",
88
+ "eslint": "^10.0.2",
89
89
  "eslint-config-prettier": "^10.1.8",
90
90
  "eslint-plugin-import": "^2.32.0",
91
91
  "eslint-plugin-prettier": "^5.5.5",
92
92
  "eslint-plugin-promise": "^7.2.1",
93
- "eslint-plugin-unused-imports": "^4.3.0",
94
- "globals": "^17.1.0",
95
- "jsdom": "^27.4.0",
93
+ "eslint-plugin-unused-imports": "^4.4.1",
94
+ "globals": "^17.3.0",
95
+ "jsdom": "^28.1.0",
96
96
  "prettier": "^3.8.1",
97
- "rimraf": "^6.1.2",
97
+ "rimraf": "^6.1.3",
98
98
  "typescript": "^5.9.3",
99
- "typescript-eslint": "^8.53.1",
99
+ "typescript-eslint": "^8.56.1",
100
100
  "vitest": "^4.0.18"
101
101
  },
102
102
  "release": {