Files
graphify/graphify/affected.py
T
balloon72 d885833112 fix node label lookup normalization (#1353)
Fixes #1338 (Unicode NFD/NFC): serve._find_node now matches tokenized labels; affected.resolve_seed NFC-normalizes + casefolds. Reviewed: full suite 2087 passed, CLI smoke clean, no regressions. Thanks @balloon72.
2026-06-17 10:29:43 +01:00

183 lines
5.4 KiB
Python

from __future__ import annotations
from collections import deque
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
import unicodedata
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 _bare_name(label: str) -> str:
"""Lowercased label with the callable decoration (trailing "()") removed."""
label = _normalize_label(label)
return label[:-2] if label.endswith("()") else label
def _normalize_label(label: str) -> str:
return unicodedata.normalize("NFC", label).casefold()
def resolve_seed(graph: nx.Graph, query: str) -> str | None:
if query in graph:
return query
query_lower = _normalize_label(query)
exact_label_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("label", ""))) == query_lower
]
if len(exact_label_matches) == 1:
return exact_label_matches[0]
# Callable labels are decorated ("name()"), so a bare "name" query falls
# through exact matching and then ties with any "name*" sibling in the
# contains pass. Match on the undecorated name before giving up.
query_bare = _bare_name(query_lower)
bare_name_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _bare_name(str(data.get("label", ""))) == query_bare
]
if len(bare_name_matches) == 1:
return bare_name_matches[0]
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("source_file", ""))) == 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 _normalize_label(str(data.get("label", "")))
]
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"))
# Force directed so stored caller→callee direction survives the round-trip;
# mirrors serve.py and __main__.py (#1174).
raw = {**raw, "directed": True}
# Normalize the edge key: graphify's `extract` output uses "edges" while
# networkx's node_link_data default is "links". Without this, an edges-keyed
# graph.json raises an uncaught KeyError: 'links' here — every other loader
# (__main__.py) already normalizes this (#738; same class as #1198).
if "links" not in raw and "edges" in raw:
raw = dict(raw, links=raw["edges"])
try:
return json_graph.node_link_graph(raw, edges="links")
except TypeError:
return json_graph.node_link_graph(raw)