- scripts/check-wiki-links.py: validates all [[...]] links source-relative (matching Forgejo behavior)
- scripts/fix-wiki-links.py: auto-rewrites broken links as source-relative paths
- scripts/.check-wiki-links-wrapper.sh: pre-commit hook wrapper
- .git/hooks/pre-commit: installs wrapper as Git hook
- Fixed 47 wiki files with broken links (mostly path-confusion and wiki/-prefix bugs)
- Total: 691 valid wiki links, 0 broken, 0 path confusion
- Manual fix: Wikipedia: Sleeper agent link → proper Markdown URL
- Manual fix: non-existent decision link → decisions-index
- Code-span and template-placeholder handling (ignores `[[example]]` and `[[{slug}]]`)
Trigger: k9ert noted https://.../concepts/llm/concepts/llm/... was 404 in wiki-catalog
233 lines
No EOL
7.9 KiB
Python
233 lines
No EOL
7.9 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 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() |