mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-05 21:25:46 +00:00
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:
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user