@@ -235,6 +235,121 @@ def match_route(
235235 return (best [0 ], best [1 ])
236236
237237
238+ _FRONTEND_EXTS = (".tsx" , ".jsx" )
239+ _FETCH_ENTRY_RE = re .compile (
240+ r"^\s*(?:url:\s*)?(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|TRACE|WEBSOCKET)\s+(\S+)\s*$" ,
241+ re .IGNORECASE ,
242+ )
243+
244+
245+ def _resolve_entry_node (
246+ graph : nx .MultiDiGraph , entry : str
247+ ) -> str | None :
248+ """Find a node id matching the given qualname (exact, case-insensitive)."""
249+ target = entry .strip ()
250+ for nid , attrs in graph .nodes (data = True ):
251+ qn = str (attrs .get ("qualname" ) or "" )
252+ if qn == target :
253+ return str (nid )
254+ # Case-insensitive fallback
255+ lower = target .lower ()
256+ for nid , attrs in graph .nodes (data = True ):
257+ qn = str (attrs .get ("qualname" ) or "" )
258+ if qn .lower () == lower :
259+ return str (nid )
260+ return None
261+
262+
263+ def _layer_for (attrs : dict [str , Any ]) -> str :
264+ """Pick a layer label from a node's attrs."""
265+ metadata = attrs .get ("metadata" ) or {}
266+ role = ""
267+ if isinstance (metadata , dict ):
268+ role_val = metadata .get ("role" )
269+ role = str (role_val ) if role_val else ""
270+ if role == "REPO" :
271+ return "db"
272+ if role == "COMPONENT" :
273+ return "frontend"
274+ file_path = str (attrs .get ("file" ) or "" ).lower ()
275+ if any (file_path .endswith (ext ) for ext in _FRONTEND_EXTS ):
276+ return "frontend"
277+ return "backend"
278+
279+
280+ def _hop_from_node (
281+ graph : nx .MultiDiGraph ,
282+ node_id : str ,
283+ * ,
284+ args : list [str ] | None = None ,
285+ kwargs : dict [str , str ] | None = None ,
286+ confidence : float = 1.0 ,
287+ ) -> FlowHop :
288+ attrs = graph .nodes .get (node_id ) or {}
289+ metadata = attrs .get ("metadata" ) or {}
290+ role = None
291+ if isinstance (metadata , dict ):
292+ role_val = metadata .get ("role" )
293+ if role_val :
294+ role = str (role_val )
295+ return FlowHop (
296+ layer = _layer_for (attrs ),
297+ qualname = str (attrs .get ("qualname" ) or node_id ),
298+ file = str (attrs .get ("file" ) or "" ),
299+ line = int (attrs .get ("line_start" ) or 0 ),
300+ args = list (args or []),
301+ kwargs = dict (kwargs or {}),
302+ role = role ,
303+ confidence = confidence ,
304+ )
305+
306+
307+ def _outgoing_calls (
308+ graph : nx .MultiDiGraph , node_id : str
309+ ) -> list [tuple [str , dict [str , Any ]]]:
310+ """Return [(target_id, edge_metadata)] for outgoing CALLS edges."""
311+ out : list [tuple [str , dict [str , Any ]]] = []
312+ for _src , dst , key , edata in graph .out_edges (node_id , keys = True , data = True ):
313+ if key == EdgeKind .CALLS .value :
314+ meta = edata .get ("metadata" ) or {}
315+ out .append ((str (dst ), meta if isinstance (meta , dict ) else {}))
316+ return out
317+
318+
319+ def _outgoing_data_edges (
320+ graph : nx .MultiDiGraph , node_id : str
321+ ) -> list [tuple [str , str ]]:
322+ """Return [(target_id, edge_kind)] for READS_FROM / WRITES_TO edges."""
323+ out : list [tuple [str , str ]] = []
324+ for _src , dst , key in graph .out_edges (node_id , keys = True ):
325+ if key in (EdgeKind .READS_FROM .value , EdgeKind .WRITES_TO .value ):
326+ out .append ((str (dst ), str (key )))
327+ return out
328+
329+
330+ def _outgoing_fetches (
331+ graph : nx .MultiDiGraph , node_id : str
332+ ) -> list [dict [str , Any ]]:
333+ """Return list of FETCH_CALL edge metadata dicts originating from this node."""
334+ out : list [dict [str , Any ]] = []
335+ for _src , _dst , key , edata in graph .out_edges (node_id , keys = True , data = True ):
336+ if key == EdgeKind .FETCH_CALL .value :
337+ meta = edata .get ("metadata" ) or {}
338+ if isinstance (meta , dict ):
339+ out .append (meta )
340+ return out
341+
342+
343+ def _edge_args (meta : dict [str , Any ]) -> tuple [list [str ], dict [str , str ]]:
344+ args_raw = meta .get ("args" ) or []
345+ kwargs_raw = meta .get ("kwargs" ) or {}
346+ args = [str (a ) for a in args_raw ] if isinstance (args_raw , list ) else []
347+ kwargs : dict [str , str ] = {}
348+ if isinstance (kwargs_raw , dict ):
349+ kwargs = {str (k ): str (v ) for k , v in kwargs_raw .items ()}
350+ return args , kwargs
351+
352+
238353def trace (
239354 graph : nx .MultiDiGraph ,
240355 entry : str ,
@@ -246,15 +361,156 @@ def trace(
246361 ``entry`` may be:
247362
248363 * a fully-qualified symbol name — walk forwards over CALLS edges
249- * ``"url:METHOD /path"`` — start from a frontend ``FETCH_CALL`` and stitch
250- through the matching ROUTE handler
364+ * ``"METHOD /path"`` (or ``"url:METHOD /path"``) — find a backend handler
365+ via :func:`match_route` and walk from there
366+
367+ Returns ``None`` when the entry cannot be located.
368+
369+ Hop construction:
370+ * each visited node becomes a :class:`FlowHop`
371+ * args/kwargs come from the *incoming* CALLS edge that brought us here
372+ * READS_FROM / WRITES_TO edges become trailing ``layer=db`` hops
373+ * outgoing FETCH_CALL edges trigger a cross-layer match via
374+ :func:`match_route`; if no match, the partial chain is returned with
375+ confidence dropped accordingly
376+ * cycle detection: already-visited nodes are skipped silently
377+ * stop after ``max_depth`` outgoing hops
378+ """
379+ # ---- Resolve the starting node --------------------------------------
380+ fetch_match = _FETCH_ENTRY_RE .match (entry )
381+ start_node : str | None = None
382+ initial_confidence = 1.0
383+ initial_args : list [str ] = []
384+ initial_kwargs : dict [str , str ] = {}
385+ initial_method : str | None = None
386+ initial_path : str | None = None
387+
388+ if fetch_match :
389+ method = fetch_match .group (1 ).upper ()
390+ path = fetch_match .group (2 )
391+ result = match_route (graph , path , method )
392+ if result is None :
393+ return DataFlow (entry = entry , hops = [], confidence = 0.0 )
394+ handler_qn , conf = result
395+ start_node = _resolve_entry_node (graph , handler_qn )
396+ initial_confidence = conf
397+ initial_method = method
398+ initial_path = path
399+ else :
400+ start_node = _resolve_entry_node (graph , entry )
401+
402+ if start_node is None :
403+ return None
251404
252- Returns ``None`` when ``entry`` cannot be located in the graph.
405+ # ---- Walk the graph -------------------------------------------------
406+ hops : list [FlowHop ] = []
407+ visited : set [str ] = set ()
408+
409+ first_hop = _hop_from_node (
410+ graph ,
411+ start_node ,
412+ args = initial_args ,
413+ kwargs = initial_kwargs ,
414+ confidence = initial_confidence ,
415+ )
416+ if initial_method is not None :
417+ first_hop .method = initial_method
418+ if initial_path is not None :
419+ first_hop .path = initial_path
420+ hops .append (first_hop )
421+ visited .add (start_node )
422+
423+ current = start_node
424+ confidences : list [float ] = [initial_confidence ]
425+ depth = 0
426+
427+ while depth < max_depth :
428+ # 1. Cross-layer fetch transition (if any)
429+ fetches = _outgoing_fetches (graph , current )
430+ consumed_via_fetch = False
431+ for fmeta in fetches :
432+ url = str (fmeta .get ("url" ) or "" )
433+ method = str (fmeta .get ("method" ) or "GET" )
434+ body_keys_raw = fmeta .get ("body_keys" ) or []
435+ body_keys = (
436+ [str (k ) for k in body_keys_raw ]
437+ if isinstance (body_keys_raw , list )
438+ else None
439+ )
440+ result = match_route (graph , url , method , body_keys = body_keys )
441+ if result is None :
442+ continue
443+ handler_qn , conf = result
444+ handler_node = _resolve_entry_node (graph , handler_qn )
445+ if handler_node is None or handler_node in visited :
446+ continue
447+ hop = _hop_from_node (
448+ graph ,
449+ handler_node ,
450+ args = [],
451+ kwargs = {},
452+ confidence = conf ,
453+ )
454+ hop .method = method
455+ hop .path = url
456+ hops .append (hop )
457+ visited .add (handler_node )
458+ confidences .append (conf )
459+ current = handler_node
460+ consumed_via_fetch = True
461+ break # follow one fetch per hop
462+ if consumed_via_fetch :
463+ depth += 1
464+ continue
253465
254- Implemented by DF4 (agent A2). Stub returns ``None`` so the CLI / MCP layer
255- can register the public surface.
256- """
257- return None
466+ # 2. Standard CALLS traversal — pick the first outgoing edge
467+ callees = _outgoing_calls (graph , current )
468+ next_step : tuple [str , list [str ], dict [str , str ]] | None = None
469+ for dst , meta in callees :
470+ if dst in visited :
471+ continue
472+ args , kwargs = _edge_args (meta )
473+ next_step = (dst , args , kwargs )
474+ break
475+
476+ if next_step is not None :
477+ dst , args , kwargs = next_step
478+ hop = _hop_from_node (
479+ graph ,
480+ dst ,
481+ args = args ,
482+ kwargs = kwargs ,
483+ confidence = 1.0 ,
484+ )
485+ hops .append (hop )
486+ visited .add (dst )
487+ confidences .append (1.0 )
488+ current = dst
489+ depth += 1
490+ continue
491+
492+ # 3. Terminal data edges (READS_FROM / WRITES_TO) — emit and stop
493+ for dst , kind in _outgoing_data_edges (graph , current ):
494+ if dst in visited :
495+ continue
496+ db_attrs = graph .nodes .get (dst ) or {}
497+ db_qn = str (db_attrs .get ("qualname" ) or dst )
498+ db_hop = FlowHop (
499+ layer = "db" ,
500+ qualname = db_qn ,
501+ file = str (db_attrs .get ("file" ) or "" ),
502+ line = int (db_attrs .get ("line_start" ) or 0 ),
503+ role = "REPO" ,
504+ confidence = 1.0 ,
505+ )
506+ db_hop .kwargs = {"op" : kind }
507+ hops .append (db_hop )
508+ visited .add (dst )
509+ confidences .append (1.0 )
510+ break
511+
512+ final_conf = min (confidences ) if confidences else 0.0
513+ return DataFlow (entry = entry , hops = hops , confidence = final_conf )
258514
259515
260516__all__ = ["DataFlow" , "FlowHop" , "match_route" , "trace" ]
0 commit comments