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>
139 lines
4.5 KiB
Python
139 lines
4.5 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate the vault graph, and optionally write it to `index/graph.json`.
|
|
|
|
Thin CLI over `kb_framework.graph`: the graph is built in memory from the
|
|
vault's Markdown. `--validate` is the gate the pre-commit hook applies — 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]]`. `--write` is for operators who want the file; nothing else
|
|
writes it, and the MCP servers never read it.
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Dict, List
|
|
|
|
from vault_root import parse_vault_args
|
|
|
|
from kb_framework.graph import ( # noqa: F401 (re-exported for list-wiki-links.py and tests)
|
|
NOTE_TYPES,
|
|
RELATION_TYPES,
|
|
GraphBuild,
|
|
LinkRef,
|
|
Note,
|
|
UnresolvedLink,
|
|
WikiLinkUse,
|
|
build_alias_map,
|
|
build_graph,
|
|
build_notes,
|
|
find_invalid_node_types,
|
|
find_invalid_types,
|
|
generate_graph,
|
|
norm,
|
|
parse_frontmatter,
|
|
parse_links,
|
|
)
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="Validate the vault graph; optionally write index/graph.json"
|
|
)
|
|
parser.add_argument(
|
|
"--validate",
|
|
action="store_true",
|
|
help="validate the graph: unresolved targets, edge relationship types, "
|
|
"note types, and legacy [[wiki links]] (all rejected)",
|
|
)
|
|
parser.add_argument(
|
|
"--write",
|
|
action="store_true",
|
|
help="write index/graph.json (default unless --validate is used alone)",
|
|
)
|
|
return parse_vault_args(parser)
|
|
|
|
|
|
def print_unresolved(unresolved: List[UnresolvedLink]) -> None:
|
|
if not unresolved:
|
|
print("Validation passed: no unresolved links")
|
|
return
|
|
print(f"Validation failed: {len(unresolved)} unresolved link(s)")
|
|
for link in unresolved:
|
|
print(f"- {link.from_path}: [[{link.target}]]")
|
|
|
|
|
|
def print_wiki_links(wiki_uses: List[WikiLinkUse]) -> None:
|
|
if not wiki_uses:
|
|
print("Validation passed: no legacy [[wiki links]]")
|
|
return
|
|
print(f"Validation failed: {len(wiki_uses)} legacy [[wiki link(s)]]")
|
|
print(" use typed markdown links; find/convert them with scripts/list-wiki-links.py")
|
|
for w in wiki_uses:
|
|
print(f"- {w.from_path}:{w.lineno}: {w.raw}")
|
|
|
|
|
|
def print_invalid_types(invalid: List[Dict[str, object]]) -> None:
|
|
if not invalid:
|
|
print("Validation passed: all edge types are in the controlled vocabulary")
|
|
return
|
|
print(f"Validation failed: {len(invalid)} edge(s) with unknown relationship type")
|
|
print(f" allowed: {', '.join(sorted(RELATION_TYPES))}")
|
|
for e in invalid:
|
|
print(f"- {e.get('from')} -> {e.get('to')}: type={e.get('type')!r}")
|
|
|
|
|
|
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 _existing_generated_at(output: Path, candidate: GraphBuild) -> str:
|
|
"""Keep the old timestamp when the written graph would be unchanged."""
|
|
if not output.exists():
|
|
return ""
|
|
try:
|
|
existing = json.loads(output.read_text(encoding="utf-8"))
|
|
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
|
return ""
|
|
if (
|
|
candidate.graph["nodes"] == existing.get("nodes", [])
|
|
and candidate.graph["edges"] == existing.get("edges", [])
|
|
):
|
|
return str(existing.get("generated_at", ""))
|
|
return ""
|
|
|
|
|
|
def main() -> None:
|
|
args = parse_args()
|
|
root = args.vault_root
|
|
output = root / "index" / "graph.json"
|
|
should_write = args.write or not args.validate
|
|
|
|
build = build_graph(root)
|
|
if should_write:
|
|
stamp = _existing_generated_at(output, build)
|
|
if stamp:
|
|
build = build_graph(root, existing_generated_at=stamp)
|
|
output.parent.mkdir(parents=True, exist_ok=True)
|
|
output.write_text(json.dumps(build.graph, indent=2) + "\n", encoding="utf-8")
|
|
print(f"Wrote {output}")
|
|
|
|
if args.validate:
|
|
print_unresolved(build.unresolved)
|
|
print_invalid_types(build.invalid_edge_types)
|
|
print_invalid_node_types(build.invalid_node_types)
|
|
print_wiki_links(build.wiki_uses)
|
|
if not build.ok:
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|