#!/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 validate_one(fp: Path) -> 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}"] # 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 # 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 for rel in front.get("relations", []): target = str(rel.get("target", "")).strip("[]") if target and not (WIKI / f"{target}.md").exists(): 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 line.startswith("wiki/") and line.endswith(".md"): 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: files = [Path(f) if Path(f).is_absolute() else WIKI / f for f in args.files] else: files = sorted(WIKI.glob("*.md")) all_errors = {} for fp in files: if fp.name in ("index.md", "log.md"): continue errors = validate_one(fp) 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()