From 1f4e3b2fe1f409068a8aa5c4fde3d63e26e2ccd2 Mon Sep 17 00:00:00 2001 From: safishamsi Date: Mon, 20 Jul 2026 15:35:40 +0100 Subject: [PATCH] fix(cli): wire god-nodes subcommand + accept --output alias on extract (#2004) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2: `god_nodes` was an analyzer, an MCP tool, and a README-advertised capability, but `graphify god_nodes` errored with "unknown command". Add a read-only `god-nodes`/`god_nodes` subcommand mirroring `affected` (--graph, --top, --json), routing labels through sanitize_label. Part 3: `--output DIR` on `extract` was silently dropped (output fell back to the default dir). It is now an alias of `--out` (both space and =forms), matching what `graphify tree` already documents. Help/usage text updated. Part 1 (affected/reverse-dep import-id mismatch) is deferred — a build-time id-resolution change, tracked separately. --- graphify/__main__.py | 6 ++- graphify/cli.py | 64 ++++++++++++++++++++++-- tests/test_extract_code_only_cli.py | 35 +++++++++++++ tests/test_god_nodes_cli.py | 76 +++++++++++++++++++++++++++++ 4 files changed, 177 insertions(+), 4 deletions(-) create mode 100644 tests/test_god_nodes_cli.py diff --git a/graphify/__main__.py b/graphify/__main__.py index d97d48fd..924ae986 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -562,6 +562,10 @@ def _run_cli() -> None: print(" --relation R edge relation to traverse in reverse (repeatable)") print(" --depth N reverse traversal depth (default 2)") 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(" --graph path to graph.json (default graphify-out/graph.json)") + print(" --json emit JSON instead of text") print(" save-result save a Q&A result to graphify-out/memory/ for graph feedback loop") print(" --question Q the question asked") print(" --answer A the answer to save") @@ -603,7 +607,7 @@ def _run_cli() -> None: print(" --token-budget N per-chunk token cap for semantic extraction (default: 60000)") print(" --max-concurrency N parallel semantic chunks in flight (default: 4; set 1 for local LLMs)") print(" --api-timeout S per-request timeout in seconds for the LLM client (default: 600)") - print(" --out DIR output dir (default: ); writes /graphify-out/") + print(" --out DIR, --output DIR output dir (default: ); writes /graphify-out/") print(" --google-workspace export .gdoc/.gsheet/.gslides shortcuts via gws before extraction") print(" --no-gitignore ignore .gitignore and .git/info/exclude (prioritizes .graphifyignore)") print(" --no-cluster skip clustering, write raw extraction only") diff --git a/graphify/cli.py b/graphify/cli.py index d8b6bac9..fe219a6c 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -919,6 +919,61 @@ def dispatch_command(cmd: str) -> None: depth=depth, ) ) + elif cmd in ("god-nodes", "god_nodes"): + # god_nodes has long been an analyzer (analyze.py), an MCP tool, and a + # README-advertised capability, but never a CLI subcommand — `graphify + # god_nodes` fell through to "unknown command" (#2004). Wire it as a + # read-only graph query, mirroring `affected`. + from graphify.affected import load_graph + from graphify.analyze import god_nodes as _god_nodes + from graphify.security import sanitize_label as _sanitize_label + graph_path = _default_graph_path() + top_n = 10 + as_json = "--json" in sys.argv + args = sys.argv[2:] + i = 0 + while i < len(args): + if args[i] == "--graph" and i + 1 < len(args): + graph_path = args[i + 1] + i += 2 + elif args[i].startswith("--graph="): + graph_path = args[i].split("=", 1)[1] + i += 1 + elif args[i] == "--top" and i + 1 < len(args): + try: + top_n = int(args[i + 1]) + except ValueError: + print("error: --top must be an integer", file=sys.stderr) + sys.exit(1) + i += 2 + elif args[i].startswith("--top="): + try: + top_n = int(args[i].split("=", 1)[1]) + except ValueError: + print("error: --top must be an integer", file=sys.stderr) + sys.exit(1) + i += 1 + else: + i += 1 + gp = Path(graph_path).resolve() + if not gp.exists(): + print(f"error: graph file not found: {gp}", file=sys.stderr) + sys.exit(1) + if not gp.suffix == ".json": + print("error: graph file must be a .json file", file=sys.stderr) + sys.exit(1) + try: + G = load_graph(gp) + 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) + if as_json: + print(json.dumps(gods, indent=2)) + else: + print("God nodes (most connected):") + for rank, n in enumerate(gods, 1): + print(f" {rank}. {_sanitize_label(str(n['label']))} - {n['degree']} edges") elif cmd == "save-result": # graphify save-result --question Q --answer A [--type T] [--nodes N1 N2 ...] # [--outcome useful|dead_end|corrected] [--correction TEXT] @@ -2331,7 +2386,7 @@ def dispatch_command(cmd: str) -> None: if len(sys.argv) < 3: print( "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama] " - "[--model M] [--mode deep] [--out DIR] [--google-workspace] [--no-cluster] " + "[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] " "[--no-gitignore] " "[--max-workers N] [--token-budget N] [--max-concurrency N] " "[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]", @@ -2415,9 +2470,12 @@ def dispatch_command(cmd: str) -> None: extract_mode = args[i + 1]; i += 2 elif a.startswith("--mode="): extract_mode = a.split("=", 1)[1]; i += 1 - elif a == "--out" and i + 1 < len(args): + elif a in ("--out", "--output") and i + 1 < len(args): + # --output is an alias of --out (#2004): it was silently dropped + # before, and `graphify tree` already documents --output, so the + # mistake is natural. (--output= does not startswith --out=.) out_dir = Path(args[i + 1]); i += 2 - elif a.startswith("--out="): + elif a.startswith(("--out=", "--output=")): out_dir = Path(a.split("=", 1)[1]); i += 1 elif a == "--no-cluster": no_cluster = True; i += 1 diff --git a/tests/test_extract_code_only_cli.py b/tests/test_extract_code_only_cli.py index 0e69454a..0878db07 100644 --- a/tests/test_extract_code_only_cli.py +++ b/tests/test_extract_code_only_cli.py @@ -56,6 +56,41 @@ def test_mixed_repo_without_key_errors_and_points_at_code_only(tmp_path): assert "--code-only" in r.stderr, "the no-key error must point users at --code-only" +def _run_relative_out(repo: Path, *extra: str): + """Like _run but with a RELATIVE GRAPHIFY_OUT so --out/--output controls the + parent dir (an absolute GRAPHIFY_OUT would override the flag).""" + env = {k: v for k, v in os.environ.items() if k not in _KEY_VARS} + env["GRAPHIFY_OUT"] = "graphify-out" + return subprocess.run( + [PYTHON, "-m", "graphify", "extract", ".", *extra], + cwd=repo, capture_output=True, text=True, env=env, + ) + + +def test_output_flag_is_alias_of_out(tmp_path): + """#2004 part 3: `--output DIR` was silently ignored on extract (output went + to the default `/graphify-out/`). It is now an alias of `--out`.""" + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text("def hello():\n return 1\n") + custom = tmp_path / "elsewhere" + + r = _run_relative_out(repo, "--code-only", "--no-cluster", "--output", str(custom)) + assert r.returncode == 0, r.stderr + assert (custom / "graphify-out" / "graph.json").exists(), "--output was ignored (#2004)" + assert not (repo / "graphify-out").exists(), "output must not go to the default dir" + + +def test_output_flag_inline_form(tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / "app.py").write_text("def hello():\n return 1\n") + custom = tmp_path / "out2" + r = _run_relative_out(repo, "--code-only", "--no-cluster", f"--output={custom}") + assert r.returncode == 0, r.stderr + assert (custom / "graphify-out" / "graph.json").exists() + + def test_no_gitignore_indexes_vcs_ignored_code_but_keeps_graphifyignore(tmp_path): repo = tmp_path / "repo" generated = repo / "proj" / "deep" / "generated" diff --git a/tests/test_god_nodes_cli.py b/tests/test_god_nodes_cli.py new file mode 100644 index 00000000..28bacc9b --- /dev/null +++ b/tests/test_god_nodes_cli.py @@ -0,0 +1,76 @@ +"""`graphify god-nodes` CLI subcommand (#2004 part 2). + +god_nodes has long been an analyzer + MCP tool + README-advertised capability +but was never wired as a CLI subcommand, so `graphify god_nodes` errored with +"unknown command". These tests pin the subcommand (both spellings), its flags, +and that file nodes are excluded from the ranking. +""" +from __future__ import annotations + +import json + +import networkx as nx +import pytest +from networkx.readwrite import json_graph + +import graphify.__main__ as mainmod + + +def _write_graph(tmp_path): + g = nx.DiGraph() + # A high-degree real entity (not a file/concept node): label != basename. + g.add_node("hub", label="Auth", file_type="code", source_file="auth.py", source_location="L1") + g.add_node("f", label="auth.py", file_type="code", source_file="auth.py", source_location=None) + 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("f", "hub", relation="contains", confidence="EXTRACTED") + gp = tmp_path / "graph.json" + gp.write_text(json.dumps(json_graph.node_link_data(g, edges="links")), encoding="utf-8") + return gp + + +def _run(monkeypatch, argv): + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr(mainmod.sys, "argv", argv) + mainmod.main() + + +def test_god_nodes_cli_text_output(monkeypatch, tmp_path, capsys): + gp = _write_graph(tmp_path) + _run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp)]) + out = capsys.readouterr().out + assert "God nodes (most connected):" in out + assert "Auth" in out + assert "edges" in out + assert "auth.py" not in out # file node excluded from the ranking + + +def test_god_nodes_cli_underscore_alias(monkeypatch, tmp_path, capsys): + # The exact spelling from the issue title. + gp = _write_graph(tmp_path) + _run(monkeypatch, ["graphify", "god_nodes", "--graph", str(gp)]) + assert "Auth" in capsys.readouterr().out + + +def test_god_nodes_cli_top_limits(monkeypatch, tmp_path, capsys): + gp = _write_graph(tmp_path) + _run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp), "--top", "1"]) + body = capsys.readouterr().out + assert body.count(" edges") == 1 + + +def test_god_nodes_cli_json(monkeypatch, tmp_path, capsys): + gp = _write_graph(tmp_path) + _run(monkeypatch, ["graphify", "god-nodes", "--graph", str(gp), "--json"]) + data = json.loads(capsys.readouterr().out) + assert isinstance(data, list) and data + assert {"id", "label", "degree"} <= set(data[0]) + assert data[0]["label"] == "Auth" + + +def test_god_nodes_cli_missing_graph_errors(monkeypatch, tmp_path, capsys): + with pytest.raises(SystemExit) as exc: + _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