From ea341fb4a0f87174bea33f3a35a871a91a6691ce Mon Sep 17 00:00:00 2001 From: mrodrig Date: Thu, 30 Oct 2025 23:23:11 -0400 Subject: [PATCH 1/4] fix(deeks): iterative traversal to avoid stack overflows; circular-safe handling; fixes mrodrig/deeks#35 --- .npmignore | 2 + package.json | 2 +- src/deeks.ts | 102 +++++++++++++++++++++++------- test/deepCircular.spec.ts | 31 +++++++++ test/manual/deepCircularStress.ts | 58 +++++++++++++++++ 5 files changed, 172 insertions(+), 23 deletions(-) create mode 100644 test/deepCircular.spec.ts create mode 100644 test/manual/deepCircularStress.ts diff --git a/.npmignore b/.npmignore index 5f322ff..0d676af 100644 --- a/.npmignore +++ b/.npmignore @@ -1,3 +1,5 @@ +# Exclude manual test files from published package +test/manual/ # Debug Logs npm-debug.log diff --git a/package.json b/package.json index 09512fe..4b876be 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ "coverage": "nyc npm run test", "lint": "eslint --ext .js,.ts src test", "prepublishOnly": "npm run build", - "test": "mocha -r ts-node/register test/index.ts" + "test": "mocha -r ts-node/register test/index.ts test/deepCircular.spec.ts" }, "repository": { "type": "git", diff --git a/src/deeks.ts b/src/deeks.ts index 7a5f5e4..5452ddc 100644 --- a/src/deeks.ts +++ b/src/deeks.ts @@ -13,8 +13,10 @@ export * from './types'; */ export function deepKeys(object: object, options?: DeeksOptions): string[] { const parsedOptions = mergeOptions(options); + const visited = new WeakSet(); + if (typeof object === 'object' && object !== null) { - return generateDeepKeysList('', object as Record, parsedOptions); + return generateDeepKeysList('', object as Record, parsedOptions, visited); } return []; } @@ -25,36 +27,92 @@ export function deepKeys(object: object, options?: DeeksOptions): string[] { * @param options * @returns Array[Array[String]] */ -export function deepKeysFromList(list: object[], options?: DeeksOptions): string[][] { +export function deepKeysFromList(list: object[], options?: DeeksOptions, visited?: WeakSet): string[][] { const parsedOptions = mergeOptions(options); + const localVisited = visited ?? new WeakSet(); + return list.map((document: object): string[] => { // for each document if (typeof document === 'object' && document !== null) { + // avoid re-traversing objects we've already seen (circular refs) + if (localVisited.has(document)) { + return []; + } + // if the data at the key is a document, then we retrieve the subHeading starting with an empty string heading and the doc - return deepKeys(document, parsedOptions); + return generateDeepKeysList('', document as Record, parsedOptions, localVisited); } return []; }); } -function generateDeepKeysList(heading: string, data: Record, options: DeeksOptions): string[] { - const keys = Object.keys(data).map((currentKey: string) => { - // If the given heading is empty, then we set the heading to be the subKey, otherwise set it as a nested heading w/ a dot - const keyName = buildKeyName(heading, escapeNestedDotsIfSpecified(currentKey, options)); - - // If we have another nested document, recur on the sub-document to retrieve the full key name - if (options.expandNestedObjects && utils.isDocumentToRecurOn(data[currentKey]) || (options.arrayIndexesAsKeys && Array.isArray(data[currentKey]) && (data[currentKey] as unknown[]).length)) { - return generateDeepKeysList(keyName, data[currentKey] as Record, options); - } else if (options.expandArrayObjects && Array.isArray(data[currentKey])) { - // If we have a nested array that we need to recur on - return processArrayKeys(data[currentKey] as object[], keyName, options); - } else if (options.ignoreEmptyArrays && Array.isArray(data[currentKey]) && !(data[currentKey] as unknown[]).length) { - return []; +function generateDeepKeysList(heading: string, data: Record, options: DeeksOptions, visited: WeakSet): string[] { + const result: string[] = []; + + // Iterative recursion simulation using an explicit stack of frames so we preserve + // left-to-right processing order (matching the recursive implementation). + type Frame = { obj: Record; keys: string[]; i: number; basePath: string }; + const rootKeys = Object.keys(data); + // mark root as visited to match recursive entry behavior + visited.add(data as unknown as object); + const stack: Frame[] = [{ obj: data, keys: rootKeys, i: 0, basePath: heading }]; + + while (stack.length) { + const frame = stack[stack.length - 1]; + + if (frame.i >= frame.keys.length) { + // finished this object + stack.pop(); + continue; } - // Otherwise return this key name since we don't have a sub document - return keyName; - }); - return utils.flatten(keys); + const currentKey = frame.keys[frame.i++]; + const value = frame.obj[currentKey]; + const keyName = buildKeyName(frame.basePath, escapeNestedDotsIfSpecified(currentKey, options)); + + // If nested document or array-as-object via arrayIndexesAsKeys, descend (push new frame) + if ((options.expandNestedObjects && utils.isDocumentToRecurOn(value)) || (options.arrayIndexesAsKeys && Array.isArray(value) && (value as unknown[]).length)) { + if (options.arrayIndexesAsKeys && Array.isArray(value)) { + // treat array like an object with numeric keys + const arr = value as unknown[]; + // push frames in reverse so they are processed in increasing index order + for (let idx = arr.length - 1; idx >= 0; idx--) { + const elem = arr[idx]; + const elemPath = buildKeyName(keyName, String(idx)); + if (typeof elem === 'object' && elem !== null && !Array.isArray(elem)) { + if (!visited.has(elem as unknown as object)) { + visited.add(elem as unknown as object); + stack.push({ obj: elem as Record, keys: Object.keys(elem as Record), i: 0, basePath: elemPath }); + } + } else { + // non-object -> behave like leaf (original recursion would have produced the index path) + result.push(elemPath); + } + } + } else { + // value is an object -> push its frame (if not visited) + if (!visited.has(value as unknown as object)) { + visited.add(value as unknown as object); + stack.push({ obj: value as Record, keys: Object.keys(value as Record), i: 0, basePath: keyName }); + } + } + } else if (options.expandArrayObjects && Array.isArray(value)) { + // call helper and append its results in order + const subKeys = processArrayKeys(value as object[], keyName, options, visited); + if (Array.isArray(subKeys)) { + for (const k of subKeys) { + result.push(k as string); + } + } + } else if (options.ignoreEmptyArrays && Array.isArray(value) && !(value as unknown[]).length) { + // skip + continue; + } else { + // leaf key + result.push(keyName); + } + } + + return utils.unique(result) as string[]; } /** @@ -65,8 +123,8 @@ function generateDeepKeysList(heading: string, data: Record, op * @param options * @returns {*} */ -function processArrayKeys(subArray: object[], currentKeyPath: string, options: DeeksOptions) { - let subArrayKeys = deepKeysFromList(subArray, options); +function processArrayKeys(subArray: object[], currentKeyPath: string, options: DeeksOptions, visited: WeakSet) { + let subArrayKeys = deepKeysFromList(subArray, options, visited); if (!subArray.length) { return options.ignoreEmptyArraysWhenExpanding ? [] : [currentKeyPath]; diff --git a/test/deepCircular.spec.ts b/test/deepCircular.spec.ts new file mode 100644 index 0000000..f7d19c8 --- /dev/null +++ b/test/deepCircular.spec.ts @@ -0,0 +1,31 @@ +'use strict'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { deepKeys } from '../src/deeks'; +import assert from 'assert'; + +describe('deep circular stress (automated)', function () { + // allow extra time for building large chains on slower CI + this.timeout(20000); + + it('handles a very deep circular chain without stack overflow', () => { + const depth = 5000; + const root: any = { root: true }; + let current = root; + for (let i = 0; i < depth; i++) { + const next: any = { ['k' + i]: i }; + current.child = next; + current = next; + } + + // close the cycle back to root + current.child = root; + + const keys = deepKeys(root, { expandNestedObjects: true }); + + assert.equal(Array.isArray(keys), true); + // expect root + depth entries + assert.equal((keys as string[]).length, depth + 1); + }); +}); diff --git a/test/manual/deepCircularStress.ts b/test/manual/deepCircularStress.ts new file mode 100644 index 0000000..8018102 --- /dev/null +++ b/test/manual/deepCircularStress.ts @@ -0,0 +1,58 @@ +'use strict'; + +/** + * MANUAL TEST: deepCircularStress + * + * This is a manual stress test that constructs a very deep circular + * object chain and runs `deepKeys` against it to exercise the + * iterative traversal and confirm it doesn't hit the JS call stack + * limit. + * + * IMPORTANT: + * - This file is intended to be run manually (it can be slow on CI). + * - It is placed under `test/manual/` and excluded from published + * packages via `.npmignore` so it won't be run automatically. + * + * How to run locally: + * # run with the default depth (5000) + * npx ts-node test/manual/deepCircularStress.ts + * + * # or override depth using DEPTH env var + * DEPTH=10000 npx ts-node test/manual/deepCircularStress.ts + * + * Exit codes: + * - 0: success + * - 2: deepKeys threw an exception (e.g. RangeError) + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { deepKeys } from '../../src/deeks'; + +const depth = Number(process.env.DEPTH) || 5000; +console.log(`Building circular chain with depth=${depth}`); + +const root: any = { root: true }; +let current = root; +for (let i = 0; i < depth; i++) { + const next: any = { ['k' + i]: i }; + current.child = next; + current = next; +} + +// close the cycle back to root +current.child = root; + +try { + console.time('deepKeys'); + const keys = deepKeys(root, { expandNestedObjects: true }); + console.timeEnd('deepKeys'); + console.log('returned keys count:', Array.isArray(keys) ? keys.length : 'not-array'); + if (Array.isArray(keys)) { + console.log('sample keys:', keys.slice(0, 10)); + } + process.exit(0); +} catch (err: any) { + console.error('deepKeys threw:', err && err.stack ? err.stack.split('\n')[0] : err); + process.exit(2); +} From cd4e3e31a1767a6f64fe29ef97e5212e4e7c4cc2 Mon Sep 17 00:00:00 2001 From: mrodrig Date: Thu, 30 Oct 2025 23:29:12 -0400 Subject: [PATCH 2/4] chore(rel): 3.2.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 247c6b7..78022fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "deeks", - "version": "3.1.2", + "version": "3.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "deeks", - "version": "3.1.2", + "version": "3.2.0", "license": "MIT", "devDependencies": { "@types/mocha": "10.0.1", diff --git a/package.json b/package.json index 4b876be..c464aff 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "deeks", - "version": "3.1.2", + "version": "3.2.0", "description": "Retrieve all keys and nested keys from objects and arrays of objects.", "main": "lib/deeks.js", "types": "lib/deeks.d.ts", From 1a8086b89223f3f6a9421100104a0ccbfb9f63ea Mon Sep 17 00:00:00 2001 From: mrodrig Date: Thu, 30 Oct 2025 23:50:44 -0400 Subject: [PATCH 3/4] test: additional tests to restore coverage to 100% --- src/deeks.ts | 8 ++--- test/deepCircular.spec.ts | 63 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 65 insertions(+), 6 deletions(-) diff --git a/src/deeks.ts b/src/deeks.ts index 5452ddc..2d9ba7d 100644 --- a/src/deeks.ts +++ b/src/deeks.ts @@ -97,11 +97,9 @@ function generateDeepKeysList(heading: string, data: Record, op } } else if (options.expandArrayObjects && Array.isArray(value)) { // call helper and append its results in order - const subKeys = processArrayKeys(value as object[], keyName, options, visited); - if (Array.isArray(subKeys)) { - for (const k of subKeys) { - result.push(k as string); - } + const subKeys = processArrayKeys(value as object[], keyName, options, visited) as string[]; + for (const k of subKeys) { + result.push(k as string); } } else if (options.ignoreEmptyArrays && Array.isArray(value) && !(value as unknown[]).length) { // skip diff --git a/test/deepCircular.spec.ts b/test/deepCircular.spec.ts index f7d19c8..d164f59 100644 --- a/test/deepCircular.spec.ts +++ b/test/deepCircular.spec.ts @@ -2,7 +2,7 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import { deepKeys } from '../src/deeks'; +import { deepKeys, deepKeysFromList } from '../src/deeks'; import assert from 'assert'; describe('deep circular stress (automated)', function () { @@ -28,4 +28,65 @@ describe('deep circular stress (automated)', function () { // expect root + depth entries assert.equal((keys as string[]).length, depth + 1); }); + + // Integrate additional coverage-focused tests here so they run under the + // same "deep circular stress (automated)" section. + it('deepKeysFromList returns [] for repeated documents (visited guard)', () => { + const obj: any = { a: 1 }; + const list = [obj, obj]; + + const keys = deepKeysFromList(list as object[]); + + assert.equal(Array.isArray(keys), true); + assert.deepEqual(keys, [['a'], []]); + }); + + it('arrayIndexesAsKeys includes numeric index paths for non-object entries', () => { + const testObj = { + list: [{ a: 1 }, 5] + }; + + const keys = deepKeys(testObj, { arrayIndexesAsKeys: true }); + + assert.equal(Array.isArray(keys), true); + // expect the object entry to yield 'list.0.a' and the non-object entry to yield 'list.1' + assert.deepEqual(keys.sort(), ['list.0.a', 'list.1'].sort()); + }); + + it('arrayIndexesAsKeys works for primitive-only arrays (index path)', () => { + const testObj = { arr: [1] }; + const keys = deepKeys(testObj, { arrayIndexesAsKeys: true }); + + assert.equal(Array.isArray(keys), true); + assert.deepEqual(keys, ['arr.0']); + }); + + it('arrayIndexesAsKeys works for nested primitive arrays', () => { + const testObj = { outer: { arr: [1] } }; + const keys = deepKeys(testObj, { arrayIndexesAsKeys: true }); + + assert.equal(Array.isArray(keys), true); + assert.deepEqual(keys, ['outer.arr.0']); + }); + + it('arrayIndexesAsKeys does not re-traverse the same object repeated in an array', () => { + const shared = { a: 1 }; + const testObj = { arr: [shared, shared] }; + const keys = deepKeys(testObj, { arrayIndexesAsKeys: true }); + + // Only one set of keys should be produced for the shared object + assert.equal(Array.isArray(keys), true); + assert.deepEqual(keys.sort(), ['arr.1.a'].sort()); + }); + + it('does not re-traverse the same nested object (visited prevents duplicate traversal)', () => { + const shared: any = { x: 1 }; + const testObj: any = { a: shared, b: shared }; + + const keys = deepKeys(testObj); + + assert.equal(Array.isArray(keys), true); + // 'a.x' should be present but 'b.x' should not because the object was already visited + assert.deepEqual(keys, ['a.x']); + }); }); From 7ec971f151755e0b611ff35f3580c59a012d0fcd Mon Sep 17 00:00:00 2001 From: mrodrig Date: Thu, 30 Oct 2025 23:55:12 -0400 Subject: [PATCH 4/4] refactor: resolve warning from GitHub Actions regarding type vs interface --- src/deeks.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/deeks.ts b/src/deeks.ts index 2d9ba7d..62853f3 100644 --- a/src/deeks.ts +++ b/src/deeks.ts @@ -50,7 +50,7 @@ function generateDeepKeysList(heading: string, data: Record, op // Iterative recursion simulation using an explicit stack of frames so we preserve // left-to-right processing order (matching the recursive implementation). - type Frame = { obj: Record; keys: string[]; i: number; basePath: string }; + interface Frame { obj: Record; keys: string[]; i: number; basePath: string } const rootKeys = Object.keys(data); // mark root as visited to match recursive entry behavior visited.add(data as unknown as object);