#!/usr/bin/env python3 # -*- coding: utf-8 -*- """Findet tote Wikilinks im Vault - ohne die Fehlalarme aus Codebloecken. WARUM ES DIESES WERKZEUG GIBT ----------------------------- `vault_doctor.py` aus dem mitgelieferten Navigator-Toolkit meldete am 2026-09-03 92 tote Wikilinks. Die Stichprobe bestand fast ausschliesslich aus Rauschen: `[[ $EUID -eq 0 ]]` (Bash-Test), `[[:space:]]` (POSIX- Zeichenklasse), `[[bild.png]]` und `[[Ziel]]` (Platzhalter in der Doku des Bookstack-Upload-Skills). Ursache ist `_WIKILINK_RE` in `vaultlib.py` Zeile 61: der Ausdruck laeuft ueber den gesamten Text, das Wort "fence" kommt in der Datei kein einziges Mal vor. Das Navigator-Toolkit ist schreibgeschuetzt und stammt aus einem fremden Skill (siehe AI/Regelwerk/navigation.md). Ein Patch dort waere beim naechsten Update verloren. Deshalb liegt die Korrektur hier daneben. HERKUNFT DER REGELN ------------------- - Wikilink-Syntax: QUELLE - `_WIKILINK_RE`, vaultlib.py Zeile 61. - Zielaufloesung (lookup-Menge und Treffer-Bedingung): QUELLE - woertlich uebernommen aus vault_doctor.py Zeilen 66-74 und 102-107. - Codebloecke und Code-Spans zaehlen nicht: QUELLE - CommonMark; Obsidian rendert dort keine Links. - Frontmatter zaehlt nicht mit: QUELLE - vaultlib.py Zeile 117 trennt es ab. - Ungeschlossener Zaun gilt bis Dateiende: PROJEKTVORGABE - die konservative Wahl, lieber einen echten Link uebersehen als Rauschen erzeugen. - Schliesszaun: QUELLE - dasselbe Zeichen wie der Oeffner, mindestens so lang, danach nur Leerzeichen oder Tab. Abgeschrieben aus markdown-it-py 4.2.0, rules_block/fence.py Zeilen 42-43, 106 und 119-123; Referenz ist CommonMark 0.31.2, Abschnitt 4.5 Fenced code blocks. Fundstellen und der offene Abgleich mit dem Spezifikationstext: ENTWURF-codeblock-erkennung.md. - Einrueckung vor dem Schliesszaun: wie beim Oeffner hoechstens drei Leerraumzeichen, ein Tab eingeschlossen. Die Regel ist bewusst symmetrisch (Entscheidung nach Pruefrunde 2): Eine strengere Schliessregel liess einen in einer Liste mit Tab eingerueckten Block bis zum Dateiende offen, dort schliesst markdown-it-py ihn aber. BEWUSSTE LUECKEN - nicht fuer erledigt halten --------------------------------------------- - Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie wuerden eingerueckte Listenpunkte mit echten Links verschlucken. - Ein Tab vor dem Schliesszaun auf oberster Ebene schliesst hier den Block. markdown-it-py zaehlt den Tab dort als vier Spalten und liest die Zeile als eingerueckten Code (state_block.py Zeilen 79-80 und 257-261). In Listen schliesst er auch dort. Listen-Einrueckung bildet dieses Werkzeug nicht nach. - Ein Oeffner mit Backtick im Info-String gilt hier als Zaun. CommonMark liest so eine Zeile als Inline-Code. Nicht behandelt. - `extract_headings()` im Toolkit hat denselben Fehler: `# Kommentar` aus einem Bash-Block zaehlt dort als Ueberschrift. Hier nicht behandelt. - Dieses Werkzeug ist ein Fangnetz gegen offensichtliche Fehler. Es belegt nicht, dass alle Wikilinks stimmen - nur, dass die gemeldeten nicht aus Codebloecken stammen. Entwurf und Angriffsliste: ENTWURF-codeblock-erkennung.md im selben Ordner. Aufruf: python .vault-tools/linkcheck/linkcheck.py --vault . python .vault-tools/linkcheck/linkcheck.py --vault . --alle """ import argparse import os import re import sys import traceback SKRIPT = os.path.dirname(os.path.abspath(__file__)) TOOLKIT = os.path.join(os.path.dirname(SKRIPT), "obsidian-vault-navigator", "scripts") _FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})") # Schliesszaun: dieselbe Einrueckung wie beim Oeffner (symmetrisch, siehe # Kopfkommentar), danach nur Leerzeichen, Tab und ein CR. Eine Zeile mit # Info-String im Block ist deshalb Inhalt, kein Ende. _SCHLUSS_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})[ \t]*\r?$") _INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1") def strip_code(body): """Ersetzt Codebloecke und Code-Spans durch Leerzeichen. Laenge und Zeilenzahl bleiben erhalten (Ersetzen statt Loeschen), damit nichts ueber eine entfernte Stelle hinweg zusammenwaechst. """ if not body: return "" zaun = None aus = [] for zeile in body.split("\n"): m = _FENCE_RE.match(zeile) if zaun is None: if m: zaun = m.group(1) aus.append(" " * len(zeile)) continue aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile)) else: s = _SCHLUSS_RE.match(zeile) # Gleiches Zeichen und mindestens so lang wie der Oeffner: ein # Dreier-Zaun schliesst keinen Vierer-Zaun. if s and s.group(1)[0] == zaun[0] and len(s.group(1)) >= len(zaun): zaun = None aus.append(" " * len(zeile)) return "\n".join(aus) def lade_toolkit(): """Importiert vaultlib aus dem schreibgeschuetzten Toolkit. Fehlt es, ist das ein BEFUND und kein Grund zu schweigen. """ if TOOLKIT not in sys.path: sys.path.insert(0, TOOLKIT) try: import vaultlib as V return V except ImportError as exc: raise RuntimeError( "vaultlib.py nicht gefunden oder nicht importierbar unter %s (%s). " "Ohne das Toolkit kann diese Pruefung nicht arbeiten." % (TOOLKIT, exc)) def sammle(V, vault): """Notizen und Anhaenge getrennt einsammeln. Anhaenge braucht es, weil `![[Pasted image 123.png]]` ein gueltiger Obsidian-Link auf eine Nicht-Markdown-Datei ist. vault_doctor.py baut seine lookup-Menge nur aus .md-Dateien und meldet solche Einbettungen deshalb als tot. """ notizen, anhaenge = [], set() for ap, rp in V.walk_vault(vault): if not rp.endswith(".md"): anhaenge |= {rp, os.path.basename(rp)} continue with open(ap, "r", encoding="utf-8", errors="replace") as fh: text = fh.read() fm, body = V.parse_frontmatter(text) notizen.append({"abs": ap, "rel": rp, "fm": fm, "body": body}) return notizen, anhaenge def baue_lookup(V, notizen): """Woertlich aus vault_doctor.py Zeilen 66-74.""" lookup = set() for n in notizen: rel = n["rel"] lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel), os.path.splitext(os.path.basename(rel))[0]} if n["fm"].get("title"): lookup.add(str(n["fm"]["title"])) if n["fm"].get("uid"): lookup.add(str(n["fm"]["uid"])) return lookup def wikilinks(V, body): """Wie V.extract_links, aber mit ausgeblendetem Code. Zusaetzlich wird ein abschliessender Backslash entfernt. In Obsidian- Tabellenzellen muss das Pipe eines Alias als `\\|` geschrieben werden, sonst bricht die Tabelle. `_WIKILINK_RE` schneidet am Pipe, laesst den Backslash aber stehen - das Ziel hiesse dann `Pfad\\` und faende nichts. Ein Dateiname kann unter Windows nicht auf Backslash enden, das Abschneiden ist also gefahrlos. """ sauber = strip_code(body) treffer = [] for m in V._WIKILINK_RE.finditer(sauber): t = m.group(1).strip().rstrip("\\").strip() if t and t not in treffer: treffer.append(t) return treffer def lauf(vault, alle): V = lade_toolkit() notizen, anhaenge = sammle(V, vault) if not notizen: raise RuntimeError("Keine Markdown-Dateien unter %s gefunden." % vault) lookup = baue_lookup(V, notizen) kaputt = [] roh_gesamt = 0 for n in notizen: roh_gesamt += len(V.extract_links(n["body"])) for raw in wikilinks(V, n["body"]): t = raw.strip().lstrip("./") # Treffer-Bedingung woertlich aus vault_doctor.py Zeile 104, # erweitert um Anhaenge (Bilder, PDF) - siehe sammle(). if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup: continue if t in anhaenge or os.path.basename(t) in anhaenge: continue kaputt.append((n["rel"], raw)) return notizen, kaputt, roh_gesamt def main(): p = argparse.ArgumentParser(description="Tote Wikilinks ohne Codeblock-Fehlalarme") p.add_argument("--vault", default=".") p.add_argument("--alle", action="store_true", help="alle Befunde statt der ersten 40") args = p.parse_args() vault = os.path.abspath(os.path.expanduser(args.vault)) befunde = [] try: notizen, befunde, roh = lauf(vault, args.alle) except Exception: # Absturz ist selbst ein Befund - und die bis dahin gesammelten # Befunde werden trotzdem ausgegeben. sys.stdout.write("# Linkcheck - ABGEBROCHEN\n\n") sys.stdout.write(traceback.format_exc()) for rel, raw in befunde: sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw)) return 2 sys.stdout.write("# Linkcheck - %d Notizen in %s\n" % (len(notizen), vault)) sys.stdout.write("\nWikilink-Vorkommen ohne Codeblock-Filter: %d\n" % roh) if not befunde: sys.stdout.write("\nKeine toten Wikilinks ausserhalb von Codebloecken.\n") return 0 grenze = len(befunde) if args.alle else 40 sys.stdout.write("\n## Tote Wikilinks (%d)\n" % len(befunde)) for rel, raw in befunde[:grenze]: sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw)) if len(befunde) > grenze: sys.stdout.write(" ... %d weitere (--alle)\n" % (len(befunde) - grenze)) return 1 if __name__ == "__main__": sys.exit(main())