#!/usr/bin/env python3 """ Wiki health check. Checks for: - Orphan pages (in topics/ but not listed in index.md) - Stale pages (last_updated > 60 days ago) - Gap topics (5+ cards in search_index with no wiki page) - Unregistered pages (in topics/ but missing from index.md) Usage: python3 lint_wiki.py python3 lint_wiki.py --fix # auto-register orphans in index.md """ from __future__ import annotations import argparse import json import os import re from collections import Counter from datetime import datetime, timezone import sys from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_failures import xkb_paths WORKSPACE = xkb_paths.WORKSPACE _SKILL_DIR = Path(__file__).resolve().parent.parent WIKI_DIR = xkb_paths.WIKI_DIR TOPICS_DIR = WIKI_DIR / "topics" INDEX_PATH = WIKI_DIR / "index.md" LOG_PATH = WIKI_DIR / "log.md" TOPIC_MAP_PATH = WIKI_DIR / "topic-map.json" SEARCH_INDEX_PATH = WORKSPACE / "memory" / "bookmarks" / "search_index.json" STALE_DAYS = 60 GAP_THRESHOLD = 5 def slugify(text: str) -> str: return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") def load_index_slugs() -> set[str]: """Slugs listed in wiki/index.md.""" if not INDEX_PATH.exists(): return set() content = INDEX_PATH.read_text(encoding="utf-8") # Match markdown links like [title](topics/slug.md) return {m.group(1) for m in re.finditer(r"\(topics/([^)]+)\.md\)", content)} def load_topic_files() -> list[Path]: if not TOPICS_DIR.exists(): return [] return list(TOPICS_DIR.glob("*.md")) def parse_frontmatter(path: Path) -> dict: content = path.read_text(encoding="utf-8") fm = {} m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL) if m: for line in m.group(1).splitlines(): kv = line.split(":", 1) if len(kv) == 2: fm[kv[0].strip()] = kv[1].strip() return fm def load_search_index() -> list[dict]: if not SEARCH_INDEX_PATH.exists(): return [] try: data = json.loads(SEARCH_INDEX_PATH.read_text(encoding="utf-8")) if isinstance(data, list): return data return data.get("items", []) except Exception as err: xkb_failures.note("search index", err, detail=str(SEARCH_INDEX_PATH)) return [] def load_topic_map() -> dict: if not TOPIC_MAP_PATH.exists(): return {} return json.loads(TOPIC_MAP_PATH.read_text(encoding="utf-8")) def append_log(entry: str) -> None: existing = LOG_PATH.read_text(encoding="utf-8") if LOG_PATH.exists() else "# Wiki Log\n---\n" LOG_PATH.write_text(existing.rstrip() + "\n" + entry + "\n", encoding="utf-8") def check_orphans(topic_files: list[Path], index_slugs: set[str]) -> list[str]: """Pages in topics/ not listed in index.md.""" orphans = [] for f in topic_files: if f.stem not in index_slugs: orphans.append(f.stem) return orphans def check_stale(topic_files: list[Path]) -> list[tuple[str, str]]: """Pages where last_updated > STALE_DAYS days ago.""" stale = [] today = datetime.now(timezone.utc).date() for f in topic_files: fm = parse_frontmatter(f) lu = fm.get("last_updated", "") if not lu: stale.append((f.stem, "no last_updated")) continue try: updated = datetime.strptime(lu, "%Y-%m-%d").date() delta = (today - updated).days if delta > STALE_DAYS: stale.append((f.stem, f"{delta} days ago")) except ValueError: stale.append((f.stem, f"unparseable date: {lu}")) return stale def check_gaps(topic_files: list[Path]) -> list[tuple[str, int]]: """Categories with 5+ cards but no wiki page.""" index = load_search_index() topic_map = load_topic_map() existing_slugs = {f.stem for f in topic_files} # Count cards per category (excluding excluded) cat_counts = Counter( e.get("category", "unknown") for e in index if not e.get("excluded") ) gaps = [] for cat, count in cat_counts.items(): if count < GAP_THRESHOLD: continue # Check if this category is mapped to any existing topic mapping = topic_map.get("mapping", {}).get(cat, {}) mapped_topics = mapping.get("topics") or [] if mapped_topics and any(t in existing_slugs for t in mapped_topics): continue # No wiki page for this category gaps.append((cat, count)) return sorted(gaps, key=lambda x: -x[1]) def fix_orphans(orphans: list[str]) -> None: """Add orphan pages to index.md.""" if not INDEX_PATH.exists(): INDEX_PATH.write_text("# Wiki Index\n\n## Topics\n\n", encoding="utf-8") content = INDEX_PATH.read_text(encoding="utf-8") today = datetime.now(timezone.utc).strftime("%Y-%m-%d") additions = [] for slug in orphans: if f"topics/{slug}.md" not in content: additions.append(f"- [{slug}](topics/{slug}.md) — (auto-registered by lint) | last_updated: {today}") if additions: # Find Topics section or append if "## Topics" in content: content = content.replace( "## Topics", "## Topics\n" + "\n".join(additions), 1, ) else: content += "\n## Topics\n" + "\n".join(additions) + "\n" INDEX_PATH.write_text(content, encoding="utf-8") print(f" [FIX] Registered {len(additions)} orphan(s) in index.md") def main() -> None: parser = argparse.ArgumentParser(description="Wiki health check") parser.add_argument("--fix", action="store_true", help="Auto-fix orphans in index.md") args = parser.parse_args() topic_files = load_topic_files() index_slugs = load_index_slugs() now = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") print(f"Wiki Lint — {now}") print(f"Topics on disk: {len(topic_files)}") print(f"Topics in index: {len(index_slugs)}") print() issues = 0 # 1. Orphans orphans = check_orphans(topic_files, index_slugs) if orphans: print(f"[WARN] Orphan pages ({len(orphans)}) — in topics/ but not in index.md:") for slug in orphans: print(f" - {slug}") issues += len(orphans) if args.fix: fix_orphans(orphans) else: print("[OK] No orphan pages") # 2. Stale pages stale = check_stale(topic_files) if stale: print(f"\n[WARN] Stale pages ({len(stale)}) — last_updated > {STALE_DAYS} days:") for slug, info in stale: print(f" - {slug}: {info}") issues += len(stale) else: print("[OK] No stale pages") # 3. Gap topics gaps = check_gaps(topic_files) if gaps: print(f"\n[INFO] Gap topics ({len(gaps)}) — {GAP_THRESHOLD}+ cards but no wiki page:") for cat, count in gaps[:10]: print(f" - {cat}: {count} cards") issues += len(gaps) else: print("[OK] No gap topics") print() if issues == 0: print("Wiki is healthy.") else: print(f"Total issues: {issues}") # Log the lint run append_log( f"\n## [{now}] lint | {len(topic_files)} pages checked — " f"{len(orphans)} orphans, {len(stale)} stale, {len(gaps)} gaps" ) import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) main()