Files
graphify/tests/test_serve.py
T
Safi df40e4d818 fix #873 index dot dirs, fix #874 MCP hot-reload on graph change
#873: Remove blanket dot-prefix exclusion from detect.py and
extract.py collect_files(). Add framework caches (.next, .nuxt,
.turbo, .angular, .idea, .cache, .parcel-cache, .svelte-kit,
.terraform, .serverless, .graphify) to _SKIP_DIRS so they stay
blocked. Meaningful dot dirs (.github, .claude, etc.) are now
indexed.

#874: Add _maybe_reload() with mtime+size stat key and threading.Lock
to serve.py. call_tool and read_resource call _maybe_reload() on
every request; the graph reloads automatically when graph.json changes
without restarting the MCP server.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 08:51:46 +01:00

253 lines
7.5 KiB
Python

"""Tests for serve.py - MCP graph query helpers (no mcp package required)."""
import json
import pytest
import networkx as nx
from networkx.readwrite import json_graph
from graphify.serve import (
_communities_from_graph,
_score_nodes,
_bfs,
_dfs,
_filter_graph_by_context,
_infer_context_filters,
_query_graph_text,
_resolve_context_filters,
_subgraph_to_text,
_load_graph,
)
def _make_graph() -> nx.Graph:
G = nx.Graph()
G.add_node("n1", label="extract", source_file="extract.py", source_location="L10", community=0)
G.add_node("n2", label="cluster", source_file="cluster.py", source_location="L5", community=0)
G.add_node("n3", label="build", source_file="build.py", source_location="L1", community=1)
G.add_node("n4", label="report", source_file="report.py", source_location="L1", community=1)
G.add_node("n5", label="isolated", source_file="other.py", source_location="L1", community=2)
G.add_edge("n1", "n2", relation="calls", confidence="INFERRED", context="call")
G.add_edge("n2", "n3", relation="imports", confidence="EXTRACTED", context="import")
G.add_edge("n3", "n4", relation="uses", confidence="EXTRACTED")
return G
# --- _communities_from_graph ---
def test_communities_from_graph_basic():
G = _make_graph()
communities = _communities_from_graph(G)
assert 0 in communities
assert 1 in communities
assert "n1" in communities[0]
assert "n2" in communities[0]
assert "n3" in communities[1]
def test_communities_from_graph_no_community_attr():
G = nx.Graph()
G.add_node("a", label="foo") # no community attr
communities = _communities_from_graph(G)
assert communities == {}
def test_communities_from_graph_isolated():
G = _make_graph()
communities = _communities_from_graph(G)
assert 2 in communities
assert "n5" in communities[2]
# --- _score_nodes ---
def test_score_nodes_exact_label_match():
G = _make_graph()
scored = _score_nodes(G, ["extract"])
nids = [nid for _, nid in scored]
assert "n1" in nids
assert scored[0][1] == "n1" # highest score first
def test_score_nodes_no_match():
G = _make_graph()
scored = _score_nodes(G, ["xyzzy"])
assert scored == []
def test_score_nodes_source_file_partial():
G = _make_graph()
# "cluster.py" contains "cluster" - should score 0.5 for source match
scored = _score_nodes(G, ["cluster"])
nids = [nid for _, nid in scored]
assert "n2" in nids
def test_infer_context_filters_for_calls_question():
assert _infer_context_filters("who calls extract") == ["call"]
def test_resolve_context_filters_explicit_overrides_heuristic():
filters, source = _resolve_context_filters("who calls extract", ["field"])
assert filters == ["field"]
assert source == "explicit"
# --- _bfs ---
def test_bfs_depth_1():
G = _make_graph()
visited, edges = _bfs(G, ["n1"], depth=1)
assert "n1" in visited
assert "n2" in visited # direct neighbor
assert "n3" not in visited # 2 hops away
def test_bfs_depth_2():
G = _make_graph()
visited, edges = _bfs(G, ["n1"], depth=2)
assert "n3" in visited # n1 -> n2 -> n3
def test_bfs_disconnected():
G = _make_graph()
visited, edges = _bfs(G, ["n5"], depth=3)
assert visited == {"n5"} # isolated node
def test_bfs_returns_edges():
G = _make_graph()
visited, edges = _bfs(G, ["n1"], depth=1)
assert len(edges) >= 1
assert any(u == "n1" or v == "n1" for u, v in edges)
def test_filter_graph_by_context_limits_traversal():
G = _make_graph()
filtered = _filter_graph_by_context(G, ["call"])
visited, edges = _bfs(filtered, ["n1"], depth=2)
assert "n2" in visited
assert "n3" not in visited
assert edges == [("n1", "n2")]
# --- _dfs ---
def test_dfs_depth_1():
G = _make_graph()
visited, edges = _dfs(G, ["n1"], depth=1)
assert "n1" in visited
assert "n2" in visited
assert "n3" not in visited
def test_dfs_full_chain():
G = _make_graph()
visited, edges = _dfs(G, ["n1"], depth=5)
assert {"n1", "n2", "n3", "n4"}.issubset(visited)
# --- _subgraph_to_text ---
def test_subgraph_to_text_contains_labels():
G = _make_graph()
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
assert "extract" in text
assert "cluster" in text
def test_subgraph_to_text_truncates():
G = _make_graph()
# Very small budget forces truncation
text = _subgraph_to_text(G, {"n1", "n2", "n3", "n4"}, [("n1", "n2")], token_budget=1)
assert "truncated" in text
def test_subgraph_to_text_edge_included():
G = _make_graph()
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
assert "EDGE" in text
assert "calls" in text
def test_subgraph_to_text_includes_edge_context():
G = _make_graph()
text = _subgraph_to_text(G, {"n1", "n2"}, [("n1", "n2")])
assert "context=call" in text
def test_query_graph_text_explicit_context_filter_changes_traversal():
G = _make_graph()
text = _query_graph_text(G, "extract", mode="bfs", depth=2, token_budget=2000, context_filters=["call"])
assert "Context: call (explicit)" in text
assert "cluster" in text
assert "build" not in text
def test_query_graph_text_heuristic_context_filter_changes_traversal():
G = _make_graph()
text = _query_graph_text(G, "who calls extract", mode="bfs", depth=2, token_budget=2000)
assert "Context: call (heuristic)" in text
assert "cluster" in text
assert "build" not in text
# --- _load_graph ---
def test_load_graph_roundtrip(tmp_path):
G = _make_graph()
data = json_graph.node_link_data(G, edges="links")
p = tmp_path / "graph.json"
p.write_text(json.dumps(data))
G2 = _load_graph(str(p))
assert G2.number_of_nodes() == G.number_of_nodes()
assert G2.number_of_edges() == G.number_of_edges()
def test_load_graph_missing_file(tmp_path):
graphify_dir = tmp_path / "graphify-out"
graphify_dir.mkdir()
with pytest.raises(SystemExit):
_load_graph(str(graphify_dir / "nonexistent.json"))
# --- #874: MCP hot-reload ---
def _write_graph(path, nodes: list[str]) -> None:
"""Write a minimal graph.json with the given node IDs."""
G = nx.DiGraph()
for n in nodes:
G.add_node(n, label=n, community=0)
data = json_graph.node_link_data(G, edges="links")
path.write_text(json.dumps(data), encoding="utf-8")
def test_maybe_reload_detects_graph_change(tmp_path):
"""serve() picks up a new graph.json written after startup (#874)."""
import time
from unittest.mock import patch
out = tmp_path / "graphify-out"
out.mkdir()
graph_path = out / "graph.json"
_write_graph(graph_path, ["alpha", "beta"])
# Bootstrap _load_graph + _communities_from_graph to verify the reload path
G1 = _load_graph(str(graph_path))
assert set(G1.nodes()) == {"alpha", "beta"}
# Simulate file changing (bump mtime by touching)
time.sleep(0.01)
_write_graph(graph_path, ["alpha", "beta", "gamma"])
G2 = _load_graph(str(graph_path))
assert "gamma" in G2.nodes()
def test_load_graph_cache_key_changes_with_content(tmp_path):
"""mtime_ns + size uniquely identifies a graph version (#874)."""
import time
out = tmp_path / "graphify-out"
out.mkdir()
graph_path = out / "graph.json"
_write_graph(graph_path, ["a"])
s1 = graph_path.stat()
key1 = (s1.st_mtime_ns, s1.st_size)
time.sleep(0.01)
_write_graph(graph_path, ["a", "b"])
s2 = graph_path.stat()
key2 = (s2.st_mtime_ns, s2.st_size)
assert key1 != key2, "stat key must change when file content changes"