206 lines
7 KiB
Python
206 lines
7 KiB
Python
|
|
#!/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()
|