125 lines
4.2 KiB
Python
125 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|||
|
|
"""
|
||
|
|
Audit [[wikilinks]] in an Obsidian vault.
|
||
|
|
|
||
|
|
Scans all .md files for [[wikilink]] references and reports:
|
||
|
|
- Broken links (target file doesn't exist)
|
||
|
|
- Ambiguous links (multiple files match the basename)
|
||
|
|
- Code-example false positives ([[wikilinks]] inside backticks)
|
||
|
|
|
||
|
|
Usage:
|
||
|
|
python3 audit-wikilinks.py [VAULT_PATH]
|
||
|
|
|
||
|
|
If VAULT_PATH is omitted, defaults to ~/m3ta-brain.
|
||
|
|
|
||
|
|
Exit codes:
|
||
|
|
0 = no broken links (ambiguous/code-examples are warnings only)
|
||
|
|
1 = broken links found
|
||
|
|
"""
|
||
|
|
import sys
|
||
|
|
import os
|
||
|
|
import re
|
||
|
|
from collections import defaultdict
|
||
|
|
|
||
|
|
VAULT = sys.argv[1] if len(sys.argv) > 1 else os.path.expanduser("~/m3ta-brain")
|
||
|
|
|
||
|
|
# Collect all .md files
|
||
|
|
md_files = []
|
||
|
|
for root, dirs, files in os.walk(VAULT):
|
||
|
|
if '.git' in root:
|
||
|
|
continue
|
||
|
|
for f in files:
|
||
|
|
if f.endswith('.md'):
|
||
|
|
full_path = os.path.join(root, f)
|
||
|
|
rel_path = os.path.relpath(full_path, VAULT)
|
||
|
|
md_files.append((rel_path, full_path))
|
||
|
|
|
||
|
|
# Build indices
|
||
|
|
basename_index = defaultdict(list)
|
||
|
|
fullpath_set = set()
|
||
|
|
for rel_path, full_path in md_files:
|
||
|
|
basename = os.path.basename(rel_path).replace('.md', '')
|
||
|
|
basename_index[basename].append(rel_path)
|
||
|
|
fullpath_set.add(rel_path)
|
||
|
|
|
||
|
|
print(f"Total .md files: {len(md_files)}")
|
||
|
|
|
||
|
|
# Extract and validate wikilinks
|
||
|
|
# Pattern matches [[target]], [[target|alias]], [[target#heading]], [[target#heading|alias]]
|
||
|
|
wikilink_pattern = re.compile(r'\[\[([^\]|#]+)(?:#[^\]|]*)?(?:\|[^\]]*)?\]\]')
|
||
|
|
# Pattern to detect if a wikilink is inside backtick code
|
||
|
|
backtick_pattern = re.compile(r'`[^`]*\[\[[^\]]*\]\][^`]*`')
|
||
|
|
|
||
|
|
broken_links = []
|
||
|
|
ambiguous_links = []
|
||
|
|
code_example_links = []
|
||
|
|
valid_links = 0
|
||
|
|
total_links = 0
|
||
|
|
|
||
|
|
for rel_path, full_path in md_files:
|
||
|
|
with open(full_path, 'r') as f:
|
||
|
|
lines = f.readlines()
|
||
|
|
for i, line in enumerate(lines, 1):
|
||
|
|
for match in wikilink_pattern.finditer(line):
|
||
|
|
target = match.group(1).strip()
|
||
|
|
if target.startswith('./'):
|
||
|
|
target = target[2:]
|
||
|
|
total_links += 1
|
||
|
|
|
||
|
|
# Check if this link is inside backtick code on the same line
|
||
|
|
# (simplified: checks if backticks surround the match on this line)
|
||
|
|
start = match.start()
|
||
|
|
prefix = line[:start]
|
||
|
|
backtick_count_before = prefix.count('`')
|
||
|
|
in_code = backtick_count_before % 2 == 1
|
||
|
|
|
||
|
|
if '/' in target:
|
||
|
|
target_path = target + '.md' if not target.endswith('.md') else target
|
||
|
|
if target_path in fullpath_set:
|
||
|
|
valid_links += 1
|
||
|
|
elif in_code:
|
||
|
|
code_example_links.append((rel_path, i, match.group(0), target))
|
||
|
|
else:
|
||
|
|
broken_links.append((rel_path, i, match.group(0), target))
|
||
|
|
else:
|
||
|
|
matches = basename_index.get(target, [])
|
||
|
|
if len(matches) == 0:
|
||
|
|
if in_code:
|
||
|
|
code_example_links.append((rel_path, i, match.group(0), target))
|
||
|
|
else:
|
||
|
|
broken_links.append((rel_path, i, match.group(0), target))
|
||
|
|
elif len(matches) > 1:
|
||
|
|
ambiguous_links.append((rel_path, i, match.group(0), target, matches))
|
||
|
|
else:
|
||
|
|
valid_links += 1
|
||
|
|
|
||
|
|
print(f"Total wikilinks: {total_links}")
|
||
|
|
print(f"Valid: {valid_links}")
|
||
|
|
print(f"Broken: {len(broken_links)}")
|
||
|
|
print(f"Ambiguous: {len(ambiguous_links)}")
|
||
|
|
print(f"Code-example (in backticks, OK): {len(code_example_links)}")
|
||
|
|
|
||
|
|
if broken_links:
|
||
|
|
print("\n=== BROKEN LINKS ===")
|
||
|
|
by_target = defaultdict(list)
|
||
|
|
for src, line, raw, target in broken_links:
|
||
|
|
by_target[target].append((src, line))
|
||
|
|
for target in sorted(by_target.keys()):
|
||
|
|
sources = by_target[target]
|
||
|
|
print(f"\n '{target}' — {len(sources)} ref(s):")
|
||
|
|
for src, line in sources[:5]:
|
||
|
|
print(f" {src}:{line}")
|
||
|
|
|
||
|
|
if ambiguous_links:
|
||
|
|
print("\n=== AMBIGUOUS LINKS ===")
|
||
|
|
for src, line, raw, target, matches in ambiguous_links:
|
||
|
|
print(f" '{target}' in {src}:{line} → {matches}")
|
||
|
|
|
||
|
|
# Exit code
|
||
|
|
if broken_links:
|
||
|
|
print("\n⚠️ Broken links found — fix before committing.")
|
||
|
|
sys.exit(1)
|
||
|
|
else:
|
||
|
|
print("\n✅ No broken links.")
|
||
|
|
sys.exit(0)
|