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)