Files
graphify/graphify/affected.py
T
2026-10-11 20:47:55 +01:00

394 lines
15 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",
"indirect_call",
"references",
"imports",
"imports_from",
# `import('…')` — emitted by the Svelte/Astro/Vue rescue passes and (since
# #2575) by plain JS/TS too. Omitting it made every dynamic import
# invisible to blast-radius traversal even where the edge WAS in the
# graph, and dynamic import is precisely how codebases break require
# cycles, so the missing edges sat under the most load-bearing modules.
"dynamic_import",
"re_exports",
"inherits",
"extends",
"implements",
"uses",
"mixes_in",
"embeds",
# `defines` — emitted when a file declares a named symbol (class, function,
# type, etc.). Without it, `affected "<Symbol>"` is blind in languages
# where the only incoming edge to a symbol is its `defines` edge from the
# source file — Dart/Flutter, Python, Go, etc. — because every other
# relation (calls, references, imports) targets the *file* node, not the
# symbol node. The reverse walk follows `defines` from the symbol back to
# its defining file and then continues through `imports` to consumers.
"defines",
"requires",
)
# Edges from a type to the types it derives from. A method declared again in a
# subtype overrides the supertype's same-named member.
_SUPERTYPE_RELATIONS = frozenset({"inherits", "extends", "implements"})
@dataclass(frozen=True)
class AffectedHit:
node_id: str
depth: int
via_relation: str
# The traversed edge's location — the actual call/import/reference SITE in
# this node's file, not the node's own definition line (#BUG1). Defaults keep
# existing constructors/tests working; None falls back to the node's def line.
via_file: "str | None" = None
via_location: "str | None" = None
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 _as_repo_relative(query: str, root: Path | None = None) -> str:
"""Repo-relative form of a path query, for matching a stored `source_file`.
The graph stores repo-relative paths, so `./src/x.py` and
`/abs/repo/src/x.py` name the same file as `src/x.py` and yet matched
nothing. `affected` then printed an empty list and exited 0 — a blast-radius
tool answering "nothing depends on this" about a file with sixteen
dependents, and indistinguishable from a genuine zero or a typo.
An absolute path is anchored to `root` when given — the repo root derived
from the graph's own location — so a seed resolves regardless of the caller's
working directory (#2706: an absolute-path seed previously only matched when
cwd happened to be the analysed repo root, which no editor or script can
guarantee). `root` falls back to the current directory to preserve the prior
behaviour when a caller has no graph location to derive it from.
Non-path queries pass through unchanged: `Path("myFunc()").as_posix()` is
`"myFunc()"`, so label resolution is untouched. An absolute path rooted
outside `root` is left alone — no basename guessing.
"""
path = Path(query)
if path.is_absolute():
anchor = root if root is not None else Path.cwd()
try:
return path.relative_to(anchor).as_posix()
except ValueError:
# Rooted outside the repo: nothing here can make it repo-relative,
# so leave it alone rather than guess at a basename that would match
# some unrelated file with the same name.
return query
return path.as_posix()
def _prefer_file_node(
graph: nx.Graph,
node_ids: list[str],
query: str,
) -> str | None:
"""Return the file-level node when a source_file query matches many nodes."""
query_basename = _normalize_label(Path(query).name)
exact_file_nodes = [
node_id
for node_id in node_ids
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
and _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
]
if len(exact_file_nodes) == 1:
return exact_file_nodes[0]
l1_nodes = [
node_id
for node_id in node_ids
if str(graph.nodes[node_id].get("source_location", "")) == "L1"
]
if len(l1_nodes) == 1:
return l1_nodes[0]
basename_nodes = [
node_id
for node_id in node_ids
if _normalize_label(str(graph.nodes[node_id].get("label", ""))) == query_basename
]
if len(basename_nodes) == 1:
return basename_nodes[0]
return None
def resolve_seed(graph: nx.Graph, query: str, root: Path | None = None) -> str | None:
# A trailing path separator must not change a source-file match — serve's
# _find_node tokenizes the path (which drops it), so strip it here for parity
# (otherwise `affected "src/x.ts/"` returned None while `explain` resolved it).
query = query.rstrip("/\\") or query
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]
# Compare paths in repo-relative form. Only this branch is path-shaped; the
# label branches above keep the query verbatim.
query_path = _normalize_label(_as_repo_relative(query, root))
exact_source_matches = [
str(node_id)
for node_id, data in graph.nodes(data=True)
if _normalize_label(str(data.get("source_file", ""))) in (query_lower, query_path)
]
if len(exact_source_matches) == 1:
return exact_source_matches[0]
if exact_source_matches:
preferred_file_node = _prefer_file_node(
graph, exact_source_matches, _as_repo_relative(query, root)
)
if preferred_file_node is not None:
return preferred_file_node
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 _supertypes(graph: nx.DiGraph, type_id: str, cache: dict[str, list[str]]) -> list[str]:
"""Every type ``type_id`` derives from, nearest first (cycles cut)."""
if type_id in cache:
return cache[type_id]
found: list[str] = []
seen = {type_id}
frontier = [type_id]
while frontier:
nxt = []
for t in frontier:
for _s, base, data in graph.out_edges(t, data=True):
base = str(base)
if str(data.get("relation", "")) in _SUPERTYPE_RELATIONS and base not in seen:
seen.add(base)
found.append(base)
nxt.append(base)
frontier = nxt
cache[type_id] = found
return found
def _overridden_members(graph: nx.DiGraph, node: str, cache: dict[str, list[str]]) -> list[str]:
"""The supertype members ``node`` overrides: same-named methods of the types
its class derives from. A call bound to one of them can run ``node`` at
runtime (`visitor.head()` typed `NodeVisitor` runs `FormattingVisitor.head`)."""
owners = [str(s) for s, _t, d in graph.in_edges(node, data=True)
if str(d.get("relation", "")) == "method"]
if not owners:
return []
label = graph.nodes[node].get("label")
found: list[str] = []
for owner in owners:
for base in _supertypes(graph, owner, cache):
for _s, member, data in graph.out_edges(base, data=True):
member = str(member)
if (str(data.get("relation", "")) == "method" and member != node
and graph.nodes[member].get("label") == label):
found.append(member)
return found
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] = []
# #1669: seed the reverse walk with the root's own member nodes (one outward
# `method`/`contains` hop). A caller can bind to a class's method node rather
# than the class node itself (e.g. `Service.call` resolves to the `def
# self.call` node, #1634), so those callers are unreachable from the class
# otherwise. The member nodes are seeds only (not reported as hits), and
# `method`/`contains` stay out of the general relation-filtered walk, so this
# adds no forward noise anywhere else.
if hasattr(graph, "out_edges"):
member_edges = graph.out_edges(seed, data=True)
else:
member_edges = (
(s, t, d) for s, t, d in graph.edges(data=True) if s == seed
)
for _s, member, data in member_edges:
if str(data.get("relation", "")) not in ("method", "contains"):
continue
member = str(member)
if member not in seen:
seen.add(member)
queue.append((member, 0))
supertype_cache: dict[str, list[str]] = {}
# Overridden members walked without being reported; one that a real edge
# also reaches (an overload calling the abstract one) is reported then.
stepping_stones: set[str] = set()
while queue:
current, current_depth = queue.popleft()
if current_depth >= depth:
continue
# A caller bound to the supertype member a method overrides depends on
# the override too, so walk that member's callers at the same depth. The
# member itself did not change: like the seed's members above, it is a
# stepping stone, not a hit. Needs edge direction, so directed graphs only
# (MultiDiGraph, what load_graph returns, is a DiGraph subclass).
if isinstance(graph, nx.DiGraph):
for overridden in _overridden_members(graph, current, supertype_cache):
if overridden not in seen:
seen.add(overridden)
stepping_stones.add(overridden)
# front of the queue: same depth as `current`, so it must be
# walked before the deeper nodes already queued
queue.appendleft((overridden, current_depth))
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)
queued = source in stepping_stones
if queued:
stepping_stones.discard(source) # walked already; report it now
elif source in seen:
continue
seen.add(source)
# Carry the matched edge's location (taken from the SAME edge dict
# whose relation passed the filter, so relation and location stay
# consistent) — that is the call/import/reference site in `source`'s
# own file, which is where the user should click (#BUG1).
hit = AffectedHit(
source, current_depth + 1, relation,
via_file=str(data.get("source_file") or "") or None,
via_location=str(data.get("source_location") or "") or None,
)
hits.append(hit)
if not queued:
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,
root: Path | None = None,
) -> str:
relation_list = tuple(relations)
seed = resolve_seed(graph, query, root)
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]
if hit.via_location:
# The relation SITE in this node's file (call/import/reference line),
# labeled by [via_relation] so it's never mistaken for a def line.
location = f"{hit.via_file or data.get('source_file') or '-'}:{hit.via_location}"
else:
location = _format_location(data) # honest fallback: the node's own def line
lines.append(
f"- {_node_label(graph, hit.node_id)} [{hit.via_relation}] {location}"
)
return "\n".join(lines)
def load_graph(path: Path) -> nx.Graph:
import json
from networkx.readwrite import json_graph
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError) as exc:
raise RuntimeError(
f"Cannot read graph file {path}: {exc}. "
"Re-run 'graphify extract' to regenerate it."
) from exc
# 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)