From 0969b5318c0f25f4e1893b84e69d8ebe11f21d83 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:03:55 +0530 Subject: [PATCH 1/3] Split the god nodes ranking by in degree and out degree god_nodes ranked purely by total degree. On a directed graph, in degree and out degree answer opposite questions: high in degree means many things depend on this node and it breaks the rest if it changes, high out degree means the node itself depends on many things and breaks easily. Collapsing them into one number ranked a heavily relied on module next to a config file with many outbound only edges as though they were comparable, and let wide shallow config trees crowd the top of the list. god_nodes now takes a sort_by parameter of total, in, or out (defaulting to total, the historical behavior), and every result on a directed graph carries in_degree and out_degree regardless of the chosen sort so a caller can always show the split. An undirected graph has no in or out concept, so sort_by is ignored there and results carry neither key. The CLI gains a by flag accepting total, in, or out, and prints the split alongside the total in text output, plus a small related fix: when several distinct nodes share one printed label, the output now names the count so the ambiguity is visible instead of looking like one safe to act on line. Co-Authored-By: Claude Sonnet 5 --- graphify/__main__.py | 1 + graphify/analyze.py | 37 +++++++++++++++++++++++++++++++------ graphify/cli.py | 26 ++++++++++++++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240f..3f73c12df1 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -590,6 +590,7 @@ def _run_cli() -> None: print(" --graph 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 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") diff --git a/graphify/analyze.py b/graphify/analyze.py index df1c206610..d99502d60f 100644 --- a/graphify/analyze.py +++ b/graphify/analyze.py @@ -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, - 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 @@ -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 diff --git a/graphify/cli.py b/graphify/cli.py index 5503a9185a..59cfb10bb6 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -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 @@ -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) @@ -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] From 28982fc490c96797c35a76f7edf3d89b2c3056d2 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:04:03 +0530 Subject: [PATCH 2/3] Add regression tests for the in and out degree split Covers a directed graph carrying in_degree/out_degree on every result, an undirected graph carrying neither key, sorting by total, in, and out, an unrecognized sort falling back to total instead of raising, the CLI text output showing the split, the by flag end to end including an invalid value, and the shared label ambiguity marker. Co-Authored-By: Claude Sonnet 5 --- tests/test_analyze.py | 76 +++++++++++++++++++++++++++++++++++++ tests/test_god_nodes_cli.py | 44 +++++++++++++++++++++ 2 files changed, 120 insertions(+) diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 7bff432cf7..c702c83115 100644 --- a/tests/test_analyze.py +++ b/tests/test_analyze.py @@ -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() diff --git a/tests/test_god_nodes_cli.py b/tests/test_god_nodes_cli.py index 28bacc9b1a..9f79a2fdf1 100644 --- a/tests/test_god_nodes_cli.py +++ b/tests/test_god_nodes_cli.py @@ -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 From 7b4d0830be39e37096c894ecbb1c34c96feb1a94 Mon Sep 17 00:00:00 2001 From: ayushcodes10 Date: Thu, 17 Sep 2026 20:04:17 +0530 Subject: [PATCH 3/3] Add changelog entry for issue 2488 Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515f..11fef2cfe1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 ` 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).