From 625d8eb9920bb508122b831395a42c1f3d379e66 Mon Sep 17 00:00:00 2001 From: Luca de Pascale Date: Sun, 9 Aug 2026 01:31:21 +0200 Subject: [PATCH] feat(explain): add --limit so connections past the top 20 are readable `graphify explain` prints the 20 most connected neighbours and then stops. #2009 improved what happens next by grouping the remainder per file, which answers "where are they" but not "what are they": on a hub node the callers themselves are unreachable from the CLI, and the only way to read them is the repo-wide grep the tool exists to avoid. `--limit N` (and `--limit=N`) raises the cut; `--limit 0` prints every connection. The default stays 20, so output is byte-identical when the flag is absent, and the same value bounds the grouped-by-file list, which carried the second hardcoded 20. Non-integer and negative values exit 1 with a message, matching how `--top` and `--max-examples` already behave. The MCP server is unaffected: it has a per-call `token_budget` and never had this cap. Six tests in tests/test_explain_cli.py: the unchanged default, a raised limit, `--limit=0`, a limit below the default (which must still summarize the rest), and both error paths. --- CHANGELOG.md | 1 + graphify/cli.py | 44 ++++++++++++++++++---- tests/test_explain_cli.py | 79 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 660fcf4e7..05ba3107f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu ## 0.9.37 (unreleased) +- Add: `graphify explain` takes `--limit N` (`--limit=N` also accepted), so the connections past the top 20 can actually be read. #2009 made the cut visible by grouping the remainder per file, which answers "where are they" but not "what are they"; on a hub node the callers themselves were unreachable from the CLI without falling back to a repo-wide grep. The default is unchanged at 20, `--limit 0` prints every connection, and the same value bounds the grouped-by-file list. The MCP server is unaffected: it already has a per-call `token_budget`. - Fix: TypeScript member calls no longer fabricate a high-confidence `calls` edge by matching a receiver type by name alone (#2553, thanks @Earthfreedom). A member call now resolves only when the receiver's type is defined in the same file or actually imported by the caller's file, so a third-party `import type { Repo }` can no longer bind to an unrelated local `class Repo`; table-inferred receivers are tiered to INFERRED rather than EXTRACTED. - Fix: TypeScript/JavaScript calls inside a callback body passed to another call (for example `export const handler = wrapper(async (req) => { helper() })`) are no longer dropped (#2552, thanks @Earthfreedom). The callback body is now walked and its calls attributed to the declaration, through the same import-gated resolution so it cannot fabricate edges. - Fix: Kotlin imports, fully-qualified calls, and one-line type bodies (#2526, #2550, #2551, thanks @spaceBrownie, @thomasrengot-hub, and @Mustaqeem66 for #2531). The extractor now matches the bundled grammar's `import` node (imports were silently dropped) and resolves each import to the real target node; a fully-qualified call like `com.example.Foo.bar()` now produces a `calls` edge; and a file with syntax the bundled grammar cannot parse (such as a one-line `class C { val x }`) now emits a warning instead of silently extracting nothing, and declarations recovered inside an error span keep their enclosing class. diff --git a/graphify/cli.py b/graphify/cli.py index 441e4ca36..1267a78ac 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -85,6 +85,23 @@ def _default_graph_path() -> str: return str(Path(_GRAPHIFY_OUT) / "graph.json") +#: How many connections `explain` prints before summarizing the rest by file. +EXPLAIN_DEFAULT_LIMIT = 20 + + +def _explain_limit(raw: str) -> int: + """Parse `--limit` for `explain`. 0 means no cap.""" + try: + value = int(raw) + except ValueError: + print("error: --limit must be an integer", file=sys.stderr) + sys.exit(1) + if value < 0: + print("error: --limit must be >= 0", file=sys.stderr) + sys.exit(1) + return value + + def _stamped_manifest_files( files_by_type: dict[str, list[str]], sem_result: dict, @@ -1432,17 +1449,27 @@ def dispatch_command(cmd: str) -> None: elif cmd == "explain": if len(sys.argv) < 3: - print('Usage: graphify explain "" [--graph path]', file=sys.stderr) + print( + 'Usage: graphify explain "" [--graph path] [--limit N]', + file=sys.stderr, + ) sys.exit(1) from graphify.serve import _find_node, find_node_ambiguity from networkx.readwrite import json_graph label = sys.argv[2] graph_path = _default_graph_path() + # How many connections to print before falling back to the grouped + # summary. 0 means no cap. Default unchanged. + limit = EXPLAIN_DEFAULT_LIMIT args = sys.argv[3:] for i, a in enumerate(args): if a == "--graph" and i + 1 < len(args): graph_path = args[i + 1] + elif a == "--limit" and i + 1 < len(args): + limit = _explain_limit(args[i + 1]) + elif a.startswith("--limit="): + limit = _explain_limit(a.split("=", 1)[1]) gp = Path(graph_path).resolve() if not gp.exists(): print(f"error: graph file not found: {gp}", file=sys.stderr) @@ -1522,7 +1549,8 @@ def dispatch_command(cmd: str) -> None: if connections: print(f"\nConnections ({len(connections)}):") connections.sort(key=lambda c: G.degree(c[1]), reverse=True) - for direction, nb, edata in connections[:20]: + shown = connections if limit == 0 else connections[:limit] + for direction, nb, edata in shown: rel = edata.get("relation", "") conf = edata.get("confidence", "") arrow = "-->" if direction == "out" else "<--" @@ -1533,9 +1561,9 @@ def dispatch_command(cmd: str) -> None: sfile = edata.get("source_file") or "" at = f" {sfile}:{loc}" if loc else "" print(f" {arrow} {G.nodes[nb].get('label', nb)} [{rel}] [{conf}]{at}") - if len(connections) > 20: - remainder = connections[20:] - print(f" ... and {len(remainder)} more") + if len(connections) > len(shown): + remainder = connections[len(shown):] + print(f" ... and {len(remainder)} more (raise with --limit N, 0 for all)") # #2009: a bare count silently hides the answer on high-degree # nodes ("who calls this, what's the impact?"). Group the cut # connections by direction + file so their shape is visible @@ -1549,12 +1577,12 @@ def dispatch_command(cmd: str) -> None: # byte-stable order (not the degree-derived insertion order). grouped = sorted(by_file.items(), key=lambda kv: (-kv[1], kv[0])) print(" Grouped by file:") - for (direction, sfile), count in grouped[:20]: + for (direction, sfile), count in grouped[:limit]: arrow = "-->" if direction == "out" else "<--" noun = "connection" if count == 1 else "connections" print(f" {arrow} {sfile}: {count} {noun}") - if len(grouped) > 20: - print(f" ... and {len(grouped) - 20} more files") + if len(grouped) > limit: + print(f" ... and {len(grouped) - limit} more files") from graphify import querylog querylog.log_query( kind="explain", diff --git a/tests/test_explain_cli.py b/tests/test_explain_cli.py index 60b3e626e..5e70b84a7 100644 --- a/tests/test_explain_cli.py +++ b/tests/test_explain_cli.py @@ -1,6 +1,9 @@ """Regression tests for `graphify explain` arrow direction (#853).""" from __future__ import annotations import json + +import pytest + import graphify.__main__ as mainmod @@ -320,3 +323,79 @@ def test_explain_matches_within_one_file_are_not_ambiguous(monkeypatch, tmp_path out = _run(monkeypatch, p, "MetricsPort", capsys) assert "Ambiguous" not in out assert "Node: MetricsPort" in out + + +# -------------------------------------------------------------------------- +# --limit: seeing past the first 20 connections (#2009 follow-up) +# -------------------------------------------------------------------------- +def _write_hub_graph(tmp_path, callers=25): + """One node called by `callers` others, i.e. more than the default cut.""" + nodes = [{"id": "hub", "label": "hub()", "source_file": "src/hub.ts", + "community": 0}] + links = [] + for i in range(callers): + nodes.append({"id": f"c{i}", "label": f"caller{i:02d}()", + "source_file": f"src/caller{i:02d}.ts", "community": 0}) + links.append({"source": f"c{i}", "target": "hub", + "relation": "calls", "confidence": "EXTRACTED"}) + p = tmp_path / "graph.json" + p.write_text(json.dumps({"directed": False, "multigraph": False, "graph": {}, + "nodes": nodes, "links": links})) + return p + + +def _run_argv(monkeypatch, capsys, *argv): + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", ["graphify", *argv]) + mainmod.main() + return capsys.readouterr().out + + +def test_default_cut_is_unchanged(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path) + out = _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p)) + assert out.count("<-- caller") == 20 + assert "... and 5 more" in out + + +def test_limit_raises_the_cut(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path) + out = _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p), + "--limit", "25") + assert out.count("<-- caller") == 25 + assert "more" not in out.split("Connections")[1] + + +def test_limit_zero_shows_every_connection(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path, callers=40) + out = _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p), + "--limit=0") + assert out.count("<-- caller") == 40 + assert "Grouped by file:" not in out + + +def test_limit_below_the_default_still_summarizes_the_rest(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path) + out = _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p), + "--limit", "3") + assert out.count("<-- caller") == 3 + assert "... and 22 more" in out + assert "Grouped by file:" in out + + +def test_a_non_numeric_limit_is_an_error(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path) + with pytest.raises(SystemExit) as exc: + _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p), + "--limit", "all") + assert exc.value.code == 1 + assert "--limit must be an integer" in capsys.readouterr().err + + +def test_a_negative_limit_is_an_error(monkeypatch, tmp_path, capsys): + p = _write_hub_graph(tmp_path) + with pytest.raises(SystemExit) as exc: + _run_argv(monkeypatch, capsys, "explain", "hub", "--graph", str(p), + "--limit", "-1") + assert exc.value.code == 1 + assert "--limit must be >= 0" in capsys.readouterr().err