Files
knowledge-base-framework/scripts/generate-graph.py
T
TotenKnopfandClaude Fable 5.1 96780317a7 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>
2026-10-06 20:40:13 +02:00

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()