Files
Pascal MichelandClaude Opus 5.5 fa1c26b407 Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:27:59 +02:00

237 lines
9.4 KiB
Python

#!/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())