a6f05ab2d5
- Phase 0: AGENTS.md cleanup (dedup quotes, renumber sections, merge qmd) - Phase 1: typed relations (manage-relations.py, graph-search.py, check-staleness.py, detect-conflicts.py) - Phase 2: frontmatter validator, weekly lint, knowledge promotion, git hooks - Fix .gitignore to track tools/ and .githooks/ - Fix git remote URL (remove plaintext token) - New wiki pages: 504 pages, 34 raw sources
151 lines
4.7 KiB
Python
151 lines
4.7 KiB
Python
#!/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
|
||
ptype = front.get("type")
|
||
if not ptype:
|
||
errors.append("Missing type")
|
||
elif isinstance(ptype, str) and ptype not in VALID_TYPES:
|
||
errors.append(f"Invalid type: '{ptype}' (valid: {', '.join(VALID_TYPES)})")
|
||
elif isinstance(ptype, list):
|
||
for t in ptype:
|
||
if t not in VALID_TYPES:
|
||
errors.append(f"Invalid type in list: '{t}'")
|
||
|
||
# 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" ❌ {e}")
|
||
sys.exit(1)
|
||
else:
|
||
print("✅ All frontmatter valid")
|
||
sys.exit(0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|