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]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-10-01 04:00:02 +02:00
1 parent e8472409e9
commit eb48f8822d
45 files changed
+8645 -262

No files matched your search

+751
View File
@@ -0,0 +1,751 @@
"""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())