@apidevtools/json-schema-ref-parser 15.3.6 → 15.5.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.
@@ -13,6 +13,7 @@ export interface InventoryEntry {
13
13
  circular: any;
14
14
  extended: any;
15
15
  external: any;
16
+ nestedResource: boolean;
16
17
  indirections: any;
17
18
  }
18
19
  /**
@@ -119,6 +119,7 @@ function inventory$Ref($refParent, $refKey, path, scopeBase, dynamicIdScope, pat
119
119
  const file = url.stripHash(pointer.path);
120
120
  const hash = url.getHash(pointer.path);
121
121
  const external = file !== $refs._root$Ref.path && !$refs._aliases[file];
122
+ const nestedResource = Boolean($refs._aliases[file]) && pointer.$ref.value !== $refs._root$Ref.value;
122
123
  const extended = $Ref.isExtended$Ref($ref);
123
124
  indirections += pointer.indirections;
124
125
  const existingEntry = findInInventory(inventory, $refParent, $refKey);
@@ -143,6 +144,7 @@ function inventory$Ref($refParent, $refKey, path, scopeBase, dynamicIdScope, pat
143
144
  circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
144
145
  extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref")
145
146
  external, // Does this $ref pointer point to a file other than the main JSON Schema file?
147
+ nestedResource, // Does this $ref resolve to an embedded schema resource with its own $id?
146
148
  indirections, // The number of indirect references that were traversed to resolve the value
147
149
  });
148
150
  // Recursively crawl the resolved value
@@ -221,8 +223,10 @@ function remap(inventory, options, rootId) {
221
223
  if (!entry.external) {
222
224
  // This $ref already resolves to the main JSON Schema file.
223
225
  // When optimizeInternalRefs is false, preserve the original internal ref path
224
- // instead of rewriting it to the fully resolved hash.
225
- if (bundleOpts.optimizeInternalRefs !== false) {
226
+ // instead of rewriting it to the fully resolved hash. References to nested
227
+ // resources must also retain their resource URI so that "#" does not point
228
+ // at the document root instead.
229
+ if (bundleOpts.optimizeInternalRefs !== false && !entry.nestedResource) {
226
230
  entry.$ref.$ref = entry.hash;
227
231
  }
228
232
  }
@@ -210,7 +210,7 @@ function dereference$Ref($ref, path, scopeBase, dynamicIdScope, pathFromRoot, pa
210
210
  }
211
211
  // Check for circular references
212
212
  const directCircular = pointer.circular;
213
- let circular = directCircular || parents.has(pointer.value);
213
+ let circular = directCircular || pointer.chainCircular || parents.has(pointer.value);
214
214
  if (circular) {
215
215
  foundCircularReference(path, $refs, options);
216
216
  }
@@ -227,7 +227,7 @@ function dereference$Ref($ref, path, scopeBase, dynamicIdScope, pathFromRoot, pa
227
227
  // The user has chosen to "ignore" circular references, so don't change the value
228
228
  dereferencedValue = $ref;
229
229
  }
230
- if (directCircular) {
230
+ if (directCircular && dereferencedValue !== $ref) {
231
231
  // The pointer is a DIRECT circular reference (i.e. it references itself).
232
232
  // So replace the $ref path with the absolute path from the JSON Schema root
233
233
  dereferencedValue.$ref = pathFromRoot;
@@ -82,6 +82,7 @@ export interface DereferenceOptions {
82
82
  * The maximum recursion depth for dereferencing nested schemas.
83
83
  * If the schema nesting exceeds this depth, a RangeError will be thrown
84
84
  * with a descriptive message instead of crashing with a stack overflow.
85
+ * This limit also applies to `$ref` resolution chains.
85
86
  *
86
87
  * Default: 500
87
88
  */
@@ -38,6 +38,10 @@ declare class Pointer<S extends object = JSONSchema, O extends ParserOptions<S>
38
38
  * Indicates whether the pointer references itself.
39
39
  */
40
40
  circular: boolean;
41
+ /**
42
+ * Indicates whether the pointer encountered a cycle elsewhere in its reference chain.
43
+ */
44
+ chainCircular: boolean;
41
45
  /**
42
46
  * The number of indirect references that were traversed to resolve the value.
43
47
  * Resolving a single pointer may require resolving multiple $Refs.
@@ -57,7 +61,7 @@ declare class Pointer<S extends object = JSONSchema, O extends ParserOptions<S>
57
61
  * the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path
58
62
  * of the resolved value.
59
63
  */
60
- resolve(obj: S, options?: O, pathFromRoot?: string): this;
64
+ resolve(obj: S, options?: O, pathFromRoot?: string, visitedRefPaths?: Set<string>, resolveFinalReference?: boolean): this;
61
65
  /**
62
66
  * Sets the value of a nested property within the given object.
63
67
  *
@@ -43,6 +43,10 @@ class Pointer {
43
43
  * Indicates whether the pointer references itself.
44
44
  */
45
45
  circular;
46
+ /**
47
+ * Indicates whether the pointer encountered a cycle elsewhere in its reference chain.
48
+ */
49
+ chainCircular;
46
50
  /**
47
51
  * The number of indirect references that were traversed to resolve the value.
48
52
  * Resolving a single pointer may require resolving multiple $Refs.
@@ -55,6 +59,7 @@ class Pointer {
55
59
  this.scopeBase = $ref.path || url.stripHash(path);
56
60
  this.value = undefined;
57
61
  this.circular = false;
62
+ this.chainCircular = false;
58
63
  this.indirections = 0;
59
64
  }
60
65
  /**
@@ -70,7 +75,7 @@ class Pointer {
70
75
  * the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path
71
76
  * of the resolved value.
72
77
  */
73
- resolve(obj, options, pathFromRoot) {
78
+ resolve(obj, options, pathFromRoot, visitedRefPaths = new Set(), resolveFinalReference = true) {
74
79
  const tokens = Pointer.parse(this.path, this.originalPath);
75
80
  const found = [];
76
81
  // Crawl the object, one token at a time
@@ -86,16 +91,18 @@ class Pointer {
86
91
  // This prevents false circular detection when e.g. a root schema has both
87
92
  // $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
88
93
  const wasCircular = this.circular;
94
+ const wasChainCircular = this.chainCircular;
89
95
  const isExtendedRef = $Ref.isExtended$Ref(this.value);
90
- if (resolveIf$Ref(this, options, pathFromRoot)) {
96
+ if (resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths)) {
91
97
  // The $ref path has changed, so append the remaining tokens to the path
92
98
  this.path = Pointer.join(this.path, tokens.slice(i));
93
99
  }
94
- else if (!wasCircular && this.circular && isExtendedRef) {
100
+ else if (isExtendedRef) {
95
101
  // resolveIf$Ref set circular=true on an extended $ref during token walking.
96
102
  // Since we still have tokens to process, the object should be walked by its
97
103
  // properties, not treated as a circular self-reference.
98
- this.circular = false;
104
+ this.circular = wasCircular;
105
+ this.chainCircular = wasChainCircular;
99
106
  }
100
107
  const token = tokens[i];
101
108
  if (this.value[token] === undefined || (this.value[token] === null && i === tokens.length - 1)) {
@@ -111,6 +118,7 @@ class Pointer {
111
118
  }
112
119
  }
113
120
  if (didFindSubstringSlashMatch) {
121
+ this.chainCircular = wasChainCircular;
114
122
  continue;
115
123
  }
116
124
  // If the token we're looking for ended up not containing any slashes but is
@@ -120,6 +128,7 @@ class Pointer {
120
128
  // We use a `null` symbol for internal tracking to differentiate between a general `null`
121
129
  // value and our expected `null` value.
122
130
  this.value = nullSymbol;
131
+ this.chainCircular = wasChainCircular;
123
132
  continue;
124
133
  }
125
134
  this.value = null;
@@ -132,6 +141,7 @@ class Pointer {
132
141
  else {
133
142
  this.value = this.value[token];
134
143
  }
144
+ this.chainCircular = wasChainCircular;
135
145
  found.push(token);
136
146
  if (this.$ref.dynamicIdScope) {
137
147
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
@@ -139,8 +149,17 @@ class Pointer {
139
149
  }
140
150
  // Resolve the final value
141
151
  const finalResolutionBase = this.$ref.dynamicIdScope ? this.scopeBase : this.path;
142
- if (!this.value || (this.value.$ref && url.resolve(finalResolutionBase, this.value.$ref) !== pathFromRoot)) {
143
- resolveIf$Ref(this, options, pathFromRoot);
152
+ if (resolveFinalReference) {
153
+ const finalRefPath = this.value?.$ref ? url.resolve(finalResolutionBase, this.value.$ref) : undefined;
154
+ const canonicalPathFromRoot = typeof pathFromRoot === "string" ? url.resolve(this.$ref.$refs._root$Ref.path, pathFromRoot) : pathFromRoot;
155
+ if ($Ref.isAllowed$Ref(this.value, options) &&
156
+ finalRefPath === canonicalPathFromRoot &&
157
+ finalRefPath !== this.path) {
158
+ this.chainCircular = true;
159
+ }
160
+ else if (!this.value || finalRefPath) {
161
+ resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths);
162
+ }
144
163
  }
145
164
  return this;
146
165
  }
@@ -256,26 +275,58 @@ class Pointer {
256
275
  * @param [pathFromRoot] - the path of place that initiated resolving
257
276
  * @returns - Returns `true` if the resolution path changed
258
277
  */
259
- function resolveIf$Ref(pointer, options, pathFromRoot) {
260
- // Is the value a JSON reference? (and allowed?)
261
- if ($Ref.isAllowed$Ref(pointer.value, options)) {
262
- const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
263
- const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
264
- if ($refPath === pointer.path && !isRootPath(pathFromRoot)) {
265
- // The value is a reference to itself, so there's nothing to do.
266
- pointer.circular = true;
267
- }
268
- else {
269
- const resolved = pointer.$ref.$refs._resolve($refPath, pointer.path, options);
278
+ function resolveIf$Ref(pointer, options, pathFromRoot, visitedRefPaths = new Set()) {
279
+ let pathChanged = false;
280
+ let currentPathFromRoot = pathFromRoot;
281
+ const addedPaths = [];
282
+ try {
283
+ // Pure reference chains can be followed iteratively. Extended refs still use
284
+ // the existing one-hop behavior because their values must be merged on return.
285
+ while ($Ref.isAllowed$Ref(pointer.value, options)) {
286
+ const extended = $Ref.isExtended$Ref(pointer.value);
287
+ const sourceValue = pointer.value;
288
+ const parentPath = pointer.path;
289
+ const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
290
+ const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
291
+ if ($refPath === pointer.path && !isRootPath(currentPathFromRoot)) {
292
+ // The value is a reference to itself, so there's nothing to do.
293
+ pointer.circular = true;
294
+ pointer.chainCircular = true;
295
+ return pathChanged;
296
+ }
297
+ const canonicalPathFromRoot = typeof currentPathFromRoot === "string"
298
+ ? url.resolve(pointer.$ref.$refs._root$Ref.path, currentPathFromRoot)
299
+ : currentPathFromRoot;
300
+ if ($refPath === canonicalPathFromRoot && $refPath !== pointer.path) {
301
+ pointer.chainCircular = true;
302
+ return pathChanged;
303
+ }
304
+ if (visitedRefPaths.has($refPath)) {
305
+ pointer.chainCircular = true;
306
+ return pathChanged;
307
+ }
308
+ const maxDepth = options?.dereference?.maxDepth ?? 500;
309
+ if (visitedRefPaths.size >= maxDepth) {
310
+ throw new RangeError(`Maximum dereference depth (${maxDepth}) exceeded at ${pointer.path}. ` +
311
+ `This likely indicates an extremely deep or recursive schema. ` +
312
+ `You can increase this limit with the dereference.maxDepth option.`);
313
+ }
314
+ visitedRefPaths.add($refPath);
315
+ addedPaths.push($refPath);
316
+ const resolved = pointer.$ref.$refs._resolve($refPath, parentPath, options, visitedRefPaths, extended);
270
317
  if (resolved === null) {
271
- return false;
318
+ return pathChanged;
272
319
  }
273
320
  pointer.indirections += resolved.indirections + 1;
274
- if ($Ref.isExtended$Ref(pointer.value)) {
321
+ pointer.chainCircular ||= resolved.circular || resolved.chainCircular;
322
+ if (extended) {
275
323
  // This JSON reference "extends" the resolved value, rather than simply pointing to it.
276
324
  // So the resolved path does NOT change. Just the value does.
277
- pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
278
- return false;
325
+ if (pointer.chainCircular) {
326
+ return pathChanged;
327
+ }
328
+ pointer.value = $Ref.dereference(sourceValue, resolved.value, options);
329
+ return pathChanged;
279
330
  }
280
331
  else {
281
332
  // Resolve the reference
@@ -285,12 +336,18 @@ function resolveIf$Ref(pointer, options, pathFromRoot) {
285
336
  // `pointer.$ref.path` is already the canonical location of the resolved resource.
286
337
  // Re-applying the resource's own `$id` here would duplicate nested path segments
287
338
  // such as `nested/nested/foo.json`.
288
- pointer.scopeBase = pointer.$ref.path;
339
+ pointer.scopeBase = resolved.scopeBase;
340
+ currentPathFromRoot = parentPath;
341
+ pathChanged = true;
289
342
  }
290
- return true;
343
+ }
344
+ return pathChanged;
345
+ }
346
+ finally {
347
+ for (const path of addedPaths) {
348
+ visitedRefPaths.delete(path);
291
349
  }
292
350
  }
293
- return undefined;
294
351
  }
295
352
  export default Pointer;
296
353
  /**
package/dist/lib/ref.d.ts CHANGED
@@ -78,9 +78,11 @@ declare class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = P
78
78
  * @param options
79
79
  * @param friendlyPath - The original user-specified path (used for error messages)
80
80
  * @param pathFromRoot - The path of `obj` from the schema root
81
+ * @param visitedRefPaths - the active paths in the current reference chain
82
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
81
83
  * @returns
82
84
  */
83
- resolve(path: string, options?: O, friendlyPath?: string, pathFromRoot?: string): Pointer<S, O> | null;
85
+ resolve(path: string, options?: O, friendlyPath?: string, pathFromRoot?: string, visitedRefPaths?: Set<string>, resolveFinalReference?: boolean): Pointer<S, O> | null;
84
86
  /**
85
87
  * Sets the value of a nested property within this {@link $Ref#value}.
86
88
  * If the property, or any of its parents don't exist, they will be created.
package/dist/lib/ref.js CHANGED
@@ -100,12 +100,14 @@ class $Ref {
100
100
  * @param options
101
101
  * @param friendlyPath - The original user-specified path (used for error messages)
102
102
  * @param pathFromRoot - The path of `obj` from the schema root
103
+ * @param visitedRefPaths - the active paths in the current reference chain
104
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
103
105
  * @returns
104
106
  */
105
- resolve(path, options, friendlyPath, pathFromRoot) {
107
+ resolve(path, options, friendlyPath, pathFromRoot, visitedRefPaths, resolveFinalReference = true) {
106
108
  const pointer = new Pointer(this, path, friendlyPath);
107
109
  try {
108
- const resolved = pointer.resolve(this.value, options, pathFromRoot);
110
+ const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolveFinalReference);
109
111
  if (resolved.value === nullSymbol) {
110
112
  resolved.value = null;
111
113
  }
@@ -86,10 +86,12 @@ export default class $Refs<S extends object = JSONSchema, O extends ParserOption
86
86
  * @param path - The path being resolved, optionally with a JSON pointer in the hash
87
87
  * @param pathFromRoot - The path of `obj` from the schema root
88
88
  * @param [options]
89
+ * @param visitedRefPaths - the active paths in the current reference chain
90
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
89
91
  * @returns
90
92
  * @protected
91
93
  */
92
- _resolve(path: string, pathFromRoot: string, options?: O): import("./pointer.js").default<S, O> | null;
94
+ _resolve(path: string, pathFromRoot: string, options?: O, visitedRefPaths?: Set<string>, resolveFinalReference?: boolean): import("./pointer.js").default<S, O> | null;
93
95
  /**
94
96
  * A map of paths/urls to {@link $Ref} objects
95
97
  *
package/dist/lib/refs.js CHANGED
@@ -133,16 +133,18 @@ export default class $Refs {
133
133
  * @param path - The path being resolved, optionally with a JSON pointer in the hash
134
134
  * @param pathFromRoot - The path of `obj` from the schema root
135
135
  * @param [options]
136
+ * @param visitedRefPaths - the active paths in the current reference chain
137
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
136
138
  * @returns
137
139
  * @protected
138
140
  */
139
- _resolve(path, pathFromRoot, options) {
141
+ _resolve(path, pathFromRoot, options, visitedRefPaths, resolveFinalReference = true) {
140
142
  const absPath = url.resolve(this._root$Ref.path, path);
141
143
  const $ref = this._getRef(absPath);
142
144
  if (!$ref) {
143
145
  throw new Error(`Error resolving $ref pointer "${path}". \n"${url.stripHash(absPath)}" not found.`);
144
146
  }
145
- return $ref.resolve(absPath, options, path, pathFromRoot);
147
+ return $ref.resolve(absPath, options, path, pathFromRoot, visitedRefPaths, resolveFinalReference);
146
148
  }
147
149
  /**
148
150
  * A map of paths/urls to {@link $Ref} objects
package/lib/bundle.ts CHANGED
@@ -20,6 +20,7 @@ export interface InventoryEntry {
20
20
  circular: any;
21
21
  extended: any;
22
22
  external: any;
23
+ nestedResource: boolean;
23
24
  indirections: any;
24
25
  }
25
26
  /**
@@ -194,6 +195,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
194
195
  const file = url.stripHash(pointer.path);
195
196
  const hash = url.getHash(pointer.path);
196
197
  const external = file !== $refs._root$Ref.path && !$refs._aliases[file];
198
+ const nestedResource = Boolean($refs._aliases[file]) && pointer.$ref.value !== $refs._root$Ref.value;
197
199
  const extended = $Ref.isExtended$Ref($ref);
198
200
  indirections += pointer.indirections;
199
201
 
@@ -219,6 +221,7 @@ function inventory$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
219
221
  circular: pointer.circular, // Is this $ref pointer DIRECTLY circular? (i.e. it references itself)
220
222
  extended, // Does this $ref extend its resolved value? (i.e. it has extra properties, in addition to "$ref")
221
223
  external, // Does this $ref pointer point to a file other than the main JSON Schema file?
224
+ nestedResource, // Does this $ref resolve to an embedded schema resource with its own $id?
222
225
  indirections, // The number of indirect references that were traversed to resolve the value
223
226
  });
224
227
 
@@ -316,8 +319,10 @@ function remap<S extends object = JSONSchema, O extends ParserOptions<S> = Parse
316
319
  if (!entry.external) {
317
320
  // This $ref already resolves to the main JSON Schema file.
318
321
  // When optimizeInternalRefs is false, preserve the original internal ref path
319
- // instead of rewriting it to the fully resolved hash.
320
- if (bundleOpts.optimizeInternalRefs !== false) {
322
+ // instead of rewriting it to the fully resolved hash. References to nested
323
+ // resources must also retain their resource URI so that "#" does not point
324
+ // at the document root instead.
325
+ if (bundleOpts.optimizeInternalRefs !== false && !entry.nestedResource) {
321
326
  entry.$ref.$ref = entry.hash;
322
327
  }
323
328
  } else if (entry.file === file && entry.hash === hash) {
@@ -318,7 +318,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
318
318
 
319
319
  // Check for circular references
320
320
  const directCircular = pointer.circular;
321
- let circular = directCircular || parents.has(pointer.value);
321
+ let circular = directCircular || pointer.chainCircular || parents.has(pointer.value);
322
322
  if (circular) {
323
323
  foundCircularReference(path, $refs, options);
324
324
  }
@@ -352,7 +352,7 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
352
352
  dereferencedValue = $ref;
353
353
  }
354
354
 
355
- if (directCircular) {
355
+ if (directCircular && dereferencedValue !== $ref) {
356
356
  // The pointer is a DIRECT circular reference (i.e. it references itself).
357
357
  // So replace the $ref path with the absolute path from the JSON Schema root
358
358
  dereferencedValue.$ref = pathFromRoot;
package/lib/options.ts CHANGED
@@ -103,6 +103,7 @@ export interface DereferenceOptions {
103
103
  * The maximum recursion depth for dereferencing nested schemas.
104
104
  * If the schema nesting exceeds this depth, a RangeError will be thrown
105
105
  * with a descriptive message instead of crashing with a stack overflow.
106
+ * This limit also applies to `$ref` resolution chains.
106
107
  *
107
108
  * Default: 500
108
109
  */
package/lib/pointer.ts CHANGED
@@ -55,6 +55,10 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
55
55
  * Indicates whether the pointer references itself.
56
56
  */
57
57
  circular: boolean;
58
+ /**
59
+ * Indicates whether the pointer encountered a cycle elsewhere in its reference chain.
60
+ */
61
+ chainCircular: boolean;
58
62
  /**
59
63
  * The number of indirect references that were traversed to resolve the value.
60
64
  * Resolving a single pointer may require resolving multiple $Refs.
@@ -74,6 +78,8 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
74
78
 
75
79
  this.circular = false;
76
80
 
81
+ this.chainCircular = false;
82
+
77
83
  this.indirections = 0;
78
84
  }
79
85
 
@@ -90,7 +96,13 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
90
96
  * the {@link Pointer#$ref} and {@link Pointer#path} will reflect the resolution path
91
97
  * of the resolved value.
92
98
  */
93
- resolve(obj: S, options?: O, pathFromRoot?: string) {
99
+ resolve(
100
+ obj: S,
101
+ options?: O,
102
+ pathFromRoot?: string,
103
+ visitedRefPaths = new Set<string>(),
104
+ resolveFinalReference = true,
105
+ ) {
94
106
  const tokens = Pointer.parse(this.path, this.originalPath);
95
107
  const found: string[] = [];
96
108
 
@@ -108,15 +120,17 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
108
120
  // This prevents false circular detection when e.g. a root schema has both
109
121
  // $ref: "#/$defs/Foo" and $defs: { Foo: {...} } as siblings.
110
122
  const wasCircular = this.circular;
123
+ const wasChainCircular = this.chainCircular;
111
124
  const isExtendedRef = $Ref.isExtended$Ref(this.value);
112
- if (resolveIf$Ref(this, options, pathFromRoot)) {
125
+ if (resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths)) {
113
126
  // The $ref path has changed, so append the remaining tokens to the path
114
127
  this.path = Pointer.join(this.path, tokens.slice(i));
115
- } else if (!wasCircular && this.circular && isExtendedRef) {
128
+ } else if (isExtendedRef) {
116
129
  // resolveIf$Ref set circular=true on an extended $ref during token walking.
117
130
  // Since we still have tokens to process, the object should be walked by its
118
131
  // properties, not treated as a circular self-reference.
119
- this.circular = false;
132
+ this.circular = wasCircular;
133
+ this.chainCircular = wasChainCircular;
120
134
  }
121
135
 
122
136
  const token = tokens[i];
@@ -134,6 +148,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
134
148
  }
135
149
  }
136
150
  if (didFindSubstringSlashMatch) {
151
+ this.chainCircular = wasChainCircular;
137
152
  continue;
138
153
  }
139
154
 
@@ -144,6 +159,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
144
159
  // We use a `null` symbol for internal tracking to differentiate between a general `null`
145
160
  // value and our expected `null` value.
146
161
  this.value = nullSymbol;
162
+ this.chainCircular = wasChainCircular;
147
163
  continue;
148
164
  }
149
165
 
@@ -160,6 +176,7 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
160
176
  this.value = this.value[token];
161
177
  }
162
178
 
179
+ this.chainCircular = wasChainCircular;
163
180
  found.push(token);
164
181
  if (this.$ref.dynamicIdScope) {
165
182
  this.scopeBase = getSchemaBasePath(this.scopeBase, this.value);
@@ -168,8 +185,20 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
168
185
 
169
186
  // Resolve the final value
170
187
  const finalResolutionBase = this.$ref.dynamicIdScope ? this.scopeBase : this.path;
171
- if (!this.value || (this.value.$ref && url.resolve(finalResolutionBase, this.value.$ref) !== pathFromRoot)) {
172
- resolveIf$Ref(this, options, pathFromRoot);
188
+ if (resolveFinalReference) {
189
+ const finalRefPath = this.value?.$ref ? url.resolve(finalResolutionBase, this.value.$ref) : undefined;
190
+ const canonicalPathFromRoot =
191
+ typeof pathFromRoot === "string" ? url.resolve(this.$ref.$refs._root$Ref.path!, pathFromRoot) : pathFromRoot;
192
+
193
+ if (
194
+ $Ref.isAllowed$Ref(this.value, options) &&
195
+ finalRefPath === canonicalPathFromRoot &&
196
+ finalRefPath !== this.path
197
+ ) {
198
+ this.chainCircular = true;
199
+ } else if (!this.value || finalRefPath) {
200
+ resolveIf$Ref(this, options, pathFromRoot, visitedRefPaths);
201
+ }
173
202
  }
174
203
 
175
204
  return this;
@@ -305,32 +334,74 @@ class Pointer<S extends object = JSONSchema, O extends ParserOptions<S> = Parser
305
334
  * @returns - Returns `true` if the resolution path changed
306
335
  */
307
336
  function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOptions<S>>(
308
- pointer: Pointer,
337
+ pointer: Pointer<S, O>,
309
338
  options: O | undefined,
310
339
  pathFromRoot?: string,
340
+ visitedRefPaths = new Set<string>(),
311
341
  ) {
312
- // Is the value a JSON reference? (and allowed?)
342
+ let pathChanged = false;
343
+ let currentPathFromRoot = pathFromRoot;
344
+ const addedPaths: string[] = [];
345
+
346
+ try {
347
+ // Pure reference chains can be followed iteratively. Extended refs still use
348
+ // the existing one-hop behavior because their values must be merged on return.
349
+ while ($Ref.isAllowed$Ref(pointer.value, options)) {
350
+ const extended = $Ref.isExtended$Ref(pointer.value);
351
+ const sourceValue = pointer.value;
352
+ const parentPath = pointer.path;
353
+ const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
354
+ const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
355
+
356
+ if ($refPath === pointer.path && !isRootPath(currentPathFromRoot)) {
357
+ // The value is a reference to itself, so there's nothing to do.
358
+ pointer.circular = true;
359
+ pointer.chainCircular = true;
360
+ return pathChanged;
361
+ }
313
362
 
314
- if ($Ref.isAllowed$Ref(pointer.value, options)) {
315
- const resolutionBase = pointer.$ref.dynamicIdScope ? pointer.scopeBase : pointer.path;
316
- const $refPath = url.resolve(resolutionBase, pointer.value.$ref);
363
+ const canonicalPathFromRoot =
364
+ typeof currentPathFromRoot === "string"
365
+ ? url.resolve(pointer.$ref.$refs._root$Ref.path!, currentPathFromRoot)
366
+ : currentPathFromRoot;
367
+ if ($refPath === canonicalPathFromRoot && $refPath !== pointer.path) {
368
+ pointer.chainCircular = true;
369
+ return pathChanged;
370
+ }
317
371
 
318
- if ($refPath === pointer.path && !isRootPath(pathFromRoot)) {
319
- // The value is a reference to itself, so there's nothing to do.
320
- pointer.circular = true;
321
- } else {
322
- const resolved = pointer.$ref.$refs._resolve($refPath, pointer.path, options);
372
+ if (visitedRefPaths.has($refPath)) {
373
+ pointer.chainCircular = true;
374
+ return pathChanged;
375
+ }
376
+
377
+ const maxDepth = options?.dereference?.maxDepth ?? 500;
378
+ if (visitedRefPaths.size >= maxDepth) {
379
+ throw new RangeError(
380
+ `Maximum dereference depth (${maxDepth}) exceeded at ${pointer.path}. ` +
381
+ `This likely indicates an extremely deep or recursive schema. ` +
382
+ `You can increase this limit with the dereference.maxDepth option.`,
383
+ );
384
+ }
385
+
386
+ visitedRefPaths.add($refPath);
387
+ addedPaths.push($refPath);
388
+
389
+ const resolved = pointer.$ref.$refs._resolve($refPath, parentPath, options, visitedRefPaths, extended);
323
390
  if (resolved === null) {
324
- return false;
391
+ return pathChanged;
325
392
  }
326
393
 
327
394
  pointer.indirections += resolved.indirections + 1;
395
+ pointer.chainCircular ||= resolved.circular || resolved.chainCircular;
328
396
 
329
- if ($Ref.isExtended$Ref(pointer.value)) {
397
+ if (extended) {
330
398
  // This JSON reference "extends" the resolved value, rather than simply pointing to it.
331
399
  // So the resolved path does NOT change. Just the value does.
332
- pointer.value = $Ref.dereference(pointer.value, resolved.value, options);
333
- return false;
400
+ if (pointer.chainCircular) {
401
+ return pathChanged;
402
+ }
403
+ pointer.value = $Ref.dereference(sourceValue, resolved.value, options);
404
+ return pathChanged;
334
405
  } else {
335
406
  // Resolve the reference
336
407
  pointer.$ref = resolved.$ref;
@@ -339,13 +410,18 @@ function resolveIf$Ref<S extends object = JSONSchema, O extends ParserOptions<S>
339
410
  // `pointer.$ref.path` is already the canonical location of the resolved resource.
340
411
  // Re-applying the resource's own `$id` here would duplicate nested path segments
341
412
  // such as `nested/nested/foo.json`.
342
- pointer.scopeBase = pointer.$ref.path!;
413
+ pointer.scopeBase = resolved.scopeBase;
414
+ currentPathFromRoot = parentPath;
415
+ pathChanged = true;
343
416
  }
417
+ }
344
418
 
345
- return true;
419
+ return pathChanged;
420
+ } finally {
421
+ for (const path of addedPaths) {
422
+ visitedRefPaths.delete(path);
346
423
  }
347
424
  }
348
- return undefined;
349
425
  }
350
426
  export default Pointer;
351
427
 
package/lib/ref.ts CHANGED
@@ -120,12 +120,21 @@ class $Ref<S extends object = JSONSchema, O extends ParserOptions<S> = ParserOpt
120
120
  * @param options
121
121
  * @param friendlyPath - The original user-specified path (used for error messages)
122
122
  * @param pathFromRoot - The path of `obj` from the schema root
123
+ * @param visitedRefPaths - the active paths in the current reference chain
124
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
123
125
  * @returns
124
126
  */
125
- resolve(path: string, options?: O, friendlyPath?: string, pathFromRoot?: string) {
127
+ resolve(
128
+ path: string,
129
+ options?: O,
130
+ friendlyPath?: string,
131
+ pathFromRoot?: string,
132
+ visitedRefPaths?: Set<string>,
133
+ resolveFinalReference = true,
134
+ ) {
126
135
  const pointer = new Pointer<S, O>(this, path, friendlyPath);
127
136
  try {
128
- const resolved = pointer.resolve(this.value, options, pathFromRoot);
137
+ const resolved = pointer.resolve(this.value, options, pathFromRoot, visitedRefPaths, resolveFinalReference);
129
138
  if (resolved.value === nullSymbol) {
130
139
  resolved.value = null;
131
140
  }
package/lib/refs.ts CHANGED
@@ -155,10 +155,18 @@ export default class $Refs<S extends object = JSONSchema, O extends ParserOption
155
155
  * @param path - The path being resolved, optionally with a JSON pointer in the hash
156
156
  * @param pathFromRoot - The path of `obj` from the schema root
157
157
  * @param [options]
158
+ * @param visitedRefPaths - the active paths in the current reference chain
159
+ * @param resolveFinalReference - whether to follow a `$ref` at the resolved value
158
160
  * @returns
159
161
  * @protected
160
162
  */
161
- _resolve(path: string, pathFromRoot: string, options?: O) {
163
+ _resolve(
164
+ path: string,
165
+ pathFromRoot: string,
166
+ options?: O,
167
+ visitedRefPaths?: Set<string>,
168
+ resolveFinalReference = true,
169
+ ) {
162
170
  const absPath = url.resolve(this._root$Ref.path!, path);
163
171
  const $ref = this._getRef(absPath);
164
172
 
@@ -166,7 +174,7 @@ export default class $Refs<S extends object = JSONSchema, O extends ParserOption
166
174
  throw new Error(`Error resolving $ref pointer "${path}". \n"${url.stripHash(absPath)}" not found.`);
167
175
  }
168
176
 
169
- return $ref.resolve(absPath, options, path, pathFromRoot);
177
+ return $ref.resolve(absPath, options, path, pathFromRoot, visitedRefPaths, resolveFinalReference);
170
178
  }
171
179
 
172
180
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apidevtools/json-schema-ref-parser",
3
- "version": "15.3.6",
3
+ "version": "15.5.0",
4
4
  "description": "Parse, Resolve, and Dereference JSON Schema $ref pointers",
5
5
  "type": "module",
6
6
  "types": "dist/lib/index.d.ts",