Files
dotfiles/scripts/vault-links.py
T
David F GliddenandClaude Opus 5 e3da9abad1 session 2026-08-23: the vault got instruments, a convention, and a name for what it lacks
Fool: FOOL-SEED-RULE.md §2a corrected two days before the beacon — the heading
claimed a ruling scope the recorded ruling could not reach (d6377af, pushed).

Obsidian: the paper bridge tested and working, first notebook transcription
filed under its own entry date; vault-links.py built and twice caught failing
while its selftest read green; Paradigm + Marshall read in full; the Archive
Convention and Re-encounter placed in the vault; the vault's CLAUDE.md
rewritten after fifteen months stale.

Files: session record + notebook-provenance reference memory + 6 KG lines
(3 drift-patterns, 2 preventions, 1 measured-state); MEMORY.md rotated with
the prior Active Session demoted verbatim to MEMORY-reference.md;
scripts/vault-links.py archive-path fix (substring -> path component, 3 new
controls, 26/26).

No new PENDING items — today's vault work is steward-direct and touches no
governed artifact.

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

330 lines
16 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})
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())