feat: mirror tag-vocabulary summary and note-type validation
Ports two changes from the knowledge-base vault this skeleton is extracted from, keeping the shared scripts byte-identical across the pair: - `graph_summary` reports `by_tag` and `tag_count`, sized by `topTags`, and drops the `by_type`/`by_status` breakdowns. An MCP-only agent could not otherwise discover the tag spellings that `graph_search_nodes` matches verbatim. - `generate-graph.py --validate` now enforces a controlled vocabulary for a note's `type`, which is where the drift signal `by_type` used to give now lives. `AGENT_QUERY_GUIDE.md`, both skills and the-knowing-one quote the enforced enum. `tests/test_generate_graph.py` is new; `make test` is 50 tests. Regenerating the graph also drops a stale `GLOBAL_AGENTS` node for a file this skeleton does not carry. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
638cbe6b39
commit
1cb6369282
@@ -0,0 +1,106 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the graph generator's validation gate.
|
||||
|
||||
`--validate` is the only thing standing between a typo in a note's frontmatter
|
||||
and a vault whose facets quietly stop meaning anything, so these tests pin the
|
||||
controlled vocabularies it enforces: the edge relationship types and the node
|
||||
`type` enum. Each filesystem test builds a throwaway vault, never the real one.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
SCRIPTS = Path(__file__).resolve().parents[1] / "scripts"
|
||||
sys.path.insert(0, str(SCRIPTS))
|
||||
|
||||
gen = importlib.import_module("generate-graph")
|
||||
|
||||
|
||||
def _node(node_id: str, note_type: str) -> dict:
|
||||
return {"id": node_id, "path": f"{node_id}.md", "type": note_type}
|
||||
|
||||
|
||||
class TestNoteTypeVocabulary(unittest.TestCase):
|
||||
"""The node `type` enum is the drift signal; it has to be enforced."""
|
||||
|
||||
def test_covers_every_type_the_taxonomy_documents(self) -> None:
|
||||
self.assertLessEqual(
|
||||
{
|
||||
"hub",
|
||||
"project",
|
||||
"area",
|
||||
"guideline",
|
||||
"policy",
|
||||
"feature",
|
||||
"roadmap",
|
||||
"decision",
|
||||
"note",
|
||||
"meta",
|
||||
"reference",
|
||||
},
|
||||
gen.NOTE_TYPES,
|
||||
)
|
||||
|
||||
def test_accepts_a_graph_whose_nodes_are_all_in_the_vocabulary(self) -> None:
|
||||
graph = {"nodes": [_node("a", "note"), _node("b", "area")], "edges": []}
|
||||
self.assertEqual(gen.find_invalid_node_types(graph), [])
|
||||
|
||||
def test_reports_a_node_whose_type_is_outside_the_vocabulary(self) -> None:
|
||||
graph = {"nodes": [_node("a", "note"), _node("b", "refrence")], "edges": []}
|
||||
invalid = gen.find_invalid_node_types(graph)
|
||||
self.assertEqual([n["id"] for n in invalid], ["b"])
|
||||
|
||||
|
||||
class TestValidateExitCode(unittest.TestCase):
|
||||
"""--validate must fail the run, not just print, on an unknown node type."""
|
||||
|
||||
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()
|
||||
(self.root / "scripts" / "generate-graph.py").write_text(
|
||||
(SCRIPTS / "generate-graph.py").read_text(encoding="utf-8"),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def tearDown(self) -> None:
|
||||
self._tmp.cleanup()
|
||||
|
||||
def write_note(self, name: str, note_type: str) -> None:
|
||||
(self.root / "03_resources" / name).write_text(
|
||||
f"---\nid: {name[:-3]}\ntitle: {name}\ntype: {note_type}\n---\n\n# {name}\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
def validate(self) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
[sys.executable, "scripts/generate-graph.py", "--validate"],
|
||||
cwd=self.root,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
def test_passes_on_a_vault_of_known_types(self) -> None:
|
||||
self.write_note("good.md", "decision")
|
||||
proc = self.validate()
|
||||
self.assertEqual(proc.returncode, 0, proc.stdout + proc.stderr)
|
||||
self.assertIn("all node types are in the controlled vocabulary", proc.stdout)
|
||||
|
||||
def test_fails_and_names_the_offending_note(self) -> None:
|
||||
self.write_note("good.md", "decision")
|
||||
self.write_note("bad.md", "descision")
|
||||
proc = self.validate()
|
||||
self.assertEqual(proc.returncode, 1)
|
||||
self.assertIn("descision", proc.stdout)
|
||||
self.assertIn("bad", proc.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,11 +1,11 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Tests for the knowledge-base MCP server's write and git tools.
|
||||
"""Tests for the knowledge-base MCP server's query, write and git tools.
|
||||
|
||||
Covers the tools an ingesting agent needs when the MCP server is its only way
|
||||
into the vault: creating notes, editing notes, capturing raw bytes (local files
|
||||
and downloads), committing staged paths, and pushing. Each test drives the
|
||||
server through `_call_tool` against a throwaway repository so no test touches
|
||||
the real vault.
|
||||
Covers the tools an agent needs when the MCP server is its only way into the
|
||||
vault: orienting with graph_summary, creating notes, editing notes, capturing
|
||||
raw bytes (local files and downloads), committing staged paths, and pushing.
|
||||
Each test drives the server through `_call_tool` against a throwaway repository
|
||||
so no test touches the real vault.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -430,3 +430,89 @@ class TestReadNoteBinary(ToolTestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
|
||||
class TestGraphSummary(ToolTestCase):
|
||||
"""graph_summary is the agent's vocabulary lookup, not a stats dump.
|
||||
|
||||
Tags are the vault's only high-cardinality facet and the `tag` filter of
|
||||
graph_search_nodes matches them verbatim, so the summary has to name them.
|
||||
"""
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
def node(self, node_id: str, **fields) -> dict:
|
||||
base = {
|
||||
"id": node_id,
|
||||
"title": node_id,
|
||||
"path": f"03_resources/{node_id}.md",
|
||||
"repo": "global",
|
||||
"type": "note",
|
||||
"status": "active",
|
||||
"tags": [],
|
||||
"inbound_count": 0,
|
||||
}
|
||||
base.update(fields)
|
||||
return base
|
||||
|
||||
def test_counts_tags_and_orders_them_by_frequency(self) -> None:
|
||||
self.write_graph(
|
||||
[
|
||||
self.node("a", tags=["kcp", "flux"]),
|
||||
self.node("b", tags=["kcp"]),
|
||||
self.node("c", tags=["kcp", "helm"]),
|
||||
]
|
||||
)
|
||||
payload = self.structured("graph_summary")
|
||||
self.assertEqual(
|
||||
payload["by_tag"],
|
||||
[
|
||||
{"tag": "kcp", "count": 3},
|
||||
{"tag": "flux", "count": 1},
|
||||
{"tag": "helm", "count": 1},
|
||||
],
|
||||
)
|
||||
|
||||
def test_reports_the_distinct_tag_total_even_when_truncated(self) -> None:
|
||||
self.write_graph(
|
||||
[self.node(f"n{i}", tags=[f"tag-{i}"]) for i in range(10)]
|
||||
)
|
||||
payload = self.structured("graph_summary", topTags=3)
|
||||
self.assertEqual(len(payload["by_tag"]), 3)
|
||||
self.assertEqual(payload["tag_count"], 10)
|
||||
|
||||
def test_drops_the_type_and_status_facets(self) -> None:
|
||||
self.write_graph([self.node("a"), self.node("b", status="deprecated")])
|
||||
payload = self.structured("graph_summary")
|
||||
self.assertNotIn("by_type", payload)
|
||||
self.assertNotIn("by_status", payload)
|
||||
|
||||
def test_keeps_repo_counts_and_most_linked_notes(self) -> None:
|
||||
self.write_graph(
|
||||
[
|
||||
self.node("hub", repo="global", inbound_count=9),
|
||||
self.node("leaf", repo="kunnus", inbound_count=1),
|
||||
]
|
||||
)
|
||||
payload = self.structured("graph_summary", topLinked=1)
|
||||
self.assertEqual(payload["by_repo"], {"global": 1, "kunnus": 1})
|
||||
self.assertEqual([n["id"] for n in payload["top_linked"]], ["hub"])
|
||||
self.assertEqual(payload["node_count"], 2)
|
||||
|
||||
def test_advertises_the_top_tags_argument(self) -> None:
|
||||
tool = next(
|
||||
t for t in mcp._tool_definitions() if t["name"] == "graph_summary"
|
||||
)
|
||||
self.assertIn("topTags", tool["inputSchema"]["properties"])
|
||||
|
||||
Reference in New Issue
Block a user