Files
Pascal MichelandClaude Opus 5 deec79a6ea 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

56 KiB

type, title, status, summary, tags, created, updated, modell, modell_begruendung
type title status summary tags created updated modell modell_begruendung
plan-task Task 01 — Zähler waisen.py mit Ausnahmedatei, Treiber und Entwurf draft 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.
plan-task
vault-tools
2026-09-29 2026-09-30 haiku 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

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:

"""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:

# 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:

"""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:

# 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.