Files
dotfiles/scripts/vault-links.py
T
David F GliddenandClaude Opus 5 9bc84a6f16 [FIX] vault-links.py: resolve Obsidian links from the files, with controls
An Obsidian link census is only worth acting on if it can be trusted, and twice
on 2026-08-23 we were one step from acting on numbers that were wrong: a regex
census that read 52 path-form links as broken, and a fuzzy matcher that offered
to rewrite [[2025-05-30]] as [[2025-08-30]] — different days, not aliases.

Implements Obsidian's resolution order (exact path, basename, alias, then
case-insensitive; ambiguous basenames to the shortest path) over the files
themselves. Not the app's index: that is a 43 MB LevelDB cache which, measured
this afternoon, was 78 minutes stale and held ZERO entries for a note created
the same day (control: 29 entries for a note months old). It also carries a
LOCK — freshest while Obsidian runs, readable only while it does not.

NEVER fuzzy-matches. Date- and week-shaped targets get no candidate at all,
with a positive control proving that is a refusal and not a broken function.

The first run was PASS-BUT-FALSELY and the log records it: selftest green at
18/18 while the live vault reported 8,296 dead targets, of which 7,830 were web
paths and app URLs the tool had no business judging. The fixture contained no
web links, so the selftest certified its author's blind spot. Now 23/23 with
two positive controls proving the new filter did not silence real breakage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JQKeKY9T9d95KpvHwwok8T
2026-08-23 20:00:01 +02:00

303 lines
14 KiB
Python
Executable File

#!/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"(?<!!)\[[^\]\n]*\]\(([^)\s#][^)\s]*)\)")
# A markdown link is a VAULT link only if it is a relative path to a .md file.
# Earned 2026-08-23: without this the scanner reported 8,296 dead targets where 466
# were real — 7,830 were web paths (/essays/, /fragments/) and app URLs (drafts://)
# pasted in from ARC. The selftest passed 18/18 throughout, because its fixture had no
# web links. PASS-BUT-FALSELY: the controls below exist so it cannot recur silently.
def is_vault_mdlink(t):
if "://" in t or t.startswith(("/", "#", "mailto:")):
return False
return t.split("#")[0].lower().endswith(".md")
DATEY = re.compile(r"^\d{4}[-.]\d{2}([-.]\d{2})?$") # 2025-05-30, 2026.08, 2025-W-less
WEEKY = re.compile(r"^\d{4}-W\d{1,2}$")
def strip_code(text):
"""Remove fenced blocks and inline spans. A link inside code is documentation, not a link."""
text = FENCE.sub(" ", text)
text = TILDE.sub(" ", text)
return INLINE.sub(" ", text)
def frontmatter(text):
if not text.startswith("---"):
return ""
end = text.find("\n---", 3)
return text[3:end] if end != -1 else ""
def parse_aliases(fm):
"""Obsidian accepts `aliases: [a, b]`, `aliases: a`, and a block list."""
m = re.search(r"^aliases?:(.*)$", fm, re.M)
if not m:
return []
out, inline = [], m.group(1).strip()
if inline.startswith("["):
out += [v.strip().strip("\"'") for v in re.findall(r"[^\[\],]+", inline) if v.strip()]
elif inline:
out.append(inline.strip("\"'"))
else:
for line in fm[m.end():].split("\n")[1:]:
mm = re.match(r"^\s+-\s*(.+?)\s*$", line)
if not mm:
break
out.append(mm.group(1).strip("\"'"))
return [a for a in out if a]
def target_of(raw):
"""[[Target|display]] / [[Target#Heading]] / [[Target#^block]] -> 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})
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 "99. Archive" in str(p):
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)
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())