Skip to content

Commit b66c02a

Browse files
committed
feat: implement recursive type detection and depth limitation for reference path extraction
1 parent ccd726e commit b66c02a

1 file changed

Lines changed: 64 additions & 3 deletions

File tree

src/util/references.util.ts

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -229,6 +229,50 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
229229
return { nodeFunctionId, paramIndexFromName };
230230
};
231231

232+
/**
233+
* Maximum property depth for reference path extraction through recursive data
234+
* types. Only applies to types that participate in a reference cycle: the
235+
* visited set keeps each individual branch finite, but a cluster of mutually
236+
* recursive types still allows combinatorially many simple paths, so those are
237+
* additionally depth-capped. Non-recursive nesting is traversed exhaustively.
238+
*/
239+
const MAX_REFERENCE_DEPTH = 7;
240+
241+
/**
242+
* Determines whether an object type participates in a reference cycle, i.e. can
243+
* reach itself again through its (non-nullable) property types. Results are
244+
* memoized in the given cache since the same named types repeat throughout a
245+
* traversal. The DFS itself is guarded by its own seen set, so it terminates on
246+
* cycles that do not lead back to the start type.
247+
*/
248+
const isRecursiveType = (
249+
type: ts.Type,
250+
checker: ts.TypeChecker,
251+
cache: Map<ts.Type, boolean>
252+
): boolean => {
253+
const cached = cache.get(type);
254+
if (cached !== undefined) return cached;
255+
256+
const seen = new Set<ts.Type>();
257+
const reachesStart = (current: ts.Type): boolean => {
258+
if (seen.has(current) || !isRealObjectType(current)) return false;
259+
seen.add(current);
260+
261+
for (const property of current.getProperties()) {
262+
if (!property.valueDeclaration) continue;
263+
const propType = checker.getNonNullableType(
264+
checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration)
265+
);
266+
if (propType === type || reachesStart(propType)) return true;
267+
}
268+
return false;
269+
};
270+
271+
const result = reachesStart(type);
272+
cache.set(type, result);
273+
return result;
274+
};
275+
232276
/**
233277
* Recursively extracts object properties from a type that match an expected type.
234278
*
@@ -243,6 +287,8 @@ const decodeParameterName = (name: string): { nodeFunctionId: string; paramIndex
243287
* @param checker - TypeScript TypeChecker for type comparison operations
244288
* @param expectedType - The target type to match when extracting properties
245289
* @param currentPath - The current property path being built during recursion (default: empty array)
290+
* @param visited - Object types already on the current traversal branch, used to break cycles in recursive data types
291+
* @param recursionCache - Memoization cache for isRecursiveType, shared across the whole traversal
246292
* @returns An array of objects containing the property path and the type at that path
247293
*
248294
* @example
@@ -253,7 +299,9 @@ const extractObjectProperties = (
253299
type: ts.Type,
254300
checker: ts.TypeChecker,
255301
expectedType: ts.Type,
256-
currentPath: ReferencePath[] = []
302+
currentPath: ReferencePath[] = [],
303+
visited: Set<ts.Type> = new Set(),
304+
recursionCache: Map<ts.Type, boolean> = new Map()
257305
): Array<{ path: ReferencePath[]; type: ts.Type }> => {
258306
const results: Array<{ path: ReferencePath[]; type: ts.Type }> = [];
259307

@@ -274,17 +322,30 @@ const extractObjectProperties = (
274322
// Recursively traverse into object properties. Traversal also runs on the
275323
// non-nullable type: a nullable object in the chain (e.g. `{bla?: TEXT} | null`)
276324
// is a union whose getProperties() is empty, which would cut off all nested paths.
277-
if (isRealObjectType(nonNullableType)) {
325+
// Recursive data types (e.g. Order.delivery.order) are cut off via the visited
326+
// set — the checker caches type identities, so a cycle revisits the same ts.Type
327+
// object. The set only tracks the current branch (backtracked below) so the same
328+
// type may still appear on sibling paths. The depth cap only applies to types
329+
// that are part of a reference cycle (checked last, it's the expensive test);
330+
// purely nested non-recursive objects are traversed to arbitrary depth.
331+
if (
332+
isRealObjectType(nonNullableType) &&
333+
!visited.has(nonNullableType) &&
334+
(currentPath.length < MAX_REFERENCE_DEPTH ||
335+
!isRecursiveType(nonNullableType, checker, recursionCache))
336+
) {
278337
const properties = nonNullableType.getProperties();
279338
if (properties && properties.length > 0) {
339+
visited.add(nonNullableType);
280340
properties.forEach((property) => {
281341
const propType = checker.getTypeOfSymbolAtLocation(property, property.valueDeclaration!);
282342
const propName = property.getName();
283343
const newPath = [...currentPath, { path: propName }];
284344

285345
// Recurse into nested properties
286-
results.push(...extractObjectProperties(propType, checker, expectedType, newPath));
346+
results.push(...extractObjectProperties(propType, checker, expectedType, newPath, visited, recursionCache));
287347
});
348+
visited.delete(nonNullableType);
288349
}
289350
}
290351

0 commit comments

Comments
 (0)