diff --git a/lib/dereference.ts b/lib/dereference.ts index e8a35418..581ad920 100644 --- a/lib/dereference.ts +++ b/lib/dereference.ts @@ -251,11 +251,14 @@ function dereference$Ref { it("should dereference successfully", async () => { const circularRefs = new Set(); @@ -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: { @@ -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: { @@ -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(); + 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(); + 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"); + }); }); diff --git a/test/specs/circular-multi-occurrence/circular-multi-occurrence.spec.ts b/test/specs/circular-multi-occurrence/circular-multi-occurrence.spec.ts new file mode 100644 index 00000000..d68061e5 --- /dev/null +++ b/test/specs/circular-multi-occurrence/circular-multi-occurrence.spec.ts @@ -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); + } + }); +}); diff --git a/test/specs/circular-multi-occurrence/schema.yaml b/test/specs/circular-multi-occurrence/schema.yaml new file mode 100644 index 00000000..500b0a68 --- /dev/null +++ b/test/specs/circular-multi-occurrence/schema.yaml @@ -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