fix(cli): wire god-nodes subcommand + accept --output alias on extract (#2004)

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.
This commit is contained in:
safishamsi
2026-07-20 15:35:40 +01:00
parent db410d0d09
commit 1f4e3b2fe1
4 changed files with 177 additions and 4 deletions
+5 -1
View File
@@ -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> 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> 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: <path>); writes <DIR>/graphify-out/")
print(" --out DIR, --output DIR output dir (default: <path>); writes <DIR>/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")
+61 -3
View File
@@ -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 <path> [--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
+35
View File
@@ -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 `<path>/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"
+76
View File
@@ -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