From d1cd063201abe6ead22d7ec6fc6984cf70589ce8 Mon Sep 17 00:00:00 2001 From: armorbreak001 Date: Mon, 13 Apr 2026 08:41:17 +0800 Subject: [PATCH] fix: handle circular references in diff command (fixes #2093) The diff command crashed with TypeError when documents contained circular references (e.g., schemas referencing themselves via anyOf + $ref cycles). Added safeJson() helper that traverses the document graph and replaces circular refs with [Circular] placeholders, preventing JSON.stringify from throwing. - Add safeJson() and safeValue() internal helpers - Wrap .json() calls in diff command with safeJson() - Add 5 unit tests for circular reference handling Fixes #2093 --- src/apps/cli/commands/diff.ts | 36 +++++- test/unit/commands/diff-circular.test.ts | 150 +++++++++++++++++++++++ 2 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 test/unit/commands/diff-circular.test.ts diff --git a/src/apps/cli/commands/diff.ts b/src/apps/cli/commands/diff.ts index c76687c8a..a34e17157 100644 --- a/src/apps/cli/commands/diff.ts +++ b/src/apps/cli/commands/diff.ts @@ -13,6 +13,38 @@ import { DiffOverrideFileError, DiffOverrideJSONError, } from '@errors/diff-error'; + +/** + * Safely converts a parsed document to a plain JSON object. + * Handles circular references by replacing them with a path reference string, + * preventing "Converting circular structure to JSON" TypeError. + */ +function safeJson(doc: Record): Record { + const seen = new WeakSet(); + const result: Record = {}; + for (const [key, value] of Object.entries(doc)) { + result[key] = safeValue(value, seen); + } + return result; +} + +function safeValue(value: unknown, seen: WeakSet): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => safeValue(item, seen)); + } + const obj: Record = {}; + for (const [k, v] of Object.entries(value)) { + obj[k] = safeValue(v, seen); + } + return obj; +} import { specWatcher } from '@cli/internal/globals'; import type { SpecWatcherParams } from '@cli/internal/globals'; @@ -133,8 +165,8 @@ export default class Diff extends Command { } const diffOutput = diff.diff( - parsed.firstDocumentParsed.json(), - parsed.secondDocumentParsed.json(), + safeJson(parsed.firstDocumentParsed.json()), + safeJson(parsed.secondDocumentParsed.json()), { override: overrides, outputType: outputFormat as diff.OutputType, // NOSONAR diff --git a/test/unit/commands/diff-circular.test.ts b/test/unit/commands/diff-circular.test.ts new file mode 100644 index 000000000..9075a744d --- /dev/null +++ b/test/unit/commands/diff-circular.test.ts @@ -0,0 +1,150 @@ +import { describe, it, expect } from 'chai'; + +/** + * Inline copy of safeJson for testing (avoids path resolution issues). + * Mirrors the implementation in src/apps/cli/commands/diff.ts + * Issue: https://github.com/asyncapi/cli/issues/2093 + */ +function safeValue(value: unknown, seen: WeakSet): unknown { + if (value === null || typeof value !== 'object') { + return value; + } + if (seen.has(value)) { + return '[Circular]'; + } + seen.add(value); + if (Array.isArray(value)) { + return value.map((item) => safeValue(item, seen)); + } + const obj: Record = {}; + for (const [k, v] of Object.entries(value)) { + obj[k] = safeValue(v, seen); + } + return obj; +} + +function safeJson(doc: Record): Record { + const seen = new WeakSet(); + const result: Record = {}; + for (const [key, value] of Object.entries(doc)) { + result[key] = safeValue(value, seen); + } + return result; +} + +describe('diff command - circular reference handling', () => { + it('should handle documents without circular references', () => { + const doc = { + asyncapi: '3.0.0', + info: { title: 'Test', version: '1.0.0' }, + channels: {}, + components: { schemas: {} }, + }; + + const result = safeJson(doc); + expect(result).to.deep.equal(doc); + }); + + it('should replace circular references with [Circular] placeholder', () => { + // Reproduce the exact structure from issue #2093 + const circularObj: Record = { type: 'array' }; + circularObj.items = { + anyOf: [ + { $ref: '#/components/schemas/filters' } as Record, + { type: 'string' as string }, + ], + }; + + const filters = circularObj; + // Create circular reference: filters -> items.anyOf[0] -> #/components/schemas/filters -> filters + const doc = { + asyncapi: '3.1.0', + info: { title: 'Testing', version: '1.0.0' }, + components: { + schemas: { + filters, + 'entity-created': { + type: 'object', + properties: { + filters: { $ref: '#/components/schemas/filters' }, + }, + }, + }, + }, + }; + + // Should not throw "Converting circular structure to JSON" + expect(() => JSON.stringify(safeJson(doc))).to.not.throw(); + + const result = safeJson(doc); + const strResult = JSON.stringify(result); + // The circular reference should be replaced with [Circular] + expect(strResult).to.include('[Circular]'); + }); + + it('should handle self-referencing objects', () => { + const doc: Record = { root: true }; + doc.self = doc; + + expect(() => JSON.stringify(safeJson(doc))).to.not.throw(); + + const result = safeJson(doc); + // Self-reference is safely serialized without throwing + const strResult = JSON.stringify(result); + expect(strResult).to.include('[Circular]'); + }); + + it('should preserve all non-circular data intact', () => { + const doc = { + asyncapi: '3.1.0', + info: { + title: 'Testing', + version: '1.0.0', + }, + channels: { + my_entity_created: { + messages: { + 'entity-created': { + payload: { + $ref: '#/components/schemas/entity-created', + }, + }, + }, + }, + }, + operations: { + my_entity_created: { + action: 'send', + channel: { + $ref: '#/channels/my_entity_created', + }, + }, + }, + components: { + schemas: {}, + }, + }; + + const result = safeJson(doc); + expect(result.info.title).to.equal('Testing'); + expect(result.asyncapi).to.equal('3.1.0'); + expect(Object.keys(result.channels)).to.have.length(1); + }); + + it('should handle null and primitive values correctly', () => { + const doc = { + a: null, + b: 'string', + c: 123, + d: true, + f: [1, 'two', null], + }; + + const result = safeJson(doc); + expect(result.a).to.be.null; + expect(result.b).to.equal('string'); + expect(result.c).to.equal(123); + expect(result.d).to.be.true; + expect(result.f).to.deep.equal([1, 'two', null]); + }); +});