"""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 # Textstellen, die in stderr stehen müssen; bei --zahl gehört stdout allein der Zahl. texte_stderr: list = field(default_factory=list) 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"), []), # Web-Adresse und Laufwerkspfad messen das Ergebnis, nicht den Schemafilter in # markdown_ziele(): Unter Windows trägt kein Dateiname einen Doppelpunkt, also findet # die Auflösung für ein Ziel mit Schema ohnehin keine Notiz. Deshalb kein Mutant dafür. Fall("markdown | Web-Adresse findet keine Notiz", 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 findet keine Notiz", 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 | Praefixfilter ohne Ruecksicht auf die Schreibung", lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["archiv/"]}'), schreibe(v, "Archiv/x.md", "# X\n")), ["C/allein.md"]), Fall("ausblendung | Ausdruck ohne Ruecksicht auf die Schreibung", lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["/(^|/)TMP//"]}'), schreibe(v, "Q/tmp/y.md", "# Y\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 | leere Eintraege werden uebersprungen", lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["", " "]}'), schreibe(v, "_index/x.md", "# X\n")), ["C/allein.md", "_index/x.md"], ["Einträge in userIgnoreFilters: 0"]), Fall("obsidian | Eintrag mit Leerzeichen wird getrimmt", lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": [" _index/ "]}'), schreibe(v, "_index/x.md", "# X\n")), ["C/allein.md"]), Fall("obsidian | zwei Schraegstriche sind kein Ausdruck", neu(".obsidian/app.json", '{"userIgnoreFilters": ["//"]}'), ["C/allein.md"], ["Einträge in userIgnoreFilters: 1"]), Fall("obsidian | ungueltiger Ausdruck wird uebersprungen und genannt", lambda v: (schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["/(/", "_index/"]}'), schreibe(v, "_index/x.md", "# X\n")), ["C/allein.md"], ["Filter übersprungen, ungültiger regulärer Ausdruck: '/(/'", "Einträge in userIgnoreFilters: 1"]), 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"]), # Regeln aus getLinkpathDest (Obsidian 1.13.7). Wo es eine Wahl gibt, liegt die richtige alphabetisch hinten. Fall("mehrdeutig | Teilbaum der Quelle vor kuerzerem Pfad", lambda v: (schreibe(v, "A/doppelt.md", "# D\n"), schreibe(v, "Z/tief/doppelt.md", "# D\n"), schreibe(v, "Z/verweis.md", "[[doppelt]]\n")), ["A/doppelt.md", "C/allein.md"]), Fall("mehrdeutig | Laenge vor Tiefe", lambda v: (schreibe(v, "Aaaaaaaaaaaa/d.md", "# D\n"), schreibe(v, "Z/B/d.md", "# D\n"), schreibe(v, "R/v.md", "[[d]]\n")), ["Aaaaaaaaaaaa/d.md", "C/allein.md"]), Fall("wikilink | Pfadende ohne Ordnergrenze", lambda v: (schreibe(v, "Obertief/allein.md", "# O\n"), schreibe(v, "R/w.md", "[[tief/allein]]\n")), ["C/allein.md"]), Fall("markdown | ./ vor gleichnamiger Notiz an der Wurzel", lambda v: (schreibe(v, "n.md", "# N\n"), schreibe(v, "Z/Q/n.md", "# N\n"), schreibe(v, "Z/Q/neu.md", "[x](./n.md)\n")), ["C/allein.md", "n.md"]), Fall("markdown | ./../ wie ../", lambda v: (schreibe(v, "n.md", "# N\n"), schreibe(v, "Z/n.md", "# N\n"), schreibe(v, "Z/Q/neu.md", "[x](./../n.md)\n")), ["C/allein.md", "n.md"]), Fall("markdown | fuehrender / sucht nur ab der Wurzel", neu("A/neu.md", "[x](/allein.md)\n"), ["A/neu.md", "C/allein.md"]), # Kein Mutant: Das folgt aus der Schleife über ../, ohne eigene Zeile im Code. Fall("markdown | ../ ueber die Wurzel hinaus bleibt an der Wurzel", neu("neu.md", "[x](../C/allein.md)\n"), []), Fall("markdown | Backslash bleibt stehen", neu("A/neu.md", "[x](..\\C\\allein.md)\n"), ["A/neu.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), Fall("zahl | uebersprungener Filter bleibt aus der Zahl", neu(".obsidian/app.json", '{"userIgnoreFilters": ["/(/"]}'), ["C/allein.md"], zahl=True, texte_stderr=["Filter übersprungen, ungültiger regulärer Ausdruck: '/(/'"]), ] 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) for text in fall.texte_stderr: if text not in err: probleme.append(f"fehlt in stderr: {text}") 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(re.compile(text[1:-1], re.IGNORECASE))", " liste.append(re.compile(\"^\" + re.escape(text), re.IGNORECASE))", ["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"]), ("Praefixfilter mit Schreibung", " liste.append(re.compile(\"^\" + re.escape(text), re.IGNORECASE))", " liste.append(re.compile(\"^\" + re.escape(text)))", ["ausblendung | Praefixfilter ohne Ruecksicht auf die Schreibung"]), ("Ausdruck mit Schreibung", " liste.append(re.compile(text[1:-1], re.IGNORECASE))", " liste.append(re.compile(text[1:-1]))", ["ausblendung | Ausdruck ohne Ruecksicht auf die Schreibung"]), ("Filtereintrag nicht getrimmt", " text = eintrag.strip()", " text = eintrag", ["obsidian | Eintrag mit Leerzeichen wird getrimmt"]), ("leerer Filtereintrag nicht uebersprungen", " if not text:", " if False:", ["obsidian | leere Eintraege werden uebersprungen"]), ("zwei Schraegstriche als Ausdruck", " if len(text) > 2 and text.startswith(\"/\") and text.endswith(\"/\"):", " if len(text) >= 2 and text.startswith(\"/\") and text.endswith(\"/\"):", ["obsidian | zwei Schraegstriche sind kein Ausdruck"]), ("ungueltiger Ausdruck bricht ab", " uebersprungen.append(f\"{eintrag!r} ({fehler})\")", " raise", ["obsidian | ungueltiger Ausdruck wird uebersprungen und genannt", "zahl | uebersprungener Filter bleibt aus der Zahl"]), ("uebersprungener Filter nicht genannt", " print(f\"Filter übersprungen, ungültiger regulärer Ausdruck: {text}\")", " pass", ["obsidian | ungueltiger Ausdruck wird uebersprungen und genannt"]), ("Meldung bei --zahl auf stdout", " print(f\"Filter übersprungen, ungültiger regulärer Ausdruck: {text}\", file=sys.stderr)", " print(f\"Filter übersprungen, ungültiger regulärer Ausdruck: {text}\")", ["zahl | uebersprungener Filter bleibt aus der Zahl"]), ("Meldung bei --zahl fehlt", " print(f\"Filter übersprungen, ungültiger regulärer Ausdruck: {text}\", file=sys.stderr)", " pass", ["zahl | uebersprungener Filter bleibt aus der Zahl"]), ("escaptes Pipe nicht entfernt", ' ziel = treffer.group(1).strip().rstrip("\\\\").strip()', " ziel = treffer.group(1).strip()", ["wikilink | escaptes Pipe in einer Tabellenzelle"]), ("Backslash im Ziel umgewandelt", " ziel = ziel.strip()", ' ziel = ziel.strip().replace("\\\\", "/")', ["markdown | Backslash bleibt stehen"]), ("./ nicht gegen den Ordner der Quelle", " pfad = ordner + pfad[2:]", " pfad = pfad[2:]", ["markdown | ./ vor gleichnamiger Notiz an der Wurzel"]), ("./../ nicht verkuerzt", " pfad = pfad[2:]", " pass", ["markdown | ./../ wie ../"]), ("fuehrender / sucht auch ueber den Namen", " if ziel.startswith(\"/\"):", " if False:", ["markdown | fuehrender / sucht nur ab der Wurzel"]), ("Pfadende mit Ordnergrenze", " passend = [rel for rel in kandidaten if rel.lower().endswith(pfad)]", " passend = [rel for rel in kandidaten if (\"/\" + rel.lower()).endswith(\"/\" + pfad)]", ["wikilink | Pfadende ohne Ordnergrenze"]), ("Teilbaum der Quelle ohne Vorrang", " treffer = nah + fern", " treffer = sorted(passend, key=len)", ["mehrdeutig | gleichnamige Notiz im selben Ordner gewinnt", "mehrdeutig | relativ zur Quelle vor dem Namen", "mehrdeutig | Teilbaum der Quelle vor kuerzerem Pfad"]), ("nur gleicher Ordner statt Teilbaum", " nah = sorted((rel for rel in passend if rel.lower().startswith(ordner)), key=len)", " nah = sorted((rel for rel in passend if _eltern(rel).lower() == ordner), key=len)", ["mehrdeutig | Teilbaum der Quelle vor kuerzerem Pfad"]), ("Tiefe vor Laenge", " fern = sorted((rel for rel in passend if not rel.lower().startswith(ordner)), key=len)", " fern = sorted((rel for rel in passend if not rel.lower().startswith(ordner)), key=lambda rel: (rel.count(\"/\"), len(rel)))", ["mehrdeutig | Laenge vor Tiefe"]), ("ohne Sortierung nach Laenge", " fern = sorted((rel for rel in passend if not rel.lower().startswith(ordner)), key=len)", " fern = [rel for rel in passend if not rel.lower().startswith(ordner)]", ["mehrdeutig | Laenge vor Tiefe", "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())