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
3 changes: 2 additions & 1 deletion graphify/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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;")
Expand Down Expand Up @@ -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
Expand Down
49 changes: 47 additions & 2 deletions graphify/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 <path> [--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: <path>/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 <path> [--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,
Expand All @@ -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 +
Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
215 changes: 215 additions & 0 deletions tests/test_extract_directed.py
Original file line number Diff line number Diff line change
@@ -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
Loading