lint_wiki.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306
  1. #!/usr/bin/env python3
  2. """
  3. lint_wiki.py — Health check for an LLM Wiki.
  4. Usage:
  5. python3 lint_wiki.py <wiki-root>
  6. Example:
  7. python3 lint_wiki.py ~/wikis/ai-research
  8. Checks:
  9. 1. Dead wikilinks — [[Target]] where Target.md doesn't exist
  10. 2. Orphan pages — wiki pages with no inbound links
  11. 3. Missing index entries — wiki pages not listed in wiki/index.md
  12. 4. Unlinked concepts — terms mentioned 3+ times but lacking their own page
  13. 5. log/ shape — every file matches YYYYMMDD.md and has the right H1
  14. 6. audit/ shape — every audit/*.md parses as a valid AuditEntry
  15. 7. Audit targets — every open audit's `target` file must exist
  16. Exit codes:
  17. 0 — no issues found
  18. 1 — issues found (printed to stdout)
  19. """
  20. import os
  21. import re
  22. import sys
  23. from collections import defaultdict
  24. from pathlib import Path
  25. WIKILINK_RE = re.compile(r"\[\[([^\]|#]+)(?:[|#][^\]]*)?\]\]")
  26. LOG_FILENAME_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})\.md$")
  27. FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL)
  28. # Required audit frontmatter fields
  29. AUDIT_REQUIRED_FIELDS = {
  30. "id", "target", "target_lines", "anchor_before", "anchor_text",
  31. "anchor_after", "severity", "author", "source", "created", "status",
  32. }
  33. VALID_SEVERITIES = {"info", "suggest", "warn", "error"}
  34. VALID_STATUSES = {"open", "resolved"}
  35. VALID_SOURCES = {"obsidian-plugin", "web-viewer", "manual"}
  36. def load_pages(wiki_dir: Path) -> dict[str, Path]:
  37. pages: dict[str, Path] = {}
  38. for p in wiki_dir.rglob("*.md"):
  39. pages[p.stem] = p
  40. rel = p.relative_to(wiki_dir)
  41. pages[str(rel.with_suffix(""))] = p
  42. return pages
  43. def extract_wikilinks(text: str) -> list[str]:
  44. return WIKILINK_RE.findall(text)
  45. def parse_frontmatter(text: str) -> dict | None:
  46. """Minimal YAML-ish frontmatter parser. Handles the flat key:value fields
  47. and one-level lists/arrays actually used by audit files. Does not handle
  48. arbitrary YAML — intentional, to avoid a pyyaml dependency."""
  49. m = FRONTMATTER_RE.match(text)
  50. if not m:
  51. return None
  52. body = m.group(1)
  53. result: dict = {}
  54. # Track multi-line folded strings via simple heuristic: quoted scalars
  55. # can contain \n; unquoted values are single-line.
  56. i = 0
  57. lines = body.split("\n")
  58. while i < len(lines):
  59. line = lines[i]
  60. if not line.strip() or line.lstrip().startswith("#"):
  61. i += 1
  62. continue
  63. if ":" not in line:
  64. i += 1
  65. continue
  66. key, _, rest = line.partition(":")
  67. key = key.strip()
  68. val = rest.strip()
  69. if val.startswith("[") and val.endswith("]"):
  70. inner = val[1:-1].strip()
  71. if not inner:
  72. result[key] = []
  73. else:
  74. parts = [p.strip() for p in inner.split(",")]
  75. parsed: list = []
  76. for p in parts:
  77. if p.isdigit() or (p.startswith("-") and p[1:].isdigit()):
  78. parsed.append(int(p))
  79. else:
  80. parsed.append(p.strip('"').strip("'"))
  81. result[key] = parsed
  82. elif val.startswith('"') and val.endswith('"'):
  83. result[key] = val[1:-1].replace("\\n", "\n").replace('\\"', '"')
  84. elif val.startswith("'") and val.endswith("'"):
  85. result[key] = val[1:-1]
  86. else:
  87. result[key] = val
  88. i += 1
  89. return result
  90. def lint(root: str) -> int:
  91. root_path = Path(root)
  92. wiki_path = root_path / "wiki"
  93. log_path = root_path / "log"
  94. audit_path = root_path / "audit"
  95. if not wiki_path.exists():
  96. print(f"ERROR: wiki/ directory not found at {wiki_path}", file=sys.stderr)
  97. return 1
  98. pages = load_pages(wiki_path)
  99. all_wiki_files = list(wiki_path.rglob("*.md"))
  100. index_path = wiki_path / "index.md"
  101. issues = 0
  102. inbound: dict[str, list[str]] = defaultdict(list)
  103. # ── Pass 1: dead wikilinks ──────────────────────────────────────────────
  104. dead_links: list[tuple[str, str]] = []
  105. for md_file in all_wiki_files:
  106. text = md_file.read_text(encoding="utf-8")
  107. for link in extract_wikilinks(text):
  108. link = link.strip()
  109. if link not in pages and Path(link).stem not in pages:
  110. dead_links.append((str(md_file.relative_to(root_path)), link))
  111. else:
  112. target = pages.get(link) or pages.get(Path(link).stem)
  113. if target:
  114. inbound[target.stem].append(md_file.stem)
  115. if dead_links:
  116. print(f"\n🔴 Dead wikilinks ({len(dead_links)}):")
  117. for source, link in dead_links:
  118. print(f" {source} → [[{link}]]")
  119. issues += len(dead_links)
  120. else:
  121. print("✅ No dead wikilinks")
  122. # ── Pass 2: orphan pages ────────────────────────────────────────────────
  123. skip_orphan = {"index"}
  124. orphans = [
  125. p for p in all_wiki_files
  126. if p.stem not in inbound and p.stem not in skip_orphan
  127. and p.parent != wiki_path # skip index.md at root
  128. ]
  129. if orphans:
  130. print(f"\n🟡 Orphan pages ({len(orphans)}) — no inbound wikilinks:")
  131. for p in orphans:
  132. print(f" {p.relative_to(root_path)}")
  133. issues += len(orphans)
  134. else:
  135. print("✅ No orphan pages")
  136. # ── Pass 3: missing index entries ───────────────────────────────────────
  137. if index_path.exists():
  138. index_text = index_path.read_text(encoding="utf-8")
  139. not_in_index = [
  140. p for p in all_wiki_files
  141. if p != index_path
  142. and f"[[{p.stem}]]" not in index_text
  143. and str(p.relative_to(wiki_path).with_suffix("")) not in index_text
  144. ]
  145. if not_in_index:
  146. print(f"\n🟡 Pages missing from index.md ({len(not_in_index)}):")
  147. for p in not_in_index:
  148. print(f" {p.relative_to(root_path)}")
  149. issues += len(not_in_index)
  150. else:
  151. print("✅ All pages in index.md")
  152. else:
  153. print("⚠️ wiki/index.md not found — skipping index check")
  154. # ── Pass 4: unlinked concepts ───────────────────────────────────────────
  155. all_text = " ".join(p.read_text(encoding="utf-8") for p in all_wiki_files)
  156. all_links = WIKILINK_RE.findall(all_text)
  157. link_counts: dict[str, int] = defaultdict(int)
  158. for link in all_links:
  159. link_counts[link.strip()] += 1
  160. missing_pages = [
  161. (link, count) for link, count in link_counts.items()
  162. if count >= 3 and link not in pages and Path(link).stem not in pages
  163. ]
  164. if missing_pages:
  165. print(f"\n🟡 Frequently linked but no page ({len(missing_pages)}):")
  166. for link, count in sorted(missing_pages, key=lambda x: -x[1]):
  167. print(f" [[{link}]] — mentioned {count}x")
  168. issues += len(missing_pages)
  169. else:
  170. print("✅ No frequently-linked missing pages")
  171. # ── Pass 5: log/ shape ───────────────────────────────────────────────────
  172. if log_path.exists() and log_path.is_dir():
  173. log_issues: list[str] = []
  174. for p in sorted(log_path.iterdir()):
  175. if p.is_dir():
  176. continue
  177. if p.name == ".gitkeep":
  178. continue
  179. m = LOG_FILENAME_RE.match(p.name)
  180. if not m:
  181. log_issues.append(f" {p.relative_to(root_path)} — filename doesn't match YYYYMMDD.md")
  182. continue
  183. y, mo, d = m.groups()
  184. iso = f"{y}-{mo}-{d}"
  185. first_line = p.read_text(encoding="utf-8").splitlines()[:1]
  186. if not first_line or first_line[0].strip() != f"# {iso}":
  187. log_issues.append(f" {p.relative_to(root_path)} — expected H1 '# {iso}'")
  188. if log_issues:
  189. print(f"\n🟡 log/ shape issues ({len(log_issues)}):")
  190. for s in log_issues:
  191. print(s)
  192. issues += len(log_issues)
  193. else:
  194. print("✅ log/ shape OK")
  195. else:
  196. print("⚠️ log/ directory not found — skipping log shape check")
  197. # ── Pass 6: audit/ shape ─────────────────────────────────────────────────
  198. audit_targets_to_check: list[tuple[str, str]] = [] # (audit_id, target)
  199. if audit_path.exists() and audit_path.is_dir():
  200. audit_files = [
  201. p for p in audit_path.rglob("*.md") if p.name != ".gitkeep"
  202. ]
  203. audit_issues: list[str] = []
  204. for p in audit_files:
  205. text = p.read_text(encoding="utf-8")
  206. fm = parse_frontmatter(text)
  207. rel = p.relative_to(root_path)
  208. if fm is None:
  209. audit_issues.append(f" {rel} — missing YAML frontmatter")
  210. continue
  211. missing = AUDIT_REQUIRED_FIELDS - set(fm.keys())
  212. if missing:
  213. audit_issues.append(
  214. f" {rel} — missing fields: {', '.join(sorted(missing))}"
  215. )
  216. continue
  217. if fm["severity"] not in VALID_SEVERITIES:
  218. audit_issues.append(
  219. f" {rel} — invalid severity '{fm['severity']}' (expected {sorted(VALID_SEVERITIES)})"
  220. )
  221. if fm["source"] not in VALID_SOURCES:
  222. audit_issues.append(
  223. f" {rel} — invalid source '{fm['source']}'"
  224. )
  225. expected_status = "resolved" if "resolved" in p.parts else "open"
  226. if fm["status"] != expected_status:
  227. audit_issues.append(
  228. f" {rel} — status '{fm['status']}' doesn't match directory (expected '{expected_status}')"
  229. )
  230. if fm["status"] == "open":
  231. audit_targets_to_check.append((fm["id"], fm["target"]))
  232. if audit_issues:
  233. print(f"\n🔴 audit/ shape issues ({len(audit_issues)}):")
  234. for s in audit_issues:
  235. print(s)
  236. issues += len(audit_issues)
  237. else:
  238. print(f"✅ audit/ shape OK ({len(audit_files)} files)")
  239. else:
  240. print("⚠️ audit/ directory not found — skipping audit shape check")
  241. # ── Pass 7: audit targets exist ──────────────────────────────────────────
  242. missing_targets: list[tuple[str, str]] = []
  243. for audit_id, target in audit_targets_to_check:
  244. target_path = root_path / target
  245. # Audit target paths are relative to wiki-root but typically point
  246. # at files under wiki/. Check both locations.
  247. if not target_path.exists():
  248. alt = wiki_path / target
  249. if not alt.exists():
  250. missing_targets.append((audit_id, target))
  251. if missing_targets:
  252. print(f"\n🔴 Open audits with missing target files ({len(missing_targets)}):")
  253. for audit_id, target in missing_targets:
  254. print(f" {audit_id} → {target}")
  255. issues += len(missing_targets)
  256. elif audit_targets_to_check:
  257. print("✅ All open-audit targets exist")
  258. # ── Summary ─────────────────────────────────────────────────────────────
  259. print(f"\n{'─'*40}")
  260. if issues == 0:
  261. print("✅ Wiki is healthy — no issues found")
  262. else:
  263. print(f"⚠️ {issues} issue(s) found — review above and fix before next ingest")
  264. return 0 if issues == 0 else 1
  265. if __name__ == "__main__":
  266. if len(sys.argv) < 2:
  267. print(__doc__)
  268. sys.exit(1)
  269. sys.exit(lint(sys.argv[1]))