← Files EW_knowledge_forgeARCHIVED FILE
skills/obsidian-knowledge-forge/scripts/validate_graph.py
5.07 KB · Oct 2, 2026 · 00:32 UTC
#!/usr/bin/env python3
"""Validate an Obsidian knowledge-graph staging directory."""
from __future__ import annotations
import argparse
import json
import re
import sys
from collections import defaultdict
from pathlib import Path
REQUIRED = {
"knowledge_id",
"title",
"project",
"domain",
"note_type",
"version",
"status",
"created",
"updated",
"up",
"related",
"replaces",
"source_refs",
"tags",
}
TEMP_MARKERS = (".openai-download-", ".tmp", ".lock", "~$")
WIKILINK = re.compile(r"!?\x5b\x5b([^\x5d|#]+)")
def frontmatter(text: str) -> tuple[dict[str, str], bool]:
if not text.startswith("---\n"):
return {}, False
end = text.find("\n---\n", 4)
if end < 0:
return {}, False
props: dict[str, str] = {}
for line in text[4:end].splitlines():
match = re.match(r"^([A-Za-z_][A-Za-z0-9_-]*):(?:\s*(.*))?$", line)
if match:
props[match.group(1)] = (match.group(2) or "").strip()
return props, True
def checkpoint_paths(checkpoint: Path | None) -> set[str]:
if checkpoint is None:
return set()
text = checkpoint.read_text(encoding="utf-8", errors="replace")
paths: set[str] = set()
for cell in re.findall(r"\|\s*([^|\n]+\.md)\s*\|", text):
normalized = cell.strip().replace("\\", "/")
paths.add(normalized)
paths.add(normalized[:-3])
return paths
def resolve_link(root: Path, target: str, known_paths: set[str]) -> bool:
target = target.strip()
if not target or "://" in target:
return True
direct = root / target
if direct.exists() or Path(str(direct) + ".md").exists():
return True
normalized = target.replace("\\", "/")
if normalized in known_paths or normalized + ".md" in known_paths:
return True
name = Path(target).name
return any(root.rglob(name)) or any(root.rglob(name + ".md"))
def validate(root: Path, known_paths: set[str] | None = None) -> dict:
errors: list[dict] = []
warnings: list[dict] = []
notes = sorted(root.rglob("*.md"))
known_paths = known_paths or set()
identities: defaultdict[str, list[str]] = defaultdict(list)
managed = 0
if (root / ".obsidian").exists():
errors.append({"code": "unsafe_settings", "path": ".obsidian"})
for path in root.rglob("*"):
if path.is_file() and any(marker in path.name for marker in TEMP_MARKERS):
errors.append({"code": "temporary_file", "path": str(path.relative_to(root))})
for note in notes:
rel = str(note.relative_to(root))
text = note.read_text(encoding="utf-8", errors="replace")
if not text.strip():
errors.append({"code": "empty_note", "path": rel})
continue
props, has_frontmatter = frontmatter(text)
if "knowledge_id" in props:
managed += 1
missing = sorted(REQUIRED - set(props))
if missing:
errors.append({"code": "missing_properties", "path": rel, "properties": missing})
identities[props["knowledge_id"]].append(rel)
if props.get("status") == "current" and props.get("up") == "[]":
warnings.append({"code": "current_note_without_parent", "path": rel})
elif not rel.startswith(("90-", "attachments/", "sources/", "projects/")):
warnings.append({
"code": "unmanaged_markdown",
"path": rel,
"message": "Markdown has no knowledge_id and is not validated as a durable note.",
})
elif has_frontmatter:
warnings.append({"code": "frontmatter_without_identity", "path": rel})
for target in WIKILINK.findall(text):
if not resolve_link(root, target, known_paths):
errors.append({"code": "broken_wikilink", "path": rel, "target": target})
for knowledge_id, paths in identities.items():
if len(paths) > 1:
errors.append({"code": "duplicate_knowledge_id", "knowledge_id": knowledge_id, "paths": paths})
return {
"root": str(root.resolve()),
"files": sum(1 for p in root.rglob("*") if p.is_file()),
"markdown_notes": len(notes),
"managed_notes": managed,
"errors": errors,
"warnings": warnings,
"passed": not errors,
}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("vault", type=Path)
parser.add_argument("--json-output", type=Path)
parser.add_argument("--checkpoint", type=Path, help="Allow links to note paths recorded in a prior checkpoint")
args = parser.parse_args()
if not args.vault.is_dir():
print(f"Vault directory not found: {args.vault}", file=sys.stderr)
return 2
known = checkpoint_paths(args.checkpoint) if args.checkpoint else set()
result = validate(args.vault, known)
rendered = json.dumps(result, ensure_ascii=False, indent=2)
if args.json_output:
args.json_output.write_text(rendered + "\n", encoding="utf-8")
print(rendered)
return 0 if result["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: ed5a97d5ca886f5d49b525e9a761a962dbfd023bb77e0e9af31087dfc38451a5