knowledge-base/scripts/check-wiki-links.py

206 lines
7 KiB
Python
Raw Normal View History

#!/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 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')
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:
# 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)
if 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()