Scripts become thin CLIs over kb_framework (graph, query, query_tools, vault, instructions) so the hosted read-only connector shares one implementation with the stdio server. The MCP server builds the graph from Markdown on demand and invalidates it on writes; refresh_graph rebuilds in memory and reports validation problems. Pre-commit hook validates only; index/graph.json is no longer read, written or staged (generate-graph.py --write remains for operators). Golden fixtures pin graph and tool payloads to the previous output. Agent and skill docs updated to match. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
125 lines
5.3 KiB
Python
125 lines
5.3 KiB
Python
#!/usr/bin/env python3
|
|
"""Tests for the graph-in-memory contract of the stdio server and the hook.
|
|
|
|
`index/graph.json` is no longer a committed artifact: the MCP server builds
|
|
the graph lazily from Markdown, invalidates it on writes, and `refresh_graph`
|
|
rebuilds (and optionally validates) in memory. The pre-commit hook only
|
|
validates. These tests pin that nothing in the default flow writes the file.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
FRAMEWORK = Path(__file__).resolve().parents[1]
|
|
sys.path.insert(0, str(FRAMEWORK / "scripts"))
|
|
mcp = importlib.import_module("mcp-server")
|
|
|
|
|
|
def write_note(root: Path, rel: str, note_id: str, body: str = "") -> None:
|
|
path = root / rel
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(f"---\nid: {note_id}\ntitle: {note_id}\ntype: note\n---\n# {note_id}\n{body}\n", encoding="utf-8")
|
|
|
|
|
|
class InMemoryVault(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self._tmp.name).resolve() / "vault"
|
|
self.root.mkdir()
|
|
write_note(self.root, "03_resources/a.md", "a", "[b](b)")
|
|
write_note(self.root, "03_resources/b.md", "b")
|
|
self.state = mcp.AppState(root=self.root)
|
|
|
|
def tearDown(self) -> None:
|
|
self._tmp.cleanup()
|
|
|
|
def call(self, name: str, **args) -> dict:
|
|
return mcp._call_tool(self.state, name, args)
|
|
|
|
|
|
class TestStdioServerBuildsGraphLazily(InMemoryVault):
|
|
def test_summary_without_graph_file(self) -> None:
|
|
payload = self.call("graph_summary")["structuredContent"]
|
|
self.assertEqual(payload["node_count"], 2)
|
|
self.assertEqual(payload["edge_count"], 1)
|
|
self.assertFalse((self.root / "index").exists())
|
|
|
|
def test_write_note_invalidates_cache(self) -> None:
|
|
self.call("graph_summary")
|
|
self.call("write_note", path="03_resources/c.md", content="---\nid: c\n---\n# c\n")
|
|
self.assertEqual(self.call("graph_summary")["structuredContent"]["node_count"], 3)
|
|
|
|
def test_edit_note_invalidates_cache(self) -> None:
|
|
self.call("graph_summary")
|
|
self.call("edit_note", path="03_resources/b.md", old_string="# b", new_string="# b\n[a](a)")
|
|
self.assertEqual(self.call("graph_summary")["structuredContent"]["edge_count"], 2)
|
|
|
|
def test_refresh_rebuilds_and_validates_in_memory(self) -> None:
|
|
self.call("graph_summary")
|
|
write_note(self.root, "03_resources/d.md", "d", "[gone](nowhere)")
|
|
ok = self.call("refresh_graph")
|
|
self.assertFalse(ok.get("isError"), ok)
|
|
self.assertEqual(ok["structuredContent"]["node_count"], 3)
|
|
bad = self.call("refresh_graph", validate=True)
|
|
self.assertTrue(bad.get("isError"), bad)
|
|
self.assertIn("nowhere", json.dumps(bad["structuredContent"]))
|
|
self.assertFalse((self.root / "index").exists())
|
|
|
|
def test_graph_resource_is_served_from_memory(self) -> None:
|
|
result = mcp._read_resource(self.state, "repo://graph")
|
|
graph = json.loads(result["contents"][0]["text"])
|
|
self.assertEqual(len(graph["nodes"]), 2)
|
|
self.assertFalse((self.root / "index").exists())
|
|
|
|
def test_read_note_by_id_uses_in_memory_graph(self) -> None:
|
|
payload = self.call("read_note", id="b")["structuredContent"]
|
|
self.assertEqual(payload["path"], "03_resources/b.md")
|
|
|
|
|
|
class TestGeneratorAndHookValidateOnly(unittest.TestCase):
|
|
def setUp(self) -> None:
|
|
self._tmp = tempfile.TemporaryDirectory()
|
|
self.root = Path(self._tmp.name).resolve() / "vault"
|
|
self.root.mkdir()
|
|
write_note(self.root, "03_resources/a.md", "a")
|
|
self.env = {k: v for k, v in os.environ.items() if k != "KNOWLEDGE_BASE_ROOT"}
|
|
|
|
def tearDown(self) -> None:
|
|
self._tmp.cleanup()
|
|
|
|
def run_cmd(self, *cmd: str) -> subprocess.CompletedProcess:
|
|
return subprocess.run(list(cmd), cwd=self.root, env=self.env, capture_output=True, text=True)
|
|
|
|
def test_validate_alone_writes_nothing(self) -> None:
|
|
proc = self.run_cmd(sys.executable, str(FRAMEWORK / "scripts/generate-graph.py"), "--vault-root", str(self.root), "--validate")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertFalse((self.root / "index").exists())
|
|
|
|
def test_write_still_produces_a_file_for_operators(self) -> None:
|
|
proc = self.run_cmd(sys.executable, str(FRAMEWORK / "scripts/generate-graph.py"), "--vault-root", str(self.root), "--write")
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertTrue((self.root / "index/graph.json").exists())
|
|
|
|
def test_hook_validates_without_requiring_a_staged_graph(self) -> None:
|
|
hook = FRAMEWORK / "scripts/pre-commit"
|
|
proc = self.run_cmd("bash", str(hook), str(self.root))
|
|
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
|
self.assertFalse((self.root / "index").exists())
|
|
write_note(self.root, "03_resources/b.md", "b", "[x](missing)")
|
|
proc = self.run_cmd("bash", str(hook), str(self.root))
|
|
self.assertNotEqual(proc.returncode, 0)
|
|
self.assertIn("missing", proc.stdout + proc.stderr)
|
|
self.assertFalse((self.root / "index").exists())
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|