#!/usr/bin/env python3 """Check relative links in the project's tracked markdown documentation. Walks docs/, wiki/ and userspace/cuda/, extracts markdown links, and fails if a relative target does not exist. External links (http/https/mailto/tel/ftp/ data/javascript), pure anchors and links inside fenced code blocks are skipped. The raw imported wiki HTML dumps and the archived wiki copy are excluded: their markup is not ours and their link shape is not representative. Usage: python3 scripts/check-docs-links.py """ import os import re import subprocess import sys from urllib.parse import unquote REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) WATCHED_ROOTS = ("docs", "wiki", "userspace/cuda") EXCLUDED_FILES = { "wiki/GPU_Driver_Internals.md", "wiki/GPU_Firmware.md", "wiki/GPU_SBOM.md", "wiki/Virtual_IO_Internals.md", } EXCLUDED_PREFIXES = ("wiki/open-iov.org/",) SKIPPED_SCHEMES = ( "http://", "https://", "mailto:", "tel:", "ftp://", "ftps://", "data:", "javascript:", ) LINK_RE = re.compile(r"!?\[[^\]]*\]\(\s*([^)]+?)\s*\)") def tracked_markdown_files(): """Return repo-relative paths of tracked markdown files (git first).""" try: out = subprocess.run( ["git", "ls-files", "--", *WATCHED_ROOTS], cwd=REPO_ROOT, check=True, capture_output=True, text=True, ).stdout except (OSError, subprocess.CalledProcessError): out = "" if out.strip(): return [line for line in out.splitlines() if line.endswith(".md")] files = [] for root in WATCHED_ROOTS: for dirpath, _dirnames, filenames in os.walk(os.path.join(REPO_ROOT, root)): for name in filenames: if name.endswith(".md"): full = os.path.join(dirpath, name) files.append(os.path.relpath(full, REPO_ROOT)) return files def excluded(relpath): if relpath in EXCLUDED_FILES: return True return relpath.startswith(EXCLUDED_PREFIXES) def strip_code_blocks(text): """Blank out fenced code blocks so example links are not checked.""" lines = [] fence = None for line in text.splitlines(): stripped = line.lstrip() marker = stripped[:3] if fence is None: if marker in ("```", "~~~"): fence = marker lines.append("") continue lines.append(line) else: if stripped.startswith(fence): fence = None lines.append("") return "\n".join(lines) def normalize_target(raw): target = raw.strip() if target.startswith("<") and target.endswith(">"): target = target[1:-1].strip() if not target: return None if target.startswith("#"): return None if target.lower().startswith(SKIPPED_SCHEMES): return None # Drop an anchor and any markdown title, then percent-decode. target = target.split()[0] if " " in target else target target = target.split("#", 1)[0] target = unquote(target) if not target: return None return target def check_file(relpath): full = os.path.join(REPO_ROOT, relpath) with open(full, "r", encoding="utf-8", errors="replace") as handle: text = strip_code_blocks(handle.read()) problems = [] in_fence = False for lineno, line in enumerate(text.splitlines(), start=1): if line.lstrip()[:3] in ("```", "~~~"): in_fence = not in_fence continue if in_fence: continue for raw in LINK_RE.findall(line): target = normalize_target(raw) if target is None: continue if target.startswith("/"): resolved = os.path.join(REPO_ROOT, target.lstrip("/")) else: resolved = os.path.normpath( os.path.join(REPO_ROOT, os.path.dirname(relpath), target) ) if not os.path.exists(resolved): problems.append((lineno, target, os.path.relpath(resolved, REPO_ROOT))) return problems def main(): files = sorted( relpath for relpath in tracked_markdown_files() if not excluded(relpath) ) failures = 0 for relpath in files: for lineno, target, resolved in check_file(relpath): failures += 1 print(f"{relpath}:{lineno}: broken link: {target} (resolved: {resolved})") if failures: print(f"\n{failures} broken relative link(s) in {len(files)} file(s).") return 1 print(f"OK: {len(files)} markdown file(s) checked, no broken relative links.") return 0 if __name__ == "__main__": sys.exit(main())