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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 36 additions & 8 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1432,17 +1449,27 @@ def dispatch_command(cmd: str) -> None:

elif cmd == "explain":
if len(sys.argv) < 3:
print('Usage: graphify explain "<node>" [--graph path]', file=sys.stderr)
print(
'Usage: graphify explain "<node>" [--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="):
Comment on lines +1469 to +1471

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

--limit without a value is silently ignored — agreed by 2 of 2 members but NOT verified (no proof, no reproducing execution) — consensus is not a verdict; needs human review

Graphify suggests a fix:

Suggested change
elif a == "--limit" and i + 1 < len(args):
limit = _explain_limit(args[i + 1])
elif a.startswith("--limit="):
elif a == "--limit":
if i + 1 >= len(args):
print("error: --limit requires a value", file=sys.stderr)
sys.exit(1)
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)
Expand Down Expand Up @@ -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 "<--"
Expand All @@ -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
Expand All @@ -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",
Expand Down
79 changes: 79 additions & 0 deletions tests/test_explain_cli.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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