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