refactor: extract kb_framework package, build graph in memory
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>
This commit is contained in:
Vendored
+105
@@ -0,0 +1,105 @@
|
||||
{
|
||||
"version": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": "README",
|
||||
"title": "README",
|
||||
"path": "README.md",
|
||||
"repo": "global",
|
||||
"type": "note",
|
||||
"status": "active",
|
||||
"tags": [],
|
||||
"last_reviewed": "",
|
||||
"outbound_links": [],
|
||||
"inbound_count": 0
|
||||
},
|
||||
{
|
||||
"id": "area-ops",
|
||||
"title": "Area Ops",
|
||||
"path": "02_areas/ops.md",
|
||||
"repo": "global",
|
||||
"type": "area",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"ops"
|
||||
],
|
||||
"last_reviewed": "",
|
||||
"outbound_links": [
|
||||
"project-alpha"
|
||||
],
|
||||
"inbound_count": 2
|
||||
},
|
||||
{
|
||||
"id": "project-alpha",
|
||||
"title": "Project Alpha",
|
||||
"path": "01_projects/alpha.md",
|
||||
"repo": "alpha",
|
||||
"type": "project",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"alpha",
|
||||
"demo"
|
||||
],
|
||||
"last_reviewed": "2026-01-01",
|
||||
"outbound_links": [
|
||||
"area-ops",
|
||||
"resource-demo"
|
||||
],
|
||||
"inbound_count": 1
|
||||
},
|
||||
{
|
||||
"id": "resource-demo",
|
||||
"title": "Demo Resource",
|
||||
"path": "03_resources/demo/index.md",
|
||||
"repo": "global",
|
||||
"type": "reference",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"demo"
|
||||
],
|
||||
"last_reviewed": "",
|
||||
"outbound_links": [
|
||||
"area-ops"
|
||||
],
|
||||
"inbound_count": 1
|
||||
}
|
||||
],
|
||||
"edges": [
|
||||
{
|
||||
"from": "area-ops",
|
||||
"to": "project-alpha",
|
||||
"kind": "markdown_link",
|
||||
"type": "part-of",
|
||||
"anchor_text": "Project Alpha",
|
||||
"annotation": "",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"from": "project-alpha",
|
||||
"to": "area-ops",
|
||||
"kind": "markdown_link",
|
||||
"type": "depends-on",
|
||||
"anchor_text": "Area Ops",
|
||||
"annotation": "operations area",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"from": "project-alpha",
|
||||
"to": "resource-demo",
|
||||
"kind": "markdown_link",
|
||||
"type": "related",
|
||||
"anchor_text": "the demo resource",
|
||||
"annotation": "resource landing page.",
|
||||
"weight": 1.0
|
||||
},
|
||||
{
|
||||
"from": "resource-demo",
|
||||
"to": "area-ops",
|
||||
"kind": "markdown_link",
|
||||
"type": "related",
|
||||
"anchor_text": "Area Ops",
|
||||
"annotation": "",
|
||||
"weight": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+259
@@ -0,0 +1,259 @@
|
||||
{
|
||||
"graph_summary": {
|
||||
"tool": "graph_summary",
|
||||
"args": {
|
||||
"topLinked": 3,
|
||||
"topTags": 2
|
||||
},
|
||||
"structuredContent": {
|
||||
"version": 1,
|
||||
"node_count": 4,
|
||||
"edge_count": 4,
|
||||
"by_repo": {
|
||||
"global": 3,
|
||||
"alpha": 1
|
||||
},
|
||||
"tag_count": 3,
|
||||
"by_tag": [
|
||||
{
|
||||
"tag": "demo",
|
||||
"count": 2
|
||||
},
|
||||
{
|
||||
"tag": "alpha",
|
||||
"count": 1
|
||||
}
|
||||
],
|
||||
"top_linked": [
|
||||
{
|
||||
"id": "area-ops",
|
||||
"title": "Area Ops",
|
||||
"path": "02_areas/ops.md",
|
||||
"inbound_count": 2
|
||||
},
|
||||
{
|
||||
"id": "project-alpha",
|
||||
"title": "Project Alpha",
|
||||
"path": "01_projects/alpha.md",
|
||||
"inbound_count": 1
|
||||
},
|
||||
{
|
||||
"id": "resource-demo",
|
||||
"title": "Demo Resource",
|
||||
"path": "03_resources/demo/index.md",
|
||||
"inbound_count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"graph_search_nodes": {
|
||||
"tool": "graph_search_nodes",
|
||||
"args": {
|
||||
"query": "alpha",
|
||||
"limit": 5
|
||||
},
|
||||
"structuredContent": {
|
||||
"total_matches": 1,
|
||||
"returned": 1,
|
||||
"items": [
|
||||
{
|
||||
"id": "project-alpha",
|
||||
"title": "Project Alpha",
|
||||
"path": "01_projects/alpha.md",
|
||||
"repo": "alpha",
|
||||
"type": "project",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"alpha",
|
||||
"demo"
|
||||
],
|
||||
"last_reviewed": "2026-01-01",
|
||||
"outbound_links": [
|
||||
"area-ops",
|
||||
"resource-demo"
|
||||
],
|
||||
"inbound_count": 1
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"graph_neighbors": {
|
||||
"tool": "graph_neighbors",
|
||||
"args": {
|
||||
"id": "area-ops"
|
||||
},
|
||||
"structuredContent": {
|
||||
"node": {
|
||||
"id": "area-ops",
|
||||
"title": "Area Ops",
|
||||
"path": "02_areas/ops.md",
|
||||
"repo": "global",
|
||||
"type": "area",
|
||||
"status": "active",
|
||||
"tags": [
|
||||
"ops"
|
||||
],
|
||||
"last_reviewed": "",
|
||||
"outbound_links": [
|
||||
"project-alpha"
|
||||
],
|
||||
"inbound_count": 2
|
||||
},
|
||||
"inbound_count": 2,
|
||||
"outbound_count": 1,
|
||||
"inbound": [
|
||||
{
|
||||
"id": "project-alpha",
|
||||
"title": "Project Alpha",
|
||||
"path": "01_projects/alpha.md",
|
||||
"direction": "inbound",
|
||||
"type": "depends-on",
|
||||
"anchor_text": "Area Ops",
|
||||
"annotation": "operations area"
|
||||
},
|
||||
{
|
||||
"id": "resource-demo",
|
||||
"title": "Demo Resource",
|
||||
"path": "03_resources/demo/index.md",
|
||||
"direction": "inbound",
|
||||
"type": "related",
|
||||
"anchor_text": "Area Ops",
|
||||
"annotation": ""
|
||||
}
|
||||
],
|
||||
"outbound": [
|
||||
{
|
||||
"id": "project-alpha",
|
||||
"title": "Project Alpha",
|
||||
"path": "01_projects/alpha.md",
|
||||
"direction": "outbound",
|
||||
"type": "part-of",
|
||||
"anchor_text": "Project Alpha",
|
||||
"annotation": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"read_note_by_id": {
|
||||
"tool": "read_note",
|
||||
"args": {
|
||||
"id": "resource-demo"
|
||||
},
|
||||
"structuredContent": {
|
||||
"path": "03_resources/demo/index.md",
|
||||
"binary": false,
|
||||
"content": "---\nid: resource-demo\ntitle: Demo Resource\ntype: reference\ntags: [demo]\n---\n\n# Demo Resource\n\nRaw original kept in `raw/`. Related: [Area Ops](02_areas/ops.md).\n"
|
||||
}
|
||||
},
|
||||
"read_note_by_path": {
|
||||
"tool": "read_note",
|
||||
"args": {
|
||||
"path": "02_areas/ops.md"
|
||||
},
|
||||
"structuredContent": {
|
||||
"path": "02_areas/ops.md",
|
||||
"binary": false,
|
||||
"content": "---\nid: area-ops\ntitle: Area Ops\ntype: area\ntags: [ops]\n---\n\n# Area Ops\n\nParent hub: [Project Alpha](project-alpha){\"type\":\"part-of\"}.\n"
|
||||
}
|
||||
},
|
||||
"tool_definitions": [
|
||||
{
|
||||
"name": "read_instructions",
|
||||
"description": "Read framework AGENTS.md and AGENT_QUERY_GUIDE.md before working with the vault. These operating documents live outside an external vault and do not require a generated graph.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graph_summary",
|
||||
"description": "Orientation for a cold start: node and edge counts, the repo scopes, the most-linked notes, and the tag vocabulary as `by_tag` (most frequent first) plus `tag_count`, the number of distinct tags. Read `by_tag` before filtering `graph_search_nodes` by `tag` \u2014 that filter matches a tag verbatim, so a guessed spelling returns nothing.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"topLinked": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 50,
|
||||
"description": "Most-linked notes to return (default 10)."
|
||||
},
|
||||
"topTags": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 500,
|
||||
"description": "Tags to return, most frequent first (default 50)."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graph_search_nodes",
|
||||
"description": "Search graph nodes by metadata and free text.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"repo": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"tag": {
|
||||
"type": "string"
|
||||
},
|
||||
"query": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "graph_neighbors",
|
||||
"description": "Show inbound and outbound links for a node ID. Each neighbor includes the edge `type` (relationship, e.g. supersedes, depends-on), `anchor_text` (the link's display text), and `annotation` (what to expect in that note).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"id"
|
||||
],
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 200
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "read_note",
|
||||
"description": "Read note content by graph node ID or vault-relative path. Provide exactly one of `id` or `path`. If both or neither are provided, the call returns an error.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"description": "Graph node ID (e.g. 'AGENTS')."
|
||||
},
|
||||
"path": {
|
||||
"type": "string",
|
||||
"description": "Vault-relative file path (e.g. 'README.md')."
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
---
|
||||
id: project-alpha
|
||||
title: Project Alpha
|
||||
type: project
|
||||
status: active
|
||||
tags: [alpha, demo]
|
||||
repo: alpha
|
||||
last_reviewed: 2026-01-01
|
||||
---
|
||||
|
||||
# Project Alpha
|
||||
|
||||
Depends on [Area Ops](area-ops){"type":"depends-on","link-info":"operations area"}.
|
||||
See [the demo resource](resource-demo) — resource landing page.
|
||||
External: [MCP spec](https://modelcontextprotocol.io/).
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
id: area-ops
|
||||
title: Area Ops
|
||||
type: area
|
||||
tags: [ops]
|
||||
---
|
||||
|
||||
# Area Ops
|
||||
|
||||
Parent hub: [Project Alpha](project-alpha){"type":"part-of"}.
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
---
|
||||
id: resource-demo
|
||||
title: Demo Resource
|
||||
type: reference
|
||||
tags: [demo]
|
||||
---
|
||||
|
||||
# Demo Resource
|
||||
|
||||
Raw original kept in `raw/`. Related: [Area Ops](02_areas/ops.md).
|
||||
@@ -0,0 +1,2 @@
|
||||
not a note
|
||||
[broken](../missing.md)
|
||||
Binary file not shown.
Vendored
+1
@@ -0,0 +1 @@
|
||||
# README
|
||||
@@ -20,6 +20,8 @@ class ExternalVaultTests(unittest.TestCase):
|
||||
self.base = Path(self.tmp.name)
|
||||
self.framework = self.base / "framework space"
|
||||
shutil.copytree(FRAMEWORK / "scripts", self.framework / "scripts")
|
||||
shutil.copytree(FRAMEWORK / "kb_framework", self.framework / "kb_framework",
|
||||
ignore=shutil.ignore_patterns("__pycache__"))
|
||||
shutil.copy2(FRAMEWORK / "Makefile", self.framework / "Makefile")
|
||||
self.vault = self.base / "vault space"
|
||||
self.other = self.base / "other"
|
||||
@@ -115,15 +117,14 @@ class ExternalVaultTests(unittest.TestCase):
|
||||
("write_note", {"path": "../escape.md", "content": "escape"})]:
|
||||
self.assertTrue(self.tool(tool, args).get("isError"))
|
||||
|
||||
def test_refresh_uses_absolute_framework_generator_and_writes_when_validating(self):
|
||||
def test_refresh_validates_in_memory_and_never_runs_vault_tools(self):
|
||||
(self.vault / "scripts").mkdir()
|
||||
(self.vault / "scripts/generate-graph.py").write_text("raise RuntimeError('vault tool used')\n")
|
||||
result = self.tool("refresh_graph", {"validate": True})
|
||||
self.assertFalse(result.get("isError"), result)
|
||||
command = result["structuredContent"]["command"]
|
||||
self.assertEqual(command[1], str(self.framework / "scripts/generate-graph.py"))
|
||||
self.assertEqual(command[command.index("--vault-root") + 1], str(self.vault))
|
||||
self.assertTrue((self.vault / "index/graph.json").exists())
|
||||
self.assertEqual(result["structuredContent"]["vault_root"], str(self.vault))
|
||||
self.assertEqual(result["structuredContent"]["node_count"], 1)
|
||||
self.assertFalse((self.vault / "index/graph.json").exists())
|
||||
summary = self.tool("graph_summary")
|
||||
self.assertFalse(summary.get("isError"), summary)
|
||||
|
||||
@@ -178,16 +179,17 @@ class ExternalVaultTests(unittest.TestCase):
|
||||
def make(*args):
|
||||
return subprocess.run(["make", "-C", str(self.framework), f"VAULT_ROOT={self.vault}", *args],
|
||||
env=self.env, capture_output=True, text=True)
|
||||
proc = make("graph", "install-hooks")
|
||||
proc = make("validate-graph", "install-hooks")
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
hook = self.vault / ".git/hooks/pre-commit"
|
||||
self.assertTrue(hook.exists())
|
||||
self.git(self.vault, "add", "index/graph.json")
|
||||
proc = subprocess.run([str(hook)], cwd=self.other, env=self.env, capture_output=True, text=True)
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
(self.vault / "03_resources/new.md").write_text("# new\n")
|
||||
(self.vault / "03_resources/new.md").write_text("# new\n[gone](nowhere)\n")
|
||||
proc = subprocess.run([str(hook)], cwd=self.other, env=self.env, capture_output=True, text=True)
|
||||
self.assertNotEqual(proc.returncode, 0)
|
||||
self.assertIn("nowhere", proc.stdout + proc.stderr)
|
||||
self.assertFalse((self.vault / "index/graph.json").exists())
|
||||
self.assertFalse((self.framework / "index/graph.json").exists())
|
||||
|
||||
def test_installer_preserves_unmanaged_hooks_and_reinstalls_managed_hook(self):
|
||||
|
||||
@@ -63,12 +63,7 @@ class TestValidateExitCode(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name).resolve() / "vault"
|
||||
(self.root / "scripts").mkdir(parents=True)
|
||||
(self.root / "03_resources").mkdir()
|
||||
for name in ("generate-graph.py", "vault_root.py"):
|
||||
(self.root / "scripts" / name).write_text(
|
||||
(SCRIPTS / name).read_text(encoding="utf-8"), encoding="utf-8",
|
||||
)
|
||||
(self.root / "03_resources").mkdir(parents=True)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
@@ -81,7 +76,7 @@ class TestValidateExitCode(unittest.TestCase):
|
||||
|
||||
def validate(self) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "scripts/generate-graph.py", "--validate"],
|
||||
[sys.executable, str(SCRIPTS / "generate-graph.py"), "--vault-root", str(self.root), "--validate"],
|
||||
cwd=self.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
#!/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()
|
||||
@@ -0,0 +1,162 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the importable `kb_framework` package.
|
||||
|
||||
The package is the contract both the stdio MCP server and the hosted connector
|
||||
consume: build the graph in memory from a vault's Markdown, answer the query
|
||||
tools over that graph, and read framework instructions. These tests pin the
|
||||
graph shape against a golden fixture produced by the file-writing generator,
|
||||
and the tool payloads against goldens captured from the stdio server, so the
|
||||
two front ends cannot drift apart.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
FRAMEWORK = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(FRAMEWORK))
|
||||
FIXTURES = FRAMEWORK / "tests" / "fixtures"
|
||||
VAULT = FIXTURES / "vault"
|
||||
|
||||
from kb_framework import graph as kbg # noqa: E402
|
||||
from kb_framework import query as kbq # noqa: E402
|
||||
from kb_framework import query_tools as kbt # noqa: E402
|
||||
from kb_framework import vault as kbv # noqa: E402
|
||||
from kb_framework import instructions as kbi # noqa: E402
|
||||
|
||||
|
||||
def golden_graph() -> dict:
|
||||
return json.loads((FIXTURES / "graph_golden.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def golden_stdio() -> dict:
|
||||
return json.loads((FIXTURES / "stdio_golden.json").read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
class TestBuildGraphInMemory(unittest.TestCase):
|
||||
def test_matches_golden_without_touching_disk(self) -> None:
|
||||
result = kbg.build_graph(VAULT)
|
||||
self.assertEqual(result.graph["nodes"], golden_graph()["nodes"])
|
||||
self.assertEqual(result.graph["edges"], golden_graph()["edges"])
|
||||
self.assertEqual(result.graph["version"], 1)
|
||||
self.assertTrue(result.graph["generated_at"])
|
||||
self.assertFalse((VAULT / "index").exists())
|
||||
|
||||
def test_reports_no_problems_for_clean_fixture(self) -> None:
|
||||
result = kbg.build_graph(VAULT)
|
||||
self.assertEqual(result.unresolved, [])
|
||||
self.assertEqual(result.wiki_uses, [])
|
||||
self.assertEqual(kbg.validation_problems(result), [])
|
||||
|
||||
def test_validation_reports_unresolved_and_bad_types(self) -> None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp)
|
||||
(root / "a.md").write_text(
|
||||
"---\nid: a\ntype: descision\n---\n[x](missing){\"type\":\"bogus\"}\n[[legacy]]\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
result = kbg.build_graph(root)
|
||||
problems = "\n".join(kbg.validation_problems(result))
|
||||
self.assertIn("missing", problems)
|
||||
self.assertIn("descision", problems)
|
||||
self.assertIn("legacy", problems)
|
||||
self.assertFalse(result.ok)
|
||||
|
||||
|
||||
class TestQueryFunctions(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.graph = golden_graph()
|
||||
self.stdio = golden_stdio()
|
||||
|
||||
def strip(self, payload: dict) -> dict:
|
||||
payload = dict(payload)
|
||||
payload.pop("generated_at", None)
|
||||
return payload
|
||||
|
||||
def test_summary_matches_stdio_golden(self) -> None:
|
||||
case = self.stdio["graph_summary"]
|
||||
payload = kbq.summary(self.graph, **case["args"])
|
||||
self.assertEqual(self.strip(payload), case["structuredContent"])
|
||||
|
||||
def test_search_matches_stdio_golden(self) -> None:
|
||||
case = self.stdio["graph_search_nodes"]
|
||||
self.assertEqual(kbq.search_nodes(self.graph, case["args"]), case["structuredContent"])
|
||||
|
||||
def test_neighbors_matches_stdio_golden(self) -> None:
|
||||
case = self.stdio["graph_neighbors"]
|
||||
self.assertEqual(kbq.neighbors(self.graph, **case["args"]), case["structuredContent"])
|
||||
|
||||
def test_neighbors_unknown_id_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
kbq.neighbors(self.graph, "nope")
|
||||
|
||||
def test_node_path_resolves_id(self) -> None:
|
||||
self.assertEqual(kbq.node_path(self.graph, "area-ops"), "02_areas/ops.md")
|
||||
with self.assertRaises(ValueError):
|
||||
kbq.node_path(self.graph, "nope")
|
||||
|
||||
|
||||
class TestVaultHelpers(unittest.TestCase):
|
||||
def test_resolve_in_root_rejects_escape_and_absolute(self) -> None:
|
||||
for bad in ("../x.md", "/etc/passwd", "", " "):
|
||||
with self.subTest(bad=bad), self.assertRaises(ValueError):
|
||||
kbv.resolve_in_root(VAULT, bad)
|
||||
self.assertEqual(kbv.resolve_in_root(VAULT, "README.md"), VAULT / "README.md")
|
||||
|
||||
def test_read_note_payload_text_and_binary(self) -> None:
|
||||
text = kbv.read_note_payload(VAULT, "02_areas/ops.md")
|
||||
self.assertEqual(text["binary"], False)
|
||||
self.assertIn("# Area Ops", text["content"])
|
||||
binary = kbv.read_note_payload(VAULT, "03_resources/demo/raw/paper.pdf")
|
||||
self.assertTrue(binary["binary"])
|
||||
self.assertEqual(set(binary), {"path", "binary", "bytes", "sha256", "hint"})
|
||||
with self.assertRaises(FileNotFoundError):
|
||||
kbv.read_note_payload(VAULT, "nope.md")
|
||||
|
||||
|
||||
class TestQueryTools(unittest.TestCase):
|
||||
"""The five read-only tools, as the stdio server and the connector expose them."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.graph = golden_graph()
|
||||
self.stdio = golden_stdio()
|
||||
|
||||
def test_definitions_match_stdio(self) -> None:
|
||||
self.assertEqual(kbt.definitions(), self.stdio["tool_definitions"])
|
||||
|
||||
def test_call_returns_stdio_result_shape(self) -> None:
|
||||
for key in ("graph_summary", "graph_search_nodes", "graph_neighbors", "read_note_by_id", "read_note_by_path"):
|
||||
case = self.stdio[key]
|
||||
with self.subTest(tool=key):
|
||||
result = kbt.call(case["tool"], case["args"], graph=self.graph, root=VAULT)
|
||||
sc = dict(result["structuredContent"])
|
||||
sc.pop("generated_at", None)
|
||||
self.assertEqual(sc, case["structuredContent"])
|
||||
self.assertEqual(result["content"][0]["type"], "text")
|
||||
self.assertEqual(json.loads(result["content"][0]["text"]).get("path", sc.get("path")), sc.get("path"))
|
||||
|
||||
def test_read_note_requires_exactly_one_selector(self) -> None:
|
||||
for args in ({}, {"id": "area-ops", "path": "README.md"}):
|
||||
with self.subTest(args=args), self.assertRaises(ValueError):
|
||||
kbt.call("read_note", args, graph=self.graph, root=VAULT)
|
||||
|
||||
def test_unknown_tool_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
kbt.call("write_note", {}, graph=self.graph, root=VAULT)
|
||||
|
||||
|
||||
class TestInstructions(unittest.TestCase):
|
||||
def test_reads_framework_documents(self) -> None:
|
||||
payload = kbi.read_instructions()
|
||||
self.assertEqual([d["path"] for d in payload["documents"]], ["AGENTS.md", "AGENT_QUERY_GUIDE.md"])
|
||||
self.assertIn("AGENT_QUERY_GUIDE", payload["documents"][0]["content"])
|
||||
self.assertTrue(Path(payload["framework_root"]).is_dir())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -40,9 +40,7 @@ class ToolTestCase(unittest.TestCase):
|
||||
self._tmp = tempfile.TemporaryDirectory()
|
||||
self.root = Path(self._tmp.name).resolve() / "vault"
|
||||
self.root.mkdir()
|
||||
self.state = mcp.AppState(
|
||||
root=self.root, graph_path=self.root / "index" / "graph.json"
|
||||
)
|
||||
self.state = mcp.AppState(root=self.root)
|
||||
_git(self.root, "init", "-q", "-b", "main")
|
||||
_git(self.root, "config", "user.email", "test@example.com")
|
||||
_git(self.root, "config", "user.name", "Test")
|
||||
@@ -440,18 +438,13 @@ class TestGraphSummary(ToolTestCase):
|
||||
"""
|
||||
|
||||
def write_graph(self, nodes: list) -> None:
|
||||
self.state.graph_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.state.graph_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"generated_at": "2026-09-14T00:00:00Z",
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
# The graph lives in memory; seed the cache the way a build would.
|
||||
self.state.graph_cache = {
|
||||
"version": 1,
|
||||
"generated_at": "2026-09-14T00:00:00Z",
|
||||
"nodes": nodes,
|
||||
"edges": [],
|
||||
}
|
||||
|
||||
def node(self, node_id: str, **fields) -> dict:
|
||||
base = {
|
||||
|
||||
Reference in New Issue
Block a user