#!/usr/bin/env python3 """ Pre-commit hook: Validate all [[...]] wiki-links in wiki/*.md files. Link format: [[path/to/file.md]] or [[path/to/file.md|Label]] Resolution: SOURCE-RELATIVE (matches Forgejo/Gitea Markdown rendering behavior) - Links resolve relative to the current file's directory - [[foo.md]] in wiki/architecture/bar.md → wiki/architecture/foo.md - [[../tools/foo.md]] in wiki/architecture/bar.md → wiki/tools/foo.md - [[concepts/llm/foo.md]] in wiki/concepts/llm/bar.md → wiki/concepts/llm/concepts/llm/foo.md (BROKEN) The third case is the "path confusion" bug — links that look root-relative but double-prefix when interpreted source-relative. This script flags them so they can be rewritten using either same-directory basename or proper ../-prefix. Usage: python3 scripts/check-wiki-links.py # check + report python3 scripts/check-wiki-links.py --strict # exit 1 on warnings too """ import os import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent WIKI_DIR = REPO_ROOT / "wiki" LINK_RE = re.compile(r'\[\[([^\]|]+)(?:\|([^\]]+))?\]\]') def find_wiki_files(): """Find all .md files under wiki/.""" files = [] for root, dirs, filenames in os.walk(WIKI_DIR): for f in filenames: if f.endswith('.md'): files.append(Path(root) / f) return sorted(files) def strip_code_spans(content: str) -> str: """Remove inline-code spans (between backticks) so we don't check links inside them. Also removes fenced code blocks (``` ... ```). """ # Remove fenced code blocks first lines = content.split('\n') out = [] in_fence = False for line in lines: if line.strip().startswith('```'): in_fence = not in_fence continue if not in_fence: out.append(line) content = '\n'.join(out) # Remove inline code spans content = re.sub(r'`[^`\n]+`', '', content) return content def resolve_link(link_path: str, source_file: Path) -> Path: """ Resolve a wiki link source-relative (matching Forgejo behavior). Also tries with .md suffix appended if needed. """ if link_path.startswith('http'): return None # Strip anchor if '#' in link_path: link_path = link_path.split('#', 1)[0] # Strip trailing slash link_path = link_path.rstrip('/') # Try as-is (relative to source file's directory) target = (source_file.parent / link_path).resolve() if target.exists(): return target # Try with .md suffix if not link_path.endswith('.md'): target = (source_file.parent / (link_path + '.md')).resolve() if target.exists(): return target return None def has_path_confusion(link_path: str, source_file: Path) -> bool: """ Detect 'path confusion': link like [[concepts/llm/foo.md]] from a file in wiki/concepts/llm/bar.md would resolve to wiki/concepts/llm/concepts/llm/foo.md. Returns True if the link looks root-relative but is used from inside a subdir. """ if link_path.startswith('http') or link_path.startswith('../') or '/' not in link_path: return False # Check if link_path starts with a directory the source file is already in try: rel = source_file.relative_to(WIKI_DIR) source_dir_parts = rel.parent.parts link_parts = Path(link_path).parts # If link starts with the same subdirectory chain if len(link_parts) >= len(source_dir_parts): if link_parts[:len(source_dir_parts)] == source_dir_parts: return True except ValueError: pass return False def check_all_links(): files = find_wiki_files() errors = [] warnings = [] stats = { 'total_links': 0, 'valid_links': 0, 'broken_links': 0, 'confusion_warnings': 0, 'files_checked': 0, } for source_file in files: stats['files_checked'] += 1 rel_source = source_file.relative_to(REPO_ROOT) content = source_file.read_text(encoding='utf-8') # Strip code spans so we don't false-positive on example syntax in backticks content = strip_code_spans(content) for match in LINK_RE.finditer(content): link_path = match.group(1).strip() stats['total_links'] += 1 if link_path.startswith('http'): stats['valid_links'] += 1 continue # Resolve source-relative target = resolve_link(link_path, source_file) if target: stats['valid_links'] += 1 else: # Skip template placeholders like {slug} if '{' in link_path: stats['valid_links'] += 1 continue # Try alternative suggestions basename = Path(link_path).name matches = [m for m in WIKI_DIR.rglob(basename) if 'test-fixtures' not in str(m)] error = { 'source': str(rel_source), 'link': link_path, 'suggestions': [str(m.relative_to(REPO_ROOT)) for m in matches[:3]], } errors.append(error) stats['broken_links'] += 1 # Check for path confusion (link works repo-relative but broken source-relative) # Skip template placeholders like {slug} if '{' not in link_path and has_path_confusion(link_path, source_file): # Only warn if the link is actually broken source-relative if not resolve_link(link_path, source_file): correct_basename = Path(link_path).name warnings.append({ 'source': str(rel_source), 'link': link_path, 'note': f"Path confusion — link starts with {link_path.split('/')[0]}/ which doubles the source-directory prefix. Use same-directory basename or proper ../-prefix.", 'suggestion': correct_basename, }) stats['confusion_warnings'] += 1 return errors, warnings, stats def main(): strict = '--strict' in sys.argv errors, warnings, stats = check_all_links() print("Wiki Link Report") print("================") print(f"Files checked: {stats['files_checked']}") print(f"Total links: {stats['total_links']}") print(f"Valid links: {stats['valid_links']}") print(f"Broken links: {stats['broken_links']}") print(f"Path confusion warns: {stats['confusion_warnings']}") print() if errors: print(f"BROKEN LINKS ({len(errors)}):") print("-" * 60) for e in errors[:30]: print(f" {e['source']}") print(f" Link: [[{e['link']}]]") if e['suggestions']: print(f" Did you mean: {e['suggestions'][0]}") print() if len(errors) > 30: print(f" ... and {len(errors) - 30} more") if warnings: print(f"\nPATH CONFUSION WARNINGS ({len(warnings)}):") print("-" * 60) for w in warnings[:20]: print(f" {w['source']}") print(f" Link: [[{w['link']}]]") print(f" {w['note']}") print(f" Suggestion: [[{w['suggestion']}]] (same dir) or [[../...]]") print() if len(warnings) > 20: print(f" ... and {len(warnings) - 20} more") failed = False if stats['broken_links'] > 0: print(f"\n❌ {stats['broken_links']} broken link(s) — commit blocked.") failed = True if stats['confusion_warnings'] > 0: print(f"\n⚠️ {stats['confusion_warnings']} path confusion warning(s).") if strict: print("Strict mode: commit blocked.") failed = True if not failed: print("\n✅ All wiki links valid.") sys.exit(1 if failed else 0) if __name__ == '__main__': main()