Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 148 additions & 14 deletions codegraph/analysis/dataflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,25 @@
This module exposes two complementary functions:

* :func:`match_route` — given a frontend ``FETCH_CALL`` URL + method, find the
qualname of the backend handler whose ``ROUTE`` edge matches. Implemented by
the DF3 stitcher (URL pattern matching with parameter normalisation).
qualname of the backend handler whose ``ROUTE`` edge matches.

* :func:`trace` — given an entry symbol (function qualname or ``url:METHOD path``
shape), walk the call graph + cross-layer edges to produce an ordered
:class:`DataFlow`. Implemented by DF4.

The dataclasses are stable contract — agents fill in the function bodies but
never modify the shapes here without coordination.
The dataclasses are stable contract — never modify the shapes here without
coordination.
"""
from __future__ import annotations

import re
from dataclasses import dataclass, field
from typing import Any

import networkx as nx

from codegraph.graph.schema import EdgeKind, NodeKind


@dataclass
class FlowHop:
Expand Down Expand Up @@ -81,24 +83,156 @@ def to_dict(self) -> dict[str, Any]:
}


_PLACEHOLDER_RE = re.compile(r"^(\{[^}]*\}|\$\{[^}]*\}|:[A-Za-z_][A-Za-z0-9_]*|-?\d+)$")


def _strip_query_fragment(path: str) -> str:
"""Drop ``?query`` and ``#fragment``; collapse trailing slash."""
for sep in ("?", "#"):
if sep in path:
path = path.split(sep, 1)[0]
if len(path) > 1 and path.endswith("/"):
path = path.rstrip("/")
return path


def _segments(path: str) -> list[str]:
"""Split ``/api/users/{id}`` into ``['api', 'users', '{id}']``."""
return [s for s in _strip_query_fragment(path).split("/") if s]


def _is_placeholder(seg: str) -> bool:
"""A segment is a placeholder if it's purely numeric, or wrapped in
``{...}`` / ``${...}`` / leading ``:`` (Express style)."""
return bool(_PLACEHOLDER_RE.match(seg))


def _normalise_path(path: str) -> list[str]:
"""Return the list of normalised segments where every placeholder
becomes the marker ``{*}`` so two paths with different placeholder
syntaxes compare equal segment-by-segment."""
return ["{*}" if _is_placeholder(s) else s for s in _segments(path)]


def _path_specificity(segs: list[str]) -> int:
"""How "concrete" a path is — more literal segments means more specific.
Used to break ties when two routes match the same fetch."""
return sum(1 for s in segs if s != "{*}")


def _route_candidates(graph: nx.MultiDiGraph) -> list[tuple[str, str, str]]:
"""Yield ``(handler_qualname, method, path)`` for every ROUTE edge.

ROUTE edges go from a backend handler FUNCTION/METHOD to a synthetic
target node with id ``route::<METHOD>::<path>``. The handler qualname
is the source node's qualname.
"""
out: list[tuple[str, str, str]] = []
for src, _dst, key, edata in graph.edges(keys=True, data=True):
if key != EdgeKind.ROUTE.value:
continue
meta = edata.get("metadata") or {}
if not isinstance(meta, dict):
continue
method = str(meta.get("method") or "").upper()
path = str(meta.get("path") or "")
if not method or not path:
continue
attrs = graph.nodes.get(src) or {}
qn = str(attrs.get("qualname") or src)
out.append((qn, method, path))
return out


def _handler_param_names(graph: nx.MultiDiGraph, handler_qn: str) -> list[str]:
"""Extract parameter names for the handler function, for body-key
overlap scoring. Looks up the node by qualname and reads
``metadata.params`` (populated by DF0)."""
for _nid, attrs in graph.nodes(data=True):
if str(attrs.get("qualname") or "") != handler_qn:
continue
kind = str(attrs.get("kind") or "")
if kind not in (NodeKind.FUNCTION.value, NodeKind.METHOD.value):
continue
meta = attrs.get("metadata") or {}
params = meta.get("params") or [] if isinstance(meta, dict) else []
names: list[str] = []
for p in params:
if isinstance(p, dict):
name = str(p.get("name") or "").lstrip("*")
if name and name not in ("self", "cls"):
names.append(name)
return names
return []


def match_route(
graph: nx.MultiDiGraph,
fetch_url: str,
fetch_method: str = "GET",
*,
body_keys: list[str] | None = None,
) -> tuple[str, float] | None:
"""Return ``(handler_qualname, confidence)`` for the route matching this
frontend fetch, or ``None`` if no route matches.
"""Return ``(handler_qualname, confidence)`` for the backend ROUTE that
matches this frontend fetch, or ``None`` if no route matches.

Confidence rubric:
* **1.0** — exact literal-segment match, no placeholders involved
* **0.9** — placeholders in either side normalise to the same shape
* up to **+0.05** bonus if the fetch's ``body_keys`` overlap with the
handler's parameter names (clamped at 0.95 / 1.0 ceilings)
* **0.5** — only a path *prefix* matches (last-resort fuzzy)
* **None** — method mismatch or no overlap

Implemented by DF3 (agent A1). Stub returns ``None`` so DF4 can build
against the contract before A1 lands.
Trailing slashes, query strings, and fragments are stripped before
matching. Method comparison is case-insensitive.

Confidence rubric (DF3 will encode):
* 1.0 — exact path + method match
* 0.9 — path with placeholders matches (``/users/{id}`` vs ``/users/42``)
* 0.7 — method matches, path matches with body-key heuristic
* 0.5 — only path prefix matches (last-resort)
When multiple routes match at the same top confidence, the more
specific one (more literal segments) wins.
"""
return None
method = (fetch_method or "GET").upper()
fetch_segs = _normalise_path(fetch_url)
raw_fetch_segs = _segments(fetch_url)
fetch_is_literal = all(not _is_placeholder(s) for s in raw_fetch_segs)

best: tuple[str, float, int] | None = None # (qn, score, specificity)

for handler_qn, route_method, route_path in _route_candidates(graph):
if route_method != method:
continue
route_segs = _normalise_path(route_path)
raw_route_segs = _segments(route_path)
route_is_literal = all(not _is_placeholder(s) for s in raw_route_segs)

if fetch_segs == route_segs:
base = 1.0 if (fetch_is_literal and route_is_literal) else 0.9
specificity = _path_specificity(route_segs)
elif (
len(fetch_segs) >= len(route_segs)
and fetch_segs[: len(route_segs)] == route_segs
and len(route_segs) > 0
):
base = 0.5
specificity = _path_specificity(route_segs)
else:
continue

# Body-key bonus: any overlap with handler params nudges score up.
if body_keys:
param_names = _handler_param_names(graph, handler_qn)
overlap = set(body_keys) & set(param_names)
if overlap:
cap = 1.0 if base >= 1.0 else (0.95 if base >= 0.9 else 0.7)
base = min(cap, base + 0.05)

if best is None or base > best[1] or (
base == best[1] and specificity > best[2]
):
best = (handler_qn, base, specificity)

if best is None:
return None
return (best[0], best[1])


def trace(
Expand Down
Loading
Loading