Files
claude-regelwerk/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-01-zaehler.md
T
Pascal MichelandClaude Opus 5 eb48f8822d Automatischer Push: 45 Datei(en) geändert
- .claude/agents/planer.md
- .claude/agents/quorum-stimme.md
- .claude/skills/bookstack-upload/SKILL.md
- .claude/skills/bookstack-upload/scripts/bookstack/archiv.py
- .claude/skills/bookstack-upload/scripts/bookstack/auswahl.py
- .claude/skills/bookstack-upload/scripts/bookstack/beschreibung.py
- .claude/skills/bookstack-upload/scripts/bookstack/bilder.py
- .claude/skills/bookstack-upload/scripts/bookstack/ergebnis.py
- .claude/skills/bookstack-upload/scripts/bookstack/hochladen.py
- .claude/skills/bookstack-upload/scripts/bookstack/konvertierung.py
- .claude/skills/bookstack-upload/scripts/bookstack/struktur.py
- .claude/skills/bookstack-upload/scripts/bookstack/verweise.py
- … und 33 weitere

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 04:00:02 +02:00

1234 lines
56 KiB
Markdown

---
type: plan-task
title: "Task 01 — Zähler waisen.py mit Ausnahmedatei, Treiber und Entwurf"
status: draft
summary: "Legt waisen.py, waisen-ausnahmen.txt, faelle_waisen.py und ENTWURF-waisen.md an; der Treiber fährt die Angriffsliste, einen Rotlauf mit eingebauten Mutanten und eine Laufzeitmessung über den echten Vault."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-30
modell: haiku
modell_begruendung: "Vier neue Dateien stehen wörtlich im Plan und werden per Skript angelegt; die Codeblöcke enthalten keine typografischen Anführungszeichen, Gedankenstriche oder Auslassungspunkte."
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 01 — Zähler waisen.py
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“.
## Zieldateien
- `D:\Claude\.vault-tools\linkcheck\waisen.py` (neu)
- `D:\Claude\.vault-tools\linkcheck\waisen-ausnahmen.txt` (neu)
- `D:\Claude\.vault-tools\linkcheck\faelle_waisen.py` (neu)
- `D:\Claude\.vault-tools\linkcheck\ENTWURF-waisen.md` (neu)
## Verbote
- `linkcheck.py`, `faelle.py` und `ENTWURF-codeblock-erkennung.md` bleiben unverändert. `strip_code()` wird importiert, nicht kopiert.
- Nichts im Navigator-Toolkit unter `.vault-tools/obsidian-vault-navigator/`.
- Keine Datei im echten Vault außerhalb der Zieldateien anlegen oder ändern. Der Treiber arbeitet in Wegwerfordnern unter dem Temp-Verzeichnis.
- Keine Kunden-, Domain- oder Projektnamen in einer Zieldatei.
## Schritt 1 — Blöcke anwenden
```
python D:/Claude/.vault-tools/plan-kopie/plan_materialisiere.py D:/Claude/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen D:/Claude task-01-zaehler.md
```
Erwartet: eine Zeile mit `'neu': 4` und die Schlusszeile `0 Befund(e).`
### `.vault-tools/linkcheck/waisen.py`
Die ganze Datei ist neu:
````python
"""Zählt die Waisen im Obsidian-Graphen des Vaults, ohne die Ausnahmen.
Ersteller: Pascal Michel
Erstellt: 2026-09-29
Entwurf: ENTWURF-waisen.md im selben Ordner (Entscheidungsliste, Herkunft, Angriffsliste)
GELTUNGSBEREICH
Eine Waise ist eine Notiz ohne Kante: keine ausgehende Verknüpfung auf eine
andere Notiz und keine eingehende. Notiz ist jede Datei mit Endung .md
außerhalb von Punktordnern, die selbst keine Punktdatei ist und die kein
Eintrag aus userIgnoreFilters in .obsidian/app.json ausblendet. Kanten
entstehen aus Wikilinks und Einbettungen im Text und im Frontmatter und aus
Markdown-Links im Text, sofern das Ziel eine Notiz ist. Code (Zäune und
Code-Spans) blendet strip_code() aus linkcheck.py aus. Notizen, auf die ein
Muster aus waisen-ausnahmen.txt passt, zählen nicht als Waisen, bleiben aber
Knoten und liefern Kanten.
BENANNTE LÜCKEN
* Die Auflösung eines Linkziels ist nachgebaut, nicht aus Obsidian
abgeschrieben: erst der Pfad ab der Wurzel, dann relativ zur Quelle, dann
über den Dateinamen; bei mehreren gleichnamigen Notizen der gleiche Ordner
wie die Quelle, sonst der kürzeste Pfad. Die Zahl kann deshalb um einzelne
Dateien vom Graphen abweichen.
* Dass Obsidian relative und URL-kodierte Markdown-Links, Markdown-Links
ohne .md und Wikilinks im Frontmatter im Graphen zählt, ist nicht aus einer
Quelle belegt, sondern in der App zu prüfen (Plan 2026-09-29-waisen,
Prüfschritte P1 bis P3).
* userIgnoreFilters werden mit Python-re ausgewertet, nicht mit JavaScript.
Ein Ordner wird übersprungen, wenn ein Filter auf seinen Pfad mit
abschließendem Schrägstrich passt. Für einen Ausdruck mit Endanker ist das
nicht dasselbe wie die Prüfung je Datei.
* Nicht als Link erkannt: Referenz-Links ([Text][Marke]), HTML-Links, Ziele
mit Klammern, Windows-Pfade mit Laufwerk.
* Kommentare (%% und <!-- -->) werden nicht ausgeblendet.
* Codeblöcke erkennt strip_code() mit dessen Lücken, siehe
ENTWURF-codeblock-erkennung.md.
DIESES SKRIPT IST EIN FANGNETZ, KEIN NACHWEIS. Wie der Graph aussieht, zeigt
nur Obsidian selbst.
Exit 0: keine Waise. Exit 1: mindestens eine Waise. Exit 2: abgebrochen, es
gilt keine Zahl; die Ursache steht in der Ausgabe.
Aufruf:
python .vault-tools/linkcheck/waisen.py --vault .
python .vault-tools/linkcheck/waisen.py --vault . --liste
python .vault-tools/linkcheck/waisen.py --vault . --zahl
"""
import argparse
import json
import os
import posixpath
import re
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from urllib.parse import unquote
HIER = Path(__file__).resolve().parent
AUSNAHMEN = HIER / "waisen-ausnahmen.txt"
ZEILE = "Waisen außerhalb der Ausnahmen: "
# Wie _WIKILINK_RE in vaultlib.py Zeile 61: das Ziel endet vor |, #, ^ und ].
_WIKILINK = re.compile(r"\[\[([^\]\|#\^]+)")
# ](ziel) oder ](<ziel mit leerzeichen>), optional mit Titel in Anfuehrungszeichen.
_MDLINK = re.compile(r"\]\(\s*(<[^>\n]*>|[^\s)]+)(?:\s+(?:\"[^\"\n]*\"|'[^'\n]*'))?\s*\)")
# https:, mailto:, obsidian:, aber auch ein Laufwerk wie D:
_SCHEMA = re.compile(r"^[A-Za-z][A-Za-z0-9+.\-]*:")
class Abbruch(Exception):
"""Eine Voraussetzung fehlt oder ist kaputt; eine Zahl gälte nicht."""
@dataclass
class Muster:
text: str
regex: re.Pattern
@dataclass
class Index:
pfade: dict = field(default_factory=dict)
namen: dict = field(default_factory=dict)
@dataclass
class Ergebnis:
notizen: list
waisen: list
ausgenommen: list
ausgehend: dict
muster: list
ohne_treffer: list
filter_anzahl: int
def lade_strip_code():
if str(HIER) not in sys.path:
sys.path.insert(0, str(HIER))
try:
from linkcheck import strip_code
except Exception as fehler:
raise Abbruch(
f"linkcheck.py nicht importierbar unter {HIER} ({type(fehler).__name__}: {fehler})"
) from fehler
return strip_code
def glob_regex(muster: str) -> re.Pattern:
teile = []
i = 0
while i < len(muster):
if muster.startswith("**/", i):
teile.append("(?:[^/]*/)*")
i += 3
elif muster.startswith("**", i):
teile.append(".*")
i += 2
elif muster[i] == "*":
teile.append("[^/]*")
i += 1
elif muster[i] == "?":
teile.append("[^/]")
i += 1
else:
teile.append(re.escape(muster[i]))
i += 1
return re.compile("".join(teile) + r"\Z", re.IGNORECASE | re.DOTALL)
def lade_ausnahmen(datei: Path) -> list:
if not datei.is_file():
raise Abbruch(f"Ausnahmedatei fehlt: {datei}")
try:
text = datei.read_text(encoding="utf-8-sig")
except (OSError, UnicodeDecodeError) as fehler:
raise Abbruch(f"Ausnahmedatei nicht lesbar: {datei} ({fehler})") from fehler
muster = []
gesehen = set()
# split statt splitlines: splitlines trennt auch an Unicode-Zeilentrennern.
for nummer, roh in enumerate(text.split("\n"), 1):
zeile = roh.strip()
if not zeile or zeile.startswith("#"):
continue
teile = zeile.split("/")
if ("\\" in zeile or zeile.startswith("/") or ":" in zeile
or ".." in teile or "." in teile or "" in teile[:-1]):
raise Abbruch(
f"Ausnahmedatei Zeile {nummer}: ungültiges Muster {zeile!r}, erlaubt ist ein Pfad "
"relativ zur Vault-Wurzel mit / als Trenner, ohne Laufwerk, ohne . und .."
)
schluessel = zeile.lower()
if schluessel in gesehen:
raise Abbruch(f"Ausnahmedatei Zeile {nummer}: Muster {zeile!r} steht doppelt")
gesehen.add(schluessel)
muster.append(Muster(zeile, glob_regex(zeile)))
return muster
def lade_filter(vault: Path) -> list:
if not (vault / ".obsidian").is_dir():
raise Abbruch(f"kein Obsidian-Vault: Ordner .obsidian fehlt unter {vault}")
app = vault / ".obsidian" / "app.json"
if not app.is_file():
return []
try:
daten = json.loads(app.read_text(encoding="utf-8-sig"))
except (OSError, ValueError) as fehler:
raise Abbruch(f".obsidian/app.json nicht lesbar: {fehler}") from fehler
if not isinstance(daten, dict):
raise Abbruch(".obsidian/app.json ist kein JSON-Objekt")
if "userIgnoreFilters" not in daten:
return []
roh = daten["userIgnoreFilters"]
if not isinstance(roh, list):
raise Abbruch(f"userIgnoreFilters ist keine Liste, sondern {type(roh).__name__}")
liste = []
for eintrag in roh:
if not isinstance(eintrag, str) or not eintrag:
raise Abbruch(f"userIgnoreFilters: ein Eintrag ist kein Text oder leer: {eintrag!r}")
if len(eintrag) >= 2 and eintrag.startswith("/") and eintrag.endswith("/"):
try:
liste.append(("regex", re.compile(eintrag[1:-1])))
except re.error as fehler:
raise Abbruch(
f"userIgnoreFilters: ungültiger regulärer Ausdruck {eintrag!r} ({fehler})"
) from fehler
else:
liste.append(("praefix", eintrag))
return liste
def ausgeblendet(rel: str, filter_liste: list) -> bool:
for art, wert in filter_liste:
if art == "praefix":
if rel.startswith(wert):
return True
elif wert.search(rel):
return True
return False
def sammle(vault: Path, filter_liste: list) -> list:
fehler = []
def bei_fehler(exc: OSError) -> None:
fehler.append(str(exc.filename or exc))
start = str(vault)
notizen = []
for ordner, unterordner, dateien in os.walk(start, onerror=bei_fehler):
rel = os.path.relpath(ordner, start).replace(os.sep, "/")
praefix = "" if rel == "." else rel + "/"
unterordner[:] = sorted(
name for name in unterordner
if not name.startswith(".") and not ausgeblendet(praefix + name + "/", filter_liste)
)
for name in sorted(dateien):
if name.startswith(".") or not name.lower().endswith(".md"):
continue
if not ausgeblendet(praefix + name, filter_liste):
notizen.append(praefix + name)
if fehler:
raise Abbruch(f"{len(fehler)} Ordner nicht lesbar (erster: {fehler[0]})")
return notizen
def lies(pfad: Path) -> str:
# utf-8-sig schluckt die BOM, errors=replace hält ungültige Bytes aus,
# der Textmodus macht aus CRLF ein LF.
with open(pfad, "r", encoding="utf-8-sig", errors="replace") as datei:
return datei.read()
def teile(text: str) -> tuple:
zeilen = text.split("\n")
if zeilen and zeilen[0].strip() == "---":
for i in range(1, len(zeilen)):
if zeilen[i].strip() == "---":
return "\n".join(zeilen[1:i]), "\n".join(zeilen[i + 1:])
# Ohne Schlusszeile ist es kein Frontmatter, sondern Text.
return "", text
def wikilink_ziele(text: str) -> list:
ziele = []
for treffer in _WIKILINK.finditer(text):
# Ein Backslash am Ende stammt aus einem escapten Pipe in einer Tabelle.
ziel = treffer.group(1).strip().rstrip("\\").strip()
if ziel:
ziele.append(ziel)
return ziele
def markdown_ziele(text: str) -> list:
ziele = []
for treffer in _MDLINK.finditer(text):
ziel = treffer.group(1).strip()
if ziel.startswith("<") and ziel.endswith(">"):
ziel = ziel[1:-1].strip()
if not ziel or ziel.startswith("#") or _SCHEMA.match(ziel):
continue
ziel = ziel.split("#", 1)[0].split("?", 1)[0]
if ziel:
ziele.append(unquote(ziel))
return ziele
def link_ziele(text: str, strip_code) -> list:
kopf, rumpf = teile(text)
rumpf = strip_code(rumpf)
ziele = wikilink_ziele(kopf)
ziele += wikilink_ziele(rumpf)
ziele += markdown_ziele(rumpf)
return ziele
def baue_index(notizen: list) -> Index:
index = Index()
for rel in notizen:
index.pfade[rel.lower()] = rel
name = posixpath.basename(rel)[:-3].lower()
index.namen.setdefault(name, []).append(rel)
return index
def _nachschlagen(pfad: str, index: Index):
klein = pfad.lower()
if klein.endswith(".md") and klein in index.pfade:
return index.pfade[klein]
return index.pfade.get(klein + ".md")
def _nach_namen(ziel: str, quelle: str, index: Index):
teil = posixpath.normpath(ziel).lower()
if not teil.endswith(".md"):
teil += ".md"
name = posixpath.basename(teil)[:-3]
kandidaten = [r for r in index.namen.get(name, []) if ("/" + r.lower()).endswith("/" + teil)]
if not kandidaten:
return None
ordner = posixpath.dirname(quelle).lower()
gleich = sorted(r for r in kandidaten if posixpath.dirname(r).lower() == ordner)
if gleich:
return gleich[0]
return sorted(kandidaten, key=lambda r: (r.count("/"), len(r), r.lower()))[0]
def aufloesen(ziel: str, quelle: str, index: Index):
ziel = ziel.strip().replace("\\", "/")
if not ziel:
return None
ordner = posixpath.dirname(quelle)
if ziel.startswith("/"):
kandidaten = [posixpath.normpath(ziel.lstrip("/"))]
elif ziel.startswith("./") or ziel.startswith("../"):
kandidaten = [posixpath.normpath(posixpath.join(ordner, ziel))]
else:
kandidaten = [posixpath.normpath(ziel), posixpath.normpath(posixpath.join(ordner, ziel))]
for kandidat in kandidaten:
if kandidat == ".." or kandidat.startswith("../"):
continue
treffer = _nachschlagen(kandidat, index)
if treffer:
return treffer
if ziel.startswith(("/", "./", "../")):
return None
return _nach_namen(ziel, quelle, index)
def analysiere(vault: Path, ausnahmen: Path = AUSNAHMEN) -> Ergebnis:
if not vault.is_dir():
raise Abbruch(f"Vault-Ordner fehlt: {vault}")
strip_code = lade_strip_code()
muster = lade_ausnahmen(ausnahmen)
filter_liste = lade_filter(vault)
notizen = sammle(vault, filter_liste)
if not notizen:
raise Abbruch(f"keine Notiz unter {vault} gefunden")
index = baue_index(notizen)
verbunden = set()
ausgehend = {}
unlesbar = []
for rel in notizen:
try:
text = lies(vault / rel)
except OSError:
unlesbar.append(rel)
continue
for roh in link_ziele(text, strip_code):
ziel = aufloesen(roh, rel, index)
if ziel is None or ziel == rel:
continue
ausgehend.setdefault(rel, set()).add(ziel)
verbunden.add(rel)
verbunden.add(ziel)
if unlesbar:
raise Abbruch(f"{len(unlesbar)} Notiz(en) nicht lesbar (erste: {unlesbar[0]})")
alle = [rel for rel in notizen if rel not in verbunden]
ausgenommen = [rel for rel in alle if any(m.regex.match(rel) for m in muster)]
waisen = [rel for rel in alle if not any(m.regex.match(rel) for m in muster)]
ohne_treffer = [m.text for m in muster if not any(m.regex.match(rel) for rel in notizen)]
return Ergebnis(notizen, waisen, ausgenommen, ausgehend, muster, ohne_treffer, len(filter_liste))
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(description="Zählt die Waisen im Obsidian-Graphen")
zerleger.add_argument("--vault", default=".")
zerleger.add_argument("--ausnahmen", default=str(AUSNAHMEN))
art = zerleger.add_mutually_exclusive_group()
art.add_argument("--liste", action="store_true", help="die Waisen einzeln ausgeben")
art.add_argument("--zahl", action="store_true", help="nur die Zahl ausgeben, für den Sitzungsstart")
args = zerleger.parse_args(argv)
vault = Path(os.path.abspath(os.path.expanduser(args.vault)))
try:
ergebnis = analysiere(vault, Path(args.ausnahmen))
except Abbruch as fehler:
print(f"Waisen - ABGEBROCHEN: {fehler}")
return 2
except Exception as fehler:
print(f"Waisen - ABGEBROCHEN: {type(fehler).__name__}: {fehler}")
if not args.zahl:
print(traceback.format_exc())
return 2
anzahl = len(ergebnis.waisen)
if args.zahl:
print(anzahl)
return 1 if anzahl else 0
print(f"# Waisen - {vault}")
print(f"Notizen im Graphen: {len(ergebnis.notizen)}")
print(f"Einträge in userIgnoreFilters: {ergebnis.filter_anzahl}")
print(f"Ausgenommene Waisen: {len(ergebnis.ausgenommen)} über {len(ergebnis.muster)} Muster aus {args.ausnahmen}")
for text in ergebnis.ohne_treffer:
print(f"Muster ohne Treffer: {text}")
print(f"{ZEILE}{anzahl}")
if args.liste:
for rel in ergebnis.waisen:
print(f" - {rel}")
return 1 if anzahl else 0
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
sys.exit(main())
````
### `.vault-tools/linkcheck/waisen-ausnahmen.txt`
Die ganze Datei ist neu:
````text
# Ausnahmen für waisen.py: Notizen, die nicht als Waisen zählen.
# Ein Muster je Zeile, relativ zur Vault-Wurzel, mit / als Trenner.
# * steht für einen Namensteil ohne /, ** für beliebig viele Ordner, ? für ein Zeichen.
# Groß- und Kleinschreibung zählt nicht.
# Hier stehen nur allgemeine Muster, keine Kunden- und keine Projektnamen:
# .vault-tools/ wird veröffentlicht.
# Begründung: AI/Regelwerk/navigation.md, Abschnitt Links schreiben und Dateien verschieben.
# Warteschlange und Archiv für BookStack, kein Wissen im Vault.
Bookstack/**
# Programmausgaben in Code-Projekten; das Programm schreibt sie täglich neu.
Code Projekte/*/data/**
````
### `.vault-tools/linkcheck/faelle_waisen.py`
Die ganze Datei ist neu:
````python
"""Fährt die Angriffsliste gegen waisen.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-29
Entwurf: ENTWURF-waisen.md im selben Ordner
Grundlinie zuerst: Liefert der unbeschädigte Wegwerf-Vault nicht genau die
erwartete Waise, misst der Rest nichts, und der Treiber bricht ab.
Je Fall eine frische Kopie der Grundlinie, genau eine Änderung und waisen.py
als eigener Prozess mit --liste oder --zahl. Drei Fehlerpfade laufen im
Prozess, weil sie sich von außen nicht auslösen lassen. Bestanden heißt:
erwarteter Exit-Code, genau die erwarteten Waisen, jede erwartete Textstelle.
Bei Exit 2 darf keine Zahl in der Ausgabe stehen.
--rotlauf baut jeden Mutanten aus MUTANTEN in eine Kopie von waisen.py und
fährt die genannten Fälle dagegen. Getötet heißt: jeder genannte Fall schlägt
fehl. Ein Muster, das nicht genau einmal in waisen.py steht, ist ein Befund.
--echt fährt waisen.py über den Vault, in dem dieser Treiber liegt, und misst
die Laufzeit.
Aufruf:
python .vault-tools/linkcheck/faelle_waisen.py
python .vault-tools/linkcheck/faelle_waisen.py --rotlauf
python .vault-tools/linkcheck/faelle_waisen.py --echt
"""
import contextlib
import importlib.util
import io
import os
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional, Union
HIER = Path(__file__).resolve().parent
WAISEN = HIER / "waisen.py"
LINKCHECK = HIER / "linkcheck.py"
ZEILE = "Waisen außerhalb der Ausnahmen: "
ZIEL_S = 10.0
APP = '{"userIgnoreFilters": ["_index/", "/(^|/)node_modules//"]}'
KEINE = "# keine Muster\n"
def schreibe(vault: Path, rel: str, inhalt) -> None:
# Immer Bytes: write_text würde unter Windows jedes LF zu CRLF machen.
pfad = vault / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(inhalt if isinstance(inhalt, bytes) else inhalt.encode("utf-8"))
def neu(rel: str, inhalt):
return lambda v: schreibe(v, rel, inhalt)
def nichts(v: Path) -> None:
return None
def baue_grundlinie(v: Path) -> None:
schreibe(v, ".obsidian/app.json", APP)
schreibe(v, "A/start.md", "Siehe [[B/ziel]].\n")
schreibe(v, "B/ziel.md", "# Ziel\n")
schreibe(v, "C/allein.md", "# Allein\n")
@dataclass
class Fall:
name: str
aendern: Callable
# None heisst: Abbruch mit Exit 2 erwartet.
waisen: Optional[list]
texte: list = field(default_factory=list)
# None heisst: die Ausnahmedatei fehlt.
ausnahmen: Union[str, bytes, None] = KEINE
zahl: bool = False
ohne_linkcheck: bool = False
ohne_pythonioencoding: bool = False
vault_fehlt: bool = False
GRUNDLINIE = Fall("GRUNDLINIE", nichts, ["C/allein.md"])
FAELLE = [
Fall("wikilink | voller Pfad", neu("A/neu.md", "[[C/allein]]\n"), []),
Fall("wikilink | nur der Name", neu("A/neu.md", "[[allein]]\n"), []),
Fall("wikilink | mit Endung .md", neu("A/neu.md", "[[C/allein.md]]\n"), []),
Fall("wikilink | mit Alias", neu("A/neu.md", "[[C/allein|Text]]\n"), []),
Fall("wikilink | mit Anker und Blockverweis",
neu("A/neu.md", "[[C/allein#Teil]] und [[C/allein^abc]]\n"), []),
Fall("wikilink | escaptes Pipe in einer Tabellenzelle",
neu("A/neu.md", "| a | [[C/allein\\|x]] |\n"), []),
Fall("wikilink | Einbettung", neu("A/neu.md", "![[C/allein]]\n"), []),
Fall("wikilink | Gross- und Kleinschreibung", neu("A/neu.md", "[[c/ALLEIN]]\n"), []),
Fall("wikilink | Pfadende statt vollem Pfad",
lambda v: ((v / "C" / "allein.md").unlink(),
schreibe(v, "Ober/Tief/allein.md", "# Allein\n"),
schreibe(v, "A/neu.md", "[[Tief/allein]]\n")),
[]),
Fall("wikilink | relativ mit ../", neu("A/neu.md", "[[../C/allein]]\n"), []),
Fall("verweis | Wikilink ins Leere", neu("A/neu.md", "[[gibtsnicht]]\n"),
["A/neu.md", "C/allein.md"]),
Fall("verweis | Wikilink auf einen Anhang",
lambda v: (schreibe(v, "A/bild.png", b"\x89PNG"), schreibe(v, "A/neu.md", "![[bild.png]]\n")),
["A/neu.md", "C/allein.md"]),
Fall("verweis | Selbstreferenz ist keine Kante",
neu("C/allein.md", "# Allein\n\n[[C/allein]]\n"), ["C/allein.md"]),
Fall("verweis | Anker in derselben Notiz",
neu("C/allein.md", "# Allein\n\n[[#Allein]]\n"), ["C/allein.md"]),
Fall("frontmatter | Wikilink in Anfuehrungszeichen",
neu("A/neu.md", "---\nverwandt: \"[[C/allein]]\"\n---\n\nText\n"), []),
Fall("frontmatter | Wikilink in einer Blockliste",
neu("A/neu.md", "---\nverwandt:\n - \"[[C/allein]]\"\n---\n"), []),
Fall("frontmatter | ohne Schlusszeile zaehlt als Text",
neu("A/neu.md", "---\nquelle: \"[x](../C/allein.md)\"\n"), []),
Fall("frontmatter | Markdown-Link im Frontmatter zaehlt nicht",
neu("A/neu.md", "---\nquelle: \"[x](../C/allein.md)\"\n---\n"),
["A/neu.md", "C/allein.md"]),
Fall("markdown | relativ mit ../", neu("A/neu.md", "[x](../C/allein.md)\n"), []),
Fall("markdown | im selben Ordner", neu("C/neu.md", "[x](allein.md)\n"), []),
Fall("markdown | Pfad ab der Wurzel ohne ./", neu("A/neu.md", "[x](C/allein.md)\n"), []),
Fall("markdown | Pfad mit fuehrendem /", neu("A/neu.md", "[x](/C/allein.md)\n"), []),
Fall("markdown | URL-kodiert mit Leerzeichen und Umlaut",
lambda v: (schreibe(v, "C/mit leer ü.md", "# M\n"),
schreibe(v, "A/neu.md", "[x](../C/mit%20leer%20%C3%BC.md)\n")),
["C/allein.md"]),
Fall("markdown | spitze Klammern mit Leerzeichen",
lambda v: (schreibe(v, "C/mit leer.md", "# M\n"),
schreibe(v, "A/neu.md", "[x](<../C/mit leer.md>)\n")),
["C/allein.md"]),
Fall("markdown | mit Titel", neu("A/neu.md", "[x](../C/allein.md \"Titel\")\n"), []),
Fall("markdown | mit Anker", neu("A/neu.md", "[x](../C/allein.md#teil)\n"), []),
Fall("markdown | ohne Endung", neu("A/neu.md", "[x](../C/allein)\n"), []),
Fall("markdown | Web-Adresse zaehlt nicht",
neu("A/neu.md", "[x](https://example.invalid/C/allein.md)\n"), ["A/neu.md", "C/allein.md"]),
Fall("markdown | Anhang zaehlt nicht",
lambda v: (schreibe(v, "A/doku.pdf", b"%PDF"), schreibe(v, "A/neu.md", "[x](doku.pdf)\n")),
["A/neu.md", "C/allein.md"]),
Fall("markdown | Laufwerkspfad zaehlt nicht",
neu("A/neu.md", "[x](D:/Vault/C/allein.md)\n"), ["A/neu.md", "C/allein.md"]),
Fall("markdown | Anker allein zaehlt nicht",
neu("C/allein.md", "# Allein\n\n[x](#allein)\n"), ["C/allein.md"]),
Fall("code | Wikilink im Zaun", neu("A/neu.md", "```\n[[C/allein]]\n```\n"),
["A/neu.md", "C/allein.md"]),
Fall("code | Markdown-Link im Code-Span",
neu("A/neu.md", "Der Link `[x](../C/allein.md)` nur erwaehnt.\n"), ["A/neu.md", "C/allein.md"]),
Fall("code | Link direkt nach dem Zaun", neu("A/neu.md", "```\ncode\n```\n[[C/allein]]\n"), []),
Fall("reihenfolge | Waise an erster und an letzter Stelle",
lambda v: (schreibe(v, "0/erste.md", "# E\n"), schreibe(v, "zz/letzte.md", "# L\n")),
["0/erste.md", "C/allein.md", "zz/letzte.md"]),
Fall("reihenfolge | Link am Dateiende ohne Zeilenumbruch", neu("A/neu.md", "Text\n\n[[C/allein]]"), []),
Fall("reihenfolge | Link vor einem Zaun ohne Ende",
neu("A/neu.md", "[[C/allein]]\n```\n[[B/ziel]]\n"), []),
Fall("ausblendung | Punktordner ist kein Knoten und keine Quelle",
neu(".versteckt/x.md", "[[C/allein]]\n"), ["C/allein.md"]),
Fall("ausblendung | Punktdatei ist kein Knoten", neu("A/.x.md", "[[C/allein]]\n"), ["C/allein.md"]),
Fall("ausblendung | Praefixfilter an der Wurzel", neu("_index/x.md", "[[C/allein]]\n"), ["C/allein.md"]),
Fall("ausblendung | Praefixfilter greift nicht in der Tiefe", neu("Tief/_index/x.md", "# X\n"),
["C/allein.md", "Tief/_index/x.md"]),
Fall("ausblendung | regulaerer Ausdruck in der Tiefe",
neu("P/node_modules/pkg/README.md", "# R\n"), ["C/allein.md"]),
Fall("ausblendung | Link auf eine ausgeblendete Notiz",
lambda v: (schreibe(v, "_index/x.md", "# X\n"), schreibe(v, "C/allein.md", "[[_index/x]]\n")),
["C/allein.md"]),
Fall("ausblendung | andere Endung ist kein Knoten", neu("C/daten.txt", "[[B/ziel]]\n"), ["C/allein.md"]),
Fall("ausblendung | Endung in Grossbuchstaben ist ein Knoten", neu("C/GROSS.MD", "# G\n"),
["C/GROSS.MD", "C/allein.md"]),
# Dateifilter: Der Ordner davor bleibt stehen, erst die Prüfung je Datei blendet aus.
Fall("ausblendung | Dateifilter blendet eine Notiz aus",
neu(".obsidian/app.json", '{"userIgnoreFilters": ["C/allein.md"]}'), []),
Fall("ausblendung | per Dateifilter ausgeblendete Notiz ist keine Quelle",
lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["D/quelle.md"]}'),
schreibe(v, "D/quelle.md", "[[C/allein]]\n")),
["C/allein.md"]),
Fall("ausnahme | Muster trifft die Waise", nichts, [],
["Ausgenommene Waisen: 1 über 1 Muster"], ausnahmen="C/**\n"),
Fall("ausnahme | ausgenommene Notiz liefert Kanten", neu("D/quelle.md", "[[C/allein]]\n"), [],
ausnahmen="D/**\n"),
Fall("ausnahme | Muster ohne Treffer wird genannt", nichts, ["C/allein.md"],
["Muster ohne Treffer: Gibtsnicht/**"], ausnahmen="Gibtsnicht/**\n"),
Fall("ausnahme | Stern innerhalb eines Namens", nichts, [], ausnahmen="*/allein.md\n"),
Fall("ausnahme | Stern ueberschreitet keinen Ordner", nichts, ["C/allein.md"],
["Muster ohne Treffer: *.md"], ausnahmen="*.md\n"),
Fall("ausnahme | Gross- und Kleinschreibung", nichts, [], ausnahmen="c/ALLEIN.md\n"),
Fall("ausnahme | nur Kommentar und Leerzeile", nichts, ["C/allein.md"], ausnahmen="# Kommentar\n\n"),
Fall("ausnahme | leere Datei", nichts, ["C/allein.md"], ausnahmen=b""),
Fall("ausnahme | CRLF", nichts, [], ausnahmen=b"C/**\r\n"),
Fall("ausnahme | BOM", nichts, [], ausnahmen=b"\xef\xbb\xbfC/**\n"),
Fall("ausnahme | Muster in erster und letzter Zeile",
neu("D/zweite.md", "# Z\n"), [], ausnahmen="C/**\n# Mitte\nD/**"),
Fall("ausnahme | Backslash ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="C\\allein.md\n"),
Fall("ausnahme | fuehrender Schraegstrich ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="/C/**\n"),
Fall("ausnahme | Punktpunkt ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="C/../**\n"),
Fall("ausnahme | Laufwerk ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="D:/C/**\n"),
Fall("ausnahme | Punkt als Pfadteil ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="./C/**\n"),
Fall("ausnahme | leerer Pfadteil ist ungueltig", nichts, None, ["ungültiges Muster"],
ausnahmen="C//allein.md\n"),
Fall("ausnahme | doppeltes Muster", nichts, None, ["steht doppelt"], ausnahmen="C/**\nc/**\n"),
Fall("ausnahme | Datei fehlt", nichts, None, ["Ausnahmedatei fehlt"], ausnahmen=None),
Fall("ausnahme | ungueltiges UTF-8", nichts, None, ["Ausnahmedatei nicht lesbar"],
ausnahmen=b"C/\xff\n"),
Fall("obsidian | Ordner .obsidian fehlt", lambda v: shutil.rmtree(v / ".obsidian"), None,
["Ordner .obsidian fehlt"]),
Fall("obsidian | app.json fehlt, kein Filter",
lambda v: ((v / ".obsidian" / "app.json").unlink(), schreibe(v, "_index/x.md", "# X\n")),
["C/allein.md", "_index/x.md"]),
Fall("obsidian | userIgnoreFilters fehlt, kein Filter",
lambda v: (schreibe(v, ".obsidian/app.json", '{"newLinkFormat": "absolute"}'),
schreibe(v, "_index/x.md", "# X\n")),
["C/allein.md", "_index/x.md"]),
Fall("obsidian | leere Filterliste",
lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": []}'),
schreibe(v, "_index/x.md", "# X\n")),
["C/allein.md", "_index/x.md"]),
Fall("obsidian | app.json ist kein JSON", neu(".obsidian/app.json", "{kaputt"), None,
[".obsidian/app.json nicht lesbar"]),
Fall("obsidian | app.json ist eine Liste", neu(".obsidian/app.json", "[]"), None,
["kein JSON-Objekt"]),
Fall("obsidian | userIgnoreFilters ist null", neu(".obsidian/app.json", '{"userIgnoreFilters": null}'),
None, ["keine Liste"]),
Fall("obsidian | Eintrag ist eine Zahl", neu(".obsidian/app.json", '{"userIgnoreFilters": [5]}'),
None, ["kein Text"]),
Fall("obsidian | Eintrag ist leer", neu(".obsidian/app.json", '{"userIgnoreFilters": [""]}'),
None, ["kein Text"]),
Fall("obsidian | ungueltiger regulaerer Ausdruck",
neu(".obsidian/app.json", '{"userIgnoreFilters": ["/(/"]}'), None,
["ungültiger regulärer Ausdruck"]),
Fall("obsidian | Filter an erster und letzter Stelle",
lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["_index/", "x", "/(^|/)tmp//"]}'),
schreibe(v, "_index/x.md", "# X\n"), schreibe(v, "Q/tmp/y.md", "# Y\n")),
["C/allein.md"]),
Fall("umgebung | linkcheck.py fehlt", nichts, None, ["linkcheck.py nicht importierbar"],
ohne_linkcheck=True),
Fall("umgebung | Vault ohne Notiz", lambda v: [shutil.rmtree(v / n) for n in ("A", "B", "C")],
None, ["keine Notiz"]),
Fall("umgebung | Vault-Ordner fehlt", nichts, None, ["Vault-Ordner fehlt"], vault_fehlt=True),
Fall("leer | leere Datei ist eine Waise", neu("C/leer.md", b""), ["C/allein.md", "C/leer.md"]),
Fall("kodierung | ungueltiges UTF-8 vor dem Link", neu("A/neu.md", b"\xff\xfe Text [[C/allein]]\n"), []),
Fall("kodierung | CRLF im Frontmatter",
neu("A/neu.md", b"---\r\nverwandt: \"[[C/allein]]\"\r\n---\r\nText\r\n"), []),
Fall("kodierung | BOM vor dem Frontmatter",
neu("A/neu.md", b"\xef\xbb\xbf---\nverwandt: \"[[C/allein]]\"\n---\n"), []),
Fall("umgebung | ohne PYTHONIOENCODING mit Umlaut und Emoji im Namen",
neu("C/Übersicht 🚀.md", "# U\n"), ["C/allein.md", "C/Übersicht 🚀.md"],
ohne_pythonioencoding=True),
# Mehrdeutig: Die richtige Wahl liegt jeweils alphabetisch hinten, sonst misst der Fall nichts.
Fall("mehrdeutig | gleichnamige Notiz im selben Ordner gewinnt",
lambda v: (schreibe(v, "Q/X/doppelt.md", "# D\n"), schreibe(v, "A/X/doppelt.md", "# D\n"),
schreibe(v, "Q/X/verweis.md", "[[X/doppelt]]\n")),
["A/X/doppelt.md", "C/allein.md"]),
Fall("mehrdeutig | relativ zur Quelle vor dem Namen",
lambda v: (schreibe(v, "Z/sub/n.md", "# N\n"), schreibe(v, "A/sub/n.md", "# N\n"),
schreibe(v, "Z/verweis.md", "[[sub/n]]\n")),
["A/sub/n.md", "C/allein.md"]),
Fall("mehrdeutig | sonst der kuerzeste Pfad",
lambda v: (schreibe(v, "A/B/doppelt.md", "# D\n"), schreibe(v, "Z/doppelt.md", "# D\n"),
schreibe(v, "Q/verweis.md", "[[doppelt]]\n")),
["A/B/doppelt.md", "C/allein.md"]),
Fall("zahl | nur die Zahl", nichts, ["C/allein.md"], zahl=True),
Fall("zahl | null Waisen", neu("A/neu.md", "[[C/allein]]\n"), [], zahl=True),
Fall("zahl | Abbruch ohne Zahl", lambda v: shutil.rmtree(v / ".obsidian"), None,
["Waisen - ABGEBROCHEN"], zahl=True),
]
ALLE = [GRUNDLINIE] + FAELLE
def frische_kopie(basis: Path, arbeit: Path) -> Path:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
shutil.copytree(basis, v)
return v
def streng_utf8(roh: bytes) -> str:
# Ein ungültiges Byte darf nicht still ersetzt werden, sonst besteht der
# Umgebungsfall auch dann, wenn waisen.py cp1252 schreibt.
try:
return roh.decode("utf-8")
except UnicodeDecodeError as exc:
return f"UNGUELTIGES-UTF-8: {exc}"
def fahre(skript: Path, argumente: list, umgebung) -> tuple:
start = time.perf_counter()
lauf = subprocess.run(
[sys.executable, str(skript), *argumente],
capture_output=True,
stdin=subprocess.DEVNULL,
timeout=300,
env=umgebung,
)
dauer = time.perf_counter() - start
return lauf.returncode, streng_utf8(lauf.stdout), lauf.stderr.decode("utf-8", errors="replace"), dauer
def pruefe_ausgabe(fall: Fall, code: int, out: str) -> list:
probleme = []
erwartet_exit = 2 if fall.waisen is None else (1 if fall.waisen else 0)
if code != erwartet_exit:
probleme.append(f"Exit {code} statt {erwartet_exit}")
for text in fall.texte:
if text not in out:
probleme.append(f"fehlt in der Ausgabe: {text}")
zeilen = out.replace("\r\n", "\n").split("\n")
if fall.waisen is None:
if any(z.startswith(ZEILE) for z in zeilen) or out.strip().isdigit():
probleme.append("Abbruch, aber eine Zahl steht in der Ausgabe")
return probleme
if fall.zahl:
if out.strip() != str(len(fall.waisen)):
probleme.append(f"--zahl gab {out.strip()!r} statt {len(fall.waisen)}")
return probleme
zahl = [z for z in zeilen if z.startswith(ZEILE)]
if zahl != [f"{ZEILE}{len(fall.waisen)}"]:
probleme.append(f"Zahlzeile {zahl!r} statt {ZEILE}{len(fall.waisen)}")
gelistet = sorted(z[4:] for z in zeilen if z.startswith(" - "))
if gelistet != sorted(fall.waisen):
probleme.append(f"Waisen {gelistet!r} statt {sorted(fall.waisen)!r}")
return probleme
def lauf_fall(fall: Fall, basis: Path, arbeit: Path, skript: Path) -> tuple:
v = frische_kopie(basis, arbeit)
fall.aendern(v)
ausnahmen = arbeit / "ausnahmen.txt"
if ausnahmen.exists():
ausnahmen.unlink()
if fall.ausnahmen is not None:
roh = fall.ausnahmen if isinstance(fall.ausnahmen, bytes) else fall.ausnahmen.encode("utf-8")
ausnahmen.write_bytes(roh)
ziel_skript = skript
if fall.ohne_linkcheck:
allein = arbeit / "ohne-linkcheck"
if allein.exists():
shutil.rmtree(allein)
allein.mkdir()
ziel_skript = allein / "waisen.py"
shutil.copy2(skript, ziel_skript)
vault = arbeit / "gibtsnicht" if fall.vault_fehlt else v
argumente = ["--vault", str(vault), "--ausnahmen", str(ausnahmen), "--zahl" if fall.zahl else "--liste"]
umgebung = None
if fall.ohne_pythonioencoding:
umgebung = dict(os.environ)
umgebung.pop("PYTHONIOENCODING", None)
code, out, err, dauer = fahre(ziel_skript, argumente, umgebung)
probleme = pruefe_ausgabe(fall, code, out)
kopf = f"exit={code} {dauer:.2f}s"
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
return False, text + f"\n stdout: {out[:600]!r}\n stderr: {err[:600]!r}"
def fahre_liste(faelle: list, skript: Path, arbeit: Path, ausgabe: bool) -> dict:
basis = arbeit / "grundlinie"
if not basis.exists():
baue_grundlinie(basis)
ergebnis = {}
for fall in faelle:
try:
ok, text = lauf_fall(fall, basis, arbeit, skript)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
ergebnis[fall.name] = ok
if ausgabe:
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
if fall is GRUNDLINIE and not ok:
break
return ergebnis
def lade_modul(name: str, pfad: Path):
spec = importlib.util.spec_from_file_location(name, pfad)
modul = importlib.util.module_from_spec(spec)
# Vor exec_module eintragen: dataclasses sucht das Modul in sys.modules.
sys.modules[name] = modul
spec.loader.exec_module(modul)
return modul
def im_prozess(modul, v: Path, ausnahmen: Path) -> tuple:
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(["--vault", str(v), "--ausnahmen", str(ausnahmen), "--liste"])
return code, puffer.getvalue()
def ausnahmen_leer(arbeit: Path) -> Path:
datei = arbeit / "ausnahmen.txt"
datei.write_bytes(KEINE.encode("utf-8"))
return datei
def ip_datei_gesperrt(v: Path, arbeit: Path) -> tuple:
modul = lade_modul("waisen_gesperrt", WAISEN)
original = modul.lies
def gesperrt(pfad):
if Path(pfad).name == "allein.md":
raise PermissionError("Testsperre")
return original(pfad)
modul.lies = gesperrt
code, out = im_prozess(modul, v, ausnahmen_leer(arbeit))
ok = code == 2 and "1 Notiz(en) nicht lesbar (erste: C/allein.md)" in out and ZEILE not in out
return ok, f"exit={code} {out[:400]!r}"
class _WalkPatch:
"""Ersetzt os.walk nur im Namensraum des geladenen Moduls (modul.os)."""
def __init__(self, echt_os, fehler_pfad: Path) -> None:
self._echt = echt_os
self._fehler_pfad = fehler_pfad
def __getattr__(self, name):
return getattr(self._echt, name)
def walk(self, start, onerror=None, **kwargs):
if onerror is not None:
fehler = OSError(13, "Testsperre")
fehler.filename = str(self._fehler_pfad)
onerror(fehler)
yield from self._echt.walk(start, onerror=onerror, **kwargs)
def ip_ordner_gesperrt(v: Path, arbeit: Path) -> tuple:
modul = lade_modul("waisen_ordner", WAISEN)
modul.os = _WalkPatch(modul.os, v / "gesperrt")
code, out = im_prozess(modul, v, ausnahmen_leer(arbeit))
ok = code == 2 and "1 Ordner nicht lesbar" in out and ZEILE not in out
return ok, f"exit={code} {out[:400]!r}"
def ip_unerwartet(v: Path, arbeit: Path) -> tuple:
modul = lade_modul("waisen_unerwartet", WAISEN)
def kaputt(notizen):
raise ValueError("Testfehler")
modul.baue_index = kaputt
code, out = im_prozess(modul, v, ausnahmen_leer(arbeit))
ok = code == 2 and "ABGEBROCHEN: ValueError: Testfehler" in out and ZEILE not in out
return ok, f"exit={code} {out[:400]!r}"
IM_PROZESS = [
("absturz | Notiz nicht lesbar", ip_datei_gesperrt),
("absturz | Ordner nicht lesbar", ip_ordner_gesperrt),
("absturz | unerwarteter Fehler", ip_unerwartet),
]
# (Name, Zeile in waisen.py, Ersatzzeile, Fälle, die rot werden müssen)
MUTANTEN = [
("Code nicht ausgeblendet",
" rumpf = strip_code(rumpf)",
" rumpf = rumpf",
["code | Wikilink im Zaun", "code | Markdown-Link im Code-Span"]),
("Markdown-Links ignoriert",
" ziele += markdown_ziele(rumpf)",
" ziele += []",
["markdown | relativ mit ../", "markdown | URL-kodiert mit Leerzeichen und Umlaut",
"markdown | ohne Endung"]),
("Frontmatter ignoriert",
" ziele = wikilink_ziele(kopf)",
" ziele = []",
["frontmatter | Wikilink in Anfuehrungszeichen", "kodierung | CRLF im Frontmatter",
"kodierung | BOM vor dem Frontmatter"]),
("Ausnahmen nicht abgezogen",
" waisen = [rel for rel in alle if not any(m.regex.match(rel) for m in muster)]",
" waisen = list(alle)",
["ausnahme | Muster trifft die Waise", "ausnahme | CRLF"]),
("Selbstreferenz als Kante",
" if ziel is None or ziel == rel:",
" if ziel is None:",
["verweis | Selbstreferenz ist keine Kante"]),
("Punktordner mitgezaehlt",
" if not name.startswith(\".\") and not ausgeblendet(praefix + name + \"/\", filter_liste)",
" if not ausgeblendet(praefix + name + \"/\", filter_liste)",
["ausblendung | Punktordner ist kein Knoten und keine Quelle"]),
("fehlende Ausnahmedatei still",
" raise Abbruch(f\"Ausnahmedatei fehlt: {datei}\")",
" return []",
["ausnahme | Datei fehlt"]),
("keine URL-Dekodierung",
" ziele.append(unquote(ziel))",
" ziele.append(ziel)",
["markdown | URL-kodiert mit Leerzeichen und Umlaut"]),
("regulaerer Filter als Praefix",
" liste.append((\"regex\", re.compile(eintrag[1:-1])))",
" liste.append((\"praefix\", eintrag))",
["ausblendung | regulaerer Ausdruck in der Tiefe"]),
("Frontmatter ohne Schlusszeile als Kopf",
" return \"\", text",
" return text, \"\"",
["frontmatter | ohne Schlusszeile zaehlt als Text"]),
("Punkt als Pfadteil erlaubt",
" or \"..\" in teile or \".\" in teile or \"\" in teile[:-1]):",
" or \"..\" in teile or \"\" in teile[:-1]):",
["ausnahme | Punkt als Pfadteil ist ungueltig"]),
("leerer Pfadteil erlaubt",
" or \"..\" in teile or \".\" in teile or \"\" in teile[:-1]):",
" or \"..\" in teile or \".\" in teile):",
["ausnahme | leerer Pfadteil ist ungueltig"]),
("Dateifilter ignoriert",
" if not ausgeblendet(praefix + name, filter_liste):",
" if True:",
["ausblendung | Dateifilter blendet eine Notiz aus",
"ausblendung | per Dateifilter ausgeblendete Notiz ist keine Quelle"]),
("gleicher Ordner ignoriert",
" gleich = sorted(r for r in kandidaten if posixpath.dirname(r).lower() == ordner)",
" gleich = []",
["mehrdeutig | gleichnamige Notiz im selben Ordner gewinnt"]),
("kein Kandidat relativ zur Quelle",
" kandidaten = [posixpath.normpath(ziel), posixpath.normpath(posixpath.join(ordner, ziel))]",
" kandidaten = [posixpath.normpath(ziel)]",
["mehrdeutig | relativ zur Quelle vor dem Namen"]),
("mehrdeutig nur alphabetisch",
" return sorted(kandidaten, key=lambda r: (r.count(\"/\"), len(r), r.lower()))[0]",
" return sorted(kandidaten, key=lambda r: r.lower())[0]",
["mehrdeutig | sonst der kuerzeste Pfad"]),
]
def rotlauf() -> int:
quelle = WAISEN.read_text(encoding="utf-8")
nach_name = {f.name: f for f in ALLE}
alle_ok = True
for name, aus, wird, rot_bei in MUTANTEN:
anzahl = quelle.count(aus + "\n")
unbekannt = [n for n in rot_bei if n not in nach_name]
if anzahl != 1 or unbekannt:
print(f"MUSTER {name}: steht {anzahl}-mal in waisen.py, unbekannte Faelle {unbekannt}")
alle_ok = False
continue
arbeit = Path(tempfile.mkdtemp(prefix="waisen-rot-")).resolve()
try:
ordner = arbeit / "skript"
ordner.mkdir()
skript = ordner / "waisen.py"
with open(skript, "w", encoding="utf-8", newline="\n") as datei:
datei.write(quelle.replace(aus + "\n", wird + "\n"))
shutil.copy2(LINKCHECK, ordner / "linkcheck.py")
ergebnis = fahre_liste([nach_name[n] for n in rot_bei], skript, arbeit, ausgabe=False)
finally:
shutil.rmtree(arbeit, ignore_errors=True)
gruen = [n for n in rot_bei if ergebnis.get(n)]
if gruen:
alle_ok = False
print(f"UEBERLEBT {name}: gruen blieben {gruen}")
else:
print(f"GETOETET {name}")
if alle_ok:
print("Rotlauf: alle Mutanten getoetet.")
return 0
print("Rotlauf: mindestens ein Mutant nicht getoetet oder ein Muster falsch.")
return 1
def echter_lauf() -> int:
vault = HIER.parent.parent
code, out, err, dauer = fahre(WAISEN, ["--vault", str(vault)], None)
print(out.rstrip())
if err.strip():
print("--- stderr ---")
print(err.rstrip())
print(f"exit={code} Laufzeit {dauer:.2f} s (Ziel: unter {ZIEL_S:.0f} s)")
if code not in (0, 1):
return 1
if dauer >= ZIEL_S:
print("LAUFZEIT UEBER ZIEL")
return 1
return 0
def main() -> int:
for pfad in (WAISEN, LINKCHECK):
if not pfad.is_file():
print(f"ABBRUCH: {pfad} fehlt")
return 2
namen = [f.name for f in ALLE] + [n for n, _ in IM_PROZESS]
if len(set(namen)) != len(namen):
print("ABBRUCH: ein Fallname steht doppelt")
return 2
if "--echt" in sys.argv[1:]:
return echter_lauf()
if "--rotlauf" in sys.argv[1:]:
return rotlauf()
arbeit = Path(tempfile.mkdtemp(prefix="waisen-faelle-")).resolve()
try:
ergebnis = fahre_liste(ALLE, WAISEN, arbeit, ausgabe=True)
if not ergebnis.get(GRUNDLINIE.name):
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
for name, funktion in IM_PROZESS:
try:
ok, text = funktion(frische_kopie(arbeit / "grundlinie", arbeit), arbeit)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
ergebnis[name] = ok
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {name} (im Prozess) {text}")
bestanden = sum(1 for ok in ergebnis.values() if ok)
print(f"\n{bestanden} von {len(ergebnis)} Faellen bestanden.")
return 0 if bestanden == len(ergebnis) else 1
finally:
shutil.rmtree(arbeit, ignore_errors=True)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
````
### `.vault-tools/linkcheck/ENTWURF-waisen.md`
Die ganze Datei ist neu:
````markdown
# Entwurf: Waisen-Zähler
Angelegt am 2026-09-29 als Entwurfsschritt nach [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], Abschnitt 1, vor der ersten Codezeile. Anforderung: Spec Waisen im Graphen vom 2026-09-29 (liegt in der Inbox, nicht veröffentlicht). Plan: `docs/plans/2026-09-29-waisen/` in diesem Ordner.
## Anlass
Am 2026-09-29 hatten laut Messung der Spec 852 von 1.996 Notizen keine Kante im Graphen, 664 davon Plandateien. Das Messskript lag nur im Scratchpad. `waisen.py` macht die Messung wiederholbar, und der Sitzungsstart zeigt die Zahl, damit neue Waisen auffallen.
## Entscheidungsliste
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Was ist ein Knoten? | Jede Datei mit Endung `.md`, Groß- und Kleinschreibung der Endung egal. Anhänge sind keine Knoten. | **Projektvorgabe**: Spec, Befund: Anhänge zeigt der Graph nicht |
| 2 | Punktordner und Punktdateien? | Weder Knoten noch Quelle einer Kante. | **Projektvorgabe**: Spec, Befund |
| 3 | userIgnoreFilters? | Zur Laufzeit aus `.obsidian/app.json`. Ein Eintrag ohne umschließende Schrägstriche ist ein Präfix ab der Vault-Wurzel, einer mit Schrägstrichen ein regulärer Ausdruck, gesucht im relativen Pfad. Ausgeblendete Dateien sind weder Knoten noch Quelle einer Kante. | **Quelle** für die Filterform: `AI/Regelwerk/navigation.md`, belegt dort am Code von Obsidian 1.13.7 (`updateUserIgnoreFilters`); beim Planen nicht selbst nachgelesen. **Projektvorgabe** für Knoten und Kanten: Spec |
| 4 | Dialekt der Ausdrücke? | Python `re` statt JavaScript. Für die Einträge im Vault am 2026-09-29 gleichwertig. | **Projektvorgabe**, Lücke im Kopfkommentar |
| 5 | Ordner vorab überspringen? | Ja, wenn ein Filter auf den Ordnerpfad mit Schrägstrich passt. Sonst läuft der Zähler durch jedes `node_modules`. | **Projektvorgabe**, Lücke für Ausdrücke mit Endanker benannt |
| 6 | Was ist ein Wikilink? | `[[Ziel]]`, `[[Ziel\|Alias]]`, `[[Ziel#Abschnitt]]`, `[[Ziel^Block]]`, Einbettung mit `!`. Ein Backslash am Ende des Ziels fällt weg (escaptes Pipe in Tabellen). | **Quelle**: `_WIKILINK_RE`, `vaultlib.py` Zeile 61; Backslash: `linkcheck.py`, `wikilinks()` |
| 7 | Wikilink im Frontmatter? | Zählt, jede Fundstelle im Frontmatter-Text, egal ob in Anführungszeichen. | **Projektvorgabe**: Spec. Ob Obsidian das so zählt: Prüfschritt P2 |
| 8 | Was ist ein Markdown-Link? | `[Text](Ziel)` im Text, auch als Bild, Ziel auch in spitzen Klammern, optional mit Titel. Anker und Abfrage fallen weg, das Ziel wird URL-dekodiert. Ziele mit Schema (`https:`, `mailto:`, `obsidian:`, Laufwerk) und reine Anker zählen nicht. Ein Ziel ohne `.md` wird mit `.md` gesucht. | **Projektvorgabe**: Spec. URL-Dekodierung und fehlende Endung: Prüfschritte P1 und P3 |
| 9 | Markdown-Link im Frontmatter? | Zählt nicht. | **Projektvorgabe** |
| 10 | Wie wird ein Ziel aufgelöst? | Mit `/` am Anfang ab der Wurzel. Mit `./` oder `../` relativ zum Ordner der Quelle. Sonst erst ab der Wurzel, dann relativ zur Quelle, dann über den Dateinamen mit passendem Pfadende. | **Projektvorgabe**, nachgebaut; Lücke benannt |
| 11 | Groß- und Kleinschreibung im Ziel? | Egal. | **Projektvorgabe**: das Dateisystem unterscheidet sie nicht |
| 12 | Mehrdeutiger Name? | Gleicher Ordner wie die Quelle, sonst kürzester Pfad, dann alphabetisch. | **Projektvorgabe**: Spec nennt das als benannte Lücke |
| 13 | Code? | Zäune und Code-Spans blendet `strip_code()` aus. | **Quelle**: `ENTWURF-codeblock-erkennung.md` |
| 14 | Kommentare mit `%%` oder als HTML? | Nicht ausgeblendet. | Lücke, benannt |
| 15 | Selbstreferenz, Link ins Leere, Link auf ausgeblendete Notiz? | Keine Kante. | **Projektvorgabe** |
| 16 | Was ist eine Waise? | Ein Knoten ohne ausgehende und ohne eingehende Kante. | **Projektvorgabe**: Spec |
| 17 | Ausnahmen? | Positivliste `waisen-ausnahmen.txt`, ein Muster je Zeile relativ zur Wurzel (`*` ohne Schrägstrich, `**` über Ordner, `?` ein Zeichen), Groß- und Kleinschreibung egal. Ausgenommene Notizen zählen nicht als Waisen, bleiben aber Knoten und Quelle von Kanten. Ein Muster ohne Treffer wird genannt. | **Projektvorgabe**: Spec, Positivliste beim Zähler, keine Kundennamen |
| 18 | Ausnahmedatei fehlt, unlesbar, Muster ungültig oder doppelt? | Befund, Exit 2, keine Zahl. | **Projektvorgabe**: `pruefwerkzeuge.md` Abschnitt 6 |
| 19 | `.obsidian` fehlt? | Befund, Exit 2: kein Obsidian-Vault. Fehlt nur `app.json` oder der Schlüssel, gilt kein Filter, wie in Obsidian ohne Einstellung. `app.json` kaputt oder mit falschem Typ: Befund. | **Projektvorgabe** |
| 20 | Datei oder Ordner nicht lesbar? | Befund, Exit 2, keine Zahl, weil eine Zahl ohne diese Dateien falsch wäre. | **Projektvorgabe**: `pruefwerkzeuge.md` Abschnitte 4 und 6 |
| 21 | Ausgabe und Exit? | Kopf, Zahlen und die Zeile `Waisen außerhalb der Ausnahmen: N`; `--liste` dazu die Pfade; `--zahl` nur N. Exit 0 ohne, 1 mit Waisen, 2 bei Abbruch. | **Projektvorgabe** |
| 22 | Laufzeit? | Unter 10 s über den Vault, gemessen mit `faelle_waisen.py --echt`. Im Sitzungsstart bricht der Hook nach 15 s ab. | **Projektvorgabe**: Hook-Budget 30 s in `.claude/settings.json` |
Keine Zeile steht auf Annahme ohne Prüfweg. Wo eine Antwort nicht aus einer Quelle stammt, ist sie Projektvorgabe und im Plan als Prüfschritt P1 bis P3 in der App benannt.
## Angriffsliste
Die einzelnen Fälle stehen an genau einer Stelle, in `faelle_waisen.py`. Hier steht, welche Klassen aus `pruefwerkzeuge.md` Abschnitt 3 abgedeckt sind.
| Klasse | Fälle |
|---|---|
| fehlt | `.obsidian`, `app.json`, Schlüssel `userIgnoreFilters`, Ausnahmedatei, `linkcheck.py`, Vault-Ordner, Schlusszeile des Frontmatters |
| null | `userIgnoreFilters` ist null |
| falscher Typ | `app.json` als Liste, Filtereintrag als Zahl, `app.json` ohne JSON |
| leer | leere Filterliste, leerer Filtereintrag, leere Ausnahmedatei, nur Kommentare, leere Notiz, Vault ohne Notiz |
| doppelt | doppeltes Muster, gleichnamige Notizen in zwei Ordnern: gleicher Ordner, relativ zur Quelle, kürzester Pfad; die richtige Wahl liegt jeweils alphabetisch hinten |
| zusätzlich | Punktordner, Punktdatei, andere Endung, Endung in Großbuchstaben, per Dateifilter ausgeblendete Notiz als Knoten und als Quelle |
| Wertebereich | Muster mit Backslash, führendem Schrägstrich, `..`, `.` als Pfadteil, leerem Pfadteil, Laufwerk; Stern über Ordnergrenze |
| Verweis ins Leere | Wikilink ins Leere, auf Anhang, auf ausgeblendete Notiz, Selbstreferenz, Anker allein |
| Reihenfolge | Waise an erster und letzter Stelle, Muster in erster und letzter Zeile, Filter an erster und letzter Stelle, Link am Dateiende ohne Zeilenumbruch |
| Umgebung | CRLF, BOM, ungültiges UTF-8 in Notiz und Ausnahmedatei, Umlaut und Emoji im Namen ohne `PYTHONIOENCODING` |
| Code | Wikilink im Zaun, Markdown-Link im Code-Span, Link nach dem Zaun, Zaun ohne Ende |
| Absturz | Notiz nicht lesbar, Ordner nicht lesbar, unerwarteter Fehler (alle im Prozess) |
## Absturzfestigkeit
Der Hauptablauf läuft in `try`. Ein erwarteter Befund (`Abbruch`) und ein unerwarteter Fehler enden beide mit Exit 2 und einer Zeile `Waisen - ABGEBROCHEN:`, ein unerwarteter Fehler ohne `--zahl` zusätzlich mit Traceback. Eine Teilzahl gibt es bewusst nicht: Der Zähler sammelt keine Einzelbefunde, die man trotz Abbruch zeigen könnte, und eine Zahl über einen Teil des Vaults wäre falsch.
## Rotlauf
`faelle_waisen.py --rotlauf` baut jeden Mutanten aus `MUTANTEN` in eine Kopie von `waisen.py` und fährt die genannten Fälle dagegen. Jeder Mutant entfernt genau eine Entscheidung aus der Liste oben. Ein Mutant, dessen Zeile nicht genau einmal in `waisen.py` steht, ist ein Befund, kein stilles Überspringen.
## Abbruchbedingung
Schluss ist, wenn der Treiber mit grüner Grundlinie ohne Fehlschlag durchläuft, der Rotlauf jeden Mutanten tötet, `--echt` unter 10 s bleibt, der Koordinator zehn Pfade aus `--liste` in Obsidian geöffnet und ohne Kante gefunden hat und eine Prüfrunde nur noch Umformulierungen findet.
## Was dieses Skript nicht ist
Ein Fangnetz. Es zählt nach nachgebauten Regeln. Wie der Graph aussieht, zeigt nur Obsidian selbst.
````
## Schritt 2 — Rot zuerst
Ohne `waisen.py` muss der Treiber abbrechen. In einem einzigen Bash-Aufruf, damit die Datei sicher zurückkommt:
```
mv D:/Claude/.vault-tools/linkcheck/waisen.py D:/Claude/.vault-tools/linkcheck/waisen.py.weg; python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py; echo "exit=$?"; mv D:/Claude/.vault-tools/linkcheck/waisen.py.weg D:/Claude/.vault-tools/linkcheck/waisen.py
```
Erwartet: `ABBRUCH: D:\Claude\.vault-tools\linkcheck\waisen.py fehlt` und `exit=2`. Danach liegt `waisen.py` wieder an seinem Platz.
## Schritt 3 — Angriffsliste
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py; echo "exit=$?"
```
Erwartet: erste Zeile `BESTANDEN GRUNDLINIE`, keine Zeile, die mit `FEHLER` beginnt, Schlusszeile `N von N Faellen bestanden.` mit zweimal derselben Zahl, `exit=0`.
## Schritt 4 — Rotlauf
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py --rotlauf; echo "exit=$?"
```
Erwartet: je Mutant eine Zeile `GETOETET`, keine Zeile `UEBERLEBT` oder `MUSTER`, Schlusszeile `Rotlauf: alle Mutanten getoetet.`, `exit=0`.
## Schritt 5 — Messung M1 über den echten Vault
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py --echt; echo "exit=$?"
```
Erwartet: die Zeilen `Notizen im Graphen:` und `Waisen außerhalb der Ausnahmen:` mit Zahlen, eine Zeile `exit=1 Laufzeit ... s` und `exit=0` am Ende. Steht dort `LAUFZEIT UEBER ZIEL`: nichts ändern, melden.
## Schritt 6 — Linkcheck unverändert
```
python D:/Claude/.vault-tools/linkcheck/faelle.py; echo "exit=$?"
```
Erwartet: `exit=0`.
## Bericht
Melde die vollständige Ausgabe von Schritt 1, 2, 4, 5 und 6, von Schritt 3 die Schlusszeile und jede Zeile, die nicht mit `BESTANDEN` beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.