add Ollama backend and cross-project global graph (#729)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Safi
2026-05-05 18:36:59 +01:00
co-authored by Claude Sonnet 4.6
parent 69d2cc84e2
commit 48888a7c26
10 changed files with 685 additions and 10 deletions
+8
View File
@@ -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 `<repo>::<id>` prefixed node IDs, preventing silent collisions; hash-based skip avoids re-ingesting unchanged graphs (#729)
- Feat: `graphify extract --global --as <tag>` 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 <path>` to specify a non-default graph.json location; positional path and flags can appear in any order (#724)
+8 -1
View File
@@ -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
+106 -5
View File
@@ -1097,8 +1097,16 @@ def main() -> None:
print(" --label NAME project label in header")
print(" extract <path> headless full extraction (AST + semantic LLM) for CI/scripts")
print(" --backend B kimi|claude (default: whichever API key is set)")
print(" --model <name> override the backend's default model")
print(" --out DIR output dir (default: <path>); writes <DIR>/graphify-out/")
print(" --no-cluster skip clustering, write raw extraction only")
print(" --global also merge the resulting graph into the global graph")
print(" --as <tag> repo tag for --global (default: target directory name)")
print(" global add <graph.json> add/update a project graph in the global graph (~/.graphify/global-graph.json)")
print(" --as <tag> repo tag (default: parent directory name)")
print(" global remove <tag> 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 <graph.json> [--as <tag>]
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 <graph.json> [--as <repo-tag>]", 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 <repo-tag>", 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()},
+23
View File
@@ -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)
+10
View File
@@ -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
+155
View File
@@ -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 <tag> 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
+25 -3
View File
@@ -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
+1 -1
View File
@@ -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" }
+279
View File
@@ -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
+70
View File
@@ -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)