Repository: oclif/core
Pull request #1222 (2024-10-14 21:37:48)

fix: improve solution for handling circular json
- Don't remove repeated objects from json when stringifying
- Replace circular ref with `[Circular ~]` instead of removing key altogether

Solution taken directly from [json-stringify-safe](https://www.npmjs.com/package/json-stringify-safe). We can use it directly but it hasn't been updated in over 9 years (although it's still widely used with 23 million weekly downloads)


Patch:
diff --git a/src/ux/colorize-json.ts b/src/ux/colorize-json.ts
index c7171aa..6a87d9b 100644
--- a/src/ux/colorize-json.ts
+++ b/src/ux/colorize-json.ts
@@ -17,53 +17,52 @@ type Options = {
   theme?: Record<string, string> | undefined
 }
 
-export function removeCycles(object: unknown) {
-  // Keep track of seen objects.
-  const seenObjects = new WeakMap<Record<string, unknown>, undefined>()
+type Replacer = (this: any, key: string, value: any) => any
 
-  const _removeCycles = (obj: unknown) => {
-    // Use object prototype to get around type and null checks
-    if (Object.prototype.toString.call(obj) === '[object Object]') {
-      // We know it is a "Record<string, unknown>" because of the conditional
-      const dictionary = obj as Record<string, unknown>
-
-      // Seen, return undefined to remove.
-      if (seenObjects.has(dictionary)) return
+function stringify(value: any, replacer?: Replacer, spaces?: string | number) {
+  return JSON.stringify(value, serializer(replacer, replacer), spaces)
+}
 
-      seenObjects.set(dictionary, undefined)
+// Inspired by https://github.com/moll/json-stringify-safe
+function serializer(replacer: Replacer | undefined, cycleReplacer: Replacer | undefined) {
+  const stack: any[] = []
+  const keys: any[] = []
 
-      for (const key in dictionary) {
-        // Delete the duplicate object if cycle found.
-        if (_removeCycles(dictionary[key]) === undefined) {
-          delete dictionary[key]
-        }
-      }
-    } else if (Array.isArray(obj)) {
-      for (const i in obj) {
-        if (_removeCycles(obj[i]) === undefined) {
-          // We don't want to delete the array, but we can replace the element with null.
-          obj[i] = null
-        }
-      }
+  if (!cycleReplacer)
+    cycleReplacer = function (key, value) {
+      if (stack[0] === value) return '[Circular ~]'
+      return '[Circular ~.' + keys.slice(0, stack.indexOf(value)).join('.') + ']'
     }
 
-    return obj
-  }
+  return function (key: any, value: any) {
+    if (stack.length > 0) {
+      // @ts-expect-error because `this` is not typed
+      const thisPos = stack.indexOf(this)
+      // @ts-expect-error because `this` is not typed
+      // eslint-disable-next-line no-bitwise
+      ~thisPos ? stack.splice(thisPos + 1) : stack.push(this)
+      // eslint-disable-next-line no-bitwise
+      ~thisPos ? keys.splice(thisPos, Number.POSITIVE_INFINITY, key) : keys.push(key)
+      // @ts-expect-error because `this` is not typed
+      if (stack.includes(value)) value = cycleReplacer.call(this, key, value)
+    } else stack.push(value)
 
-  return _removeCycles(object)
+    // @ts-expect-error because `this` is not typed
+    return replacer ? replacer.call(this, key, value) : value
+  }
 }
 
-function formatInput(json?: unknown, options?: Options) {
-  return options?.pretty
-    ? JSON.stringify(typeof json === 'string' ? JSON.parse(json) : json, null, 2)
+export function stringifyInput(json?: unknown, options?: Options): string {
+  const str = options?.pretty
+    ? stringify(typeof json === 'string' ? JSON.parse(json) : json, undefined, 2)
     : typeof json === 'string'
       ? json
-      : JSON.stringify(json)
+      : stringify(json)
+  return str
 }
 
 export function tokenize(json?: unknown, options?: Options) {
-  let input = formatInput(removeCycles(json), options)
-
+  let input = stringifyInput(json, options)
   const tokens = []
   let foundToken = false
 


Test patch:
diff --git a/test/ux/colorize-json.test.ts b/test/ux/colorize-json.test.ts
index 57a89cc..ddee1a4 100644
--- a/test/ux/colorize-json.test.ts
+++ b/test/ux/colorize-json.test.ts
@@ -1,6 +1,6 @@
 import {expect} from 'chai'
 
-import {removeCycles, tokenize} from '../../src/ux/colorize-json'
+import {stringifyInput, tokenize} from '../../src/ux/colorize-json'
 
 describe('colorizeJson', () => {
   it('tokenizes a basic JSON object', () => {
@@ -149,13 +149,65 @@ describe('colorizeJson', () => {
       {type: 'colon', value: ':'},
       {type: 'string', value: '"quux"'},
       {type: 'brace', value: '}'},
+      {type: 'comma', value: ','},
+      {type: 'key', value: '"circular"'},
+      {type: 'colon', value: ':'},
+      {type: 'string', value: '"[Circular ~]"'},
       {type: 'brace', value: '}'},
     ])
   })
 })
 
-describe('removeCycles', () => {
-  it('removes circular references from objects', () => {
+describe('formatInput', () => {
+  it('converts a json object to a string', () => {
+    const obj = {
+      foo: 'bar',
+      baz: {
+        qux: 'quux',
+      },
+    }
+
+    const result = stringifyInput(obj)
+    expect(result).to.equal('{"foo":"bar","baz":{"qux":"quux"}}')
+  })
+
+  it('converts a json string to a string', () => {
+    const objString = '{"foo":"bar","baz":{"qux":"quux"}}'
+
+    const result = stringifyInput(objString)
+    expect(result).to.deep.equal(objString)
+  })
+
+  it('adds indentation to json object when pretty is true', () => {
+    const obj = {
+      foo: 'bar',
+      baz: {
+        qux: 'quux',
+      },
+    }
+
+    const result = stringifyInput(obj, {pretty: true})
+    expect(result).to.equal(`{
+  "foo": "bar",
+  "baz": {
+    "qux": "quux"
+  }
+}`)
+  })
+
+  it('adds indentation to json string when pretty is true', () => {
+    const objString = '{"foo":"bar","baz":{"qux":"quux"}}'
+
+    const result = stringifyInput(objString, {pretty: true})
+    expect(result).to.equal(`{
+  "foo": "bar",
+  "baz": {
+    "qux": "quux"
+  }
+}`)
+  })
+
+  it('removes circular references from json objects', () => {
     const obj = {
       foo: 'bar',
       baz: {
@@ -165,16 +217,18 @@ describe('removeCycles', () => {
     // @ts-expect-error
     obj.circular = obj
 
-    const result = removeCycles(obj)
-    expect(result).to.deep.equal({
+    const result = stringifyInput(obj)
+
+    expect(JSON.parse(result)).to.deep.equal({
       foo: 'bar',
       baz: {
         qux: 'quux',
       },
+      circular: '[Circular ~]',
     })
   })
 
-  it('removes circular references from objects in array', () => {
+  it('removes circular references from objects in json array', () => {
     const obj = {
       foo: 'bar',
       baz: {
@@ -185,8 +239,8 @@ describe('removeCycles', () => {
     obj.circular = obj
     const arr = [{foo: 'bar'}, obj]
 
-    const result = removeCycles(arr)
-    expect(result).to.deep.equal([
+    const result = stringifyInput(arr)
+    expect(JSON.parse(result)).to.deep.equal([
       {
         foo: 'bar',
       },
@@ -195,7 +249,24 @@ describe('removeCycles', () => {
           qux: 'quux',
         },
         foo: 'bar',
+        circular: '[Circular ~.1]',
       },
     ])
   })
+
+  it('does not remove repeated objects', () => {
+    const repeatedObj = {
+      name: 'FooBar',
+      state: 'Unchanged',
+      path: '/path/to/file.txt',
+    }
+    const obj = {
+      key1: [{key2: [repeatedObj]}],
+      key2: [repeatedObj],
+    }
+
+    const result = JSON.parse(stringifyInput(obj))
+    expect(result.key1[0].key2[0]).to.deep.equal(repeatedObj)
+    expect(result.key2[0]).to.deep.equal(repeatedObj)
+  })
 })