diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a0daf515..11fef2cfe 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). diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240..3f73c12df 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 df1c20661..d99502d60 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 5503a9185..59cfb10bb 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] diff --git a/tests/test_analyze.py b/tests/test_analyze.py index 7bff432cf..c702c8311 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 28bacc9b1..9f79a2fdf 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