Skip to content

Commit 9bf46e7

Browse files
authored
Merge pull request #7 from smochan/feat/df4-trace
feat(dataflow): DF4 — trace() + CLI + MCP for end-to-end data flow
2 parents 4d2f235 + 7b18669 commit 9bf46e7

5 files changed

Lines changed: 683 additions & 7 deletions

File tree

codegraph/analysis/dataflow.py

Lines changed: 263 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
238353
def 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"]

codegraph/cli.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,15 @@
2727
baseline_app = typer.Typer(help="Manage baseline snapshots.", no_args_is_help=True)
2828
hook_app = typer.Typer(help="Manage git hooks.", no_args_is_help=True)
2929
mcp_app = typer.Typer(help="Run codegraph as an MCP server.", no_args_is_help=True)
30+
dataflow_app = typer.Typer(
31+
help="Trace data flows across frontend / backend / db layers.",
32+
no_args_is_help=True,
33+
)
3034
app.add_typer(query_app, name="query")
3135
app.add_typer(baseline_app, name="baseline")
3236
app.add_typer(hook_app, name="hook")
3337
app.add_typer(mcp_app, name="mcp")
38+
app.add_typer(dataflow_app, name="dataflow")
3439

3540
console = Console()
3641

@@ -1057,6 +1062,74 @@ def hook_uninstall(
10571062
)
10581063

10591064

1065+
@dataflow_app.command("trace")
1066+
def dataflow_trace_cmd(
1067+
entry: str = typer.Argument(
1068+
...,
1069+
help=(
1070+
"Entry point — either a function qualname (e.g. "
1071+
"'app.handlers.get_user'), or a fetch shape "
1072+
"('GET /api/users/{id}')."
1073+
),
1074+
),
1075+
depth: int = typer.Option(6, "--depth", help="Max trace depth (hops)."),
1076+
fmt: str = typer.Option("markdown", "--format", help="markdown|json"),
1077+
) -> None:
1078+
"""Trace a data flow from an entry point through the call graph and
1079+
cross-layer edges. Output is markdown or JSON.
1080+
"""
1081+
import json as _json
1082+
1083+
from codegraph.analysis.dataflow import trace as _trace
1084+
1085+
repo_root = Path.cwd()
1086+
graph = _open_graph(repo_root)
1087+
if graph is None:
1088+
raise typer.Exit(1)
1089+
1090+
flow = _trace(graph, entry, max_depth=depth)
1091+
if flow is None:
1092+
console.print(
1093+
f"[yellow]No symbol or route matched: {entry}[/yellow]"
1094+
)
1095+
raise typer.Exit(2)
1096+
1097+
if fmt == "json":
1098+
typer.echo(_json.dumps(flow.to_dict(), indent=2))
1099+
return
1100+
1101+
# Default: rich markdown-ish output
1102+
console.print(
1103+
f"\n[bold]Flow trace from:[/bold] {flow.entry} "
1104+
f"([cyan]confidence: {flow.confidence:.2f}[/cyan])\n"
1105+
)
1106+
if not flow.hops:
1107+
console.print(" [yellow](no hops)[/yellow]")
1108+
return
1109+
layer_styles = {
1110+
"frontend": "magenta",
1111+
"backend": "cyan",
1112+
"db": "yellow",
1113+
}
1114+
for i, hop in enumerate(flow.hops):
1115+
prefix = " " if i == 0 else " ↓\n "
1116+
layer_label = f"[{layer_styles.get(hop.layer, 'white')}][{hop.layer}][/]"
1117+
loc = f"{hop.file}:{hop.line}" if hop.file else ""
1118+
role = f" [bold]{hop.role}[/bold]" if hop.role else ""
1119+
line1 = f"{prefix}{layer_label} {loc} [white]{hop.qualname}[/white]{role}"
1120+
console.print(line1)
1121+
if hop.method and hop.path:
1122+
console.print(
1123+
f" [dim]{hop.method} {hop.path}[/dim]"
1124+
)
1125+
if hop.args or hop.kwargs:
1126+
args_str = ", ".join(hop.args)
1127+
kwargs_str = ", ".join(f"{k}={v}" for k, v in hop.kwargs.items())
1128+
joined = ", ".join(s for s in (args_str, kwargs_str) if s)
1129+
console.print(f" [dim]args: ({joined})[/dim]")
1130+
console.print()
1131+
1132+
10601133
@app.command()
10611134
def embed(
10621135
model: str = typer.Option(

0 commit comments

Comments
 (0)