From 48888a7c26a8f3eea7a7580cae60b15e49ed0eb9 Mon Sep 17 00:00:00 2001 From: Safi Date: Tue, 5 May 2026 18:36:59 +0100 Subject: [PATCH] add Ollama backend and cross-project global graph (#729) Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 8 ++ README.md | 9 +- graphify/__main__.py | 111 ++++++++++++++- graphify/build.py | 23 +++ graphify/dedup.py | 10 ++ graphify/global_graph.py | 155 +++++++++++++++++++++ graphify/llm.py | 28 +++- pyproject.toml | 2 +- tests/test_global_graph.py | 279 +++++++++++++++++++++++++++++++++++++ tests/test_ollama.py | 70 ++++++++++ 10 files changed, 685 insertions(+), 10 deletions(-) create mode 100644 graphify/global_graph.py create mode 100644 tests/test_global_graph.py create mode 100644 tests/test_ollama.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0437213..c95be25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,14 @@ Full release notes with details on each version: [GitHub Releases](https://github.com/safishamsi/graphify/releases) +## 0.7.7 (2026-05-05) + +- Feat: Ollama backend for headless extraction -- `graphify extract ./docs --backend ollama`; auto-detected when `OLLAMA_BASE_URL` is set; defaults to `qwen2.5-coder:7b`; zero cost ($0.00); sentinel API key handles OpenAI client auth requirement (#729) +- Feat: Cross-project global graph at `~/.graphify/global.json` -- `graphify global add/remove/list/path` to register multiple project graphs with `::` prefixed node IDs, preventing silent collisions; hash-based skip avoids re-ingesting unchanged graphs (#729) +- Feat: `graphify extract --global --as ` flag -- after building a project graph, auto-registers it into the global graph in one step (#729) +- Feat: `merge-graphs` now prefix-relabels each input graph before composing, preventing silent node ID collisions when two projects share entity names (#729) +- Fix: `deduplicate_entities` raises `ValueError` if called with nodes spanning multiple repos (cross-project dedup disabled by design -- per-project graphs are deduplicated in isolation) (#729) + ## 0.7.6 (2026-05-05) - Fix: `cluster-only` now accepts `--graph ` to specify a non-default graph.json location; positional path and flags can appear in any order (#724) diff --git a/README.md b/README.md index ce5e94a..b92ace1 100644 --- a/README.md +++ b/README.md @@ -221,7 +221,7 @@ The MCP server gives your assistant structured access: `query_graph`, `get_node` - **Code files** — processed locally via tree-sitter. Nothing leaves your machine. - **Video / audio** — transcribed locally with faster-whisper. Nothing leaves your machine. -- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` (Claude) or `MOONSHOT_API_KEY` (Kimi). The `--dedup-llm` flag uses the same key. +- **Docs, PDFs, images** — sent to your AI assistant for semantic extraction (via the `/graphify` skill, using whatever model your IDE session runs). Headless `graphify extract` requires `ANTHROPIC_API_KEY` (Claude), `MOONSHOT_API_KEY` (Kimi), or a running Ollama instance (`OLLAMA_BASE_URL`). The `--dedup-llm` flag uses the same key. - No telemetry, no usage tracking, no analytics. --- @@ -275,10 +275,17 @@ graphify antigravity install / uninstall graphify extract ./docs # headless LLM extraction for CI (no IDE needed) graphify extract ./docs --backend claude # explicit backend: claude (ANTHROPIC_API_KEY) or kimi (MOONSHOT_API_KEY) +graphify extract ./docs --backend ollama # local Ollama (set OLLAMA_BASE_URL / OLLAMA_MODEL) graphify extract ./docs --no-cluster # raw extraction only, skip clustering graphify extract ./docs --dedup-llm # LLM tiebreaker for ambiguous entity pairs (uses same API key) +graphify extract ./docs --global --as myrepo # extract and register into the cross-project global graph GRAPHIFY_MAX_OUTPUT_TOKENS=32768 graphify extract ./docs --backend claude # raise output cap for dense corpora +graphify global add graphify-out/graph.json myrepo # register a project graph into ~/.graphify/global.json +graphify global remove myrepo # remove a project from the global graph +graphify global list # show all registered repos + node/edge counts +graphify global path # print path to the global graph file + graphify clone https://github.com/karpathy/nanoGPT graphify merge-graphs a.json b.json --out merged.json graphify watch ./src diff --git a/graphify/__main__.py b/graphify/__main__.py index c86c707..d23059d 100644 --- a/graphify/__main__.py +++ b/graphify/__main__.py @@ -1097,8 +1097,16 @@ def main() -> None: print(" --label NAME project label in header") print(" extract headless full extraction (AST + semantic LLM) for CI/scripts") print(" --backend B kimi|claude (default: whichever API key is set)") + print(" --model override the backend's default model") print(" --out DIR output dir (default: ); writes /graphify-out/") print(" --no-cluster skip clustering, write raw extraction only") + print(" --global also merge the resulting graph into the global graph") + print(" --as repo tag for --global (default: target directory name)") + print(" global add add/update a project graph in the global graph (~/.graphify/global-graph.json)") + print(" --as repo tag (default: parent directory name)") + print(" global remove remove a repo's nodes from the global graph") + print(" global list list repos in the global graph") + print(" global path print path to the global graph file") print(" benchmark [graph.json] measure token reduction vs naive full-corpus approach") print(" hook install install post-commit/post-checkout git hooks (all platforms)") print(" hook uninstall remove git hooks") @@ -1714,6 +1722,7 @@ def main() -> None: sys.exit(1) import networkx as _nx from networkx.readwrite import json_graph as _jg + from graphify.build import prefix_graph_for_global as _prefix graphs = [] for gp in graph_paths: if not gp.exists(): @@ -1728,12 +1737,12 @@ def main() -> None: G = _jg.node_link_graph(data, edges="links") except TypeError: G = _jg.node_link_graph(data) - # Tag every node with which repo it came from - repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name - for node in G.nodes: - G.nodes[node].setdefault("repo", repo_tag) graphs.append(G) - merged = _nx.compose_all(graphs) + merged = _nx.Graph() + for G, gp in zip(graphs, graph_paths): + repo_tag = gp.parent.parent.name # graphify-out/../ → repo dir name + prefixed = _prefix(G, repo_tag) + merged = _nx.compose(merged, prefixed) try: out_data = _jg.node_link_data(merged, edges="links") except TypeError: @@ -1911,6 +1920,62 @@ def main() -> None: result = run_benchmark(graph_path, corpus_words=corpus_words) print_benchmark(result) + elif cmd == "global": + subcmd = sys.argv[2] if len(sys.argv) > 2 else "" + from graphify.global_graph import ( + global_add as _global_add, + global_remove as _global_remove, + global_list as _global_list, + global_path as _global_path, + ) + if subcmd == "add": + # graphify global add [--as ] + args = sys.argv[3:] + source = None + tag = None + i = 0 + while i < len(args): + if args[i] == "--as" and i + 1 < len(args): + tag = args[i + 1]; i += 2 + elif not source: + source = Path(args[i]); i += 1 + else: + i += 1 + if not source: + print("Usage: graphify global add [--as ]", file=sys.stderr) + sys.exit(1) + tag = tag or source.parent.parent.name + try: + result = _global_add(source, tag) + if result["skipped"]: + print(f"'{tag}' unchanged since last add — global graph not modified.") + else: + print(f"Added '{tag}' to global graph: +{result['nodes_added']} nodes, " + f"-{result['nodes_removed']} pruned. Global: {_global_path()}") + except Exception as exc: + print(f"error: {exc}", file=sys.stderr); sys.exit(1) + elif subcmd == "remove": + tag = sys.argv[3] if len(sys.argv) > 3 else "" + if not tag: + print("Usage: graphify global remove ", file=sys.stderr); sys.exit(1) + try: + removed = _global_remove(tag) + print(f"Removed '{tag}' from global graph ({removed} nodes pruned).") + except KeyError as exc: + print(f"error: {exc}", file=sys.stderr); sys.exit(1) + elif subcmd == "list": + repos = _global_list() + if not repos: + print("Global graph is empty. Use 'graphify global add' to add a project.") + else: + print(f"Global graph: {_global_path()}") + for tag, info in repos.items(): + print(f" {tag}: {info.get('node_count', '?')} nodes, added {info.get('added_at', '?')[:10]}") + elif subcmd == "path": + print(_global_path()) + else: + print("Usage: graphify global [add|remove|list|path]", file=sys.stderr); sys.exit(1) + elif cmd == "extract": # Headless full-pipeline extraction for CI / scripts (#698). # Runs detect -> AST extraction on code -> semantic LLM extraction on @@ -1935,6 +2000,9 @@ def main() -> None: out_dir: Path | None = None no_cluster = False dedup_llm = False + model_override: str | None = None + global_merge = False + global_repo_tag: str | None = None args = sys.argv[3:] i = 0 while i < len(args): @@ -1943,6 +2011,10 @@ def main() -> None: backend = args[i + 1]; i += 2 elif a.startswith("--backend="): backend = a.split("=", 1)[1]; i += 1 + elif a == "--model" and i + 1 < len(args): + model_override = args[i + 1]; i += 2 + elif a.startswith("--model="): + model_override = a.split("=", 1)[1]; i += 1 elif a == "--out" and i + 1 < len(args): out_dir = Path(args[i + 1]); i += 2 elif a.startswith("--out="): @@ -1951,6 +2023,10 @@ def main() -> None: no_cluster = True; i += 1 elif a == "--dedup-llm": dedup_llm = True; i += 1 + elif a == "--global": + global_merge = True; i += 1 + elif a == "--as" and i + 1 < len(args): + global_repo_tag = args[i + 1]; i += 2 else: i += 1 @@ -2084,6 +2160,7 @@ def main() -> None: fresh = _extract_corpus_parallel( [Path(p) for p in uncached_paths], backend=backend, + model=model_override, root=target, ) except ImportError as exc: @@ -2150,6 +2227,18 @@ def main() -> None: _save_manifest(files_by_type, manifest_path=str(manifest_path)) except Exception as exc: print(f"[graphify extract] warning: could not write manifest: {exc}", file=sys.stderr) + if global_merge: + from graphify.global_graph import global_add as _global_add + _tag = global_repo_tag or target.name + try: + result = _global_add(graphify_out / "graph.json", _tag) + if result["skipped"]: + print(f"[graphify global] '{_tag}' unchanged since last add — skipped.") + else: + print(f"[graphify global] '{_tag}' merged into global graph " + f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).") + except Exception as exc: + print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr) sys.exit(0) # Build graph + cluster + score + write. @@ -2193,6 +2282,18 @@ def main() -> None: surprises = [] _to_json(G, communities, str(graph_json_path), force=True) + if global_merge: + from graphify.global_graph import global_add as _global_add + _tag = global_repo_tag or target.name + try: + result = _global_add(graphify_out / "graph.json", _tag) + if result["skipped"]: + print(f"[graphify global] '{_tag}' unchanged since last add — skipped.") + else: + print(f"[graphify global] '{_tag}' merged into global graph " + f"(+{result['nodes_added']} nodes, -{result['nodes_removed']} pruned).") + except Exception as exc: + print(f"[graphify global] warning: failed to merge into global graph: {exc}", file=sys.stderr) analysis = { "communities": {str(k): v for k, v in communities.items()}, "cohesion": {str(k): v for k, v in cohesion.items()}, diff --git a/graphify/build.py b/graphify/build.py index 2b45520..6154c16 100644 --- a/graphify/build.py +++ b/graphify/build.py @@ -271,3 +271,26 @@ def build_merge( ) return G + + +def prefix_graph_for_global(G: nx.Graph, repo_tag: str) -> nx.Graph: + """Return a copy of G with all node IDs prefixed with repo_tag::. + + Labels are preserved unchanged (for display). A 'local_id' attribute + is added to each node so the original ID can be recovered. Edges are + rewritten to match the new prefixed IDs. The 'repo' attribute is set + on every node. + """ + relabel = {n: f"{repo_tag}::{n}" for n in G.nodes} + H = nx.relabel_nodes(G, relabel, copy=True) + for node, data in H.nodes(data=True): + data["repo"] = repo_tag + data.setdefault("local_id", node.split("::", 1)[1]) + return H + + +def prune_repo_from_graph(G: nx.Graph, repo_tag: str) -> int: + """Remove all nodes tagged with repo_tag from G in-place. Returns count removed.""" + to_remove = [n for n, d in G.nodes(data=True) if d.get("repo") == repo_tag] + G.remove_nodes_from(to_remove) + return len(to_remove) diff --git a/graphify/dedup.py b/graphify/dedup.py index af5cef8..8a4188e 100644 --- a/graphify/dedup.py +++ b/graphify/dedup.py @@ -103,6 +103,16 @@ def deduplicate_entities( Returns: (deduped_nodes, deduped_edges) with edges rewired to survivors """ + # Guard: cross-project dedup is not supported — nodes from different repos + # share label names by coincidence and must never be merged by string similarity. + # If you need to dedup a global graph, run deduplicate_entities per-repo first. + repos_seen = {n.get("repo") for n in nodes if n.get("repo")} + if len(repos_seen) > 1: + raise ValueError( + f"deduplicate_entities: nodes span multiple repos {sorted(repos_seen)!r}. " + f"Cross-project dedup is disabled — run dedup per-repo before merging." + ) + if len(nodes) <= 1: return nodes, edges diff --git a/graphify/global_graph.py b/graphify/global_graph.py new file mode 100644 index 0000000..dfcc826 --- /dev/null +++ b/graphify/global_graph.py @@ -0,0 +1,155 @@ +from __future__ import annotations +import json +import hashlib +import sys +from datetime import datetime, timezone +from pathlib import Path +import networkx as nx +from networkx.readwrite import json_graph as _jg + +_GLOBAL_DIR = Path.home() / ".graphify" +_GLOBAL_GRAPH = _GLOBAL_DIR / "global-graph.json" +_GLOBAL_MANIFEST = _GLOBAL_DIR / "global-manifest.json" + + +def _load_manifest() -> dict: + if _GLOBAL_MANIFEST.exists(): + try: + return json.loads(_GLOBAL_MANIFEST.read_text(encoding="utf-8")) + except Exception: + pass + return {"version": 1, "repos": {}} + + +def _save_manifest(manifest: dict) -> None: + _GLOBAL_DIR.mkdir(parents=True, exist_ok=True) + _GLOBAL_MANIFEST.write_text(json.dumps(manifest, indent=2), encoding="utf-8") + + +def _load_global_graph() -> nx.Graph: + if _GLOBAL_GRAPH.exists(): + data = json.loads(_GLOBAL_GRAPH.read_text(encoding="utf-8")) + if "links" not in data and "edges" in data: + data = dict(data, links=data["edges"]) + try: + return _jg.node_link_graph(data, edges="links") + except TypeError: + return _jg.node_link_graph(data) + return nx.Graph() + + +def _save_global_graph(G: nx.Graph) -> None: + _GLOBAL_DIR.mkdir(parents=True, exist_ok=True) + try: + data = _jg.node_link_data(G, edges="links") + except TypeError: + data = _jg.node_link_data(G) + _GLOBAL_GRAPH.write_text(json.dumps(data, indent=2), encoding="utf-8") + + +def _file_hash(path: Path) -> str: + h = hashlib.sha256() + h.update(path.read_bytes()) + return h.hexdigest()[:16] + + +def global_add(source_path: Path, repo_tag: str) -> dict: + """Add or update a project graph in the global graph. + + Returns a summary dict with keys: repo_tag, nodes_added, nodes_removed, skipped. + Skipped=True means the source graph hasn't changed since last add. + """ + from graphify.build import prefix_graph_for_global, prune_repo_from_graph + + if not source_path.exists(): + raise FileNotFoundError(f"graph not found: {source_path}") + + manifest = _load_manifest() + src_hash = _file_hash(source_path) + + existing = manifest["repos"].get(repo_tag, {}) + existing_path = existing.get("source_path", "") + if existing_path and existing_path != str(source_path.resolve()): + print( + f"[graphify global] warning: repo tag '{repo_tag}' previously pointed to " + f"{existing_path!r}, now updating to {str(source_path.resolve())!r}. " + f"Use --as to give it a different name.", + file=sys.stderr, + ) + if existing.get("source_hash") == src_hash: + return {"repo_tag": repo_tag, "nodes_added": 0, "nodes_removed": 0, "skipped": True} + + # Load source graph + data = json.loads(source_path.read_text(encoding="utf-8")) + if "links" not in data and "edges" in data: + data = dict(data, links=data["edges"]) + try: + src_G = _jg.node_link_graph(data, edges="links") + except TypeError: + src_G = _jg.node_link_graph(data) + + # Prefix IDs for cross-project isolation + prefixed = prefix_graph_for_global(src_G, repo_tag) + + # Load global graph and prune stale nodes for this repo + G = _load_global_graph() + removed = prune_repo_from_graph(G, repo_tag) + + # Merge external-library nodes (no source_file) by label to avoid duplication + external_labels = { + d.get("label", ""): n + for n, d in G.nodes(data=True) + if not d.get("source_file") and d.get("label") + } + nodes_to_skip = set() + for node, data in prefixed.nodes(data=True): + if not data.get("source_file") and data.get("label") in external_labels: + nodes_to_skip.add(node) + + # Compose: add prefixed nodes (except deduplicated externals) into global graph + for node, data in prefixed.nodes(data=True): + if node not in nodes_to_skip: + G.add_node(node, **data) + for u, v, data in prefixed.edges(data=True): + if u not in nodes_to_skip and v not in nodes_to_skip: + G.add_edge(u, v, **data) + + added = prefixed.number_of_nodes() - len(nodes_to_skip) + _save_global_graph(G) + + manifest["repos"][repo_tag] = { + "added_at": datetime.now(timezone.utc).isoformat(), + "source_path": str(source_path.resolve()), + "node_count": added, + "edge_count": prefixed.number_of_edges(), + "source_hash": src_hash, + } + _save_manifest(manifest) + + return {"repo_tag": repo_tag, "nodes_added": added, "nodes_removed": removed, "skipped": False} + + +def global_remove(repo_tag: str) -> int: + """Remove all nodes for repo_tag from the global graph. Returns count removed.""" + from graphify.build import prune_repo_from_graph + + manifest = _load_manifest() + if repo_tag not in manifest["repos"]: + raise KeyError(f"repo '{repo_tag}' not in global graph") + + G = _load_global_graph() + removed = prune_repo_from_graph(G, repo_tag) + _save_global_graph(G) + + del manifest["repos"][repo_tag] + _save_manifest(manifest) + return removed + + +def global_list() -> dict: + """Return the manifest repos dict.""" + return _load_manifest().get("repos", {}) + + +def global_path() -> Path: + return _GLOBAL_GRAPH diff --git a/graphify/llm.py b/graphify/llm.py index 6b6736e..07c8455 100644 --- a/graphify/llm.py +++ b/graphify/llm.py @@ -60,6 +60,14 @@ BACKENDS: dict[str, dict] = { "temperature": None, # kimi-k2.6 enforces its own fixed temperature; sending any value raises 400 "max_tokens": 16384, }, + "ollama": { + "base_url": os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434/v1"), + "default_model": os.environ.get("OLLAMA_MODEL", "qwen2.5-coder:7b"), + "env_key": "OLLAMA_API_KEY", + "pricing": {"input": 0.0, "output": 0.0}, + "temperature": 0, + "max_tokens": 16384, + }, } @@ -129,6 +137,8 @@ def _call_openai_compat( user_message: str, temperature: float | None = 0, max_tokens: int = 8192, + *, + backend: str = "", ) -> dict: """Call any OpenAI-compatible API (Kimi, OpenAI, etc.) and return parsed JSON.""" try: @@ -162,6 +172,14 @@ def _call_openai_compat( # mid-generation. The JSON we got back is truncated; callers should # treat this as a signal to retry with smaller input. result["finish_reason"] = resp.choices[0].finish_reason + output_tokens = result["output_tokens"] + if output_tokens < 50 and backend == "ollama": + print( + "[graphify] warning: ollama returned very few tokens — the model may be " + "too small or not following the JSON instruction format. " + "Try a larger model with --model (e.g. --model qwen2.5-coder:14b).", + file=sys.stderr, + ) return result @@ -210,6 +228,8 @@ def extract_files_direct( cfg = BACKENDS[backend] key = api_key or os.environ.get(cfg["env_key"], "") + if not key and backend == "ollama": + key = "ollama" # Ollama ignores auth but openai client requires non-empty if not key: raise ValueError( f"No API key for backend '{backend}'. " @@ -222,7 +242,7 @@ def extract_files_direct( if backend == "claude": return _call_claude(key, mdl, user_msg, max_tokens=max_out) else: - return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0), max_tokens=max_out) + return _call_openai_compat(cfg["base_url"], key, mdl, user_msg, temperature=cfg.get("temperature", 0), max_tokens=max_out, backend=backend) def _estimate_file_tokens(path: Path) -> int: @@ -485,11 +505,13 @@ def estimate_cost(backend: str, input_tokens: int, output_tokens: int) -> float: def detect_backend() -> str | None: """Return the name of whichever backend has an API key set, or None. - Kimi is checked first (opt-in). Falls back to Claude if ANTHROPIC_API_KEY is set. - Claude is the default for the skill.md subagent pipeline and is never forced here. + Priority: kimi → ollama (if OLLAMA_BASE_URL set) → claude. + Ollama is opt-in via env var — never auto-probed. """ if os.environ.get("MOONSHOT_API_KEY"): return "kimi" + if os.environ.get("OLLAMA_BASE_URL"): + return "ollama" if os.environ.get("ANTHROPIC_API_KEY"): return "claude" return None diff --git a/pyproject.toml b/pyproject.toml index 3837569..378a3c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "graphifyy" -version = "0.7.6" +version = "0.7.7" description = "AI coding assistant skill (Claude Code, Codex, OpenCode, Cursor, Gemini CLI, Aider, OpenClaw, Factory Droid, Trae, Hermes, Kiro, Pi, Google Antigravity) - turn any folder of code, docs, papers, images, or videos into a queryable knowledge graph" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_global_graph.py b/tests/test_global_graph.py new file mode 100644 index 0000000..3e84fdf --- /dev/null +++ b/tests/test_global_graph.py @@ -0,0 +1,279 @@ +"""Tests for the global graph infrastructure (graphify/global_graph.py), +prefix/prune helpers in graphify/build.py, and the cross-repo guard in +graphify/dedup.py.""" +from __future__ import annotations + +import json +import pytest +import networkx as nx +from unittest.mock import patch + + +# ── helpers ────────────────────────────────────────────────────────────────── + +def _make_graph(nodes, edges=None): + """Build a simple nx.Graph from node dicts.""" + G = nx.Graph() + for n in nodes: + nid = n["id"] + G.add_node(nid, **{k: v for k, v in n.items() if k != "id"}) + for e in (edges or []): + G.add_edge( + e["source"], + e["target"], + **{k: v for k, v in e.items() if k not in ("source", "target")}, + ) + return G + + +def _graph_to_json(G, path): + from networkx.readwrite import json_graph as jg + try: + data = jg.node_link_data(G, edges="links") + except TypeError: + data = jg.node_link_data(G) + path.write_text(json.dumps(data), encoding="utf-8") + + +# ── build.py helpers ────────────────────────────────────────────────────────── + +def test_prefix_graph_preserves_label(): + from graphify.build import prefix_graph_for_global + G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + H = prefix_graph_for_global(G, "repoA") + assert "repoA::userservice" in H.nodes + assert "userservice" not in H.nodes + assert H.nodes["repoA::userservice"]["label"] == "UserService" + + +def test_prefix_graph_sets_repo_and_local_id(): + from graphify.build import prefix_graph_for_global + G = _make_graph([{"id": "userservice", "label": "UserService"}]) + H = prefix_graph_for_global(G, "repoA") + data = H.nodes["repoA::userservice"] + assert data["repo"] == "repoA" + assert data["local_id"] == "userservice" + + +def test_prefix_graph_rewrites_edges(): + from graphify.build import prefix_graph_for_global + G = _make_graph( + [{"id": "a", "label": "A"}, {"id": "b", "label": "B"}], + [{"source": "a", "target": "b"}], + ) + H = prefix_graph_for_global(G, "repo1") + assert H.has_edge("repo1::a", "repo1::b") + assert not H.has_edge("a", "b") + + +def test_prune_repo_removes_correct_nodes(): + from graphify.build import prune_repo_from_graph + G = nx.Graph() + G.add_node("repoA::userservice", repo="repoA", label="UserService") + G.add_node("repoB::userservice", repo="repoB", label="UserService") + G.add_node("repoA::auth", repo="repoA", label="Auth") + removed = prune_repo_from_graph(G, "repoA") + assert removed == 2 + assert "repoB::userservice" in G.nodes + assert "repoA::userservice" not in G.nodes + assert "repoA::auth" not in G.nodes + + +def test_prune_repo_returns_zero_if_not_present(): + from graphify.build import prune_repo_from_graph + G = nx.Graph() + G.add_node("repoA::x", repo="repoA") + removed = prune_repo_from_graph(G, "repoB") + assert removed == 0 + assert G.number_of_nodes() == 1 + + +# ── global_graph.py ─────────────────────────────────────────────────────────── + +def test_global_add_creates_global_graph(tmp_path): + src_graph = tmp_path / "graph.json" + G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + _graph_to_json(G, src_graph) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add + result = global_add(src_graph, "repoA") + + assert result["skipped"] is False + assert result["nodes_added"] > 0 + manifest_path = global_dir / "global-manifest.json" + assert manifest_path.exists() + manifest = json.loads(manifest_path.read_text()) + assert "repoA" in manifest["repos"] + + +def test_global_add_skip_on_unchanged_hash(tmp_path): + src_graph = tmp_path / "graph.json" + G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + _graph_to_json(G, src_graph) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add + global_add(src_graph, "repoA") + result2 = global_add(src_graph, "repoA") + + assert result2["skipped"] is True + + +def test_global_add_two_repos_no_collision(tmp_path): + g1 = tmp_path / "graph1.json" + g2 = tmp_path / "graph2.json" + G1 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + G2 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + _graph_to_json(G1, g1) + _graph_to_json(G2, g2) + + global_dir = tmp_path / ".graphify" + global_graph_path = global_dir / "global-graph.json" + global_manifest_path = global_dir / "global-manifest.json" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_graph_path), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_manifest_path): + from graphify.global_graph import global_add, _load_global_graph + global_add(g1, "repoA") + global_add(g2, "repoB") + G = _load_global_graph() + + assert "repoA::userservice" in G.nodes + assert "repoB::userservice" in G.nodes + assert G.number_of_nodes() == 2 # no silent merge + + +def test_global_remove(tmp_path): + src_graph = tmp_path / "graph.json" + G = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + _graph_to_json(G, src_graph) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add, global_remove + global_add(src_graph, "repoA") + removed = global_remove("repoA") + + assert removed > 0 + # manifest should no longer list repoA - need to re-patch for list call + global_dir2 = global_dir # same dir + with patch("graphify.global_graph._GLOBAL_DIR", global_dir2), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir2 / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir2 / "global-manifest.json"): + from graphify.global_graph import global_list + repos = global_list() + assert "repoA" not in repos + + +def test_global_remove_unknown_tag_raises(tmp_path): + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_remove + with pytest.raises(KeyError): + global_remove("nonexistent") + + +def test_global_add_collision_warning(tmp_path, capsys): + g1 = tmp_path / "graph1.json" + g2 = tmp_path / "graph2.json" + G = _make_graph([{"id": "x", "label": "X", "source_file": "x.py"}]) + _graph_to_json(G, g1) + _graph_to_json(G, g2) + + global_dir = tmp_path / ".graphify" + with patch("graphify.global_graph._GLOBAL_DIR", global_dir), \ + patch("graphify.global_graph._GLOBAL_GRAPH", global_dir / "global-graph.json"), \ + patch("graphify.global_graph._GLOBAL_MANIFEST", global_dir / "global-manifest.json"): + from graphify.global_graph import global_add + global_add(g1, "myrepo") + global_add(g2, "myrepo") # different source path, same tag + + captured = capsys.readouterr() + assert "warning" in captured.err.lower() or "warning" in captured.out.lower() + + +# ── dedup guard ─────────────────────────────────────────────────────────────── + +def test_dedup_raises_on_cross_repo_nodes(): + from graphify.dedup import deduplicate_entities + nodes = [ + {"id": "repoA::userservice", "label": "UserService", "repo": "repoA"}, + {"id": "repoB::userservice", "label": "UserService", "repo": "repoB"}, + ] + with pytest.raises(ValueError, match="multiple repos"): + deduplicate_entities(nodes, [], communities={}) + + +def test_dedup_ok_with_single_repo(): + from graphify.dedup import deduplicate_entities + nodes = [ + {"id": "repoA::userservice", "label": "UserService", "repo": "repoA"}, + {"id": "repoA::auth", "label": "Auth", "repo": "repoA"}, + ] + result_nodes, result_edges = deduplicate_entities(nodes, [], communities={}) + assert len(result_nodes) == 2 # no false merge + + +def test_dedup_ok_with_no_repo_attr(): + from graphify.dedup import deduplicate_entities + nodes = [ + {"id": "userservice", "label": "UserService"}, + {"id": "auth", "label": "Auth"}, + ] + result_nodes, result_edges = deduplicate_entities(nodes, [], communities={}) + assert len(result_nodes) == 2 + + +# ── merge-graphs prefix ─────────────────────────────────────────────────────── + +def test_merge_graphs_prefixes_ids(tmp_path): + """merge-graphs should prefix node IDs with repo name to avoid silent collision.""" + from graphify.build import prefix_graph_for_global + from networkx.readwrite import json_graph as jg + + # Two graphs with same node ID + G1 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + G2 = _make_graph([{"id": "userservice", "label": "UserService", "source_file": "src/user.py"}]) + + repo1 = tmp_path / "repo1" / "graphify-out" + repo2 = tmp_path / "repo2" / "graphify-out" + repo1.mkdir(parents=True) + repo2.mkdir(parents=True) + + g1_path = repo1 / "graph.json" + g2_path = repo2 / "graph.json" + _graph_to_json(G1, g1_path) + _graph_to_json(G2, g2_path) + + # Simulate what merge-graphs now does (prefix before compose) + graphs = [] + graph_paths = [g1_path, g2_path] + for gp in graph_paths: + data = json.loads(gp.read_text()) + if "links" not in data and "edges" in data: + data = dict(data, links=data["edges"]) + try: + G = jg.node_link_graph(data, edges="links") + except TypeError: + G = jg.node_link_graph(data) + repo_tag = gp.parent.parent.name + graphs.append(prefix_graph_for_global(G, repo_tag)) + + merged = nx.Graph() + for G in graphs: + merged = nx.compose(merged, G) + + assert "repo1::userservice" in merged.nodes + assert "repo2::userservice" in merged.nodes + assert merged.number_of_nodes() == 2 # no silent collapse diff --git a/tests/test_ollama.py b/tests/test_ollama.py new file mode 100644 index 0000000..ddf7aaf --- /dev/null +++ b/tests/test_ollama.py @@ -0,0 +1,70 @@ +"""Tests for the Ollama backend additions in graphify/llm.py.""" +from __future__ import annotations + +from graphify.llm import detect_backend, BACKENDS + + +def test_ollama_in_backends(): + assert "ollama" in BACKENDS + assert BACKENDS["ollama"]["pricing"]["input"] == 0.0 + assert BACKENDS["ollama"]["pricing"]["output"] == 0.0 + assert "max_tokens" in BACKENDS["ollama"] + + +def test_detect_backend_ollama(monkeypatch): + monkeypatch.delenv("MOONSHOT_API_KEY", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + assert detect_backend() == "ollama" + + +def test_detect_backend_kimi_beats_ollama(monkeypatch): + monkeypatch.setenv("MOONSHOT_API_KEY", "test-key") + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + assert detect_backend() == "kimi" + + +def test_detect_backend_ollama_beats_claude(monkeypatch): + monkeypatch.delenv("MOONSHOT_API_KEY", raising=False) + monkeypatch.setenv("OLLAMA_BASE_URL", "http://localhost:11434/v1") + monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-test") + assert detect_backend() == "ollama" + + +def test_detect_backend_none_without_envvars(monkeypatch): + monkeypatch.delenv("MOONSHOT_API_KEY", raising=False) + monkeypatch.delenv("OLLAMA_BASE_URL", raising=False) + monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False) + assert detect_backend() is None + + +def test_ollama_api_key_sentinel(monkeypatch): + """extract_files_direct with backend=ollama and no OLLAMA_API_KEY should use sentinel 'ollama' not raise.""" + monkeypatch.delenv("OLLAMA_API_KEY", raising=False) + from unittest.mock import patch + from pathlib import Path + import tempfile + + fake_result = { + "nodes": [], + "edges": [], + "hyperedges": [], + "input_tokens": 0, + "output_tokens": 10, + "finish_reason": "stop", + } + with patch("graphify.llm._call_openai_compat", return_value=fake_result) as mock_call: + from graphify.llm import extract_files_direct + with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f: + f.write("x = 1\n") + tmp = Path(f.name) + try: + extract_files_direct([tmp], backend="ollama", root=tmp.parent) + # Should have called _call_openai_compat with api_key="ollama" + assert mock_call.called + call_kwargs = mock_call.call_args + api_key_used = call_kwargs.args[1] if call_kwargs.args else call_kwargs.kwargs.get("api_key", "") + assert api_key_used == "ollama" + finally: + tmp.unlink(missing_ok=True)