| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306 |
- #!/usr/bin/env python3
- """
- lint_wiki.py — Health check for an LLM Wiki.
- Usage:
- python3 lint_wiki.py <wiki-root>
- Example:
- python3 lint_wiki.py ~/wikis/ai-research
- Checks:
- 1. Dead wikilinks — [[Target]] where Target.md doesn't exist
- 2. Orphan pages — wiki pages with no inbound links
- 3. Missing index entries — wiki pages not listed in wiki/index.md
- 4. Unlinked concepts — terms mentioned 3+ times but lacking their own page
- 5. log/ shape — every file matches YYYYMMDD.md and has the right H1
- 6. audit/ shape — every audit/*.md parses as a valid AuditEntry
- 7. Audit targets — every open audit's `target` file must exist
- Exit codes:
- 0 — no issues found
- 1 — issues found (printed to stdout)
- """
- import os
- import re
- import sys
- from collections import defaultdict
- from pathlib import Path
- WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
- LOG_FILENAME_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})\.md$")
- FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
- # Required audit frontmatter fields
- AUDIT_REQUIRED_FIELDS = {
- "id", "target", "target_lines", "anchor_before", "anchor_text",
- "anchor_after", "severity", "author", "source", "created", "status",
- }
- VALID_SEVERITIES = {"info", "suggest", "warn", "error"}
- VALID_STATUSES = {"open", "resolved"}
- VALID_SOURCES = {"obsidian-plugin", "web-viewer", "manual"}
- def load_pages(wiki_dir: Path) -> dict[str, Path]:
- pages: dict[str, Path] = {}
- for p in wiki_dir.rglob("*.md"):
- pages[p.stem] = p
- rel = p.relative_to(wiki_dir)
- pages[str(rel.with_suffix(""))] = p
- return pages
- def extract_wikilinks(text: str) -> list[str]:
- return WIKILINK_RE.findall(text)
- def parse_frontmatter(text: str) -> dict | None:
- """Minimal YAML-ish frontmatter parser. Handles the flat key:value fields
- and one-level lists/arrays actually used by audit files. Does not handle
- arbitrary YAML — intentional, to avoid a pyyaml dependency."""
- m = FRONTMATTER_RE.match(text)
- if not m:
- return None
- body = m.group(1)
- result: dict = {}
- # Track multi-line folded strings via simple heuristic: quoted scalars
- # can contain \n; unquoted values are single-line.
- i = 0
- lines = body.split("\n")
- while i < len(lines):
- line = lines[i]
- if not line.strip() or line.lstrip().startswith("#"):
- i += 1
- continue
- if ":" not in line:
- i += 1
- continue
- key, _, rest = line.partition(":")
- key = key.strip()
- val = rest.strip()
- if val.startswith("[") and val.endswith("]"):
- inner = val[1:-1].strip()
- if not inner:
- result[key] = []
- else:
- parts = [p.strip() for p in inner.split(",")]
- parsed: list = []
- for p in parts:
- if p.isdigit() or (p.startswith("-") and p[1:].isdigit()):
- parsed.append(int(p))
- else:
- parsed.append(p.strip('"').strip("'"))
- result[key] = parsed
- elif val.startswith('"') and val.endswith('"'):
- result[key] = val[1:-1].replace("\\n", "\n").replace('\\"', '"')
- elif val.startswith("'") and val.endswith("'"):
- result[key] = val[1:-1]
- else:
- result[key] = val
- i += 1
- return result
- def lint(root: str) -> int:
- root_path = Path(root)
- wiki_path = root_path / "wiki"
- log_path = root_path / "log"
- audit_path = root_path / "audit"
- if not wiki_path.exists():
- print(f"ERROR: wiki/ directory not found at {wiki_path}", file=sys.stderr)
- return 1
- pages = load_pages(wiki_path)
- all_wiki_files = list(wiki_path.rglob("*.md"))
- index_path = wiki_path / "index.md"
- issues = 0
- inbound: dict[str, list[str]] = defaultdict(list)
- # ── Pass 1: dead wikilinks ──────────────────────────────────────────────
- dead_links: list[tuple[str, str]] = []
- for md_file in all_wiki_files:
- text = md_file.read_text(encoding="utf-8")
- for link in extract_wikilinks(text):
- link = link.strip()
- if link not in pages and Path(link).stem not in pages:
- dead_links.append((str(md_file.relative_to(root_path)), link))
- else:
- target = pages.get(link) or pages.get(Path(link).stem)
- if target:
- inbound[target.stem].append(md_file.stem)
- if dead_links:
- print(f"\n🔴 Dead wikilinks ({len(dead_links)}):")
- for source, link in dead_links:
- print(f" {source} → [[{link}]]")
- issues += len(dead_links)
- else:
- print("✅ No dead wikilinks")
- # ── Pass 2: orphan pages ────────────────────────────────────────────────
- skip_orphan = {"index"}
- orphans = [
- p for p in all_wiki_files
- if p.stem not in inbound and p.stem not in skip_orphan
- and p.parent != wiki_path # skip index.md at root
- ]
- if orphans:
- print(f"\n🟡 Orphan pages ({len(orphans)}) — no inbound wikilinks:")
- for p in orphans:
- print(f" {p.relative_to(root_path)}")
- issues += len(orphans)
- else:
- print("✅ No orphan pages")
- # ── Pass 3: missing index entries ───────────────────────────────────────
- if index_path.exists():
- index_text = index_path.read_text(encoding="utf-8")
- not_in_index = [
- p for p in all_wiki_files
- if p != index_path
- and f"[[{p.stem}]]" not in index_text
- and str(p.relative_to(wiki_path).with_suffix("")) not in index_text
- ]
- if not_in_index:
- print(f"\n🟡 Pages missing from index.md ({len(not_in_index)}):")
- for p in not_in_index:
- print(f" {p.relative_to(root_path)}")
- issues += len(not_in_index)
- else:
- print("✅ All pages in index.md")
- else:
- print("⚠️ wiki/index.md not found — skipping index check")
- # ── Pass 4: unlinked concepts ───────────────────────────────────────────
- all_text = " ".join(p.read_text(encoding="utf-8") for p in all_wiki_files)
- all_links = WIKILINK_RE.findall(all_text)
- link_counts: dict[str, int] = defaultdict(int)
- for link in all_links:
- link_counts[link.strip()] += 1
- missing_pages = [
- (link, count) for link, count in link_counts.items()
- if count >= 3 and link not in pages and Path(link).stem not in pages
- ]
- if missing_pages:
- print(f"\n🟡 Frequently linked but no page ({len(missing_pages)}):")
- for link, count in sorted(missing_pages, key=lambda x: -x[1]):
- print(f" [[{link}]] — mentioned {count}x")
- issues += len(missing_pages)
- else:
- print("✅ No frequently-linked missing pages")
- # ── Pass 5: log/ shape ───────────────────────────────────────────────────
- if log_path.exists() and log_path.is_dir():
- log_issues: list[str] = []
- for p in sorted(log_path.iterdir()):
- if p.is_dir():
- continue
- if p.name == ".gitkeep":
- continue
- m = LOG_FILENAME_RE.match(p.name)
- if not m:
- log_issues.append(f" {p.relative_to(root_path)} — filename doesn't match YYYYMMDD.md")
- continue
- y, mo, d = m.groups()
- iso = f"{y}-{mo}-{d}"
- first_line = p.read_text(encoding="utf-8").splitlines()[:1]
- if not first_line or first_line[0].strip() != f"# {iso}":
- log_issues.append(f" {p.relative_to(root_path)} — expected H1 '# {iso}'")
- if log_issues:
- print(f"\n🟡 log/ shape issues ({len(log_issues)}):")
- for s in log_issues:
- print(s)
- issues += len(log_issues)
- else:
- print("✅ log/ shape OK")
- else:
- print("⚠️ log/ directory not found — skipping log shape check")
- # ── Pass 6: audit/ shape ─────────────────────────────────────────────────
- audit_targets_to_check: list[tuple[str, str]] = [] # (audit_id, target)
- if audit_path.exists() and audit_path.is_dir():
- audit_files = [
- p for p in audit_path.rglob("*.md") if p.name != ".gitkeep"
- ]
- audit_issues: list[str] = []
- for p in audit_files:
- text = p.read_text(encoding="utf-8")
- fm = parse_frontmatter(text)
- rel = p.relative_to(root_path)
- if fm is None:
- audit_issues.append(f" {rel} — missing YAML frontmatter")
- continue
- missing = AUDIT_REQUIRED_FIELDS - set(fm.keys())
- if missing:
- audit_issues.append(
- f" {rel} — missing fields: {', '.join(sorted(missing))}"
- )
- continue
- if fm["severity"] not in VALID_SEVERITIES:
- audit_issues.append(
- f" {rel} — invalid severity '{fm['severity']}' (expected {sorted(VALID_SEVERITIES)})"
- )
- if fm["source"] not in VALID_SOURCES:
- audit_issues.append(
- f" {rel} — invalid source '{fm['source']}'"
- )
- expected_status = "resolved" if "resolved" in p.parts else "open"
- if fm["status"] != expected_status:
- audit_issues.append(
- f" {rel} — status '{fm['status']}' doesn't match directory (expected '{expected_status}')"
- )
- if fm["status"] == "open":
- audit_targets_to_check.append((fm["id"], fm["target"]))
- if audit_issues:
- print(f"\n🔴 audit/ shape issues ({len(audit_issues)}):")
- for s in audit_issues:
- print(s)
- issues += len(audit_issues)
- else:
- print(f"✅ audit/ shape OK ({len(audit_files)} files)")
- else:
- print("⚠️ audit/ directory not found — skipping audit shape check")
- # ── Pass 7: audit targets exist ──────────────────────────────────────────
- missing_targets: list[tuple[str, str]] = []
- for audit_id, target in audit_targets_to_check:
- target_path = root_path / target
- # Audit target paths are relative to wiki-root but typically point
- # at files under wiki/. Check both locations.
- if not target_path.exists():
- alt = wiki_path / target
- if not alt.exists():
- missing_targets.append((audit_id, target))
- if missing_targets:
- print(f"\n🔴 Open audits with missing target files ({len(missing_targets)}):")
- for audit_id, target in missing_targets:
- print(f" {audit_id} → {target}")
- issues += len(missing_targets)
- elif audit_targets_to_check:
- print("✅ All open-audit targets exist")
- # ── Summary ─────────────────────────────────────────────────────────────
- print(f"\n{'─'*40}")
- if issues == 0:
- print("✅ Wiki is healthy — no issues found")
- else:
- print(f"⚠️ {issues} issue(s) found — review above and fix before next ingest")
- return 0 if issues == 0 else 1
- if __name__ == "__main__":
- if len(sys.argv) < 2:
- print(__doc__)
- sys.exit(1)
- sys.exit(lint(sys.argv[1]))
|