#!/usr/bin/env python3 # file: scripts/audit_markdown_links.py # version: 1 """Validate that local inline Markdown link targets exist.""" from __future__ import annotations import argparse import pathlib import re import sys import urllib.parse _IGNORED_DIRECTORIES = frozenset({".git", ".idea", ".venv", "__pycache__", "dist", "node_modules", "target", "builds"}) _INLINE_LINK = re.compile(r"(? tuple[list[pathlib.Path], list[str]]: files: list[pathlib.Path] = [] errors: list[str] = [] for raw_path in raw_paths: path = pathlib.Path(raw_path) if path.is_dir(): files.extend( candidate for candidate in path.rglob("*.md") if candidate.is_file() and not any(part in _IGNORED_DIRECTORIES for part in candidate.parts) ) elif path.is_file() and path.suffix.lower() == ".md": files.append(path) else: errors.append(f"Markdown link audit: unsupported or missing path: {path}") return sorted(set(files)), errors def _destination(raw_destination: str) -> str: destination = raw_destination.strip() if destination.startswith("<"): closing = destination.find(">") return destination[1:closing] if closing > 0 else destination return destination.split(maxsplit=1)[0] if destination else "" def _local_target(destination: str) -> str | None: if not destination or destination.startswith("#") or destination.startswith("//"): return None parsed = urllib.parse.urlsplit(destination) if parsed.scheme or parsed.netloc: return None target = urllib.parse.unquote(parsed.path) return target or None def _audit_file(path: pathlib.Path) -> tuple[int, list[str]]: errors: list[str] = [] checked = 0 fence: str | None = None for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), start=1): stripped = line.lstrip() if stripped.startswith("```") or stripped.startswith("~~~"): marker = stripped[:3] if fence is None: fence = marker elif fence == marker: fence = None continue if fence is not None: continue for match in _INLINE_LINK.finditer(line): destination = _destination(match.group(1)) target = _local_target(destination) if target is None: continue checked += 1 if target.startswith("/"): candidate = pathlib.Path.cwd() / target.lstrip("/") else: candidate = path.parent / target if not candidate.exists(): errors.append(f"{path}:{line_number}: missing local Markdown target: {destination}") return checked, errors def main() -> int: """Run the read-only local Markdown link audit.""" parser = argparse.ArgumentParser() parser.add_argument("paths", nargs="+", help="Markdown files or directories to audit") arguments = parser.parse_args() files, selection_errors = _selected_files(arguments.paths) if selection_errors: for error in selection_errors: print(error, file=sys.stderr) return 2 if not files: print("Markdown link audit: no Markdown files selected", file=sys.stderr) return 2 link_count = 0 errors: list[str] = [] for path in files: current_count, current_errors = _audit_file(path) link_count += current_count errors.extend(current_errors) if errors: for error in errors: print(error, file=sys.stderr) print(f"Markdown link audit: {len(errors)} error(s) across {len(files)} file(s)", file=sys.stderr) return 1 print(f"Markdown link audit: clean ({link_count} local link(s), {len(files)} file(s))") return 0 if __name__ == "__main__": raise SystemExit(main())