Ctrl K
ComunidadGuías…

scripts/lint_wiki.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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()