#!/usr/bin/env python3 """Report unresolved links in the docs vault (serverAppConfigs-docs). The vault is the project's source of truth, so a link to a page that does not exist is a promise the repo cannot keep. This models **Obsidian's** resolution rules rather than a naive one — three separate false-positive classes were found while writing it, and a linter that cries wolf is worse than no linter: 1. Wikilinks are **case-insensitive** (`[[qBittorrent]]` -> `qbittorrent.md`). A case-sensitive matcher reported 45 breaks; 39 were this. 2. A path-qualified wikilink (`[[folder/Note]]`) resolves by **path suffix**, not by stem, and must not be stem-matched. Treating the whole string as a stem reported the PP pages as missing when they exist one directory down. 3. `%20` in a markdown link is URL-encoding. Dealing in raw text reported `Network%20Architecture.md` as missing beside the real file. Exit status is 1 when any link is unresolved, so it gates a test. """ import pathlib import re import sys import urllib.parse WIKI = re.compile(r"\[\[([^\]\|#]+)(?:#[^\]\|]*)?(?:\|[^\]]*)?\]\]") MD = re.compile(r"(? str: return path[:-3] if path.endswith(".md") else path def build_index(vault: pathlib.Path): pages = sorted(vault.rglob("*.md")) stems: dict[str, list[pathlib.Path]] = {} for page in pages: stems.setdefault(page.stem.casefold(), []).append(page) relative = [strip_md(str(p.relative_to(vault)).casefold()) for p in pages] return pages, stems, relative def wiki_resolves(target: str, stems, relative) -> bool: want = target.strip().rstrip("\\").casefold() if "/" in want: return any(r == want or r.endswith("/" + want) for r in relative) return want in stems def md_resolves(base: pathlib.Path, target: str) -> bool: text = urllib.parse.unquote(target.strip()) if text.startswith(EXTERNAL): return True text = text.split("#")[0].strip() return not text or (base / text).exists() def main(argv: list[str]) -> int: vault = pathlib.Path(argv[1] if len(argv) > 1 else "serverAppConfigs-docs") pages, stems, relative = build_index(vault) broken: list[str] = [] for page in pages: text = page.read_text(errors="replace") for target in WIKI.findall(text): if not wiki_resolves(target, stems, relative): broken.append(f"[wiki] {page.relative_to(vault)} -> {target.strip()}") for target in MD.findall(text): if not md_resolves(page.parent, target): broken.append(f"[md] {page.relative_to(vault)} -> {target.strip()}") print(f"{len(pages)} pages, {len(broken)} unresolved links") for line in broken: print(" " + line) return 1 if broken else 0 if __name__ == "__main__": sys.exit(main(sys.argv))