#!/usr/bin/env python # -*- coding: utf-8 -*- """ validate-frontmatter.py — Pre-commit hook: 验证被修改的 wiki 页面 frontmatter 检查项: - 必须包含 categories(含 [[LLM Wiki]]) - 必须包含 tags(含 wiki) - 必须包含 type(合法值列表) - 必须包含 source - 如果 status=superseded,必须包含 superseded_by - relations 的 target 必须指向存在的页面 用法: python tools/scripts/validate-frontmatter.py # 所有 wiki 页面 python tools/scripts/validate-frontmatter.py --files file1.md file2.md # 指定文件 python tools/scripts/validate-frontmatter.py --git-hook # 从 git diff 读取 """ import argparse import re import subprocess import sys from pathlib import Path try: import yaml except ImportError: yaml = None WIKI = Path(r"D:\Applications\app\kepano-obsidian-main\wiki") VALID_TYPES = [ "concept", "entity", "tool", "reference", "place", "institution", "method", "knowledge-card", "synthesis", "index", "log", "research-report", "lesson" ] def build_vault_stems(vault_root: Path) -> set[str]: """扫描整个 vault 收集所有 .md 文件的 stem""" stems = set() for p in vault_root.rglob("*.md"): rel = p.relative_to(vault_root).as_posix() if rel.startswith(".git/") or "node_modules" in rel: continue stems.add(p.stem) return stems def is_wiki_file(fp: Path) -> bool: """判断文件是否在 wiki/ 目录下""" try: return fp.resolve().relative_to(WIKI.resolve()) is not None except ValueError: return False def validate_one(fp: Path, vault_stems: set[str] | None = None) -> list[str]: """验证单个文件,返回错误列表""" errors = [] content = fp.read_text(encoding="utf-8") m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if not m: return ["No frontmatter found"] if not yaml: return ["PyYAML not installed"] try: front = yaml.safe_load(m.group(1)) or {} except yaml.YAMLError as e: return [f"YAML parse error: {e}"] # 非 wiki/ 文件跳过 wiki 专用检查 wiki_only = is_wiki_file(fp) if wiki_only: # categories must contain [[LLM Wiki]] cats = front.get("categories", []) if isinstance(cats, str): cats = [cats] if not any(str(c).strip("[]") == "LLM Wiki" for c in cats): errors.append("Missing [[LLM Wiki]] in categories") # tags must contain wiki tags = front.get("tags", []) if isinstance(tags, str): tags = [tags] if "wiki" not in tags: errors.append("Missing 'wiki' in tags") # type must exist and be valid # type must exist and be valid # Convention: type can be scalar (e.g. 'concept') or list (e.g. ['entity', 'People', 'Author']) # When list, the FIRST element is the canonical type; subsequent are role/subtype annotations ptype = front.get("type") if not ptype: errors.append("Missing type") elif isinstance(ptype, str): if ptype not in VALID_TYPES: errors.append(f"Invalid type: '{ptype}' (valid: {', '.join(VALID_TYPES)})") elif isinstance(ptype, list): if not ptype: errors.append("type list is empty") else: primary = ptype[0] if primary not in VALID_TYPES: errors.append(f"Invalid primary type: '{primary}' (valid: {', '.join(VALID_TYPES)})") # Role/subtype annotations (2nd+ elements) are not validated against VALID_TYPES # source must exist if not front.get("source"): errors.append("Missing source") # status=superseded must have superseded_by if front.get("status") == "superseded" and not front.get("superseded_by"): errors.append("status=superseded but missing superseded_by") # relations targets must exist (check entire vault) all_stems = vault_stems if vault_stems is not None else build_vault_stems(WIKI.parent) for rel in front.get("relations", []): target = str(rel.get("target", "")).strip("[]") if target and target not in all_stems: errors.append(f"Relation target [[{target}]] not found") return errors def get_git_changed_wiki_files() -> list[Path]: """获取 git 暂存区中被修改的 wiki/ 及根目录 .md 文件""" try: result = subprocess.run( ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], capture_output=True, text=True, check=True, cwd=WIKI.parent ) except (subprocess.CalledProcessError, FileNotFoundError): return [] files = [] for line in result.stdout.splitlines(): line = line.strip() if not line.endswith(".md"): continue # 只校验 wiki/ 和根目录的 .md 文件 if line.startswith("wiki/") or "/" not in line: fp = WIKI.parent / line if fp.exists(): files.append(fp) return files def main(): parser = argparse.ArgumentParser(description="Validate wiki page frontmatter") parser.add_argument("--files", nargs="+", help="Specific files to check") parser.add_argument("--git-hook", action="store_true", help="Read from git diff") args = parser.parse_args() if args.git_hook: files = get_git_changed_wiki_files() if not files: sys.exit(0) elif args.files: def resolve(f: str) -> Path: p = Path(f) if p.is_absolute(): return p posix = p.as_posix() # 根目录文件如 AGENTS.md → vault_root / AGENTS.md if "/" not in posix and posix.endswith(".md"): return WIKI.parent / posix # wiki/ 前缀清理 relative = posix.removeprefix("wiki/") return WIKI / relative files = [resolve(f) for f in args.files] else: files = sorted(WIKI.glob("*.md")) vault_stems = build_vault_stems(WIKI.parent) all_errors = {} for fp in files: if fp.name in ("index.md", "log.md"): continue errors = validate_one(fp, vault_stems) if errors: all_errors[fp.stem] = errors if all_errors: print(f"=== Frontmatter Validation Errors ({len(all_errors)} files) ===") for name, errs in sorted(all_errors.items()): print(f"\n [[{name}]]:") for e in errs: print(f" [ERR] {e}") sys.exit(1) else: print("[OK] All frontmatter valid") sys.exit(0) if __name__ == "__main__": main()