Files
claude-regelwerk/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-04-bestandsskript.md
T
Pascal MichelandClaude Opus 5 e8472409e9 Automatischer Push: 25 Datei(en) geändert
- .claude/agents/executer.md
- .claude/agents/recherche.md
- .claude/skills/anleitung-straffen/SKILL.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/00-uebersicht.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-01-zaehler.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-02-sitzungsstart.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-03-regeltexte.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-04-bestandsskript.md
- .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-05-bestandslauf.md
- .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/00-uebersicht.md
- .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-01-quorum-werkzeug.md
- .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-02-quorum-workflow.md
- … und 13 weitere

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

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

45 KiB

type, title, status, summary, tags, created, updated, modell, modell_begruendung
type title status summary tags created updated modell modell_begruendung
plan-task Task 04 — Bestandsskript waisen_bestand.py mit Treiber draft Legt waisen_bestand.py an, das verwaiste Notizen über Muster ihrem Hub zuordnet, im Probelauf nur auflistet und mit --anwenden erst sichert und dann anhängt, und faelle_waisen_bestand.py mit Git-Fällen und Rotlauf.
plan-task
vault-tools
2026-09-29 2026-09-29 haiku Zwei neue Dateien stehen wörtlich im Plan und werden per Skript angelegt; keine typografischen Zeichen in den Codeblöcken.

Übersicht: 00-uebersicht.md

Task 04 — Bestandsskript

Globale Vorgaben: 00-uebersicht.md im selben Ordner, Abschnitt „Globale Vorgaben“.

Voraussetzung: Task 01 ist umgesetzt. waisen_bestand.py importiert waisen.py aus demselben Ordner.

Zieldateien

  • D:\Claude\.vault-tools\linkcheck\waisen_bestand.py (neu)
  • D:\Claude\.vault-tools\linkcheck\faelle_waisen_bestand.py (neu)

Verbote

  • waisen_bestand.py in diesem Task nie gegen den echten Vault fahren, auch nicht als Probelauf. Das ist Task 05.
  • waisen.py, linkcheck.py und waisen-ausnahmen.txt bleiben unverändert.
  • Kein git im echten Vault oder in einem echten Projekt-Repo. Die Git-Fälle des Treibers arbeiten nur im Wegwerfordner.

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-04-bestandsskript.md

Erwartet: eine Zeile mit 'neu': 2 und die Schlusszeile 0 Befund(e).

.vault-tools/linkcheck/waisen_bestand.py

Die ganze Datei ist neu:

"""Hängt verwaiste Notizen einmalig an ihren Hub an: nur anhängen, nie ersetzen.

Ersteller: Pascal Michel
Erstellt:  2026-09-29
Plan:      docs/plans/2026-09-29-waisen/ im selben Ordner

Ohne --anwenden ist es ein Probelauf: Er listet je Zieldatei den Text, der
angehängt würde, und schreibt nichts. Mit --anwenden kopiert das Skript jede
Zieldatei zuerst in den Sicherungsordner außerhalb des Vaults, prüft jede
Kopie Byte für Byte und hängt erst danach an. Das Zeilenende jeder Datei
bleibt, wie es war. Kein Commit, kein git add.

REGELN, alle über Muster, ohne Namen einzelner Projekte oder Kunden
  Code Projekte/<p>/docs/plans/<ordner>/<datei>.md
      00-uebersicht.md desselben Ordners bekommt eine Zeile mit relativem Link
      aufs Projekt-README, wenn sie es nicht schon verlinkt, und den Abschnitt
      "Dateien in diesem Ordner" mit relativen Links auf jede .md im Ordner.
      Taskdateien fasst das Skript nie an.
  Code Projekte/<p>/CHANGELOG.md und jede Datei unter Code Projekte/<p>/docs/
  außerhalb von Planordnern
      eine Zeile mit relativem Link aufs Projekt-README.
  IT Workspace/**/_arbeitsstand/**
      Planordner wie oben, aber der Link nach oben ist ein Wikilink auf die
      einzige _MOC-*.md im Ordner über _arbeitsstand; jede andere Datei
      bekommt eine Zeile mit diesem Wikilink.
  IT Workspace/**/findings/<datei>.md
      der Bericht im Ordner über findings (ABSCHLUSSBERICHT.md, sonst
      FULL-AUDIT-REPORT.md) bekommt den Abschnitt "Befunddateien dieses Laufs"
      mit einem Wikilink mit vollem Pfad je Datei.
  AI/secondbrain/<ordner>/JJJJ-MM-TT-<thema>.md
      eine Zeile "Tagesnotiz: [[Inbox/JJJJ-MM-TT]]", nur wenn die Tagesnotiz
      existiert.
  Alles andere kommt auf die Liste für den Koordinator. Geraten wird nichts.

ÜBERSPRUNGEN UND AUFGELISTET wird eine Zieldatei, die
  * in einem Git-Repo unterhalb des Vaults liegt und dort in irgendeinem
    Arbeitsbaum uncommittet geändert oder neu ist oder auf einem anderen
    lokalen Zweig seit dem gemeinsamen Stand geändert wurde,
  * nicht als UTF-8 lesbar ist oder gemischte Zeilenenden hat,
  * die anzuhängende Überschrift oder Linkzeile schon enthält.
  Ein Git-Repo, das sich nicht prüfen lässt, sperrt alle seine Zieldateien.

BENANNTE LÜCKEN
  * Welche Notiz eine Waise ist, entscheidet waisen.py mit dessen Lücken.
  * Nur lokale Zweige zählen, entfernte nicht.
  * Eine Datei, die zwischen dem Lesen und dem Anhängen geändert wird, bricht
    den Lauf ab; was bis dahin angehängt war, nennt die Ausgabe.

Exit 0: Lauf durch. Exit 2: abgebrochen, die Ursache steht in der Ausgabe.

Aufruf:
    python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude
    python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude --anwenden --sicherung <ordner außerhalb des Vaults>
"""

import argparse
import os
import re
import shutil
import subprocess
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from urllib.parse import quote

HIER = Path(__file__).resolve().parent
if str(HIER) not in sys.path:
    sys.path.insert(0, str(HIER))

try:
    import waisen
    _IMPORTFEHLER = ""
except Exception as _fehler:
    waisen = None
    _IMPORTFEHLER = f"{type(_fehler).__name__}: {_fehler}"

ORDNER_KOPF = "## Dateien in diesem Ordner"
BEFUND_KOPF = "## Befunddateien dieses Laufs"
BERICHTE = ("ABSCHLUSSBERICHT.md", "FULL-AUDIT-REPORT.md")
_DATUM = re.compile(r"^(\d{4}-\d{2}-\d{2})")
GESCHRIEBEN = []


class Abbruch(Exception):
    """Der Lauf kann nicht sicher weiter."""


@dataclass
class Ziel:
    rel: str
    zeilen: list = field(default_factory=list)
    ordner: Optional[str] = None
    befunde: Optional[str] = None
    ausloeser: list = field(default_factory=list)
    hubs: set = field(default_factory=set)
    gelistet: list = field(default_factory=list)


def existiert(vault: Path, rel: str) -> bool:
    return (vault / rel).is_file()


def rel_link(von: str, zu: str) -> str:
    start = von.split("/")[:-1]
    ziel = zu.split("/")
    gemeinsam = 0
    while gemeinsam < min(len(start), len(ziel) - 1) and start[gemeinsam] == ziel[gemeinsam]:
        gemeinsam += 1
    teile = [".."] * (len(start) - gemeinsam) + ziel[gemeinsam:]
    return quote("/".join(teile), safe="/")


def _code_projekt(rel: str, teile: list, vault: Path) -> tuple:
    readme = "/".join(teile[:2]) + "/README.md"
    innen = teile[2:]
    if innen[:2] == ["docs", "plans"] and len(innen) == 4:
        uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md"
        if not existiert(vault, uebersicht):
            return ("rest", "", "Planordner ohne 00-uebersicht.md", "")
        if not existiert(vault, readme):
            return ("rest", "", f"{readme} fehlt", "")
        return ("ordner", uebersicht, f"Projekt: [README]({rel_link(uebersicht, readme)})", readme)
    if innen[:2] == ["docs", "plans"] and len(innen) > 4:
        return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "")
    if innen == ["CHANGELOG.md"] or (innen[:1] == ["docs"] and len(innen) >= 2):
        if not existiert(vault, readme):
            return ("rest", "", f"{readme} fehlt", "")
        return ("zeile", rel, f"Projekt: [README]({rel_link(rel, readme)})", readme)
    return ("rest", "", "keine Regel passt", "")


def _arbeitsstand(rel: str, teile: list, vault: Path) -> tuple:
    stelle = teile.index("_arbeitsstand")
    projekt = "/".join(teile[:stelle])
    try:
        mocs = sorted(
            name for name in os.listdir(vault / projekt)
            if name.startswith("_MOC-") and name.lower().endswith(".md") and (vault / projekt / name).is_file()
        )
    except OSError as fehler:
        return ("rest", "", f"Projektordner nicht lesbar ({fehler})", "")
    if len(mocs) != 1:
        return ("rest", "", f"{len(mocs)} _MOC-Dateien in {projekt}, erwartet genau eine", "")
    moc = f"{projekt}/{mocs[0]}"
    zeile = f"Projekt: [[{moc[:-3]}]]"
    innen = teile[stelle + 1:]
    if innen[:2] == ["docs", "plans"] and len(innen) == 4:
        uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md"
        if not existiert(vault, uebersicht):
            return ("rest", "", "Planordner ohne 00-uebersicht.md", "")
        return ("ordner", uebersicht, zeile, moc)
    if innen[:2] == ["docs", "plans"] and len(innen) > 4:
        return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "")
    return ("zeile", rel, zeile, moc)


def _befund(rel: str, teile: list, vault: Path) -> tuple:
    lauf = "/".join(teile[:-2])
    for name in BERICHTE:
        if existiert(vault, f"{lauf}/{name}"):
            bericht = f"{lauf}/{name}"
            return ("befund", bericht, "/".join(teile[:-1]), bericht)
    return ("rest", "", f"kein Bericht ({' oder '.join(BERICHTE)}) in {lauf}", "")


def _dump(rel: str, teile: list, vault: Path) -> tuple:
    treffer = _DATUM.match(teile[3])
    if not treffer:
        return ("rest", "", "Dump ohne Datum am Anfang des Dateinamens", "")
    tag = treffer.group(1)
    if not existiert(vault, f"Inbox/{tag}.md"):
        return ("rest", "", f"Tagesnotiz Inbox/{tag}.md fehlt", "")
    return ("zeile", rel, f"Tagesnotiz: [[Inbox/{tag}]]", f"Inbox/{tag}.md")


def klassifiziere(rel: str, vault: Path) -> tuple:
    """Liefert (art, ziel, wert, hub). art ist zeile, ordner, befund oder rest."""
    teile = rel.split("/")
    if teile[0] == "Code Projekte" and len(teile) >= 3:
        return _code_projekt(rel, teile, vault)
    if teile[0] == "IT Workspace" and "_arbeitsstand" in teile[1:-1]:
        return _arbeitsstand(rel, teile, vault)
    if teile[0] == "IT Workspace" and len(teile) >= 3 and teile[-2] == "findings":
        return _befund(rel, teile, vault)
    if teile[:2] == ["AI", "secondbrain"] and len(teile) == 4:
        return _dump(rel, teile, vault)
    return ("rest", "", "keine Regel passt", "")


def plane(vault: Path, ergebnis) -> tuple:
    ziele = {}
    rest = []
    for rel in ergebnis.waisen:
        art, ziel_rel, wert, hub = klassifiziere(rel, vault)
        if art == "rest":
            rest.append((rel, wert))
            continue
        ziel = ziele.setdefault(ziel_rel, Ziel(ziel_rel))
        ziel.ausloeser.append(rel)
        ziel.hubs.add(hub)
        if art == "befund":
            ziel.befunde = wert
            continue
        if art == "ordner":
            ziel.ordner = ziel_rel.rsplit("/", 1)[0]
            if hub in ergebnis.ausgehend.get(ziel_rel, set()):
                continue
        if wert not in ziel.zeilen:
            ziel.zeilen.append(wert)
    return ziele, rest


def md_dateien(vault: Path, ordner: str) -> list:
    pfad = vault / ordner
    return sorted(
        name for name in os.listdir(pfad)
        if name.lower().endswith(".md") and not name.startswith(".") and (pfad / name).is_file()
    )


def anhang(vault: Path, ziel: Ziel) -> list:
    zeilen = list(ziel.zeilen)
    if ziel.ordner is not None:
        namen = [n for n in md_dateien(vault, ziel.ordner) if n != "00-uebersicht.md"]
        if namen:
            if zeilen:
                zeilen.append("")
            zeilen += [ORDNER_KOPF, ""]
            zeilen += [f"- [{n}]({quote(n, safe='')})" for n in namen]
            ziel.gelistet += [f"{ziel.ordner}/{n}" for n in namen]
    if ziel.befunde is not None:
        namen = md_dateien(vault, ziel.befunde)
        if namen:
            if zeilen:
                zeilen.append("")
            zeilen += [BEFUND_KOPF, ""]
            zeilen += [f"- [[{ziel.befunde}/{n[:-3]}]]" for n in namen]
            ziel.gelistet += [f"{ziel.befunde}/{n}" for n in namen]
    return zeilen


def pruefe_ziel(vault: Path, rel: str, zeilen: list) -> tuple:
    """Liefert (Grund zum Überspringen oder None, alter Inhalt, Zeilenende)."""
    alt = (vault / rel).read_bytes()
    try:
        text = alt.decode("utf-8")
    except UnicodeDecodeError:
        return "nicht als UTF-8 lesbar", alt, ""
    crlf = alt.count(b"\r\n")
    lf = alt.count(b"\n") - crlf
    if crlf and lf:
        return "gemischte Zeilenenden", alt, ""
    ende = "\r\n" if crlf else "\n"
    vorhanden = text.replace("\r\n", "\n").split("\n")
    for zeile in zeilen:
        if zeile and not zeile.startswith("- "):
            if zeile in vorhanden:
                return f"enthält schon die Zeile {zeile!r}", alt, ende
    return None, alt, ende


def anhang_bytes(alt: bytes, zeilen: list, ende: str) -> bytes:
    vorne = ende if alt and not alt.endswith(b"\n") else ""
    return (vorne + ende + ende.join(zeilen) + ende).encode("utf-8")


def repo_von(vault: Path, rel: str) -> Optional[Path]:
    ordner = (vault / rel).parent
    while ordner != vault and vault in ordner.parents:
        if (ordner / ".git").exists():
            return ordner
        ordner = ordner.parent
    return None


def git(ordner: Path, *argumente: str) -> bytes:
    umgebung = dict(os.environ)
    # Kein Index-Auffrischen: der Probelauf schreibt auch in .git nichts.
    umgebung["GIT_OPTIONAL_LOCKS"] = "0"
    try:
        lauf = subprocess.run(
            ["git", "-C", str(ordner), *argumente],
            capture_output=True,
            stdin=subprocess.DEVNULL,
            timeout=120,
            env=umgebung,
        )
    except (OSError, subprocess.TimeoutExpired) as fehler:
        raise Abbruch(f"git {' '.join(argumente)} in {ordner}: {fehler}") from fehler
    if lauf.returncode != 0:
        meldung = lauf.stderr.decode("utf-8", errors="replace").strip()
        raise Abbruch(f"git {' '.join(argumente)} in {ordner}: Exit {lauf.returncode}: {meldung}")
    return lauf.stdout


def geaenderte_pfade(repo: Path) -> dict:
    """Pfad relativ zum Repo, klein geschrieben, und wo er geändert ist."""
    wo = {}
    liste = git(repo, "worktree", "list", "--porcelain").decode("utf-8", errors="replace")
    baeume = [z[len("worktree "):].strip() for z in liste.split("\n") if z.startswith("worktree ")]
    if not baeume:
        raise Abbruch("git worktree list nennt keinen Arbeitsbaum")
    for baum in baeume:
        roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all")
        eintraege = roh.decode("utf-8", errors="replace").split("\0")
        i = 0
        while i < len(eintraege):
            eintrag = eintraege[i]
            i += 1
            if len(eintrag) < 4:
                continue
            wo.setdefault(eintrag[3:].lower(), f"Arbeitsbaum {baum}")
            if "R" in eintrag[:2] or "C" in eintrag[:2]:
                if i < len(eintraege) and eintraege[i]:
                    wo.setdefault(eintraege[i].lower(), f"Arbeitsbaum {baum}")
                i += 1
    aktuell = git(repo, "rev-parse", "--abbrev-ref", "HEAD").decode("utf-8", errors="replace").strip()
    zweige = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/").decode("utf-8", errors="replace")
    for zweig in (z.strip() for z in zweige.split("\n")):
        if not zweig or zweig == aktuell:
            continue
        diff = git(repo, "diff", "--name-only", "-z", f"HEAD...{zweig}", "--")
        for pfad in diff.decode("utf-8", errors="replace").split("\0"):
            if pfad:
                wo.setdefault(pfad.lower(), f"Zweig {zweig}")
    return wo


def pruefe_alle(vault: Path, ziele: dict) -> tuple:
    repos = {}
    fertig = []
    uebersprungen = []
    for rel in sorted(ziele):
        ziel = ziele[rel]
        zeilen = anhang(vault, ziel)
        repo = repo_von(vault, rel)
        if repo is not None:
            if repo not in repos:
                try:
                    repos[repo] = geaenderte_pfade(repo)
                except Abbruch as fehler:
                    repos[repo] = str(fehler)
            stand = repos[repo]
            if isinstance(stand, str):
                uebersprungen.append((ziel, f"Git-Repo nicht prüfbar: {stand}"))
                continue
            innen = (vault / rel).relative_to(repo).as_posix().lower()
            if innen in stand:
                uebersprungen.append((ziel, f"in Git geändert ({stand[innen]})"))
                continue
        grund, alt, ende = pruefe_ziel(vault, rel, zeilen)
        if grund:
            uebersprungen.append((ziel, grund))
            continue
        fertig.append((ziel, zeilen, alt, ende))
    return fertig, uebersprungen


def zeige(vault: Path, anwenden: bool, vorher: int, fertig: list, uebersprungen: list, rest: list, mitverbunden: int) -> None:
    print(f"# Bestandslauf - {'Anwenden' if anwenden else 'Probelauf'} - {vault}")
    print(f"Waisen außerhalb der Ausnahmen vorher: {vorher}")
    print()
    print(f"## Anhänge ({len(fertig)} Dateien)")
    for ziel, zeilen, _alt, ende in fertig:
        art = "CRLF" if ende == "\r\n" else "LF"
        print(f"ZIEL {ziel.rel}  [{art}]  wegen {len(ziel.ausloeser)} Waise(n)")
        for zeile in zeilen:
            print(f"    | {zeile}")
    print()
    print(f"## Übersprungen ({len(uebersprungen)})")
    for ziel, grund in uebersprungen:
        print(f"UEBERSPRUNGEN {ziel.rel}: {grund} (betrifft {len(ziel.ausloeser)} Waise(n))")
    print()
    print(f"## Liste für den Koordinator ({len(rest)})")
    for rel, grund in rest:
        print(f"REST {rel}: {grund}")
    print(f"Durch Anhänge mitverbunden, ohne eigene Regel: {mitverbunden}")


def sichern_und_anhaengen(vault: Path, sicherung: Path, fertig: list) -> None:
    sicherung.mkdir(parents=True)
    for ziel, _zeilen, alt, _ende in fertig:
        kopie = sicherung / ziel.rel
        kopie.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(vault / ziel.rel, kopie)
        if kopie.read_bytes() != alt:
            raise Abbruch(f"Sicherung weicht vom Original ab: {kopie}")
    for ziel, zeilen, alt, ende in fertig:
        pfad = vault / ziel.rel
        if pfad.read_bytes() != alt:
            raise Abbruch(f"{ziel.rel} hat sich seit dem Lesen geändert")
        neu = anhang_bytes(alt, zeilen, ende)
        with open(pfad, "ab") as datei:
            datei.write(neu)
        GESCHRIEBEN.append(ziel.rel)
        if pfad.read_bytes() != alt + neu:
            raise Abbruch(f"{ziel.rel}: Inhalt nach dem Anhängen weicht ab")
    (sicherung / "GEAENDERT.txt").write_text("\n".join(GESCHRIEBEN) + "\n", encoding="utf-8", newline="\n")


def lauf(vault: Path, ausnahmen: Path, anwenden: bool, sicherung) -> int:
    GESCHRIEBEN.clear()
    if waisen is None:
        raise Abbruch(f"waisen.py nicht importierbar ({_IMPORTFEHLER})")
    sicherung_pfad = None
    if anwenden:
        if not sicherung:
            raise Abbruch("--anwenden braucht --sicherung mit einem Ordner außerhalb des Vaults")
        sicherung_pfad = Path(os.path.abspath(sicherung)).resolve()
        if sicherung_pfad == vault or vault in sicherung_pfad.parents:
            raise Abbruch(f"Sicherungsordner liegt im Vault: {sicherung_pfad}")
        if sicherung_pfad.exists():
            raise Abbruch(f"Sicherungsordner existiert schon: {sicherung_pfad}")
    ergebnis = waisen.analysiere(vault, ausnahmen)
    ziele, rest = plane(vault, ergebnis)
    fertig, uebersprungen = pruefe_alle(vault, ziele)
    verbunden = set()
    for ziel, _zeilen, _alt, _ende in fertig:
        verbunden.add(ziel.rel)
        verbunden.update(ziel.hubs)
        verbunden.update(ziel.gelistet)
    anzahl = len(rest)
    rest = [(rel, grund) for rel, grund in rest if rel not in verbunden]
    zeige(vault, anwenden, len(ergebnis.waisen), fertig, uebersprungen, rest, anzahl - len(rest))
    if anwenden:
        sichern_und_anhaengen(vault, sicherung_pfad, fertig)
        nachher = waisen.analysiere(vault, ausnahmen)
        print()
        print(f"Angehängt an {len(GESCHRIEBEN)} Dateien. Sicherung und GEAENDERT.txt: {sicherung_pfad}")
        print(f"Waisen außerhalb der Ausnahmen nachher: {len(nachher.waisen)}")
    return 0


def main(argv=None) -> int:
    zerleger = argparse.ArgumentParser(description="Hängt verwaiste Notizen einmalig an ihren Hub an")
    zerleger.add_argument("--vault", required=True)
    zerleger.add_argument("--ausnahmen", default=str(HIER / "waisen-ausnahmen.txt"))
    zerleger.add_argument("--anwenden", action="store_true")
    zerleger.add_argument("--sicherung")
    args = zerleger.parse_args(argv)
    vault = Path(os.path.abspath(args.vault)).resolve()
    try:
        return lauf(vault, Path(args.ausnahmen), args.anwenden, args.sicherung)
    except Exception as fehler:
        erwartet = isinstance(fehler, Abbruch) or (waisen is not None and isinstance(fehler, waisen.Abbruch))
        art = "" if erwartet else f"{type(fehler).__name__}: "
        print(f"Bestandslauf - ABGEBROCHEN: {art}{fehler}")
        if not erwartet:
            print(traceback.format_exc())
        if GESCHRIEBEN:
            print(f"Vor dem Abbruch schon angehängt ({len(GESCHRIEBEN)}), die Sicherung liegt vor:")
            for rel in GESCHRIEBEN:
                print(f"  - {rel}")
        return 2


if __name__ == "__main__":
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except AttributeError:
        pass
    sys.exit(main())

.vault-tools/linkcheck/faelle_waisen_bestand.py

Die ganze Datei ist neu:

"""Fährt die Angriffsliste gegen waisen_bestand.py, je Fall auf einem Wegwerf-Vault.

Ersteller: Pascal Michel
Erstellt:  2026-09-29
Plan:      docs/plans/2026-09-29-waisen/ im selben Ordner

Grundlinie zuerst: Der Probelauf über den unbeschädigten Wegwerf-Vault muss
genau die erwarteten Anhänge und genau die erwartete Liste liefern und darf
nichts ändern. Sonst misst der Rest nichts.

Je Fall eine frische Kopie der Grundlinie, genau eine Änderung, das Skript als
eigener Prozess. Die Git-Fälle legen im Wegwerf-Vault ein Repo mit Zweig oder
Arbeitsbaum an und brauchen git im PATH. Verglichen wird jede Datei des
Wegwerf-Vaults vor und nach dem Lauf, Byte für Byte.

--rotlauf baut jeden Mutanten aus MUTANTEN in eine Kopie des Skripts und fährt
die genannten Fälle dagegen. Getötet heißt: jeder genannte Fall schlägt fehl.

Aufruf:
    python .vault-tools/linkcheck/faelle_waisen_bestand.py
    python .vault-tools/linkcheck/faelle_waisen_bestand.py --rotlauf
"""

import os
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional

HIER = Path(__file__).resolve().parent
BESTAND = HIER / "waisen_bestand.py"
BEGLEITER = [HIER / "waisen.py", HIER / "linkcheck.py", HIER / "waisen-ausnahmen.txt"]

P = "Code Projekte/active - Alpha"
E = P + "/docs/plans/2026-01-01-eins"
S = "IT Workspace/SEO/kunde-audit/15012026"
X = "IT Workspace/Thema/Projekt X"
XP = X + "/_arbeitsstand/docs/plans/2026-01-03-p"
DUMP = "AI/secondbrain/alpha-memory/2026-01-01-thema.md"
DUMP_OHNE = "AI/secondbrain/alpha-memory/2026-01-05-ohne.md"
UMLAUT = "task-09-prüfung x.md"
UMLAUT_LINK = "task-09-pr%C3%BCfung%20x.md"
ORDNER_KOPF = "## Dateien in diesem Ordner"
BEFUND_KOPF = "## Befunddateien dieses Laufs"

ZIELE = {
    P + "/CHANGELOG.md": ["Projekt: [README](README.md)"],
    P + "/docs/code/modul.md": ["Projekt: [README](../../README.md)"],
    P + "/docs/plans/2026-01-02-einzel.md": ["Projekt: [README](../../README.md)"],
    E + "/00-uebersicht.md": ["Projekt: [README](../../../README.md)", "", ORDNER_KOPF, "",
                              "- [task-01-a.md](task-01-a.md)", f"- [{UMLAUT}]({UMLAUT_LINK})"],
    DUMP: ["Tagesnotiz: [[Inbox/2026-01-01]]"],
    S + "/ABSCHLUSSBERICHT.md": [BEFUND_KOPF, "", f"- [[{S}/findings/a]]", f"- [[{S}/findings/b]]"],
    X + "/_arbeitsstand/notiz.md": [f"Projekt: [[{X}/_MOC-projekt-x]]"],
    XP + "/00-uebersicht.md": [f"Projekt: [[{X}/_MOC-projekt-x]]", "", ORDNER_KOPF, "",
                               "- [task-01-t.md](task-01-t.md)"],
}
REST = {
    P + "/notizen.md": "keine Regel passt",
    DUMP_OHNE: "Tagesnotiz Inbox/2026-01-05.md fehlt",
}


def schreibe(v: Path, rel: str, inhalt) -> None:
    # Immer Bytes: write_text würde unter Windows jedes LF zu CRLF machen.
    pfad = v / rel
    pfad.parent.mkdir(parents=True, exist_ok=True)
    pfad.write_bytes(inhalt if isinstance(inhalt, bytes) else inhalt.encode("utf-8"))


def haenge_an(v: Path, rel: str, inhalt: bytes) -> None:
    with open(v / rel, "ab") as datei:
        datei.write(inhalt)


def _schreibbar(funktion, pfad, _info) -> None:
    # Git legt Objektdateien schreibgeschützt an; rmtree scheitert sonst unter Windows.
    os.chmod(pfad, stat.S_IWRITE)
    funktion(pfad)


def entferne(pfad: Path) -> None:
    if not pfad.exists():
        return
    if sys.version_info >= (3, 12):
        shutil.rmtree(pfad, onexc=_schreibbar)
    else:
        shutil.rmtree(pfad, onerror=_schreibbar)


def nichts(v: Path) -> None:
    return None


def baue_grundlinie(v: Path) -> None:
    schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["_index/"]}')
    schreibe(v, "Wissen/start.md",
             f"[[{P}/README]]\n[[IT Workspace/SEO/_MOC-seo]]\n[[{X}/_MOC-projekt-x]]\n")
    schreibe(v, P + "/README.md", "# Alpha\n")
    schreibe(v, P + "/CHANGELOG.md", "# Changelog")
    schreibe(v, P + "/notizen.md", "# Notizen\n")
    schreibe(v, P + "/data/2026-01-01.md", "# Auswertung\n")
    schreibe(v, P + "/docs/code/modul.md", b"# Modul\r\n\r\nText\r\n")
    schreibe(v, P + "/docs/plans/2026-01-02-einzel.md", "# Einzel\n")
    schreibe(v, E + "/00-uebersicht.md", "# Eins\n")
    schreibe(v, E + "/task-01-a.md", "# A\n")
    schreibe(v, E + "/" + UMLAUT, "# Z\n")
    schreibe(v, DUMP, "# Dump\n")
    schreibe(v, DUMP_OHNE, "# Dump\n")
    schreibe(v, "Inbox/2026-01-01.md", "# Tag\n")
    schreibe(v, "IT Workspace/SEO/_MOC-seo.md", f"[[{S}/ABSCHLUSSBERICHT]]\n")
    schreibe(v, S + "/ABSCHLUSSBERICHT.md", "# Bericht\n")
    schreibe(v, S + "/findings/a.md", "# A\n")
    schreibe(v, S + "/findings/b.md", "# B\n")
    schreibe(v, X + "/_MOC-projekt-x.md", "# X\n")
    schreibe(v, X + "/_arbeitsstand/notiz.md", "# N\n")
    schreibe(v, XP + "/00-uebersicht.md", "# P\n")
    schreibe(v, XP + "/task-01-t.md", "# T\n")
    schreibe(v, "Bookstack/archiv/seite.md", "# S\n")
    schreibe(v, "_index/INDEX.md", "# I\n")


def git(ordner: Path, *argumente: str) -> None:
    lauf = subprocess.run(
        ["git", "-C", str(ordner), "-c", "user.name=Faelle", "-c", "[email protected]",
         "-c", "commit.gpgsign=false", *argumente],
        capture_output=True,
        stdin=subprocess.DEVNULL,
    )
    if lauf.returncode != 0:
        raise RuntimeError(f"git {' '.join(argumente)}: {lauf.stderr.decode('utf-8', errors='replace')}")


def repo_anlegen(v: Path, ohne: Optional[str] = None) -> Path:
    repo = v / P
    git(repo, "init", "-q", "-b", "main")
    git(repo, "config", "core.autocrlf", "false")
    git(repo, "add", "-A")
    if ohne:
        git(repo, "rm", "-q", "--cached", ohne)
    git(repo, "commit", "-q", "-m", "Stand")
    return repo


def zweig_aendert(v: Path) -> None:
    repo = repo_anlegen(v)
    git(repo, "checkout", "-q", "-b", "neben")
    schreibe(v, E + "/00-uebersicht.md", "# Eins auf dem Zweig\n")
    git(repo, "commit", "-q", "-am", "Zweig")
    git(repo, "checkout", "-q", "main")


def arbeitsbaum_aendert(v: Path) -> None:
    repo = repo_anlegen(v)
    baum = v / ".arbeitsbaeume" / "wb"
    git(repo, "worktree", "add", "-q", "-b", "wb", str(baum))
    haenge_an(baum, "docs/plans/2026-01-01-eins/00-uebersicht.md", b"offen\n")


def eigener_baum_aendert(v: Path) -> None:
    repo_anlegen(v)
    haenge_an(v, E + "/00-uebersicht.md", b"offen\n")


def nie_committet(v: Path) -> None:
    repo_anlegen(v, ohne="CHANGELOG.md")


def sauberes_repo(v: Path) -> None:
    repo_anlegen(v)


def abbild(v: Path) -> dict:
    ergebnis = {}
    for pfad in sorted(v.rglob("*")):
        rel = pfad.relative_to(v)
        if ".git" in rel.parts or not pfad.is_file():
            continue
        ergebnis[rel.as_posix()] = pfad.read_bytes()
    return ergebnis


@dataclass
class Fall:
    name: str
    aendern: Callable = nichts
    # probe, anwenden, zweimal, ohne_sicherung, sicherung_da, sicherung_im_vault, anwenden_abbruch
    modus: str = "probe"
    code: int = 0
    ziele: Optional[dict] = None
    ziel_zeilen: dict = field(default_factory=dict)
    mit_ziel: list = field(default_factory=list)
    ohne_ziel: list = field(default_factory=list)
    rest: Optional[dict] = None
    mit_rest: dict = field(default_factory=dict)
    ueber: dict = field(default_factory=dict)
    texte: list = field(default_factory=list)
    nachher: dict = field(default_factory=dict)


GRUNDLINIE = Fall("GRUNDLINIE", ziele=ZIELE, rest=REST,
                  texte=["Waisen außerhalb der Ausnahmen vorher: 15",
                         "Durch Anhänge mitverbunden, ohne eigene Regel: 1"])

FAELLE = [
    Fall("anwenden    | schreibt genau die erwarteten Bytes", modus="anwenden", ziele=ZIELE, rest=REST,
         texte=["Waisen außerhalb der Ausnahmen nachher: 2"],
         nachher={
             P + "/CHANGELOG.md": b"# Changelog\n\nProjekt: [README](README.md)\n",
             P + "/docs/code/modul.md": b"# Modul\r\n\r\nText\r\n\r\nProjekt: [README](../../README.md)\r\n",
             E + "/00-uebersicht.md": (
                 "# Eins\n\nProjekt: [README](../../../README.md)\n\n## Dateien in diesem Ordner\n\n"
                 "- [task-01-a.md](task-01-a.md)\n- [task-09-prüfung x.md](task-09-pr%C3%BCfung%20x.md)\n"
             ).encode("utf-8"),
         }),
    Fall("anwenden    | zweiter Lauf findet nichts mehr", modus="zweimal", ziele=ZIELE, rest=REST),
    Fall("anwenden    | ohne --sicherung bricht ab", modus="ohne_sicherung", code=2,
         texte=["braucht --sicherung"]),
    Fall("anwenden    | Sicherungsordner existiert schon", modus="sicherung_da", code=2,
         texte=["existiert schon"]),
    Fall("anwenden    | Sicherungsordner im Vault", modus="sicherung_im_vault", code=2,
         texte=["liegt im Vault"]),
    Fall("anwenden    | Befund des Zaehlers bricht ohne Schreiben ab",
         lambda v: entferne(v / ".obsidian"), modus="anwenden_abbruch", code=2,
         texte=["Bestandslauf - ABGEBROCHEN", "Ordner .obsidian fehlt"]),
    Fall("zeilenende  | gemischte Zeilenenden werden uebersprungen",
         lambda v: schreibe(v, P + "/docs/code/modul.md", b"# Modul\r\nText\n"),
         ueber={P + "/docs/code/modul.md": "gemischte Zeilenenden"}, ohne_ziel=[P + "/docs/code/modul.md"]),
    Fall("kodierung   | kein UTF-8 wird uebersprungen",
         lambda v: schreibe(v, P + "/docs/plans/2026-01-02-einzel.md", b"# Einzel \xff\n"),
         ueber={P + "/docs/plans/2026-01-02-einzel.md": "nicht als UTF-8 lesbar"}),
    Fall("git         | anderer Zweig hat die Uebersicht geaendert", zweig_aendert,
         ueber={E + "/00-uebersicht.md": "Zweig neben"}, mit_ziel=[P + "/CHANGELOG.md"]),
    Fall("git         | anderer Arbeitsbaum hat die Uebersicht uncommittet geaendert", arbeitsbaum_aendert,
         ueber={E + "/00-uebersicht.md": "Arbeitsbaum"}, mit_ziel=[P + "/CHANGELOG.md"]),
    Fall("git         | Uebersicht im eigenen Arbeitsbaum geaendert", eigener_baum_aendert,
         ueber={E + "/00-uebersicht.md": "Arbeitsbaum"}),
    Fall("git         | nie committete Datei zaehlt als geaendert", nie_committet,
         ueber={P + "/CHANGELOG.md": "Arbeitsbaum"}),
    Fall("git         | sauberes Repo ohne Zweig wird angehaengt", sauberes_repo, ziele=ZIELE, rest=REST),
    Fall("regel       | Planordner ohne 00-uebersicht.md",
         lambda v: (v / E / "00-uebersicht.md").unlink(),
         mit_rest={E + "/task-01-a.md": "Planordner ohne 00-uebersicht.md",
                   E + "/" + UMLAUT: "Planordner ohne 00-uebersicht.md"}),
    Fall("regel       | Planordner nur mit Uebersicht",
         lambda v: ((v / E / "task-01-a.md").unlink(), (v / E / UMLAUT).unlink()),
         ziel_zeilen={E + "/00-uebersicht.md": ["Projekt: [README](../../../README.md)"]}),
    Fall("regel       | Projekt ohne README",
         lambda v: (v / P / "README.md").unlink(),
         mit_rest={P + "/CHANGELOG.md": "README.md fehlt", E + "/task-01-a.md": "README.md fehlt"},
         ohne_ziel=[P + "/CHANGELOG.md", E + "/00-uebersicht.md"]),
    Fall("regel       | Befunde ohne Bericht",
         lambda v: (v / S / "ABSCHLUSSBERICHT.md").unlink(),
         mit_rest={S + "/findings/a.md": "kein Bericht", S + "/findings/b.md": "kein Bericht"}),
    Fall("regel       | FULL-AUDIT-REPORT als Bericht",
         lambda v: (v / S / "ABSCHLUSSBERICHT.md").rename(v / S / "FULL-AUDIT-REPORT.md"),
         ziel_zeilen={S + "/FULL-AUDIT-REPORT.md": [BEFUND_KOPF, "", f"- [[{S}/findings/a]]",
                                                    f"- [[{S}/findings/b]]"]},
         ohne_ziel=[S + "/ABSCHLUSSBERICHT.md"]),
    Fall("regel       | zwei MOC-Dateien ueber _arbeitsstand",
         lambda v: schreibe(v, X + "/_MOC-zwei.md", "# Z\n"),
         mit_rest={X + "/_arbeitsstand/notiz.md": "2 _MOC-Dateien"}),
    Fall("regel       | Abschnitt schon vorhanden",
         lambda v: schreibe(v, E + "/00-uebersicht.md", "# Eins\n\n## Dateien in diesem Ordner\n\n- alt\n"),
         ueber={E + "/00-uebersicht.md": "enthält schon"}),
    Fall("regel       | Uebersicht verlinkt das README schon",
         lambda v: schreibe(v, E + "/00-uebersicht.md", "# Eins\n\n[README](../../../README.md)\n"),
         ziel_zeilen={E + "/00-uebersicht.md": [ORDNER_KOPF, "", "- [task-01-a.md](task-01-a.md)",
                                                f"- [{UMLAUT}]({UMLAUT_LINK})"]}),
    Fall("regel       | Dump tiefer als ein Ordner",
         lambda v: schreibe(v, "AI/secondbrain/alpha-memory/drehbuecher/2026-01-01-x.md", "# D\n"),
         mit_rest={"AI/secondbrain/alpha-memory/drehbuecher/2026-01-01-x.md": "keine Regel passt"}),
]

ALLE = [GRUNDLINIE] + FAELLE

# (Name, Zeile in waisen_bestand.py, Ersatzzeile, Fälle, die rot werden müssen)
MUTANTEN = [
    ("Zeilenende immer LF",
     r'    ende = "\r\n" if crlf else "\n"',
     r'    ende = "\n"',
     ["anwenden    | schreibt genau die erwarteten Bytes"]),
    ("Git-Pruefung aus",
     "        repo = repo_von(vault, rel)",
     "        repo = None",
     ["git         | anderer Zweig hat die Uebersicht geaendert",
      "git         | anderer Arbeitsbaum hat die Uebersicht uncommittet geaendert",
      "git         | Uebersicht im eigenen Arbeitsbaum geaendert",
      "git         | nie committete Datei zaehlt als geaendert"]),
    ("vorhandene Zeile nicht erkannt",
     "            if zeile in vorhanden:",
     "            if False:",
     ["regel       | Abschnitt schon vorhanden"]),
    ("Sicherung leer",
     "        shutil.copy2(vault / ziel.rel, kopie)",
     "        kopie.write_bytes(b\"\")",
     ["anwenden    | schreibt genau die erwarteten Bytes"]),
    ("Liste nicht gefiltert",
     "    rest = [(rel, grund) for rel, grund in rest if rel not in verbunden]",
     "    rest = list(rest)",
     ["GRUNDLINIE"]),
    ("Sicherung im Vault erlaubt",
     "        if sicherung_pfad == vault or vault in sicherung_pfad.parents:",
     "        if False:",
     ["anwenden    | Sicherungsordner im Vault"]),
]


def streng_utf8(roh: bytes) -> str:
    try:
        return roh.decode("utf-8")
    except UnicodeDecodeError as exc:
        return f"UNGUELTIGES-UTF-8: {exc}"


def fahre(skript: Path, argumente: list) -> tuple:
    start = time.perf_counter()
    lauf = subprocess.run(
        [sys.executable, str(skript), *argumente],
        capture_output=True,
        stdin=subprocess.DEVNULL,
        timeout=300,
    )
    dauer = time.perf_counter() - start
    return lauf.returncode, streng_utf8(lauf.stdout), lauf.stderr.decode("utf-8", errors="replace"), dauer


def lies_ausgabe(out: str) -> tuple:
    ziele, rest, ueber = {}, {}, {}
    aktuell = None
    for zeile in out.replace("\r\n", "\n").split("\n"):
        if zeile.startswith("ZIEL "):
            aktuell = zeile[5:].split("  [", 1)[0]
            ziele[aktuell] = []
        elif zeile.startswith("    | ") and aktuell is not None:
            ziele[aktuell].append(zeile[6:])
        else:
            aktuell = None
            if zeile.startswith("REST "):
                rel, _, grund = zeile[5:].partition(": ")
                rest[rel] = grund
            elif zeile.startswith("UEBERSPRUNGEN "):
                rel, _, grund = zeile[14:].partition(": ")
                ueber[rel] = grund
    return ziele, rest, ueber


def bewerte(fall: Fall, code: int, out: str, v: Path, vorher: dict, sicherung: Path) -> list:
    probleme = []
    if code != fall.code:
        probleme.append(f"Exit {code} statt {fall.code}")
    ziele, rest, ueber = lies_ausgabe(out)
    if fall.ziele is not None:
        if sorted(ziele) != sorted(fall.ziele):
            probleme.append(f"Ziele {sorted(ziele)} statt {sorted(fall.ziele)}")
        for rel, zeilen in fall.ziele.items():
            if rel in ziele and ziele[rel] != zeilen:
                probleme.append(f"{rel}: Zeilen {ziele[rel]!r} statt {zeilen!r}")
    for rel, zeilen in fall.ziel_zeilen.items():
        if ziele.get(rel) != zeilen:
            probleme.append(f"{rel}: Zeilen {ziele.get(rel)!r} statt {zeilen!r}")
    for rel in fall.mit_ziel:
        if rel not in ziele:
            probleme.append(f"Ziel fehlt: {rel}")
    for rel in fall.ohne_ziel:
        if rel in ziele:
            probleme.append(f"Ziel unerwartet: {rel}")
    if fall.rest is not None and sorted(rest) != sorted(fall.rest):
        probleme.append(f"Liste {sorted(rest)} statt {sorted(fall.rest)}")
    for rel, teil in list((fall.rest or {}).items()) + list(fall.mit_rest.items()):
        if rel not in rest:
            probleme.append(f"nicht auf der Liste: {rel}")
        elif teil not in rest[rel]:
            probleme.append(f"Liste {rel}: {rest[rel]!r} ohne {teil!r}")
    for rel, teil in fall.ueber.items():
        if rel not in ueber:
            probleme.append(f"nicht uebersprungen: {rel}")
        elif teil not in ueber[rel]:
            probleme.append(f"uebersprungen {rel}: {ueber[rel]!r} ohne {teil!r}")
    for text in fall.texte:
        if text not in out:
            probleme.append(f"fehlt in der Ausgabe: {text}")
    nachher = abbild(v)
    geaendert = sorted(r for r in set(vorher) | set(nachher) if vorher.get(r) != nachher.get(r))
    if fall.modus in ("anwenden", "zweimal"):
        erwartet = sorted(fall.ziele or {})
        if geaendert != erwartet:
            probleme.append(f"geaendert {geaendert} statt {erwartet}")
        for rel in erwartet:
            kopie = sicherung / rel
            if not kopie.is_file() or kopie.read_bytes() != vorher.get(rel):
                probleme.append(f"Sicherung fehlt oder weicht ab: {rel}")
            if rel in fall.nachher or rel not in nachher:
                continue
            ende = "\r\n" if b"\r\n" in vorher.get(rel, b"") else "\n"
            schwanz = (ende.join(fall.ziele[rel]) + ende).encode("utf-8")
            if not nachher[rel].startswith(vorher.get(rel, b"")) or not nachher[rel].endswith(schwanz):
                probleme.append(f"{rel}: nicht genau alter Inhalt plus Anhang")
        for rel, soll in fall.nachher.items():
            if nachher.get(rel) != soll:
                probleme.append(f"{rel}: {nachher.get(rel)!r} statt {soll!r}")
    elif geaendert:
        probleme.append(f"hat Dateien geaendert: {geaendert}")
    if fall.modus == "anwenden_abbruch" and sicherung.exists():
        probleme.append("Sicherungsordner trotz Abbruch angelegt")
    return probleme


def lauf_fall(fall: Fall, basis: Path, arbeit: Path, skript: Path) -> tuple:
    v = arbeit / "fall"
    entferne(v)
    for alt in arbeit.glob("sicherung*"):
        entferne(alt)
    shutil.copytree(basis, v)
    fall.aendern(v)
    vorher = abbild(v)
    sicherung = arbeit / "sicherung"
    argumente = ["--vault", str(v)]
    if fall.modus in ("anwenden", "zweimal", "anwenden_abbruch"):
        argumente += ["--anwenden", "--sicherung", str(sicherung)]
    elif fall.modus == "ohne_sicherung":
        argumente += ["--anwenden"]
    elif fall.modus == "sicherung_da":
        sicherung.mkdir()
        argumente += ["--anwenden", "--sicherung", str(sicherung)]
    elif fall.modus == "sicherung_im_vault":
        argumente += ["--anwenden", "--sicherung", str(v / "Sicherung")]
    code, out, err, dauer = fahre(skript, argumente)
    probleme = bewerte(fall, code, out, v, vorher, sicherung)
    if fall.modus == "zweimal" and not probleme:
        erster = abbild(v)
        code2, out2, err2, _ = fahre(skript, ["--vault", str(v), "--anwenden", "--sicherung",
                                             str(arbeit / "sicherung-2")])
        ziele2, _, _ = lies_ausgabe(out2)
        if code2 != 0:
            probleme.append(f"zweiter Lauf Exit {code2}")
        if ziele2:
            probleme.append(f"zweiter Lauf will anhaengen: {sorted(ziele2)}")
        if abbild(v) != erster:
            probleme.append("zweiter Lauf hat Dateien geaendert")
        out, err = out + out2, err + err2
    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[:2000]!r}\n    stderr: {err[:800]!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 rotlauf() -> int:
    quelle = BESTAND.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_bestand.py, unbekannte Faelle {unbekannt}")
            alle_ok = False
            continue
        arbeit = Path(tempfile.mkdtemp(prefix="bestand-rot-")).resolve()
        try:
            ordner = arbeit / "skript"
            ordner.mkdir()
            for begleiter in BEGLEITER:
                shutil.copy2(begleiter, ordner / begleiter.name)
            skript = ordner / BESTAND.name
            with open(skript, "w", encoding="utf-8", newline="\n") as datei:
                datei.write(quelle.replace(aus + "\n", wird + "\n"))
            ergebnis = fahre_liste([nach_name[n] for n in rot_bei], skript, arbeit, ausgabe=False)
        finally:
            entferne(arbeit)
        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 main() -> int:
    for pfad in [BESTAND] + BEGLEITER:
        if not pfad.is_file():
            print(f"ABBRUCH: {pfad} fehlt")
            return 2
    if shutil.which("git") is None:
        print("ABBRUCH: git fehlt im PATH, die Git-Faelle messen sonst nichts")
        return 2
    namen = [f.name for f in ALLE]
    if len(set(namen)) != len(namen):
        print("ABBRUCH: ein Fallname steht doppelt")
        return 2
    if "--rotlauf" in sys.argv[1:]:
        return rotlauf()
    arbeit = Path(tempfile.mkdtemp(prefix="bestand-faelle-")).resolve()
    try:
        ergebnis = fahre_liste(ALLE, BESTAND, arbeit, ausgabe=True)
        if not ergebnis.get(GRUNDLINIE.name):
            print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
            return 2
        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:
        entferne(arbeit)


if __name__ == "__main__":
    try:
        sys.stdout.reconfigure(encoding="utf-8")
    except AttributeError:
        pass
    raise SystemExit(main())

Schritt 2 — Rot zuerst

Ohne waisen_bestand.py muss der Treiber abbrechen. In einem einzigen Bash-Aufruf:

mv D:/Claude/.vault-tools/linkcheck/waisen_bestand.py D:/Claude/.vault-tools/linkcheck/waisen_bestand.py.weg; python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py; echo "exit=$?"; mv D:/Claude/.vault-tools/linkcheck/waisen_bestand.py.weg D:/Claude/.vault-tools/linkcheck/waisen_bestand.py

Erwartet: ABBRUCH: D:\Claude\.vault-tools\linkcheck\waisen_bestand.py fehlt und exit=2. Danach liegt die Datei wieder an ihrem Platz.

Schritt 3 — Angriffsliste

PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.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. Der Lauf legt Git-Repos im Temp-Ordner an und dauert deshalb länger als der Treiber aus Task 01.

Schritt 4 — Rotlauf

PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py --rotlauf; echo "exit=$?"

Erwartet: je Mutant eine Zeile GETOETET, keine Zeile UEBERLEBT oder MUSTER, Schlusszeile Rotlauf: alle Mutanten getoetet., exit=0.

Schritt 5 — Zähler unverändert grün

PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py; echo "exit=$?"

Erwartet: exit=0.

Bericht

Melde die vollständige Ausgabe von Schritt 1, 2, 4 und 5, von Schritt 3 die Schlusszeile und jede Zeile, die nicht mit BESTANDEN beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.