diff --git a/graphify/__main__.py b/graphify/__main__.py index 4a68e7240..7e195f218 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -638,6 +638,7 @@ def _run_cli() -> None: 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") + print(" --directed build a directed graph (DiGraph) preserving source→target edge direction") print(" --code-only index code (local AST, no API key) and skip doc/paper/image files") print(" --postgres DSN extract schema from a live PostgreSQL database") print(" maps tables, views, functions + FK relationships;") @@ -741,7 +742,7 @@ def _run_cli() -> None: # (e.g. "cursor install --help" was silently installing into Cursor, #821). # Exempt: free-text commands (user string may contain these tokens), and # "install"/"uninstall" which have their own per-subcommand help handlers. - _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall"} + _FREE_TEXT_CMDS = {"query", "explain", "path", "save-result", "install", "uninstall", "extract"} if cmd not in _FREE_TEXT_CMDS and any(a in {"-h", "--help", "-?"} for a in sys.argv[2:]): print(f"Run 'graphify --help' for full usage.") return diff --git a/graphify/cli.py b/graphify/cli.py index d2c02db61..e63285040 100644 --- a/graphify/cli.py +++ b/graphify/cli.py @@ -1269,6 +1269,7 @@ def dispatch_command(cmd: str) -> None: # direction of a link persisted in flipped endpoint order. _raw = dict( _raw, + directed=False, links=[ { **link, @@ -3175,11 +3176,42 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": # Unlike the skill.md path (which runs through Claude Code subagents), # this calls extract_corpus_parallel directly using whichever backend # has an API key set. + if any(a in ("-h", "--help", "-?") for a in sys.argv[2:]): + print( + "Usage: graphify extract [--directed] [--backend gemini|kimi|claude|openai|deepseek|ollama]\n" + " [--model M] [--mode deep] [--out DIR|--output DIR]\n" + " [--google-workspace] [--no-cluster] [--no-gitignore]\n" + " [--code-only] [--no-dedup] [--max-workers N]\n" + " [--token-budget N] [--max-concurrency N]\n" + " [--api-timeout S] [--postgres DSN] [--cargo]\n" + " [--allow-partial] [--timing]\n" + "\n" + "Headless full-pipeline extraction for CI / scripts.\n" + "\n" + "Options:\n" + " --directed build a directed graph (DiGraph) preserving source→target edge direction\n" + " --backend B LLM backend for semantic extraction (default: auto-detect)\n" + " --model M override default model for the chosen backend\n" + " --mode deep aggressive INFERRED-edge semantic extraction\n" + " --out, --output DIR output directory (default: /graphify-out/)\n" + " --no-cluster skip clustering, write raw extraction only\n" + " --code-only index code (local AST, no API key) and skip doc/paper/image files\n" + " --no-dedup skip entity deduplication entirely\n" + " --no-gitignore ignore .gitignore and .git/info/exclude\n" + " --google-workspace export Google Workspace shortcuts before extraction\n" + " --postgres DSN extract schema from a live PostgreSQL database\n" + " --cargo extract crate→crate dependencies from Cargo.toml\n" + " --allow-partial overwrite existing graph even if extraction was incomplete\n" + " --force skip incremental manifest gate and cache reads\n" + " --timing report wall-clock elapsed time per stage" + ) + sys.exit(0) + if len(sys.argv) < 3: print( "Usage: graphify extract [--backend gemini|kimi|claude|openai|deepseek|ollama] " "[--model M] [--mode deep] [--out DIR|--output DIR] [--google-workspace] [--no-cluster] " - "[--no-gitignore] [--code-only] [--no-dedup] " + "[--no-gitignore] [--code-only] [--no-dedup] [--directed] " "[--max-workers N] [--token-budget N] [--max-concurrency N] " "[--api-timeout S] [--postgres DSN] [--cargo] [--allow-partial] [--timing]", file=sys.stderr, @@ -3204,6 +3236,7 @@ def _to_simple(g: "_nx.Graph") -> "_nx.Graph": cli_cargo: bool = False cli_allow_partial: bool = False no_cluster = False + cli_directed = False dedup_llm = False # --no-dedup: skip entity deduplication entirely. On an incremental # merge the fuzzy pass runs over the COMBINED node set (existing graph + @@ -3278,6 +3311,8 @@ def _parse_float(name: str, raw: str) -> float: out_dir = Path(a.split("=", 1)[1]); i += 1 elif a == "--no-cluster": no_cluster = True; i += 1 + elif a == "--directed": + cli_directed = True; i += 1 elif a == "--dedup-llm": dedup_llm = True; i += 1 elif a == "--no-dedup": @@ -4253,6 +4288,15 @@ def _invalidate_file_manifest_for_db_graph() -> None: _cleared_semantic = _shrink[2] merged["nodes"] = _dedupe_nodes(merged["nodes"]) merged["edges"] = _dedupe_edges(merged["edges"]) + if cli_directed: + merged["directed"] = True + elif merge_existing_graph and existing_graph_path.exists(): + try: + _prev_raw = json.loads(existing_graph_path.read_text(encoding="utf-8")) + if _prev_raw.get("directed") is True: + merged["directed"] = True + except Exception: + pass # Disambiguate colliding-basename file-node labels (#2032). This raw # --no-cluster path bypasses build_from_json (where the clustered path # gets this), so apply it directly on the merged node list. @@ -4366,6 +4410,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: [merged], graph_path=existing_graph_path, prune_sources=_prune_sources or None, + directed=True if cli_directed else None, dedup=not no_dedup, dedup_llm_backend=dedup_backend, root=target, @@ -4395,7 +4440,7 @@ def _invalidate_file_manifest_for_db_graph() -> None: print(f"[graphify extract] {exc}", file=sys.stderr) sys.exit(1) else: - G = _build([merged], dedup=not no_dedup, dedup_llm_backend=dedup_backend, root=target) + G = _build([merged], directed=cli_directed, dedup=not no_dedup, dedup_llm_backend=dedup_backend, root=target) stages.mark("build") if G.number_of_nodes() == 0: print( diff --git a/tests/test_extract_directed.py b/tests/test_extract_directed.py new file mode 100644 index 000000000..2036a52e0 --- /dev/null +++ b/tests/test_extract_directed.py @@ -0,0 +1,215 @@ +"""Regression tests for Graphify issue #3495: opt-in --directed flag for extract.""" +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import networkx as nx +import pytest + +PYTHON = sys.executable + +_LLM_ENV_KEYS = ( + "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GOOGLE_API_KEY", + "MOONSHOT_API_KEY", "DEEPSEEK_API_KEY", "OLLAMA_BASE_URL", + "AWS_PROFILE", "AWS_REGION", "AWS_DEFAULT_REGION", "AWS_ACCESS_KEY_ID", +) + + +def _run(args: list[str], cwd: Path) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if k not in _LLM_ENV_KEYS} + return subprocess.run( + [PYTHON, "-m", "graphify"] + args, + cwd=cwd, + capture_output=True, + text=True, + env=env, + ) + + +def _create_sample_code_repo(tmp_path: Path) -> Path: + proj = tmp_path / "sample_repo" + proj.mkdir(parents=True, exist_ok=True) + # Caller calls callee + (proj / "callee.py").write_text( + "def helper():\n return 42\n", + encoding="utf-8", + ) + (proj / "caller.py").write_text( + "from callee import helper\n\n" + "def main():\n return helper()\n", + encoding="utf-8", + ) + return proj + + +def test_extract_help_mentions_directed(): + """`graphify extract --help` should describe --directed.""" + r = subprocess.run( + [PYTHON, "-m", "graphify", "extract", "--help"], + capture_output=True, + text=True, + ) + assert r.returncode == 0 + assert "--directed" in r.stdout + assert "DiGraph" in r.stdout or "directed" in r.stdout.lower() + + +def test_extract_without_directed_is_undirected(tmp_path): + """By default, extract preserves existing behavior: directed=false, nx.Graph.""" + proj = _create_sample_code_repo(tmp_path) + r = _run(["extract", str(proj), "--code-only"], tmp_path) + assert r.returncode == 0, r.stderr + + graph_path = proj / "graphify-out" / "graph.json" + assert graph_path.exists() + data = json.loads(graph_path.read_text(encoding="utf-8")) + assert data.get("directed") is False, f"Expected directed: false, got {data.get('directed')}" + + # Links should have source -> target preserved + links = data.get("links", data.get("edges", [])) + call_links = [l for l in links if l.get("relation") == "calls"] + assert len(call_links) > 0 + # caller should be source, helper/callee should be target + for link in call_links: + assert "caller" in link["source"] + assert "helper" in link["target"] or "callee" in link["target"] + + +def test_extract_with_directed_flag(tmp_path): + """With --directed, graph.json has directed=true, built as DiGraph.""" + proj = _create_sample_code_repo(tmp_path) + r = _run(["extract", str(proj), "--code-only", "--directed"], tmp_path) + assert r.returncode == 0, r.stderr + + graph_path = proj / "graphify-out" / "graph.json" + assert graph_path.exists() + data = json.loads(graph_path.read_text(encoding="utf-8")) + assert data.get("directed") is True, f"Expected directed: true, got {data.get('directed')}" + + links = data.get("links", data.get("edges", [])) + call_links = [l for l in links if l.get("relation") == "calls"] + assert len(call_links) > 0 + for link in call_links: + assert "caller" in link["source"] + assert "helper" in link["target"] or "callee" in link["target"] + + +def test_extract_no_cluster_with_directed(tmp_path): + """`--no-cluster --directed` persists directed: true in graph.json.""" + proj = _create_sample_code_repo(tmp_path) + r = _run(["extract", str(proj), "--code-only", "--no-cluster", "--directed"], tmp_path) + assert r.returncode == 0, r.stderr + + graph_path = proj / "graphify-out" / "graph.json" + assert graph_path.exists() + data = json.loads(graph_path.read_text(encoding="utf-8")) + assert data.get("directed") is True, f"Expected directed: true, got {data.get('directed')}" + + links = data.get("links", data.get("edges", [])) + call_links = [l for l in links if l.get("relation") == "calls"] + assert len(call_links) > 0 + for link in call_links: + assert "caller" in link["source"] + assert "helper" in link["target"] or "callee" in link["target"] + + +def test_extract_no_cluster_without_directed(tmp_path): + """`--no-cluster` without `--directed` preserves current output behavior (no directed key).""" + proj = _create_sample_code_repo(tmp_path) + r = _run(["extract", str(proj), "--code-only", "--no-cluster"], tmp_path) + assert r.returncode == 0, r.stderr + + graph_path = proj / "graphify-out" / "graph.json" + assert graph_path.exists() + data = json.loads(graph_path.read_text(encoding="utf-8")) + assert "directed" not in data, f"Expected no directed key, got {data.get('directed')}" + + +def test_incremental_preserves_directed_clustered(tmp_path): + """An incremental extract without --directed does not silently downgrade a directed graph.""" + proj = _create_sample_code_repo(tmp_path) + # First run with --directed + r1 = _run(["extract", str(proj), "--code-only", "--directed"], tmp_path) + assert r1.returncode == 0, r1.stderr + + graph_path = proj / "graphify-out" / "graph.json" + data1 = json.loads(graph_path.read_text(encoding="utf-8")) + assert data1.get("directed") is True + + # Modify caller.py slightly + (proj / "caller.py").write_text( + "from callee import helper\n\n" + "def main():\n # touched\n return helper()\n", + encoding="utf-8", + ) + + # Re-run extract WITHOUT --directed + r2 = _run(["extract", str(proj), "--code-only"], tmp_path) + assert r2.returncode == 0, r2.stderr + + data2 = json.loads(graph_path.read_text(encoding="utf-8")) + assert data2.get("directed") is True, ( + "Incremental run must not downgrade directed graph to undirected" + ) + + +def test_incremental_preserves_directed_no_cluster(tmp_path): + """An incremental --no-cluster extract without --directed does not downgrade directed graph.""" + proj = _create_sample_code_repo(tmp_path) + # First run with --no-cluster --directed + r1 = _run(["extract", str(proj), "--code-only", "--no-cluster", "--directed"], tmp_path) + assert r1.returncode == 0, r1.stderr + + graph_path = proj / "graphify-out" / "graph.json" + data1 = json.loads(graph_path.read_text(encoding="utf-8")) + assert data1.get("directed") is True + + # Modify caller.py slightly + (proj / "caller.py").write_text( + "from callee import helper\n\n" + "def main():\n # touched\n return helper()\n", + encoding="utf-8", + ) + + # Re-run extract --no-cluster WITHOUT --directed + r2 = _run(["extract", str(proj), "--code-only", "--no-cluster"], tmp_path) + assert r2.returncode == 0, r2.stderr + + data2 = json.loads(graph_path.read_text(encoding="utf-8")) + assert data2.get("directed") is True, ( + "Incremental --no-cluster run must not downgrade directed graph to undirected" + ) + + +def test_query_traversal_on_directed_graph(monkeypatch, tmp_path, capsys): + """`graphify query` on a directed graph still traverses bidirectionally (finds callers when querying callee).""" + import graphify.__main__ as mainmod + + # Create a directed graph on disk + G = nx.DiGraph() + G.add_node("caller", label="caller_fn", source_file="a.py", source_location="L1", community=0) + G.add_node("callee", label="callee_fn", source_file="b.py", source_location="L1", community=0) + G.add_edge("caller", "callee", relation="calls", confidence="EXTRACTED", context="call") + + from networkx.readwrite import json_graph + data = json_graph.node_link_data(G, edges="links") + assert data.get("directed") is True + + graph_path = tmp_path / "graph.json" + graph_path.write_text(json.dumps(data), encoding="utf-8") + + monkeypatch.setattr(mainmod, "_check_skill_version", lambda _: None) + monkeypatch.setattr( + mainmod.sys, + "argv", + ["graphify", "query", "callee_fn", "--graph", str(graph_path)], + ) + mainmod.main() + out = capsys.readouterr().out + # Traversing backwards to caller must succeed even though graph on disk is directed + assert "caller_fn" in out + assert "caller_fn --calls" in out