#!/usr/bin/env python3 """ vault-links.py — resolve Obsidian wikilinks from the FILES, not from Obsidian. Why this exists, and why not the alternatives (measured 2026-08-23): * Obsidian's own index is a 43 MB LevelDB under ~/Library/Application Support/obsidian/. It is a CACHE written by the app: at 18:04 it was 78 minutes stale, and contained ZERO entries for a note created at 16:42 the same day (control: 29 entries for a note that had existed for months, so the search could see one when present). It also carries a LOCK — freshest while Obsidian runs, readable only while it does not. Undocumented and version-dependent besides. * The Local REST API plugin exposes the live index, but only while Obsidian is open, which a SessionStart hook cannot assume. So: implement the RULES, read the FILES. Always current, works with the app shut, version-independent, and — the point — testable. `--selftest` carries positive controls (it must find a break that is really there) and negative controls (it must NOT report one that isn't, and must NOT invent a near-match). THE REFUSAL THAT MATTERS. This tool never fuzzy-matches. On 2026-08-23 a fuzzy matcher proposed rewriting [[2025-05-30]] to [[2025-08-30]] and [[2025-07-24]] to [[2025-05-24]]. Those are different days, not aliases; applying them would have silently falsified the steward's own record. Candidates are ADVISORY, are never emitted for date-shaped targets, and are never applied by this tool. """ import argparse, json, os, re, sys, tempfile, shutil from urllib.parse import unquote from collections import defaultdict from pathlib import Path DEFAULT_VAULT = os.path.expanduser( "~/Library/Mobile Documents/iCloud~md~obsidian/Documents/David, root-and-branch") FENCE = re.compile(r"^```.*?^```", re.S | re.M) TILDE = re.compile(r"^~~~.*?^~~~", re.S | re.M) INLINE = re.compile(r"`[^`\n]*`") WIKI = re.compile(r"!?\[\[([^\]\n]+)\]\]") MDLINK = re.compile(r"(? Target.""" t = raw.split("|", 1)[0] t = t.split("#", 1)[0] return t.strip() class Index: def __init__(self, vault): self.vault = Path(vault) self.by_relpath, self.by_name, self.by_alias = {}, defaultdict(list), {} for p in self.vault.rglob("*"): if not p.is_file() or "/.obsidian/" in str(p) or p.name.startswith("."): continue rel = p.relative_to(self.vault).as_posix() self.by_relpath[rel] = p if p.suffix == ".md": self.by_relpath[rel[:-3]] = p self.by_name[p.stem].append(p) for a in parse_aliases(frontmatter(p.read_text(errors="replace"))): self.by_alias.setdefault(a, p) else: self.by_name[p.name].append(p) self.lower_relpath = {k.lower(): v for k, v in self.by_relpath.items()} self.lower_name = {k.lower(): v for k, v in self.by_name.items()} self.lower_alias = {k.lower(): v for k, v in self.by_alias.items()} def resolve(self, target): """Obsidian's order: exact path -> exact basename -> alias -> case-insensitive. Ambiguous basenames take the shortest path, as Obsidian does. Never fuzzy.""" if not target or target.startswith(("http://", "https://", "mailto:")): return ("external", None, "") t = target.strip().lstrip("./") if t in self.by_relpath: return ("ok", self.by_relpath[t], "path") hits = self.by_name.get(t) or self.by_name.get(t + ".md") if hits: best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) return ("ok", best, "name" + (" [ambiguous, shortest path]" if len(hits) > 1 else "")) if t in self.by_alias: return ("ok", self.by_alias[t], "alias") base = t.split("/")[-1] hits = self.by_name.get(base) if hits: best = min(hits, key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) return ("ok", best, "basename-of-path") low = t.lower() for table, kind in ((self.lower_relpath, "path"), (self.lower_alias, "alias")): if low in table: v = table[low] return ("case", v if isinstance(v, Path) else v, f"case-insensitive {kind}") if low in self.lower_name: best = min(self.lower_name[low], key=lambda p: (len(p.relative_to(self.vault).parts), len(str(p)))) return ("case", best, "case-insensitive name") return ("dead", None, "") def candidates(self, target): """ADVISORY ONLY. Never for date-shaped targets — see the module docstring.""" base = target.split("/")[-1] if DATEY.match(base) or WEEKY.match(base): return [] pref = re.compile(r"^\d{1,2}[a-z]?\.\s+") return sorted({n for n in self.by_name if pref.match(n) and pref.sub("", n) == base}) ARCHIVE_DIR = "99. Archive" def in_archive(path, vault): """Exact path-COMPONENT match, never a substring. Earned 2026-08-23, hours after this file was written to avoid exactly this class: `"99. Archive" in str(p)` also matches `99. Archives—Previous Iterations`, an ARC folder inside 00. Compass. 78 live notes were silently excluded from every "live vault" figure of the day (1153 counted as archive; the real archive holds 1070). A substring is not a path.""" return ARCHIVE_DIR in path.relative_to(vault).parts def scan(vault, include_archive=False, once_per_note=True): idx = Index(vault) dead, case_only, total = defaultdict(set), defaultdict(set), 0 for p in sorted(idx.vault.rglob("*.md")): if "/.obsidian/" in str(p): continue if not include_archive and in_archive(p, idx.vault): continue text = strip_code(p.read_text(errors="replace")) raws = [target_of(m) for m in WIKI.findall(text)] raws += [unquote(t) for t in MDLINK.findall(text) if is_vault_mdlink(t)] seen = set() for t in raws: if once_per_note: if t in seen: continue seen.add(t) total += 1 st, _, _ = idx.resolve(t) if st == "dead": dead[t].add(p.stem) elif st == "case": case_only[t].add(p.stem) return idx, dead, case_only, total def chk(label, cond, state): state["ok" if cond else "fail"] += 1 print(f" {'PASS' if cond else 'FAIL'} {label}") def selftest(): state = {"ok": 0, "fail": 0} d = tempfile.mkdtemp() try: w = lambda rel, body: (Path(d, rel).parent.mkdir(parents=True, exist_ok=True), Path(d, rel).write_text(body, encoding="utf-8")) w("Alpha.md", "---\naliases:\n - Al\n - \"Alpha Prime\"\n---\nbody\n") w("sub/Beta.md", "plain\n") w("sub/deep/Beta.md", "duplicate basename\n") w("2025-08-30.md", "a real day\n") w("00. Numbered Thing.md", "prefixed\n") w("img.png", "") w("Source.md", "[[Alpha]] [[Al]] [[Alpha Prime]] [[sub/Beta]] [[Alpha|shown]] [[Alpha#Head]]\n" "[[Alpha#^blk]] ![[img.png]] [[alpha]] [[Nonexistent Note]] [[2025-05-30]]\n" "`[[InlineCode]]` \n```\n[[FencedCode]]\n```\n[md](sub/Beta.md)\n" "[web](/essays/) [app](drafts://open?uuid=X) [ext](https://a.b/c)\n" "[enc](sub/Beta.md) [gone](sub/Missing.md)\n") idx = Index(d) R = lambda t: idx.resolve(t)[0] chk("exact filename resolves", R("Alpha") == "ok", state) chk("alias resolves (block list)", R("Al") == "ok", state) chk("quoted alias resolves", R("Alpha Prime") == "ok", state) chk("path-form link resolves [the 52 false positives of 2026-08-23]", R("sub/Beta") == "ok", state) chk("display text stripped", target_of("Alpha|shown") == "Alpha", state) chk("heading anchor stripped", target_of("Alpha#Head") == "Alpha", state) chk("block ref stripped", target_of("Alpha#^blk") == "Alpha", state) chk("non-markdown attachment resolves", R("img.png") == "ok", state) chk("ambiguous basename resolves to the shortest path [Obsidian's rule]", idx.resolve("Beta")[1] == Path(d, "sub/Beta.md"), state) chk("case-mismatch is reported as 'case', not silently ok and not dead", R("alpha") == "case", state) # --- negative controls: it must NOT report what is not there --- chk("a genuinely missing note IS reported dead [POSITIVE CONTROL — without this," " every 'dead' count below proves nothing]", R("Nonexistent Note") == "dead", state) _, dead, _, _ = scan(d) chk("link inside an inline code span is IGNORED [negative control]", "InlineCode" not in dead, state) chk("link inside a fenced block is IGNORED [negative control]", "FencedCode" not in dead, state) chk("a markdown-style link to a .md file IS resolved", "sub/Beta.md" not in dead, state) chk("an absolute web path is NOT a vault link [/essays/ — 7,830 false positives" " on 2026-08-23]", not is_vault_mdlink("/essays/"), state) chk("an app URL is NOT a vault link [drafts://]", not is_vault_mdlink("drafts://open?uuid=X"), state) chk("an external http link is NOT a vault link", not is_vault_mdlink("https://a.b/c"), state) chk("a relative .md link IS a vault link [POSITIVE CONTROL — proves the three" " refusals above are a filter, not a blanket rejection]", is_vault_mdlink("sub/Beta.md"), state) chk("a BROKEN relative .md link is still caught [POSITIVE CONTROL — the filter" " must not have silenced real markdown-link breakage]", "sub/Missing.md" in dead, state) # --- THE refusal. This is why the tool exists in this form. --- chk("a date-shaped dead link gets NO candidate [2025-05-30 must never be offered" " 2025-08-30 — different days, not aliases]", idx.candidates("2025-05-30") == [], state) chk("candidate machinery still works for a real prefix case [POSITIVE CONTROL —" " proves the empty result above is a refusal, not a broken function]", idx.candidates("Numbered Thing") == ["00. Numbered Thing"], state) chk("a week-shaped target also gets no candidate", idx.candidates("2026-W30") == [], state) # --- the clean-vault control --- d2 = tempfile.mkdtemp() Path(d2, "Only.md").write_text("[[Only]]\n") _, dead2, case2, tot2 = scan(d2) chk("a vault with no broken links reports ZERO [negative control — proves the" " scanner is not manufacturing findings]", len(dead2) == 0 and tot2 == 1, state) shutil.rmtree(d2, ignore_errors=True) # --- archive exclusion must match a path COMPONENT, not a substring --- d3 = Path(tempfile.mkdtemp()) (d3 / "99. Archive").mkdir(); (d3 / "99. Archives—Previous Iterations").mkdir() (d3 / "99. Archive" / "Old.md").write_text("x\n") (d3 / "99. Archives—Previous Iterations" / "Live.md").write_text("x\n") (d3 / "Plain.md").write_text("x\n") chk("a note in '99. Archive/' IS excluded [positive control]", in_archive(d3 / "99. Archive" / "Old.md", d3), state) chk("a note in '99. Archives—Previous Iterations/' is NOT excluded [the 78-note" " over-match of 2026-08-23 — a substring is not a path]", not in_archive(d3 / "99. Archives—Previous Iterations" / "Live.md", d3), state) chk("a note at the vault root is NOT excluded", not in_archive(d3 / "Plain.md", d3), state) shutil.rmtree(d3, ignore_errors=True) finally: shutil.rmtree(d, ignore_errors=True) print(f"\n{'SELFTEST PASS' if not state['fail'] else 'SELFTEST FAIL'}" f" — {state['ok']} ok, {state['fail']} failed") return 0 if not state["fail"] else 1 def main(): ap = argparse.ArgumentParser(description="Resolve Obsidian wikilinks from the files.") ap.add_argument("--vault", default=DEFAULT_VAULT) ap.add_argument("--selftest", action="store_true") ap.add_argument("--include-archive", action="store_true") ap.add_argument("--json", action="store_true") ap.add_argument("--candidates", action="store_true", help="show ADVISORY repair candidates (never dates; never applied)") a = ap.parse_args() if a.selftest: return selftest() if not os.path.isdir(a.vault): print(f"STOP: no vault at {a.vault}", file=sys.stderr) return 2 idx, dead, case_only, total = scan(a.vault, a.include_archive) if a.json: print(json.dumps({"total_links": total, "dead": {k: sorted(v) for k, v in dead.items()}, "case_only": {k: sorted(v) for k, v in case_only.items()}}, indent=1)) return 0 scope = "whole vault" if a.include_archive else "live vault (99. Archive excluded)" print(f"vault: {a.vault}\nscope: {scope} links checked: {total} " f"(once per note per target)\n") print(f"UNRESOLVED : {len(dead)} targets, {sum(len(v) for v in dead.values())} references") print(f"CASE-ONLY : {len(case_only)} targets [resolve in Obsidian; a rename would break them]") for t, srcs in sorted(dead.items(), key=lambda kv: -len(kv[1]))[:25]: line = f" {len(srcs):3d} {t}" if a.candidates: c = idx.candidates(t) if c: line += f"\n advisory candidate: {c[0]} [NOT applied]" print(line) if len(dead) > 25: print(f" … {len(dead)-25} more (use --json for all)") print("\nNo fuzzy matching is performed. Candidates are advisory and never date-shaped.") return 0 if __name__ == "__main__": sys.exit(main())