Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion lib/dereference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,11 +251,14 @@ function dereference$Ref<S extends object = JSONSchema, O extends ParserOptions<
// overhaul.
if (typeof cache.value === "object" && "$ref" in cache.value && "$ref" in $ref) {
if (cache.value.$ref === $ref.$ref) {
// Fire onCircular for cached circular refs so callers are notified of every occurrence
foundCircularReference(path, $refs, options);
return cache;
} else {
// no-op
// no-op - fall through to re-process (handles external ref edge case)
}
} else {
foundCircularReference(path, $refs, options);
return cache;
}
}
Expand Down
66 changes: 59 additions & 7 deletions test/specs/circular-extensive/circular-extensive.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import { describe, it } from "vitest";
import { describe, it, expect } from "vitest";
import $RefParser from "../../../lib/index.js";
import helper from "../../utils/helper.js";
import path from "../../utils/path.js";

import { expect } from "vitest";

describe("Schema with an extensive amount of circular $refs", () => {
it("should dereference successfully", async () => {
const circularRefs = new Set<string>();
Expand All @@ -31,8 +29,9 @@ describe("Schema with an extensive amount of circular $refs", () => {
},
});

// Ensure that a circular $ref **was** dereferenced.
expect(circularRefs).toHaveLength(23);
// With circular: true (default), circular $refs are replaced with the resolved object.
// onCircular fires for each $ref location pointing to a circular target (118 unique paths).
expect(circularRefs.size).toBe(118);
expect(schema.components?.schemas?.Customer?.properties?.customerNode).toStrictEqual({
type: "array",
items: {
Expand Down Expand Up @@ -74,8 +73,11 @@ describe("Schema with an extensive amount of circular $refs", () => {
},
});

// Ensure that a circular $ref was **not** dereferenced.
expect(circularRefs).toHaveLength(23);
// With circular: 'ignore', circular $refs remain as { $ref: "..." } objects.
// onCircular fires for each $ref location (same 118 paths as above), PLUS 55 additional
// "interior paths" - $refs inside circular schemas that get re-encountered when the
// containing schema is accessed from multiple entry points.
expect(circularRefs.size).toBe(173);
expect(schema.components?.schemas?.Customer?.properties?.customerNode).toStrictEqual({
type: "array",
items: {
Expand Down Expand Up @@ -104,4 +106,54 @@ describe("Schema with an extensive amount of circular $refs", () => {
expect(parser.$refs.circular).to.equal(true);
}
});

it("should expose path differences between circular: true and circular: 'ignore'", async () => {
const SCHEMA_PATH = "test/specs/circular-extensive/schema.json";

// Collect paths with circular: true (default)
const pathsTrue = new Set<string>();
await new $RefParser().dereference(path.rel(SCHEMA_PATH), {
dereference: {
onCircular: (ref: string) => pathsTrue.add(ref.split("#")[1]),
},
});

// Collect paths with circular: 'ignore'
const pathsIgnore = new Set<string>();
await new $RefParser().dereference(path.rel(SCHEMA_PATH), {
dereference: {
circular: "ignore",
onCircular: (ref: string) => pathsIgnore.add(ref.split("#")[1]),
},
});

// Verify the counts
expect(pathsTrue.size).toBe(118);
expect(pathsIgnore.size).toBe(173);

// All paths in 'true' mode should also be in 'ignore' mode
const pathsOnlyInTrue = [...pathsTrue].filter((p) => !pathsIgnore.has(p));
expect(pathsOnlyInTrue).toHaveLength(0);

// 'ignore' mode has 55 additional paths not found in 'true' mode
const pathsOnlyInIgnore = [...pathsIgnore].filter((p) => !pathsTrue.has(p));
expect(pathsOnlyInIgnore).toHaveLength(55);

// These extra paths are "interior paths" within circular schemas that get
// re-visited because $ref objects allow re-entry from different traversal routes.
// With circular: true, these paths aren't reported because the same object
// instance is detected by parents.has() which doesn't trigger onCircular.
//
// Example extra paths (interior of circular schemas reached via different routes):
// Customer contains customerNode.items → CustomerNode (circular).
// - In 'true' mode: Customer.customerNode.items becomes the resolved CustomerNode object.
// When Customer is accessed from another route, customerNode.items is the same object
// instance already in `parents`, so no onCircular fires for that interior path.
// - In 'ignore' mode: Customer.customerNode.items remains { $ref: "..." }.
// When Customer is accessed from another route, the $ref is re-encountered and
// triggers onCircular via cache hit, reporting the interior path.
expect(pathsOnlyInIgnore).toContain("/components/schemas/Customer/properties/customerNode/items");
expect(pathsOnlyInIgnore).toContain("/components/schemas/Customer/properties/customerExternalReference/items");
expect(pathsOnlyInIgnore).toContain("/components/schemas/Node/properties/configWcCodeNode/items");
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect } from "vitest";
import $RefParser from "../../../lib/index.js";
import path from "../../utils/path.js";

/**
* Tests that onCircular is called for EVERY occurrence of a circular $ref,
* not just the first detection. The schema has 5 $refs to #/definitions/Node:
* 1. definitions/Node/properties/self (self-reference)
* 2. definitions/Container/properties/primaryNode
* 3. definitions/Container/properties/secondaryNode
* 4. definitions/Container/properties/nodeList/items
* 5. root
*/
describe("Schema with multiple occurrences of the same circular $ref target", () => {
const SCHEMA_PATH = "test/specs/circular-multi-occurrence/schema.yaml";

const EXPECTED_PATH_SUFFIXES = [
"definitions/Node/properties/self",
"definitions/Container/properties/primaryNode",
"definitions/Container/properties/secondaryNode",
"definitions/Container/properties/nodeList/items",
"/root",
] as const;

it.each([
{ mode: "true (default)", circular: true as const },
{ mode: "'ignore'", circular: "ignore" as const },
])("should call onCircular for every occurrence (circular: $mode)", async ({ circular }) => {
const circularRefs: string[] = [];

const parser = new $RefParser();
await parser.dereference(path.rel(SCHEMA_PATH), {
dereference: {
circular,
onCircular: (refPath: string) => circularRefs.push(refPath),
},
});

// Exactly 5 calls, all unique paths
expect(circularRefs).toHaveLength(5);
expect(new Set(circularRefs).size).toBe(5);
expect(parser.$refs.circular).toBe(true);

// Each expected path suffix should appear exactly once
for (const expectedSuffix of EXPECTED_PATH_SUFFIXES) {
const matches = circularRefs.filter((p) => p.endsWith(expectedSuffix));
expect(matches, `Expected exactly one path ending with "${expectedSuffix}"`).toHaveLength(1);
}
});
});
26 changes: 26 additions & 0 deletions test/specs/circular-multi-occurrence/schema.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# Test schema: Multiple occurrences of the same circular $ref target
# Verifies onCircular is called for ALL 5 occurrences, not just the first.

definitions:
Node:
type: object
properties:
name:
type: string
self:
$ref: "#/definitions/Node" # 1. Self-reference

Container:
type: object
properties:
primaryNode:
$ref: "#/definitions/Node" # 2. First external ref
secondaryNode:
$ref: "#/definitions/Node" # 3. Second external ref
nodeList:
type: array
items:
$ref: "#/definitions/Node" # 4. Array items ref

root:
$ref: "#/definitions/Node" # 5. Root-level ref