mirror of
https://github.com/safishamsi/graphify.git
synced 2026-10-15 16:39:43 +00:00
394 lines
15 KiB
Python
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)
|