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
@@ -23,7 +23,9 @@ Schema:
|
|||||||
- `title` (string): note title
|
- `title` (string): note title
|
||||||
- `path` (string): vault-relative markdown path
|
- `path` (string): vault-relative markdown path
|
||||||
- `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...)
|
- `repo` (string): repo scope (`tenant-controller`, `controller-util`, `global`, ...)
|
||||||
- `type` (string): note classification (`guideline`, `policy`, `project`, etc.)
|
- `type` (string): note classification from a controlled vocabulary —
|
||||||
|
`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`;
|
||||||
|
absent frontmatter means `note`, and `--validate` fails on anything else
|
||||||
- `status` (string): lifecycle state (`active`, `draft`, `deprecated`)
|
- `status` (string): lifecycle state (`active`, `draft`, `deprecated`)
|
||||||
- `tags` (string array): topic tags
|
- `tags` (string array): topic tags
|
||||||
- `last_reviewed` (string): `YYYY-MM-DD`
|
- `last_reviewed` (string): `YYYY-MM-DD`
|
||||||
@@ -64,6 +66,10 @@ Examples with `jq`:
|
|||||||
# list repos
|
# list repos
|
||||||
jq -r '.nodes[].repo' index/graph.json | sort -u
|
jq -r '.nodes[].repo' index/graph.json | sort -u
|
||||||
|
|
||||||
|
# list the tag vocabulary, most frequent first (tags are matched verbatim)
|
||||||
|
jq -r '[.nodes[].tags[]?] | group_by(.) | map({tag: .[0], count: length})
|
||||||
|
| sort_by(-.count)[] | "\(.count)\t\(.tag)"' index/graph.json
|
||||||
|
|
||||||
# list top linked notes
|
# list top linked notes
|
||||||
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
|
jq -r '.nodes | sort_by(-(.inbound_count // 0))[] | "\(.inbound_count // 0)\t\(.id)\t\(.path)"' index/graph.json
|
||||||
|
|
||||||
@@ -96,8 +102,8 @@ For `jg`, use the same filters if your local `jg` is jq-compatible.
|
|||||||
Validation:
|
Validation:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# validate: non-zero exit on unresolved links, edge types outside the
|
# validate: non-zero exit on unresolved links, edge types or note types
|
||||||
# vocabulary, OR any remaining legacy [[wiki links]]
|
# outside their vocabularies, OR any remaining legacy [[wiki links]]
|
||||||
python3 scripts/generate-graph.py --validate
|
python3 scripts/generate-graph.py --validate
|
||||||
|
|
||||||
# regenerate graph and validate
|
# regenerate graph and validate
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ through MCP.
|
|||||||
|
|
||||||
## MCP tools
|
## MCP tools
|
||||||
|
|
||||||
- `graph_summary` — overview of the graph: counts, types, tags, central nodes.
|
- `graph_summary` — overview of the graph: counts, repo scopes, the tag
|
||||||
|
vocabulary (`by_tag`, most frequent first) and central nodes.
|
||||||
- `graph_search_nodes` — find nodes by tag / type / repo / id / text.
|
- `graph_search_nodes` — find nodes by tag / type / repo / id / text.
|
||||||
- `graph_neighbors` — outbound + inbound links for a node id, each with its edge
|
- `graph_neighbors` — outbound + inbound links for a node id, each with its edge
|
||||||
`type` + `annotation` (traverse the graph).
|
`type` + `annotation` (traverse the graph).
|
||||||
@@ -56,15 +57,18 @@ README.md vault hub
|
|||||||
```
|
```
|
||||||
|
|
||||||
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
||||||
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status`
|
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
|
||||||
|
`generate-graph.py --validate`), `status`
|
||||||
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes link
|
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes link
|
||||||
with typed markdown links — `[text](note-id){"type":"…"}` — resolved by `id`.
|
with typed markdown links — `[text](note-id){"type":"…"}` — resolved by `id`.
|
||||||
Legacy `[[wiki links]]` are rejected by the graph validator and must not be written.
|
Legacy `[[wiki links]]` are rejected by the graph validator and must not be written.
|
||||||
|
|
||||||
## 1. Orient
|
## 1. Orient
|
||||||
|
|
||||||
Start broad with `graph_summary` to see node counts, types, tags, and the
|
Start broad with `graph_summary` to see node counts, repo scopes, the tag
|
||||||
most-central notes. Then `read_note` the entry points:
|
vocabulary and the most-central notes. Its `by_tag` list is the only place the
|
||||||
|
tag spellings are visible — read it before filtering by `tag`, which matches
|
||||||
|
verbatim. Then `read_note` the entry points:
|
||||||
|
|
||||||
- vault hub (`README.md`) — entry points.
|
- vault hub (`README.md`) — entry points.
|
||||||
- `03_resources/repos/repo-landscape.md` — index of documented repos, grouped.
|
- `03_resources/repos/repo-landscape.md` — index of documented repos, grouped.
|
||||||
@@ -80,8 +84,9 @@ Use `graph_search_nodes` to locate notes:
|
|||||||
|
|
||||||
- by **repo** — every note tied to a repo (e.g. `tenant-controller`).
|
- by **repo** — every note tied to a repo (e.g. `tenant-controller`).
|
||||||
- by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ...
|
- by **type** — `policy`, `decision`, `feature`, `guideline`, `project`, ...
|
||||||
- by **tag** — e.g. `kcp`.
|
- by **tag** — e.g. `kcp`. Exact match; take the spelling from `by_tag`.
|
||||||
- by **text** — when tag/type/id unknown, full-text search the notes.
|
- by **text** — `query` matches a node's id, title, path and tags, not the note
|
||||||
|
body. Widen with a tag or repo filter rather than a longer phrase.
|
||||||
|
|
||||||
Use `graph_summary` to spot central (most-linked) notes worth reading early.
|
Use `graph_summary` to spot central (most-linked) notes worth reading early.
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ already captured into `raw/` (a PDF, an image) at the absolute path
|
|||||||
## MCP tools
|
## MCP tools
|
||||||
|
|
||||||
Read:
|
Read:
|
||||||
- `graph_summary` — counts, types, tags, most-linked notes.
|
- `graph_summary` — counts, repo scopes, the tag vocabulary (`by_tag`), most-linked notes.
|
||||||
- `graph_search_nodes` — find notes by tag / type / repo / id / free text.
|
- `graph_search_nodes` — find notes by tag / type / repo / id / free text.
|
||||||
- `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
|
- `graph_neighbors` — inbound + outbound links of a node, with edge `type` and `annotation`.
|
||||||
- `read_note` — the Markdown of a note (by `id` or vault-relative `path`). On a
|
- `read_note` — the Markdown of a note (by `id` or vault-relative `path`). On a
|
||||||
|
|||||||
+1
-13
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"generated_at": "2026-09-11T07:34:35.807809+00:00",
|
"generated_at": "2026-09-14T09:33:34.687166+00:00",
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": ".claude/agents/the-knowing-one",
|
"id": ".claude/agents/the-knowing-one",
|
||||||
@@ -50,18 +50,6 @@
|
|||||||
"outbound_links": [],
|
"outbound_links": [],
|
||||||
"inbound_count": 0
|
"inbound_count": 0
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"id": "GLOBAL_AGENTS",
|
|
||||||
"title": "GLOBAL_AGENTS.md",
|
|
||||||
"path": "GLOBAL_AGENTS.md",
|
|
||||||
"repo": "global",
|
|
||||||
"type": "note",
|
|
||||||
"status": "active",
|
|
||||||
"tags": [],
|
|
||||||
"last_reviewed": "",
|
|
||||||
"outbound_links": [],
|
|
||||||
"inbound_count": 0
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "agents/README",
|
"id": "agents/README",
|
||||||
"title": "Agents",
|
"title": "Agents",
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
"""Build `index/graph.json` from the vault's Markdown, and validate it.
|
||||||
|
|
||||||
|
The graph is the derived navigation layer agents query instead of walking the
|
||||||
|
tree: one node per note (frontmatter plus link counts) and one edge per typed
|
||||||
|
markdown link. `--validate` is the gate that keeps it trustworthy — it fails on
|
||||||
|
links that resolve to nothing, on edge relationship types or note `type` values
|
||||||
|
outside their controlled vocabularies, and on legacy `[[wiki links]]`.
|
||||||
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
@@ -36,6 +45,24 @@ RELATION_TYPES = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Strict controlled vocabulary for a note's frontmatter `type`. A note without
|
||||||
|
# one is a "note"; --validate fails on any value outside this set, which is what
|
||||||
|
# keeps the facet meaningful as the vault grows.
|
||||||
|
NOTE_TYPES = {
|
||||||
|
"hub",
|
||||||
|
"project",
|
||||||
|
"area",
|
||||||
|
"guideline",
|
||||||
|
"policy",
|
||||||
|
"feature",
|
||||||
|
"roadmap",
|
||||||
|
"decision",
|
||||||
|
"note",
|
||||||
|
"meta",
|
||||||
|
"reference",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class LinkRef:
|
class LinkRef:
|
||||||
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
|
target: str # raw target text (resolution key: id / rel-path / title / stem, or a URL)
|
||||||
@@ -402,7 +429,8 @@ def parse_args() -> argparse.Namespace:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--validate",
|
"--validate",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="validate links: unresolved targets, edge relationship types, "
|
help="validate the graph: unresolved targets, edge relationship types, "
|
||||||
|
"note types, "
|
||||||
"and legacy [[wiki links]] (all rejected)",
|
"and legacy [[wiki links]] (all rejected)",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
@@ -453,6 +481,25 @@ def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
|
|||||||
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
|
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
|
||||||
|
|
||||||
|
|
||||||
|
def find_invalid_node_types(graph: Dict[str, object]) -> List[Dict[str, object]]:
|
||||||
|
return [
|
||||||
|
n
|
||||||
|
for n in graph.get("nodes", []) # type: ignore[union-attr]
|
||||||
|
if n.get("type") not in NOTE_TYPES
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def print_invalid_node_types(invalid: List[Dict[str, object]]) -> None:
|
||||||
|
if not invalid:
|
||||||
|
print("Validation passed: all node types are in the controlled vocabulary")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Validation failed: {len(invalid)} note(s) with unknown type")
|
||||||
|
print(f" allowed: {', '.join(sorted(NOTE_TYPES))}")
|
||||||
|
for n in invalid:
|
||||||
|
print(f"- {n.get('id')} ({n.get('path')}): type={n.get('type')!r}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
root = Path(__file__).resolve().parents[1]
|
root = Path(__file__).resolve().parents[1]
|
||||||
@@ -490,8 +537,10 @@ def main() -> None:
|
|||||||
print_unresolved(unresolved)
|
print_unresolved(unresolved)
|
||||||
invalid = find_invalid_types(graph)
|
invalid = find_invalid_types(graph)
|
||||||
print_invalid_types(invalid)
|
print_invalid_types(invalid)
|
||||||
|
invalid_nodes = find_invalid_node_types(graph)
|
||||||
|
print_invalid_node_types(invalid_nodes)
|
||||||
print_wiki_links(wiki_uses)
|
print_wiki_links(wiki_uses)
|
||||||
if unresolved or invalid or wiki_uses:
|
if unresolved or invalid or invalid_nodes or wiki_uses:
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+40
-18
@@ -25,6 +25,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import urllib.error
|
import urllib.error
|
||||||
import urllib.request
|
import urllib.request
|
||||||
|
from collections import Counter
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
@@ -196,6 +197,11 @@ _WILDCARD_PATHS = {".", "./", "-A", "--all", "*", "-a", ":/"}
|
|||||||
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
|
_DEFAULT_MAX_CAPTURE_BYTES = 100 * 1024 * 1024
|
||||||
_DOWNLOAD_TIMEOUT_SECONDS = 60
|
_DOWNLOAD_TIMEOUT_SECONDS = 60
|
||||||
|
|
||||||
|
# graph_summary defaults: enough most-linked notes to spot the hubs, and enough
|
||||||
|
# tags to cover the vocabulary an agent actually filters on.
|
||||||
|
_DEFAULT_TOP_LINKED = 10
|
||||||
|
_DEFAULT_TOP_TAGS = 50
|
||||||
|
|
||||||
|
|
||||||
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
|
def _resolve_in_root(state: AppState, raw_path: Any, *, label: str = "path") -> Path:
|
||||||
"""Turn a vault-relative path into an absolute path inside the repo root.
|
"""Turn a vault-relative path into an absolute path inside the repo root.
|
||||||
@@ -245,12 +251,31 @@ def _tool_definitions() -> List[Dict[str, Any]]:
|
|||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"name": "graph_summary",
|
"name": "graph_summary",
|
||||||
"description": "Return aggregate counts from index/graph.json.",
|
"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": {
|
"inputSchema": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"topLinked": {"type": "integer", "minimum": 1, "maximum": 50}
|
"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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -453,21 +478,16 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
graph = _load_graph(state)
|
graph = _load_graph(state)
|
||||||
nodes = graph.get("nodes", [])
|
nodes = graph.get("nodes", [])
|
||||||
edges = graph.get("edges", [])
|
edges = graph.get("edges", [])
|
||||||
top_n = int(args.get("topLinked", 10))
|
top_n = int(args.get("topLinked", _DEFAULT_TOP_LINKED))
|
||||||
|
top_tags = int(args.get("topTags", _DEFAULT_TOP_TAGS))
|
||||||
|
|
||||||
by_repo: Dict[str, int] = {}
|
by_repo: Counter[str] = Counter(str(node.get("repo", "unknown")) for node in nodes)
|
||||||
by_type: Dict[str, int] = {}
|
tag_counts: Counter[str] = Counter(
|
||||||
by_status: Dict[str, int] = {}
|
str(tag) for node in nodes for tag in (node.get("tags") or [])
|
||||||
for node in nodes:
|
|
||||||
by_repo[str(node.get("repo", "unknown"))] = (
|
|
||||||
by_repo.get(str(node.get("repo", "unknown")), 0) + 1
|
|
||||||
)
|
|
||||||
by_type[str(node.get("type", "unknown"))] = (
|
|
||||||
by_type.get(str(node.get("type", "unknown")), 0) + 1
|
|
||||||
)
|
|
||||||
by_status[str(node.get("status", "unknown"))] = (
|
|
||||||
by_status.get(str(node.get("status", "unknown")), 0) + 1
|
|
||||||
)
|
)
|
||||||
|
# Frequent tags first, alphabetical within a tie, so a truncated list is
|
||||||
|
# both the most useful slice and stable across calls.
|
||||||
|
ranked_tags = sorted(tag_counts.items(), key=lambda item: (-item[1], item[0]))
|
||||||
|
|
||||||
sorted_nodes = sorted(
|
sorted_nodes = sorted(
|
||||||
nodes,
|
nodes,
|
||||||
@@ -479,9 +499,11 @@ def _tool_graph_summary(state: AppState, args: Dict[str, Any]) -> Dict[str, Any]
|
|||||||
"generated_at": graph.get("generated_at"),
|
"generated_at": graph.get("generated_at"),
|
||||||
"node_count": len(nodes),
|
"node_count": len(nodes),
|
||||||
"edge_count": len(edges),
|
"edge_count": len(edges),
|
||||||
"by_repo": by_repo,
|
"by_repo": dict(by_repo),
|
||||||
"by_type": by_type,
|
"tag_count": len(tag_counts),
|
||||||
"by_status": by_status,
|
"by_tag": [
|
||||||
|
{"tag": tag, "count": count} for tag, count in ranked_tags[:top_tags]
|
||||||
|
],
|
||||||
"top_linked": [
|
"top_linked": [
|
||||||
{
|
{
|
||||||
"id": node.get("id"),
|
"id": node.get("id"),
|
||||||
|
|||||||
@@ -229,7 +229,8 @@ last_reviewed: <today, YYYY-MM-DD>
|
|||||||
|
|
||||||
- `ingested` and `last_reviewed` = today (the `currentDate` from session context).
|
- `ingested` and `last_reviewed` = today (the `currentDate` from session context).
|
||||||
- `type` must stay within the vault taxonomy enum
|
- `type` must stay within the vault taxonomy enum
|
||||||
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`) — use `note`;
|
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`),
|
||||||
|
which `generate-graph.py --validate` enforces — use `note`;
|
||||||
the `resource` tag is what marks it.
|
the `resource` tag is what marks it.
|
||||||
- Markdown link targets resolve by `id`, rel-path, title, or unique stem — always
|
- Markdown link targets resolve by `id`, rel-path, title, or unique stem — always
|
||||||
use the `id` as the target, e.g. `[Human title](resource/foo/bar)`.
|
use the `id` as the target, e.g. `[Human title](resource/foo/bar)`.
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ README.md vault hub; AGENT_QUERY_GUIDE.md graph query refere
|
|||||||
```
|
```
|
||||||
|
|
||||||
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
Every note carries taxonomy frontmatter: `id`, `title`, `type`
|
||||||
(`hub|project|guideline|policy|feature|roadmap|decision|note|meta`), `status`
|
(`hub|project|area|guideline|policy|feature|roadmap|decision|note|meta|reference`, enforced by
|
||||||
|
`generate-graph.py --validate`), `status`
|
||||||
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are
|
(`active|draft|deprecated`), `tags`, `last_reviewed`, optional `repo`. Notes are
|
||||||
linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve
|
linked with typed markdown links — `[text](note-id){"type":"…"}` — that resolve
|
||||||
by `id`. Legacy `[[wiki links]]` are rejected by `make validate-graph`.
|
by `id`. Legacy `[[wiki links]]` are rejected by `make validate-graph`.
|
||||||
|
|||||||
@@ -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
|
#!/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
|
Covers the tools an agent needs when the MCP server is its only way into the
|
||||||
into the vault: creating notes, editing notes, capturing raw bytes (local files
|
vault: orienting with graph_summary, creating notes, editing notes, capturing
|
||||||
and downloads), committing staged paths, and pushing. Each test drives the
|
raw bytes (local files and downloads), committing staged paths, and pushing.
|
||||||
server through `_call_tool` against a throwaway repository so no test touches
|
Each test drives the server through `_call_tool` against a throwaway repository
|
||||||
the real vault.
|
so no test touches the real vault.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -430,3 +430,89 @@ class TestReadNoteBinary(ToolTestCase):
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.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