"""Wiki link graph checks for hoard-lint: orphan pages, dead links (forward and reverse), ambiguous link targets, and the minimum-link requirement for newly added wiki pages. Usage: python wiki_link_check.py (run from repo root) Scope: this commit's changes to wiki/ -- `git diff HEAD -M` (staged + unstaged) plus untracked new files under wiki/, restricted to that subtree. Renames are split into a synthetic delete (old path) + add (new path) pair so downstream logic only ever sees A/M/D; the add-half keeps "renamed_from" so minlink can skip it (a rename isn't newly-created content). The link graph is built from the current working-tree content of every wiki/**/*.md file. Checks that reason about pre-commit state (orphan, deadlink_reverse) resolve against a separate resolver built from the HEAD tree's page set, not the current one -- resolving a removed link against today's (already-edited) graph would misattribute what it used to point to. Self-links never count as incoming for orphan purposes. wiki/log.md and wiki/log-archive.md are exempt from deadlink checks in both directions (append-only history, not meant to be link-clean). Wikilink parsing: `[[target]]`, `[[target|alias]]`, `[[target#section]]`, `[[target#section|alias]]`; embeds (`![[...]]`), self-section links (`[[#section]]`), and anything inside fenced or inline code is excluded. Target resolution tries, in order, and stops at the first tier with >=1 candidate: 1. exact normalized path (relative to wiki/, no extension) 2. path relative to the linking file's own directory 3. unique path-suffix match (matches the tail components of exactly one page) 4. unique basename match (matches the filename of exactly one page) A tier with more than one candidate is ambiguous, not a fall-through to the next tier. Targets under raw/ or the two whitelisted chaos/ capture files are treated as valid (existence-checked on disk) but are not part of the wiki graph. Prints one JSON object to stdout: {"orphan": [...], "deadlink_forward": [...], "deadlink_reverse": [...], "ambiguous": [...], "minlink": [...]} Each list is empty when that check is clean. Exit codes: 0 = all clean, 1 = at least one finding, 2 = the script itself failed (traceback on stderr, no JSON printed). """ import json import posixpath import re import subprocess import sys import traceback from pathlib import Path WIKI_ROOT_EXEMPT = {"index", "log", "log-archive"} # orphan incoming-link exemption DEADLINK_EXEMPT = {"log", "log-archive"} # forward/reverse deadlink exemption, neither direction CHAOS_WHITELIST = {"chaos/白板", "chaos/想法"} LINK_RE = re.compile(r"(? str: # core.quotepath=false keeps non-ASCII paths as raw UTF-8 instead of # octal-escaped and quoted, which would never match the on-disk names. result = subprocess.run( ["git", "-c", "core.quotepath=false", *args], capture_output=True, text=True, encoding="utf-8", check=True, ) return result.stdout def repo_root() -> Path: return Path(run_git("rev-parse", "--show-toplevel").strip()) def strip_inline_code(line: str) -> str: """Blank CommonMark-style backtick-run code spans (`` `x` ``, ` ``x`` `, ...): a run of N backticks opens, only a run of exactly N backticks closes it. Unmatched runs are left as literal text.""" out = [] i, n = 0, len(line) while i < n: if line[i] != "`": out.append(line[i]) i += 1 continue j = i while j < n and line[j] == "`": j += 1 run_len = j - i k = j close_start = close_end = -1 while k < n: if line[k] == "`": k2 = k while k2 < n and line[k2] == "`": k2 += 1 if k2 - k == run_len: close_start, close_end = k, k2 break k = k2 else: k += 1 if close_start == -1: out.append(line[i:j]) i = j else: out.append(" " * (close_end - i)) i = close_end return "".join(out) def strip_code(text: str) -> str: """Blank out fenced/inline code so link regex can't match inside it, without changing line count or offsets. Fenced blocks track the opening marker's character and length; a closing fence must use the same character, be at least as long, and contain nothing else.""" lines = text.split("\n") out = [] fence_char = None fence_len = 0 for line in lines: stripped = line.strip() if fence_char is None: m = FENCE_RE.match(stripped) if m: fence_char = m.group(1)[0] fence_len = len(m.group(1)) out.append("") continue out.append(strip_inline_code(line)) else: if stripped and set(stripped) == {fence_char} and len(stripped) >= fence_len: fence_char = None fence_len = 0 out.append("") return "\n".join(out) def normalize_target(raw_target: str) -> str: link_part = raw_target.split("|", 1)[0] target = link_part.split("#", 1)[0].strip() if not target: return "" # self-section link [[#...]] if target.endswith(".md"): target = target[:-3] if target.startswith("wiki/"): target = target[len("wiki/"):] return target.lstrip("/") def extract_links(content: str): """Yield (line_no, target, raw_bracket_text) for each page-type wikilink.""" cleaned = strip_code(content) for i, line in enumerate(cleaned.split("\n"), start=1): for m in LINK_RE.finditer(line): raw = m.group(1) target = normalize_target(raw) if target: yield i, target, raw def changed_wiki_files(): """Return list of {"status": "A"|"M"|"D", "path": repo-root-relative}. A rename's new-path entry additionally carries "renamed_from": old_path.""" entries = [] diff_out = run_git("diff", "HEAD", "-M", "--name-status", "--", "wiki/") for line in diff_out.splitlines(): if not line.strip(): continue parts = line.split("\t") status = parts[0] if status.startswith("R"): old_path, new_path = parts[1], parts[2] entries.append({"status": "D", "path": old_path}) entries.append({"status": "A", "path": new_path, "renamed_from": old_path}) elif status in ("A", "M", "D"): entries.append({"status": status, "path": parts[1]}) # other statuses (T, C, U, X) shouldn't occur for plain markdown pages untracked = run_git("ls-files", "--others", "--exclude-standard", "--", "wiki/") for line in untracked.splitlines(): if line.strip(): entries.append({"status": "A", "path": line.strip()}) return entries def head_wiki_paths() -> set: """relpaths (relative to wiki/, no ext) that existed at HEAD.""" out = run_git("ls-tree", "-r", "--name-only", "HEAD", "--", "wiki/") paths = set() for line in out.splitlines(): line = line.strip() if line.startswith("wiki/") and line.endswith(".md"): paths.add(line[len("wiki/"):-3]) return paths def show_head(path: str) -> str: try: return run_git("show", f"HEAD:{path}") except subprocess.CalledProcessError: return "" def current_pages(root: Path) -> dict: """relpath (relative to wiki/, no ext, posix) -> absolute Path.""" pages = {} for p in (root / "wiki").rglob("*.md"): rel = p.relative_to(root / "wiki").as_posix()[:-3] pages[rel] = p return pages def rel_of(repo_path: str) -> str: p = repo_path[len("wiki/"):] if repo_path.startswith("wiki/") else repo_path if p.endswith(".md"): p = p[:-3] return p class Resolver: def __init__(self, pages: dict): self.pages = pages self.parts = {rel: rel.split("/") for rel in pages} def resolve(self, target: str, source_rel: str): """Return (status, result): status in {"resolved", "ambiguous", "unresolved"}; result is a relpath for "resolved", a list of relpaths for "ambiguous", None otherwise.""" target = posixpath.normpath(target) if target in self.pages: return "resolved", target source_dir = posixpath.dirname(source_rel) candidate = posixpath.normpath(posixpath.join(source_dir, target)) if candidate in self.pages: return "resolved", candidate target_parts = target.split("/") suffix_matches = [ rel for rel, parts in self.parts.items() if len(parts) >= len(target_parts) and parts[-len(target_parts):] == target_parts ] if len(suffix_matches) == 1: return "resolved", suffix_matches[0] if len(suffix_matches) > 1: return "ambiguous", suffix_matches basename_matches = [ rel for rel, parts in self.parts.items() if parts[-1] == target_parts[-1] ] if len(basename_matches) == 1: return "resolved", basename_matches[0] if len(basename_matches) > 1: return "ambiguous", basename_matches return "unresolved", None _raw_stems = None def raw_stems(root: Path) -> set: """Filenames (no extension) of everything under raw/, built once.""" global _raw_stems if _raw_stems is None: _raw_stems = {p.stem for p in (root / "raw").rglob("*") if p.is_file()} return _raw_stems def is_valid_external(target: str, root: Path) -> bool: if target in CHAOS_WHITELIST: return (root / f"{target}.md").exists() if target.startswith("raw/"): if (root / target).exists(): return True if (root / f"{target}.md").exists(): return True return False # Bare-basename link into raw/ -- what wiki pages actually write, and what # Obsidian resolves against the whole vault. A wiki page deleted in an # earlier commit whose name collides with a raw file would slip through # here, but deadlink_reverse already reports those at deletion time. return target.split("/")[-1] in raw_stems(root) def wiki_path(rel: str) -> str: return f"wiki/{rel}.md" def main() -> int: # Windows consoles often default stdout/stderr to a non-UTF-8 codepage # (e.g. cp950); force UTF-8 so JSON with Chinese text round-trips cleanly. sys.stdout.reconfigure(encoding="utf-8") sys.stderr.reconfigure(encoding="utf-8") root = repo_root() pages = current_pages(root) resolver = Resolver(pages) changed = changed_wiki_files() head_paths = head_wiki_paths() head_resolver = Resolver({rel: None for rel in head_paths}) changed_md = [e for e in changed if e["path"].endswith(".md")] changed_by_rel = {rel_of(e["path"]): e["status"] for e in changed_md} # single extraction pass over every current page's current content all_links = {} # rel -> list[(line_no, target, raw)] for rel, path in pages.items(): all_links[rel] = list(extract_links(path.read_text(encoding="utf-8"))) # forward resolution: incoming index (all pages) + forward/ambiguous # findings (changed A/M pages only, excluding the deadlink-exempt pages) incoming = {rel: [] for rel in pages} deadlink_forward = [] ambiguous = [] for rel, links in all_links.items(): is_changed_am = changed_by_rel.get(rel) in ("A", "M") and rel not in DEADLINK_EXEMPT for line_no, target, raw in links: status, result = resolver.resolve(target, rel) if status == "resolved": if result != rel: # self-links don't count as incoming incoming[result].append((rel, line_no)) elif status == "ambiguous": if is_changed_am: ambiguous.append({ "file": wiki_path(rel), "line": line_no, "link": f"[[{raw}]]", "matches": [wiki_path(m) for m in result], }) else: if is_changed_am and not is_valid_external(target, root): deadlink_forward.append({ "file": wiki_path(rel), "line": line_no, "link": f"[[{raw}]]", }) # deadlink_reverse: pages deleted/renamed-away this commit, still # referenced (per how the link resolved at HEAD, not by name-string # comparison) by pages that were NOT touched this commit (touched ones # are already covered above, since the old target no longer resolves) deadlink_reverse = [] deleted = [e for e in changed_md if e["status"] == "D"] for e in deleted: old_rel = rel_of(e["path"]) for rel, links in all_links.items(): if rel in changed_by_rel or rel in DEADLINK_EXEMPT: continue for line_no, target, raw in links: status, result = head_resolver.resolve(target, rel) if status == "resolved" and result == old_rel: deadlink_reverse.append({ "file": wiki_path(rel), "line": line_no, "link": f"[[{raw}]]", "stale_link_to": wiki_path(old_rel), }) # orphan: pages that lost their last incoming link this commit. The # removed link is resolved against the HEAD-state graph (what it # actually pointed to before this commit), not the current graph, which # may have been reshuffled by this same commit's other changes. orphan = [] seen_orphans = set() for e in changed_md: if e["status"] not in ("M", "D"): continue before_content = show_head(e["path"]) before_links = list(extract_links(before_content)) source_rel = rel_of(e["path"]) after_targets = {t for _, t, _ in all_links.get(source_rel, [])} if e["status"] == "M" else set() for line_no, target, raw in before_links: if target in after_targets: continue # link still present, not removed status, result = head_resolver.resolve(target, source_rel) if status != "resolved": continue if result not in pages: continue # target itself no longer exists; not an orphan case if result in WIKI_ROOT_EXEMPT or result in seen_orphans: continue others = [src for src, _ in incoming.get(result, []) if src != result] if others: continue # still has at least one incoming link from another page seen_orphans.add(result) orphan.append({ "page": wiki_path(result), "removed_from": e["path"], "removed_at_line": line_no, }) # minlink: newly added pages need >=1 pre-existing outgoing + incoming # link. A page added purely via `git mv` (renamed_from set) already had # whatever links it had before the rename; it isn't newly-created content. minlink = [] for e in changed_md: if e["status"] != "A" or e.get("renamed_from"): continue rel = rel_of(e["path"]) if rel not in pages: continue # shouldn't happen, but don't crash on it outgoing_ok = False for _, target, _ in all_links.get(rel, []): status2, target2 = resolver.resolve(target, rel) if status2 == "resolved" and target2 in head_paths: outgoing_ok = True break incoming_ok = any(src in head_paths for src, _ in incoming.get(rel, [])) if outgoing_ok and incoming_ok: continue if not outgoing_ok and not incoming_ok: missing = "both" elif not outgoing_ok: missing = "outgoing" else: missing = "incoming" minlink.append({"file": e["path"], "missing": missing}) findings = { "orphan": orphan, "deadlink_forward": deadlink_forward, "deadlink_reverse": deadlink_reverse, "ambiguous": ambiguous, "minlink": minlink, } print(json.dumps(findings, ensure_ascii=False, indent=2)) return 1 if any(findings.values()) else 0 if __name__ == "__main__": try: sys.exit(main()) except Exception: traceback.print_exc() sys.exit(2)