@@ -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