Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ Full release notes with details on each version: [GitHub Releases](https://githu

## 0.9.63 (2026-09-16)

- Feature: `god-nodes` now splits its ranking by in-degree and out-degree on a directed graph, not just total degree. High in-degree (many things depend on this node) and high out-degree (this node depends on many things) answer opposite questions, and collapsing them into one number ranked a heavily-relied-on module next to a config file with many outbound-only edges as though comparable. Every result carries both counts; the CLI's new `--by total|in|out` flag re-ranks by either one, and text output now flags when a printed label is shared by more than one node (#2488, thanks @luliaz0601).

- Feature: Elixir `alias`/`import`/`require`/`use` targets now resolve onto the module's `defmodule` node across files, so the internal module dependency graph is no longer dropped as dangling. Only top-level modules are indexed (a nested `defmodule`, labeled with its bare inner name, cannot capture an unrelated `use <Name>` from another file), and a same-file reference is left unresolved so it cannot clobber the structural `contains` edge (#3603, thanks @ayushcodes10).
- Feature: a Rust `self.method()` call now resolves to a method defined on the same type in another file (the common split-`impl`-block layout), pooling methods across every `impl` of one type and refusing to link when two unrelated types share a bare name (#3602, thanks @ayushcodes10).
- Feature: a Ruby member call `obj.foo` on a known-type receiver now resolves to a method `foo` inherited from a superclass, including across files, using the same conservative promotion as the implicit-self resolver — a single owning class, matching method kind, and one unambiguous ancestry chain, or it stays dangling (#3585, thanks @oleksii-tumanov).
Expand Down
1 change: 1 addition & 0 deletions graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ def _run_cli() -> None:
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" god-nodes list the most connected nodes (architectural hubs)")
print(" --top N how many to show (default 10)")
print(" --by total|in|out rank by total degree, in-degree, or out-degree (default total, directed graphs only)")
print(" --graph <path> path to graph.json (default graphify-out/graph.json)")
print(" --json emit JSON instead of text")
print(" prs PR dashboard: CI state, review status, worktree mapping")
Expand Down
37 changes: 31 additions & 6 deletions graphify/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ def _is_json_key_node(G: nx.Graph, node_id: str) -> bool:


def god_nodes(G: nx.Graph, top_n: int = 10,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regressiongod_nodes()

37 callers depend on it (afferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

exclude_hubs_percentile: float | None = None) -> list[dict]:
exclude_hubs_percentile: float | None = None,
sort_by: str = "total") -> list[dict]:
"""Return the top_n most-connected real entities - the core abstractions.

File-level hub nodes are excluded: they accumulate import/contains edges
Expand All @@ -118,28 +119,52 @@ def god_nodes(G: nx.Graph, top_n: int = 10,
threshold computation ``cluster()`` applies (#3205) - so the one setting
suppresses utility hubs in the ranking AND in community resolution,
instead of only the latter. ``None`` keeps the historical ranking.

``sort_by`` picks the ranking metric: ``"total"`` (default, historical
behavior), ``"in"``, or ``"out"``. On a directed graph, total degree sums
in-degree (how many things depend on this node - break it and they break)
and out-degree (how many things this node depends on - it breaks easily),
which answer opposite questions; collapsing them ranks a heavily-depended-on
module next to a config file with many outbound-only edges as if
comparable (#2488). Every result carries ``in_degree``/``out_degree`` when
the graph is directed, regardless of ``sort_by``, so a caller can always
show the split. On an undirected graph in/out has no meaning; ``sort_by``
is ignored and results carry no ``in_degree``/``out_degree`` keys.
"""
degree = dict(G.degree())
directed = G.is_directed()
if directed:
in_degree = dict(G.in_degree())
out_degree = dict(G.out_degree())
degree = {n: in_degree[n] + out_degree[n] for n in G.nodes()}
sort_key = {"total": degree, "in": in_degree, "out": out_degree}.get(sort_by, degree)
else:
degree = dict(G.degree())
sort_key = degree
hub_threshold: float | None = None
if exclude_hubs_percentile is not None:
degrees = sorted(degree.values())
if degrees:
idx = max(0, int(len(degrees) * exclude_hubs_percentile / 100) - 1)
hub_threshold = degrees[idx]
sorted_nodes = sorted(degree.items(), key=lambda x: x[1], reverse=True)
sorted_nodes = sorted(sort_key.items(), key=lambda x: x[1], reverse=True)
result = []
for node_id, deg in sorted_nodes:
for node_id, _rank_deg in sorted_nodes:
deg = degree[node_id]
if hub_threshold is not None and deg > hub_threshold:
continue
if _is_file_node(G, node_id) or _is_concept_node(G, node_id) or _is_json_key_node(G, node_id):
continue
if G.nodes[node_id].get("label", "") in _BUILTIN_NOISE_LABELS:
continue
result.append({
entry = {
"id": node_id,
"label": G.nodes[node_id].get("label", node_id),
"degree": deg,
})
}
if directed:
entry["in_degree"] = in_degree[node_id]
entry["out_degree"] = out_degree[node_id]
result.append(entry)
if len(result) >= top_n:
break
return result
Expand Down
26 changes: 24 additions & 2 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,7 @@ def dispatch_command(cmd: str) -> None:
graph_path = _default_graph_path()
top_n = 10
gn_exclude_hubs: float | None = None
gn_sort_by = "total"
as_json = "--json" in sys.argv
args = sys.argv[2:]
i = 0
Expand Down Expand Up @@ -1437,8 +1438,17 @@ def dispatch_command(cmd: str) -> None:
print("error: --exclude-hubs must be a number (percentile 0-100)", file=sys.stderr)
sys.exit(1)
i += 1
elif args[i] == "--by" and i + 1 < len(args):
gn_sort_by = args[i + 1]
i += 2
elif args[i].startswith("--by="):
gn_sort_by = args[i].split("=", 1)[1]
i += 1
else:
i += 1
if gn_sort_by not in ("total", "in", "out"):
print(f"error: --by must be one of total, in, out (got {gn_sort_by!r})", file=sys.stderr)
sys.exit(1)
gp = Path(graph_path).resolve()
if not gp.exists():
print(f"error: graph file not found: {gp}", file=sys.stderr)
Expand All @@ -1451,13 +1461,25 @@ def dispatch_command(cmd: str) -> None:
except Exception as exc:
print(f"error: could not load graph: {exc}", file=sys.stderr)
sys.exit(1)
gods = _god_nodes(G, top_n=top_n, exclude_hubs_percentile=gn_exclude_hubs)
gods = _god_nodes(G, top_n=top_n, exclude_hubs_percentile=gn_exclude_hubs, sort_by=gn_sort_by)
if as_json:
print(json.dumps(gods, indent=2))
else:
# #2488: a label shared by multiple nodes ranks as one indistinguishable
# line, so a per-label count of REAL (in-graph) nodes flags the ambiguity
# rather than letting the output look actionable when it is not.
label_counts: dict[str, int] = {}
for node_id, data in G.nodes(data=True):
label_counts[str(data.get("label", node_id))] = label_counts.get(str(data.get("label", node_id)), 0) + 1
print("God nodes (most connected):")
for rank, n in enumerate(gods, 1):
print(f" {rank}. {_sanitize_label(str(n['label']))} - {n['degree']} edges")
label = _sanitize_label(str(n["label"]))
suffix = ""
if "in_degree" in n:
suffix = f" ({n['in_degree']} in / {n['out_degree']} out)"
count = label_counts.get(str(n["label"]), 1)
ambiguity = f" [{count} nodes share this label]" if count > 1 else ""
print(f" {rank}. {label} - {n['degree']} edges{suffix}{ambiguity}")
elif cmd == "save-result":
# graphify save-result --question Q --answer A [--type T] [--nodes N1 N2 ...]
# [--outcome useful|dead_end|corrected] [--correction TEXT]
Expand Down
76 changes: 76 additions & 0 deletions tests/test_analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,82 @@ def test_god_nodes_have_required_keys():
assert "degree" in result[0]


def _make_directed_god_graph():
"""A small directed graph shaped like #2488's repro: one node with many
inbound edges (a widely-depended-on abstraction) and one with many
outbound edges (a config-shaped node that depends on many things), so
they rank as neighbors under total degree despite being opposites."""
G = nx.DiGraph()
G.add_node("hub", label="hub()", file_type="code", source_file="auth.py")
G.add_node("config", label="config", file_type="code", source_file="tsconfig.json")
for i in range(4):
caller = f"caller{i}"
G.add_node(caller, label=f"caller{i}()", file_type="code", source_file=f"c{i}.py")
G.add_edge(caller, "hub") # 4 inbound edges to hub
for i in range(4):
dep = f"dep{i}"
G.add_node(dep, label=f"dep{i}", file_type="code", source_file="tsconfig.json")
G.add_edge("config", dep) # 4 outbound edges from config
return G


def test_god_nodes_directed_graph_carries_in_out_degree():
"""#2488: a directed graph's results must carry in_degree/out_degree
alongside total degree, so a caller can tell "many things depend on this"
from "this depends on many things" without re-deriving it."""
G = _make_directed_god_graph()
result = god_nodes(G, top_n=10)
by_id = {r["id"]: r for r in result}
assert by_id["hub"]["in_degree"] == 4
assert by_id["hub"]["out_degree"] == 0
assert by_id["hub"]["degree"] == 4
assert by_id["config"]["in_degree"] == 0
assert by_id["config"]["out_degree"] == 4
assert by_id["config"]["degree"] == 4


def test_god_nodes_undirected_graph_has_no_in_out_degree():
"""An undirected graph has no in/out distinction, so results must not
carry in_degree/out_degree keys at all rather than a meaningless value."""
G = make_graph()
result = god_nodes(G, top_n=3)
for r in result:
assert "in_degree" not in r
assert "out_degree" not in r


def test_god_nodes_sort_by_in_ranks_by_inbound_only():
G = _make_directed_god_graph()
# 8 more callers into hub so its in-degree clearly exceeds config's total.
for i in range(4, 8):
caller = f"caller{i}"
G.add_node(caller, label=f"caller{i}()", file_type="code", source_file=f"c{i}.py")
G.add_edge(caller, "hub")
result = god_nodes(G, top_n=1, sort_by="in")
assert result[0]["id"] == "hub"
assert result[0]["in_degree"] == 8


def test_god_nodes_sort_by_out_ranks_by_outbound_only():
G = _make_directed_god_graph()
for i in range(4, 8):
dep = f"dep{i}"
G.add_node(dep, label=f"dep{i}", file_type="code", source_file="tsconfig.json")
G.add_edge("config", dep)
result = god_nodes(G, top_n=1, sort_by="out")
assert result[0]["id"] == "config"
assert result[0]["out_degree"] == 8


def test_god_nodes_sort_by_unknown_falls_back_to_total():
"""An unrecognized sort_by must not raise -- it degrades to the
historical total-degree ranking rather than crashing a caller."""
G = _make_directed_god_graph()
result = god_nodes(G, top_n=10, sort_by="bogus")
degrees = [r["degree"] for r in result]
assert degrees == sorted(degrees, reverse=True)


def test_surprising_connections_cross_source_multi_file():
"""Multi-file graph: should find cross-file edges between real entities."""
G = make_graph()
Expand Down
44 changes: 44 additions & 0 deletions tests/test_god_nodes_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,3 +74,47 @@ def test_god_nodes_cli_missing_graph_errors(monkeypatch, tmp_path, capsys):
_run(monkeypatch, ["graphify", "god-nodes", "--graph", str(tmp_path / "nope.json")])
assert exc.value.code == 1
assert "graph file not found" in capsys.readouterr().err


def test_god_nodes_cli_text_output_shows_in_out_split(monkeypatch, tmp_path, capsys):
"""#2488: text output on a directed graph must show the in/out split, not
just total degree, so a heavily-depended-on node is distinguishable from
one that merely depends on many things."""
gp = _write_graph(tmp_path)
_run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp)])
out = capsys.readouterr().out
assert "(5 in / 0 out)" in out # hub: 4 callers + the file's contains edge, 0 outbound


def test_god_nodes_cli_by_in(monkeypatch, tmp_path, capsys):
gp = _write_graph(tmp_path)
_run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp), "--by", "in", "--top", "1"])
out = capsys.readouterr().out
assert "Auth" in out


def test_god_nodes_cli_by_invalid_errors(monkeypatch, tmp_path, capsys):
gp = _write_graph(tmp_path)
with pytest.raises(SystemExit) as exc:
_run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp), "--by", "bogus"])
assert exc.value.code == 1
assert "--by must be one of total, in, out" in capsys.readouterr().err


def test_god_nodes_cli_flags_ambiguous_shared_labels(monkeypatch, tmp_path, capsys):
"""#2488: two distinct nodes sharing one label must be flagged in the
text output, since the ranking otherwise looks safe to act on when the
printed label doesn't identify a single node."""
g = nx.DiGraph()
g.add_node("hub", label="Auth", file_type="code", source_file="auth.py", source_location="L1")
g.add_node("hub2", label="Auth", file_type="code", source_file="other.py", source_location="L1")
for i in range(4):
g.add_node(f"caller{i}", label=f"c{i}()", file_type="code", source_file=f"m{i}.py", source_location="L1")
g.add_edge(f"caller{i}", "hub", relation="calls", confidence="EXTRACTED")
g.add_edge("caller0", "hub2", relation="calls", confidence="EXTRACTED")
gp = tmp_path / "graph.json"
gp.write_text(json.dumps(json_graph.node_link_data(g, edges="links")), encoding="utf-8")

_run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp)])
out = capsys.readouterr().out
assert "2 nodes share this label" in out
Loading