#!/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()