Files
graphify/graphify/affected.py
T
2026-05-22 13:24:54 +01:00

152 lines
4.0 KiB
Python

from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import networkx as nx
DEFAULT_AFFECTED_RELATIONS = (
"calls",
"references",
"imports",
"imports_from",
"re_exports",
"inherits",
"extends",
"implements",
"uses",
"mixes_in",
"embeds",
)
@dataclass(frozen=True)
class AffectedHit:
node_id: str
depth: int
via_relation: str
def _node_label(graph: nx.Graph, node_id: str) -> str:
data = graph.nodes[node_id]
return str(data.get("label") or node_id)
def _format_location(data: dict) -> str:
source_file = data.get("source_file") or "-"
source_location = data.get("source_location")
if source_location:
return f"{source_file}:{source_location}"
return str(source_file)
def resolve_seed(graph: nx.Graph, query: str) -> str | None:
if query in graph:
return query
query_lower = query.lower()
exact_label_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if str(data.get("label", "")).lower() == query_lower
]
if len(exact_label_matches) == 1:
return exact_label_matches[0]
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if str(data.get("source_file", "")).lower() == query_lower
]
if len(exact_source_matches) == 1:
return exact_source_matches[0]
contains_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if query_lower in str(data.get("label", "")).lower()
]
if len(contains_matches) == 1:
return contains_matches[0]
return None
def affected_nodes(
graph: nx.Graph,
seed: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> list[AffectedHit]:
relation_set = set(relations)
seen = {seed}
queue: deque[tuple[str, int]] = deque([(seed, 0)])
hits: list[AffectedHit] = []
while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
continue
if hasattr(graph, "in_edges"):
incoming = graph.in_edges(current, data=True)
else:
incoming = (
(source, target, data)
for source, target, data in graph.edges(data=True)
if target == current
)
for source, _target, data in incoming:
relation = str(data.get("relation", ""))
if relation not in relation_set:
continue
source = str(source)
if source in seen:
continue
seen.add(source)
hit = AffectedHit(source, current_depth + 1, relation)
hits.append(hit)
queue.append((source, current_depth + 1))
return hits
def format_affected(
graph: nx.Graph,
query: str,
*,
relations: Iterable[str] = DEFAULT_AFFECTED_RELATIONS,
depth: int = 2,
) -> str:
relation_list = tuple(relations)
seed = resolve_seed(graph, query)
if seed is None:
return f"No unique node match for {query}"
hits = affected_nodes(graph, seed, relations=relation_list, depth=depth)
lines = [
f"Affected nodes for {_node_label(graph, seed)}",
f"Relations: {', '.join(relation_list)}",
f"Depth: {depth}",
]
if not hits:
lines.append("No affected nodes found.")
return "\n".join(lines)
for hit in hits:
data = graph.nodes[hit.node_id]
lines.append(
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {_format_location(data)}"
)
return "\n".join(lines)
def load_graph(path: Path) -> nx.Graph:
import json
from networkx.readwrite import json_graph
raw = json.loads(path.read_text(encoding="utf-8"))
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
return json_graph.node_link_graph(raw)