Erste Fassung: Claude-Werkzeuge aus dem Vault

.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]>
This commit is contained in:
Pascal MichelandClaude Opus 5.5 committed 2026-10-14 11:27:59 +02:00
commit fa1c26b407
137 files changed
+43926

No files matched your search

@@ -0,0 +1,130 @@
# Entwurf: Codeblock-Erkennung bei der Link-Extraktion
Angelegt 2026-09-03. Entwurfsschritt nach [[AI/Regelwerk/pruefwerkzeuge|pruefwerkzeuge.md]], Abschnitt 1 — geschrieben **vor** der ersten Codezeile.
## Anlass
`vault_doctor.py` meldete 92 tote Wikilinks. Die Stichprobe zeigt fast ausschließlich Fehlalarme:
- `[[ $EUID -eq 0 ]]` — Bash-Test aus einem Codeblock
- `[[:space:]]` — POSIX-Zeichenklasse aus einem Regex
- `[[bild.png]]`, `[[Ziel]]`, `[[Notiz]]` — Platzhalter in der Dokumentation des Bookstack-Upload-Skills, das Wikilinks verarbeitet
- `[[1, 2]]`, `[[float]]`, `[[A]]` — Typangaben und Indizes aus Codebeispielen
Ursache: `_WIKILINK_RE` (Zeile 61 in `vaultlib.py`) läuft über den gesamten Text. Das Wort `fence` kommt in der Datei kein einziges Mal vor.
**Ein Prüfwerkzeug, dessen Befunde zu vier Fünfteln Rauschen sind, wird nicht mehr gelesen.** Das ist der eigentliche Schaden, nicht die 92.
## Entscheidungsliste
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 1 | Was ist ein Wikilink? | `[[Ziel]]`, `[[Ziel\|Alias]]`, `[[Ziel#Überschrift]]`, `[[Ziel^Block]]` | **Quelle** — `_WIKILINK_RE`, `vaultlib.py` Zeile 61 |
| 2 | Wo darf keiner erkannt werden? | In eingezäunten Codeblöcken (` ``` `, `~~~`) und in Code-Spans (Backticks) | **Quelle** — CommonMark: Inhalt von Code wird nicht als Markup interpretiert; Obsidian rendert dort keine Links |
| 3 | Zählt das YAML-Frontmatter mit? | Nein, ist bereits abgetrennt | **Quelle** — `vaultlib.py` Zeile 117: `body = text[m.end():]` |
| 4 | Eingerückte Codeblöcke (vier Leerzeichen)? | **Nicht behandelt.** Der Vault verwendet durchgängig eingezäunte Blöcke; eine Vier-Leerzeichen-Regel würde eingerückte Listenpunkte mit legitimen Links verschlucken | **Projektvorgabe**, als Lücke im Kopfkommentar benannt |
| 5 | Ungeschlossener Zaun am Dateiende? | Gilt bis Dateiende als Code | **Projektvorgabe** — die konservative Wahl: lieber einen echten Link übersehen als einen Fehlalarm erzeugen |
| 6 | Auch Überschriften betroffen? | **Ja, gleicher Fehler** — `extract_headings` liest `# Kommentar` aus Bash-Blöcken als Überschrift. **In dieser Runde nicht geändert**, weil es den Index-Inhalt verschiebt und einen eigenen Nachweis braucht | **Befund, benannt statt stillschweigend miterledigt** |
| 7 | Welche Zeile schließt einen Zaun? | Nur eine Zeile aus demselben Zaunzeichen, mindestens so lang wie der Öffner, bis zu drei LEERZEICHEN eingerückt (kein Tab), danach nur Leerzeichen oder Tab. Eine Zeile mit Info-String (drei Backticks und `bash`) im Block ist Inhalt | **Quelle** — Fundstellen im Abschnitt „Nachbesserung 2026-09-13" und „Nachbesserung Runde 2" |
Keine Zeile bleibt auf „Annahme" stehen.
## Angriffsliste
Je Fall ein eigener Durchlauf, Grundlinie zuerst.
| # | Fall | Erwartung |
|---|---|---|
| 1 | leerer Text | keine Links, kein Absturz |
| 2 | nur Fließtext mit einem Link | Link bleibt |
| 3 | Link **vor** und **nach** einem Zaun | beide bleiben — *Reihenfolge: erste und letzte Position* |
| 4 | ` ```bash ` mit Link darin | Link weg |
| 5 | `~~~` als Zaunzeichen | Link weg |
| 6 | Zaun ohne Abschluss, Link danach | Link weg |
| 7 | Inline-Code `` `[[x]]` `` | Link weg |
| 8 | doppelte Backticks | Link weg |
| 9 | einzelner Backtick ohne Partner | Link bleibt — darf nicht den Rest verschlucken |
| 10 | vier Backticks als Zaun | Link weg |
| 11 | eingerückter Zaun in einer Liste | Link weg |
| 12 | Link unmittelbar an der Zaunzeile | bleibt |
| 13 | echter Bestandsfall `[[ $EUID -eq 0 ]]` in einem Block | kein Link |
| 14 | echter Bestandsfall `[[:space:]]` in Inline-Code | kein Link |
| 15 | escaptes Pipe in einer Tabellenzelle `[[Ziel\|Alias]]` | Ziel ohne Backslash |
| 16 | escaptes Pipe ohne Alias | Ziel ohne Backslash |
| 17 | Dreier-Zaun mit Info-String innerhalb eines Vierer-Zauns, darin `[[:space:]]` | kein Link aus dem Block, Link danach bleibt |
| 18 | Dreier-Schließzaun in einem Vierer-Zaun | schließt nicht |
| 19 | Vierer-Zaun schließt Vierer-Zaun | schließt, Link danach bleibt |
| 20 | Zeile mit drei Backticks und `bash` in einem Dreier-Block | schließt nicht |
| 21 | Schließzaun mit Leerzeichen und Tab dahinter | schließt — neuer Ausdruck, eigener Fall |
| 22 | Schließzaun mit CR am Zeilenende | schließt — neuer Ausdruck, eigener Fall |
| 23 | anderes Zaunzeichen (Backtick-Öffner, Tilde-Schließzeile) | schließt nicht — eigener Fall für „gleiches Zaunzeichen" |
| 24 | Schließzaun mit vier Leerzeichen davor | schließt nicht — Einrückungsgrenze |
| 25 | Schließzaun mit Tab davor | schließt nicht — Tab zählt nicht als Einrückung, siehe „Nachbesserung Runde 2" |
## Nachbesserungen aus der ersten Messung
Der Lauf über den Vault brachte nach dem Codeblock-Filter noch 18 Befunde — wieder Fehlalarme, zwei neuer Klassen. Beide bekamen nach Abschnitt 5 des Regelwerks ihren eigenen Fall:
- **Escaptes Pipe.** In Obsidian-Tabellenzellen muss das Pipe eines Alias als `\|` geschrieben werden. `_WIKILINK_RE` schneidet am Pipe, lässt den Backslash aber stehen — das Ziel hieß dann `Pfad\` und fand nichts. Fälle 15 und 16.
- **Bildeinbettungen.** `![[Pasted image 123.png]]` ist ein gültiger Link auf eine Nicht-Markdown-Datei. Die Nachschlagemenge von `vault_doctor.py` enthält nur `.md`. Behoben durch eine zweite Menge aus allen übrigen Dateien.
Ergebnis: **92 gemeldete, 0 echte tote Links.**
## Nachbesserung 2026-09-13: Schließzaun nach CommonMark
**Anlass.** Der Linkcheck meldete am 2026-09-13 einen toten Wikilink `[[:space:]]` in `Code Projekte/active - Logging/docs/plans/2026-09-08-a-betriebsmodelle/task-09-gate.md`. Die Datei öffnet in Zeile 356 einen Block mit vier Backticks und zeigt darin ab Zeile 406 ein eingerücktes Beispiel mit drei Backticks und `bash`. Die alte Regel schloss den äußeren Block schon an Zeile 406, weil jede Zaunzeile aus demselben Zeichen genügte. Zeile 407 mit der POSIX-Zeichenklasse lag damit im Fließtext.
**Regel.** Ein Block schließt nur mit einer Zeile, die
- aus demselben Zaunzeichen besteht wie der Öffner (Backtick oder Tilde),
- mindestens so viele Zeichen hat wie der Öffner,
- nach den Zaunzeichen nur Leerzeichen oder Tab trägt, also keinen Info-String.
Die Einrückung von bis zu drei Zeichen übernimmt der Schließzaun vom Öffner (`\s{0,3}`). Ein CR am Zeilenende ist zugelassen, damit eine Eingabe mit CRLF nicht an jedem Schließzaun vorbeiläuft.
**Herkunft.**
| Aussage | Fundstelle |
|---|---|
| dasselbe Zeichen | markdown-it-py 4.2.0, `markdown_it/rules_block/fence.py` Zeile 106 |
| mindestens so lang | ebenda Zeilen 42–43, `closing_len >= opening_len` |
| danach nur Leerraum | ebenda Zeilen 119–123; Leerraum heißt Leerzeichen oder Tab laut `markdown_it/common/utils.py` Zeilen 167–169 |
Gelesen am 2026-09-13 unter `Code Projekte/active - Agentic Cockpit/.venv/Lib/site-packages/markdown_it/`. Die Bibliothek nennt ihre Regel selbst CommonMark-konform (`fence.py` Zeile 145). Referenz der Regel ist die CommonMark-Spezifikation 0.31.2, Abschnitt 4.5 Fenced code blocks, https://spec.commonmark.org/0.31.2/#fenced-code-blocks. **Den Wortlaut der Spezifikation hat beim Planen niemand gezogen, der Planlauf hatte keinen Netzzugriff. Belegt ist die Regel an der Implementierung. Der Abgleich mit dem Spezifikationstext ist offen.**
**Benannte Lücke.** Ein Öffner mit Backtick im Info-String ist nach markdown-it-py kein Zaun (`fence.py` Zeilen 79–80). Hier gilt er weiter als Zaun. Diese Runde ändert nur den Schließzaun. Die Öffnerregel verschiebt, welche Zeilen überhaupt als Code gelten, und braucht einen eigenen Lauf über den Vault.
**Was schiefgehen kann.** Hat bisher eine Zeile mit Info-String einen Block versehentlich geschlossen, läuft der Block jetzt weiter, notfalls bis zum Dateiende. Wikilinks dahinter prüft der Linkcheck dann nicht mehr. Deshalb gehört zur Abnahme ein Vergleichslauf alt gegen neu über den ganzen Vault.
## Nachbesserung Runde 2: gleiches Zaunzeichen und Einrückung ohne Tab
**Anlass.** Die erste Prüfrunde nach der Nachbesserung 2026-09-13 fand zwei Lücken in der Angriffsliste, keine im Verhalten der zwei damals geänderten Bedingungen — aber beide Bedingungen hatten bis dahin keinen eigenen Fall:
- Die Bedingung „gleiches Zaunzeichen" (`s.group(1)[0] == zaun[0]`) hatte keinen Fall, der einen Backtick-Öffner gegen eine Tilde-Schließzeile prüft. Fall 23.
- Die Einrückung vor dem Schließzaun ließ `\s{0,3}` zu, also auch einen Tab. Ein Tab zählt bei markdown-it-py als vier Spalten (`state_block.py` Zeilen 79–80 für die Tab-Rechnung, 257–261 für den Vergleich `>= 4`) und macht die Zeile zu eingerücktem Code — sie schließt dort nicht. Fall 25 belegt das: Gegen die alte Fassung mit `\s{0,3}` schlägt er fehl (rot), gegen `[ ]{0,3}` besteht er.
**Korrektur.** `_SCHLUSS_RE` in `linkcheck.py` verlangt jetzt `^[ ]{0,3}` statt `^\s{0,3}` vor dem Zaunzeichen — nur Leerzeichen, kein Tab. Die Bedingung „gleiches Zaunzeichen" selbst war schon vorher im Code (`s.group(1)[0] == zaun[0]`), nur ungeprüft; Fall 23 sichert sie jetzt ab, Fall 24 die Grenze von drei Einrückungszeichen.
**Herkunft.** Wie in der Nachbesserung 2026-09-13, `markdown_it/rules_block/fence.py` und zusätzlich `markdown_it/rules_block/state_block.py` Zeilen 257–261 für die Tab-als-vier-Spalten-Regel, gelesen unter `Code Projekte/active - Agentic Cockpit/.venv/Lib/site-packages/markdown_it/` am 2026-09-13.
## Nachbesserung nach Prüfrunde 2: Schließregel wieder symmetrisch
**Anlass.** Die zweite Prüfrunde hat gezeigt, dass `^[ ]{0,3}` vor dem Schließzaun eine Rückentwicklung war. In einem Listenpunkt, der mit Tab eingerückt ist, öffnet `linkcheck.py` den Block an der Tab-Zeile (die Öffnerregel erlaubt `\s{0,3}`), schließt ihn aber nie mehr, und alles bis zum Dateiende gilt als Code. markdown-it-py zieht dort die Einrückung des Listeninhalts ab und schließt den Block. Belegt mit markdown-it-py 4.2.0 (`MarkdownIt('commonmark')`) gegen beide Fassungen.
**Korrektur.** `_SCHLUSS_RE` verlangt wieder `^\s{0,3}`, symmetrisch zum Öffner. Fall 25 erwartet jetzt, dass ein Tab-Zaun schließt; Fall 26 prüft den Listenfall.
**Benannte Lücke.** Auf oberster Ebene zählt markdown-it-py den Tab als vier Spalten und liest die Zeile als eingerückten Code; dort schließt ein Tab-Zaun nicht, `linkcheck.py` schließt ihn trotzdem. Listen-Einrückung bildet das Werkzeug nicht nach, deshalb bleibt es bei der einfachen, symmetrischen Regel. Im Vault gab es am 2026-09-13 keine Datei, die das betrifft (Messung des Prüfers).
**Die Regelzeile oben** („Die Einrückung von bis zu drei Zeichen übernimmt der Schließzaun vom Öffner") gilt damit wieder uneingeschränkt.
## Absturzfestigkeit
`extract_links` wird von `build_index.py` über den ganzen Vault gerufen. Ein Absturz legt den Index still. Die Hilfsfunktion arbeitet deshalb rein zeilenweise über Zeichenketten, ohne Indexarithmetik über Zeilengrenzen, und ersetzt Codeinhalt durch Leerzeichen statt ihn zu löschen — Länge und Zeilenzahl bleiben erhalten, nichts kann über eine entfernte Stelle hinweg zusammenwachsen.
## Abbruchbedingung
Schluss ist, wenn ein Lauf über den Vault die Fehlalarme aus der Stichprobe nicht mehr meldet **und** alle Angriffsfälle aus `faelle.py` grün sind. Was danach an toten Links übrig bleibt, ist eine Aufräumliste — kein Prüfauftrag mehr.
## Was dieses Skript nicht ist
Ein Fangnetz gegen offensichtliche Fehler. Es belegt nicht, dass alle Wikilinks im Vault stimmen — nur, dass die gemeldeten nicht aus Codeblöcken stammen.
+83
View File
@@ -0,0 +1,83 @@
# 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`. Jeder Eintrag wird getrimmt, ein leerer fällt weg. Ein Eintrag mit mehr als zwei Zeichen, der mit `/` beginnt und endet, ist ein regulärer Ausdruck, gesucht im relativen Pfad; jeder andere ist ein Präfix ab der Vault-Wurzel. Beide gelten ohne Rücksicht auf Groß- und Kleinschreibung. Ausgeblendete Dateien sind weder Knoten noch Quelle einer Kante. | **Quelle** für die Filterregeln: Obsidian 1.13.7, `updateUserIgnoreFilters` und `isUserIgnored`, siehe Abschnitt Quelle der Obsidian-Regeln. **Projektvorgabe** für Knoten und Kanten: Spec |
| 4 | Dialekt der Ausdrücke? | Python `re` statt JavaScript. Ein Ausdruck, den nur einer der beiden Dialekte versteht, wirkt verschieden. 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. Der Schemafilter ist Doppelschutz: Unter Windows trägt kein Dateiname einen Doppelpunkt, die Auflösung findet für ein Ziel mit Schema ohnehin keine Notiz. Deshalb misst ihn kein Fall und kein Mutant. | **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? | Kandidaten sind die Notizen mit demselben Dateinamen samt Endung, klein geschrieben: erst mit dem Ziel wie geschrieben, wenn sein Name einen Punkt trägt, sonst mit angehängtem `.md`. Ein Ziel mit `./` oder `../` wird gegen den Ordner der Quelle gerechnet, `./../` wie `../`, ein `../` über die Wurzel hinaus bleibt an der Wurzel. Dann gewinnt der Kandidat, dessen Pfad dem Ziel gleicht; ein `/` vorn fällt dafür weg. Ein Ziel mit `/` vorn endet danach ohne Treffer. Sonst zählt jeder Kandidat, dessen Pfad auf das Ziel endet, ohne Ordnergrenze. Backslashes bleiben stehen. | **Quelle**: Obsidian 1.13.7, `getLinkpathDest`. Ob der Linkleser von Obsidian Backslashes vorher umwandelt: **Lücke**, benannt; Prüfweg ist der Vergleich vorher und nachher in Task 06 des Plans |
| 11 | Groß- und Kleinschreibung im Ziel? | Egal. | **Quelle**: `getLinkpathDest` vergleicht in Kleinschreibung |
| 12 | Mehrdeutiger Name? | Erst die Kandidaten, deren Pfad mit dem Ordner der Quelle beginnt (der ganze Teilbaum, ohne Ordnergrenze), dann die übrigen; in beiden Gruppen der kürzere Pfad in Zeichen zuerst. Bei gleicher Länge entscheidet die Reihenfolge des Durchlaufs, alphabetisch je Ordner. `waisen.py` nimmt den ersten Kandidaten. | **Quelle**: Obsidian 1.13.7, `getLinkpathDest` und die Vergleichsfunktion nach Pfadlänge. Reihenfolge bei gleicher Länge: **Lücke**, benannt. Dass der Graph den ersten Kandidaten nimmt: **Projektvorgabe**, der Auszug zeigt es nicht |
| 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, `userIgnoreFilters` null oder keine Liste, ein Eintrag kein Text: Befund, Exit 2. Ein leerer Eintrag fällt weg. Ein ungültiger Ausdruck fällt weg und steht als Zeile `Filter übersprungen, ungültiger regulärer Ausdruck:` in der Ausgabe, bei `--zahl` auf stderr, damit stdout nur die Zahl trägt. | **Projektvorgabe** für die Abbrüche, strenger als Obsidian 1.13.7, das null als keinen Filter nimmt. **Quelle** für leere und ungültige Einträge: `updateUserIgnoreFilters` |
| 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.
## Quelle der Obsidian-Regeln
Nachgebessert am 2026-09-30 nach der Prüfung der ersten Welle (Plan `docs/plans/2026-09-29-waisen/`, Task 06).
- Filter und Linkauflösung folgen Obsidian 1.13.7: `updateUserIgnoreFilters`, `isUserIgnored`, `getLinkpathDest` und die Vergleichsfunktion nach Pfadlänge, mit der `getLinkpathDest` sortiert.
- Gelesen am 2026-09-30 mit `grep -a` im Programmabbild `obsidian-1.13.7.asar` unter `%APPDATA%\obsidian\`. Nachgebaut, nicht abgeschrieben.
- Gegenprobe des Prüfers am selben Tag: Sein Nachbau dieser Regeln, der zusätzlich Backslashes in `/` umwandelt, fand im echten Vault 0 Linkziele, die von `waisen.py` in der Fassung vom 2026-09-29 abweichen.
- Nicht aus der Quelle belegt: ob der Linkleser von Obsidian Backslashes vor der Auflösung umwandelt, und in welcher Reihenfolge sein Namensverzeichnis gleich lange Pfade führt.
- Eine neue Obsidian-Version macht diese Herkunft nicht falsch, aber ungeprüft. Prüfweg: dieselben Funktionen im neuen Abbild nachlesen.
## 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, leere und nur aus Leerzeichen bestehende Filtereinträge, 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, Teilbaum der Quelle vor kürzerem Pfad, Länge vor Tiefe, `./` und `./../` vor gleichnamiger Notiz an der Wurzel; wo es eine Wahl gibt, liegt die richtige 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; Filter aus zwei Schrägstrichen, Filter mit Leerzeichen, Präfixfilter und Ausdruck in anderer Schreibung als der Pfad, ungültiger Ausdruck mit und ohne `--zahl`; Linkziel mit Backslash, mit `/` vorn ohne Treffer an der Wurzel, mit `../` über die Wurzel hinaus, Pfadende ohne 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) |
Nicht gemessen: der Schemafilter aus Entscheidung 8. Die Fälle `Web-Adresse findet keine Notiz` und `Laufwerkspfad findet keine Notiz` bestehen auch ohne ihn, weil die Auflösung für ein Ziel mit Doppelpunkt nie eine Notiz findet.
## 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.
@@ -0,0 +1,207 @@
---
type: plan
title: "Waisen im Graphen: Hub-Regel, Zähler im Sitzungsstart, Bestandslauf"
status: done
summary: "waisen.py zählt verwaiste Notizen so, wie Obsidian den Graphen baut, der Sitzungsstart meldet die Zahl, Regeltexte und Planer verlinken künftig den Hub, und ein einmaliger Bestandslauf hängt den Bestand an, nur anhängend und mit Probelauf."
tags: [plan, vault-tools]
created: 2026-09-29
updated: 2026-10-05
---
# Waisen im Graphen
Anforderung: Spec `Inbox/2026-09-29-spec-vernetzung.md` (Pascals Entscheidungen vom 2026-09-29). Die Spec liegt nicht im veröffentlichten Umfang, dieser Plan schon. Deshalb stehen hier keine Kunden-, Domain- oder Projektnamen.
## Ziel
Heute hat laut Spec fast die Hälfte der Notizen im Graphen keine Kante, vor allem Plandateien, Codedoku und SEO-Einzelbefunde. Nach diesem Plan zählt `.vault-tools/linkcheck/waisen.py` die Waisen reproduzierbar, der Sitzungsstart zeigt die Zahl als eine Zeile, die Regel „jede neue Datei verlinkt ihren Hub“ steht an einer Stelle (`navigation.md`) und ist in Planer, Codedoku- und SEO-Regel verankert, und ein einmaliger Lauf hängt den Bestand an seine Hubs, ohne eine Zeile zu ersetzen.
## Nicht im Umfang
- Taskdateien im Bestand. Der Bestandslauf fasst sie nie an, sie werden über ihre Übersicht angebunden.
- Querverweise zwischen IT-Workspace-Artikeln, ein Graph-Filter in Obsidian, die Dateien mit nur ausgehenden Links, die Verbindung Projekt und Themen-Seed (alles laut Spec).
- Die Gruppe „Einzelne“ der Spec und alles, wofür kein Muster greift. Das landet auf der Liste für den Koordinator und wird von Hand entschieden.
- Commits. Weder Skript noch Executer committen. Der Koordinator committet je Repo nach Pascals Ja.
- Der SEO-Audit-Skill selbst. Er liegt außerhalb des Vaults und wird nicht geändert.
## Architektur
| Datei | Rolle | Task |
|---|---|---|
| `.vault-tools/linkcheck/waisen.py` | Zähler, Prüfwerkzeug nach `pruefwerkzeuge.md` | 01 |
| `.vault-tools/linkcheck/waisen-ausnahmen.txt` | Positivliste der Ausnahmen, nur allgemeine Muster | 01 |
| `.vault-tools/linkcheck/faelle_waisen.py` | Angriffsliste, Rotlauf mit eingebauten Mutanten, `--echt` misst die Laufzeit | 01 |
| `.vault-tools/linkcheck/ENTWURF-waisen.md` | Entscheidungsliste mit Herkunft je Antwort, Angriffsklassen, Abbruchbedingung | 01 |
| `.vault-tools/session-hooks/sitzungsstart.py` | neue Zeile `- Waisen außerhalb der Ausnahmen: N`, `waisen.py --zahl` als eigener Prozess, 15 s Grenze | 02 |
| `.vault-tools/session-hooks/faelle_sitzungsstart.py`, `ENTWURF.md` | Grundlinie mit `.obsidian/app.json`, neue Fälle, Nachtrag | 02 |
| `AI/Regelwerk/navigation.md`, `CLAUDE.md`, `.claude/agents/planer.md`, `AI/Regelwerk/codedoku.md`, `AI/Regelwerk/seo.md` | Hub-Regel und ihre Verweise | 03 |
| `.vault-tools/linkcheck/waisen_bestand.py` | einmaliger Bestandslauf, Probelauf ohne Schalter | 04 |
| `.vault-tools/linkcheck/faelle_waisen_bestand.py` | Angriffsliste mit Git-Fällen, Rotlauf | 04 |
`waisen.py` nutzt `strip_code()` aus `linkcheck.py` und nicht das Navigator-Toolkit. `waisen_bestand.py` importiert `waisen.py` und nimmt dessen Waisenliste und ausgehende Kanten, damit Zähler und Bestandslauf dieselbe Wahrheit haben.
Die Entscheidungsliste des Zählers mit Herkunft je Antwort steht wörtlich in `task-01-zaehler.md`, Block `ENTWURF-waisen.md`. Nach der Umsetzung gilt die Datei im Baum.
## Erzeuger-Inventar
Gelesen am 2026-09-29: sechs gespeicherte Workflows, vier Skills im Vault, vier Agentendateien.
| Erzeuger | Fundstelle | Legt an | Folge im Plan |
|---|---|---|---|
| Agent `planer` | `.claude/agents/planer.md` Zeile 16 (Ablageform) und 25 (Nachbesserung, Folgenliste) | Übersicht, Taskdateien, Nachbesserungen, Folgenliste in `docs/plans/` | neue Regelzeile nach Zeile 16 (Task 03) |
| Workflow `planung` | `.claude/workflows/planung.js` Zeile 112 („Schreib genau eine Datei“), Zeile 136 (`agentType: 'planer'`) | Taskdateien, geschrieben vom `planer` | keine eigene Änderung: der Auftrag geht an `agentType: 'planer'`, die Regel kommt aus `planer.md` |
| Workflows `executer-welle`, `pruefung`, `plankritik`, `welle-mit-pruefung`, `verbrauch` | kein Schreibauftrag für Markdown in den Prompts (grep nach `anleg`, `ableg`, `.md`) | nichts | keine |
| Agent `executer` | `.claude/agents/executer.md` Zeile 15: nur die genannten Zieldateien | was der Plan vorgibt | keine, die Zeile steht dann im Plan |
| Agent `pruefer` | Zeile 6: Werkzeuge ohne `Write` | nichts | keine |
| Agent `recherche` | Zeile 15: Hilfsdatei nur mit genanntem Ablageort | nur auf Anweisung | keine, die Regel gilt über den Stamm |
| Skill `anleitung-straffen` | `.claude/skills/anleitung-straffen/SKILL.md` Zeile 22 und 23 | `<name>-hintergrund.md` neben der Anleitung | keine: Zeile 23 verlinkt Anleitung und Hintergrund schon in beide Richtungen |
| Skills `bookstack-upload`, `plane-abgleich` | schreiben nur Frontmatter-Felder bzw. `todo.md` | keine neue Markdown-Datei | keine |
| Skill `plan-executer-pruefung` | verweist auf `codeworkflow.md`, legt selbst nichts an (Plankritik 2026-09-30) | nichts | keine |
| SEO-Audit-Skill (`/seo`, `/seo-audit`) | liegt nicht unter `.claude/skills/` im Vault | Befunddateien unter `findings/` | Regel in `seo.md`: der Bericht listet seine Befunddateien (Task 03) |
| Koordinator selbst | `AI/Regelwerk/codeworkflow.md` Zeile 38 und 113 | Anforderungen, Prüfberichte, Specs, Dumps | allgemeine Regel in `navigation.md` und Stammzeile (Task 03) |
## Planwerkzeuge und die neue Linkzeile
Frage: Vertragen die Planwerkzeuge eine Zeile `Übersicht: [00-uebersicht.md](00-uebersicht.md)` direkt unter dem Frontmatter einer Taskdatei und einen angehängten Abschnitt am Ende einer `00-uebersicht.md`? Gelesen, nicht gemessen:
| Werkzeug | Was es auswertet | Fundstelle | Wirkung der Linkzeile |
|---|---|---|---|
| `plan-kopie/plan_materialisiere.py` | Zieldatei nur aus Überschriften `^#{2,4} ` mit Pfad in Backticks; Zaun per `lstrip().startswith("```")`; Marke ist die letzte nicht leere Zeile vor dem Zaun; Löschziele nur unter `## Zieldateien` | Zeile 26 bis 27, 40 bis 44, 57 bis 60, 76 bis 81 | keine: die Zeile ist weder Überschrift noch Zaun und steht nie zwischen Marke und Zaun |
| `plan-kopie/plan_bloecke.py` | dieselbe Logik | Zeile 23 bis 25, 39 bis 42, 54 bis 60 | keine |
| `plan-diff/plan_diff.py` | Pfadzeile nur mit Pfad in Backticks, höchstens zehn Zeilen vor dem Zaun; Zieldateien nur unter `## Zieldateien` | Zeile 32, 44 bis 55, 78 bis 88 | keine: ein Markdown-Link trägt keine Backticks Nur am Text abgelesen: Die Plankritik vom 2026-09-30 maß, dass `plan_diff.py` die Ganzdatei-Blöcke dieses Plans gar nicht liest (Exit 3, `KEIN BLOCK`). |
| `plan-kopie/welle_vergleich.py` | nur `## Zieldateien` | Zeile 40 bis 47 | keine |
| `plan-kopie/rot_nachweis.py` | nur `## Zieldateien` | Zeile 75 bis 81 | keine |
| `plan-kopie/plan_zeichen.py` | nur Inhalte von Codeblöcken | Zeile 35 | keine |
Die Übersicht selbst parst kein Werkzeug: `grep uebersicht` über `plan-kopie/*.py` trifft nichts, die Workflows nennen sie nur als Lesehinweis (`executer-welle.js` Zeile 63, `plankritik.js` Zeile 71). Ein angehängter Abschnitt ändert keinen Aus-Block, weil nur hinten angefügt wird. Ein späteres „Häng diesen Block ans Ende“ auf eine Übersicht landet hinter dem neuen Abschnitt, das ist harmlos. Der bekannte Fall, ein Absatz zwischen Marke und Codeblock, kann durch die Linkzeile nicht entstehen.
Die Taskdateien dieses Plans tragen die Linkzeile schon. Die Plankritik fährt `plan_bloecke.py` und `plan_zeichen.py` darüber und materialisiert sie. Das ist Messschritt M4.
## Prüfschritte in der App (Pascal)
Keine Quelle im Vault belegt, wie Obsidian relative Markdown-Links zählt (Grep über `AI/` und `IT Workspace/` nach `Markdown-Link`, `resolvedLinks`, `getFirstLinkpathDest` ohne Treffer). Deshalb drei Handgriffe in Obsidian, bevor Task 03 und Phase B von Task 05 starten:
1. Lege `Inbox/test-links.md` an mit diesem Inhalt:
```markdown
---
verwandt: "[[AI/Regelwerk/frontmatter]]"
---
[eins](../AI/Regelwerk/navigation.md)
[zwei](../IT%20Workspace/Nextcloud/_MOC-nextcloud.md)
[drei](../AI/Regelwerk/schreibstil)
```
2. Öffne die Notiz, dann die lokale Graphansicht und den Bereich „Ausgehende Links“.
- **P1:** `navigation` und `_MOC-nextcloud` erscheinen als verknüpft, der Graph zeigt beide Kanten. Das belegt relative Links mit `../` und URL-kodierte Leerzeichen.
- **P2:** `frontmatter` erscheint als verknüpft. Das belegt Wikilinks im Frontmatter.
- **P3:** `schreibstil` erscheint als verknüpft. Das belegt Markdown-Links ohne `.md`.
3. Notiz löschen.
Ein Nein bei P1 stoppt Task 03 und Task 05, weil die Hub-Regel dann in Projekt-Repos nichts verbindet. Ein Nein bei P2 oder P3 heißt: Entscheidung 7 bzw. 8 im Entwurf ändern, bevor der Zähler gilt.
## Globale Vorgaben
- Nur die Zieldateien der eigenen Taskdatei. Kein Commit, kein `git add`.
- Codeblöcke wörtlich anwenden mit `python D:/Claude/.vault-tools/plan-kopie/plan_materialisiere.py D:/Claude/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen D:/Claude <taskdatei>`. Nichts von Hand nachschreiben, auch keinen „offensichtlichen“ Tippfehler korrigieren.
- Befehle im Bash-Werkzeug (Git-Bash), nicht in der PowerShell. `python` ist das Windows-Python.
- Läuft ein Befehl anders als im Plan erwartet: nichts anpassen, die vollständige Ausgabe melden. Ein Widerspruch im Plan wird gemeldet, nicht still aufgelöst.
- Das Feld `updated` in geänderten Regelwerk-Dateien setzt der Koordinator beim Commit, weil das Datum der Umsetzung beim Planen nicht feststeht. `status` bleibt, wie er ist: Pascal hat die Änderung mit der Spec angeordnet.
- In Dateien unter `.vault-tools/` stehen keine Kunden-, Domain- oder Projektnamen. Die Testfälle nutzen erfundene Namen (`active - Alpha`, `kunde-audit`, `Projekt X`).
## Tasks
| Task | Inhalt | Modell | Voraussetzung | Welle |
|---|---|---|---|---|
| `task-01-zaehler.md` | `waisen.py`, Ausnahmedatei, Treiber, Entwurf | haiku | keine | 1 |
| `task-02-sitzungsstart.md` | Zeile im SessionStart-Hook, Treiber, Nachtrag im Entwurf | haiku | Task 01 | 2 |
| `task-03-regeltexte.md` | Hub-Regel in `navigation.md`, Stamm, Planer, Codedoku, SEO | sonnet, effort low | P1 in der App | 1 oder 2 |
| `task-04-bestandsskript.md` | `waisen_bestand.py` und Treiber | haiku | Task 01 | 2 |
| `task-05-bestandslauf.md` | Probelauf, Halt, nach Freigabe echter Lauf | sonnet | Tasks 01, 02, 04, 06; P1 bis P3 | 4 |
| `task-06-nachbesserung-obsidian-regeln.md` | Nachbesserung nach der Prüfung von Welle 1 (2026-09-30): userIgnoreFilters und Linkziele wie Obsidian 1.13.7, Delta gegen den Baum | haiku | Task 01 | 3 |
Die Zieldateien der Tasks 01 bis 04 sind disjunkt. Task 02 und 04 brauchen die Dateien aus Task 01 zur Laufzeit, deshalb Welle 2. Task 05 ändert keine Datei im Plan-Sinn, er fährt Skripte gegen den echten Vault und hält nach Phase A an.
## Messschritte
Der Planer hatte keine Shell. Was gemessen werden muss, steht als Schritt in einem Task:
| Nr. | Was | Wo | Erwartung |
|---|---|---|---|
| M1 | Laufzeit von `waisen.py` über den ganzen Vault | Task 01, `faelle_waisen.py --echt` | unter 10 s |
| M2 | Laufzeit des ganzen Sitzungsstarts mit der neuen Zeile | Task 02, `faelle_sitzungsstart.py --echt` | Ziel des Treibers bleibt 5 s; darüber ist ein offener Punkt, kein Fehler von Task 02 |
| M3 | Zahl der Plan-Übersichten und Anhänge je Gruppe | Task 05, Phase A, Auszählung von `probelauf.txt` | Größenordnung wie in der Spec |
| M4 | Planwerkzeuge mit Linkzeile unter dem Frontmatter | Plankritik über diesen Plan | `plan_bloecke.py vor`: `0 Befund(e).`; Materialisieren ohne Befund |
## Fertig heißt
1. `python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py` endet mit Exit 0, und `--rotlauf` endet mit Exit 0, jeder Mutant `GETOETET`.
2. `python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py --echt` endet mit Exit 0 (M1).
3. `python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py` endet mit Exit 0; `--echt` zeigt die Zeile `- Waisen außerhalb der Ausnahmen: ` mit einer Zahl (M2).
4. `python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py` endet mit Exit 0, `--rotlauf` ebenfalls, jeder Mutant `GETOETET`.
5. `python D:/Claude/.vault-tools/linkcheck/faelle.py` endet weiter mit Exit 0.
6. `regelwerk_check.py` meldet nach Task 03 keine Befundzeile, die vorher nicht da war.
7. Je Welle sind die Zieldateien byte-gleich mit der Kopie, auf die der Koordinator den Plan vorher angewendet hat (`welle_vergleich.py`).
8. Phase A von Task 05 liegt als `probelauf.txt` vor, der Koordinator hat die Liste gelesen und im Sitzungsprotokoll je Gruppe die Zahl und fünf Stichproben notiert, und Phase B ist freigegeben (Pascal hat am 2026-09-30 alles genehmigt).
9. M4: `plan_bloecke.py --projekt D:/Claude vor` über alle Taskdateien endet mit `0 Befund(e).`, und das Materialisieren in der Plankritik lief ohne Befund.
10. `python D:/Claude/.vault-tools/session-hooks/faelle_nach_edit.py` endet weiter mit Exit 0 (Task 02).
11. `linkcheck.py --alle` meldet nach Task 03 keinen neuen toten Link aus einer der Zieldateien.
12. Die Zahl im Sitzungsstart der nächsten Sitzung ist dieselbe wie die von `waisen.py` zur selben Zeit; beide Ausgaben stehen im Sitzungsprotokoll.
13. Nach Phase B: Probelauf und echter Lauf nennen dieselben Anhänge, die Sicherung liegt außerhalb des Vaults, und Pascals Bildschirmfoto des Graphen ist die Abnahme.
## Lauf mit echten Daten
- **Zähler:** `PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen.py --vault D:/Claude --liste`. Die Spec maß am 2026-09-29 1.996 Notizen und 852 Waisen, darunter 10 in `Bookstack/` und 7 unter `data/`. Ohne diese Ausnahmen sind rund 835 zu erwarten, dazu die seitdem entstandenen Dateien. Weicht die Zahl um mehr als etwa 20 ab, vergleicht der Koordinator die Gruppen der Spec mit der Liste, bevor Task 05 startet. Stichprobe: zehn Pfade aus der Liste in Obsidian öffnen, Rückverweise und ausgehende Links müssen leer sein. **Neue Basis:** Die Plankritik maß am 2026-09-30 mit `waisen.py` aus der Kopie 2.215 Notizen, 865 Waisen und 17 ausgenommene; 32 der neuen Waisen liegen unter `docs/` an der Vault-Wurzel (Pläne und Specs einer anderen Sitzung, nicht veröffentlicht). Verglichen wird mit dieser Messung, nicht mit der Spec.
- **Sitzungsstart:** nächste Sitzung nach Task 02, die Zeile steht im Kontext, und die Zahl ist dieselbe wie beim Zähler.
- **Bestandslauf:** Task 05. Auffallen müsste: jede Übersicht unter `Code Projekte/*/docs/plans/*/` mit verwaisten Tasks erscheint als `ZIEL`, jede Taskdatei nie. Die Liste für den Koordinator enthält die Gruppe „Einzelne“, die Administrator-Anleitungen und alles ohne Muster. **Seit der Nachbesserung vom 2026-09-30** bekommen auch Pläne und Specs unter `docs/` an der Vault-Wurzel einen Anhang (`Vault: [README](…)`); Dateien tiefer als ein Planordner, etwa unter `docs/plans/<ordner>/befunde/`, stehen auf der Liste für den Koordinator und bekommen von Hand eine Zeile auf die Übersicht ihres Planordners.
## Risiken
- **`CLAUDE.md` liegt im Arbeitsbaum mit CRLF**, obwohl `.gitattributes` `eol=lf` vorgibt (Plankritik 2026-09-30). `plan_materialisiere.py` schreibt LF, Task 03 ändert also jedes Zeilenende mit. Das ist gewollt, `git diff` zeigt es nicht, weil der Index LF hat.
- **Obsidian zählt anders als nachgebaut.** Dann weicht die Zahl vom Graphen ab. Erkennbar an P1 bis P3 und an der Stichprobe. Die Auflösung mehrdeutiger Namen ist nachgebaut, das steht als Lücke im Kopf von `waisen.py`.
- **Der Sitzungsstart wird zu langsam.** Über 15 s bricht die Waisenzeile ab und erscheint als Fehlerzeile, die übrigen Zeilen bleiben. Über 30 s gesamt verwirft Claude Code die ganze Ausgabe. M1 und M2 messen das.
- **Ein anderer Agent schreibt während Phase B.** Das Skript liest jede Zieldatei direkt vor dem Anhängen noch einmal und bricht ab, wenn sie sich geändert hat. Was bis dahin angehängt war, nennt die Ausgabe, die Sicherung liegt vor.
- **Eine Executer-Welle läuft vor dem Commit der Bestandsänderungen in einem Repo.** Dann meldet `welle_vergleich.py` die angehängten Übersichten als „außerhalb der Zieldateien“. Abhilfe: erst committen oder die Meldung erwarten.
- **Obsidian Sync auf einem zweiten Gerät.** Phase B ändert viele Dateien auf einmal. Während des Laufs Obsidian auf anderen Geräten schließen.
- **Veröffentlichung um 04:00.** Die Dateien unter `.vault-tools/` gehen halbfertig hinaus, sobald sie auf der Platte liegen. Sie enthalten keine Namen. Die angehängten Zeilen in `AI/`, `IT Workspace/`, `Inbox/` und in den Projekt-Repos werden nicht veröffentlicht.
## Getroffene Annahmen
- Relative Markdown-Links, URL-kodierte Pfade, Wikilinks im Frontmatter und Markdown-Links ohne `.md` zählen im Graphen. Prüfschritte P1 bis P3.
- Die Git-Prüfung gilt für jede Zieldatei in einem Repo, nicht nur für Übersichten, und eine nie committete Zieldatei zählt als geändert. Das ist strenger als die Spec, damit ein Commit je Repo keine fremde halbe Arbeit mitnimmt.
- Unter `Code Projekte/<projekt>/docs/` bekommt jede verwaiste Datei außerhalb von `docs/plans/` die README-Zeile. Die Spec nennt Codedoku, Specs, Abnahme und Recherche; das Muster ist `docs/**`. Pascal sieht im Probelauf, was darunter fällt.
- Die Administrator-Anleitung hat keinen festen Namen. Sie kommt auf die Liste für den Koordinator, statt geraten zu werden.
- Bericht eines SEO-Laufs ist `ABSCHLUSSBERICHT.md` im Ordner über `findings/`, sonst `FULL-AUDIT-REPORT.md`. Beide Namen stammen aus dem Audit-Skill, nicht aus einem Kundennamen.
- Die Projektübersicht eines IT-Workspace-Projekts mit `_arbeitsstand` ist die einzige `_MOC-*.md` im Ordner darüber. Gibt es keine oder mehrere, kommt die Datei auf die Liste.
- Der Abschnitt „Dateien in diesem Ordner“ listet jede `.md` im Ordner außer der Übersicht, auch schon verlinkte, wie in der Spec.
## Offene Punkte
**Entschieden am 2026-09-30:** Pascal hat die Spec samt relativen Markdown-Links in Projekt-Repos abgenommen, P1 bis P3 in der App bestanden und alle Vorschläge dieser Liste angenommen. Task 03 und Phase B von Task 05 sind damit frei.
1. **P1 bis P3 in der App**, vor Task 03 und vor Phase B von Task 05.
2. **Abweichung von der Spec, Workflows:** Die Spec sieht vor, dass gespeicherte Workflows, die in Planordner schreiben, dieselbe Zeile bekommen. Das Inventar zeigt: Nur `planung.js` lässt schreiben, und zwar über `agentType: 'planer'`. Die Regel kommt aus `planer.md`, eine zweite Fassung im Workflow würde driften. Vorschlag: keine Änderung an Workflows.
3. **Abweichung von der Spec, Administrator-Anleitung:** Die Spec nennt sie in der Bestandstabelle bei „eine Zeile mit Link aufs README“. Ohne festen Dateinamen gibt es kein Muster; sie kommt auf die Liste für den Koordinator.
4. **Laufzeitziel des Sitzungsstarts:** `faelle_sitzungsstart.py` erwartet bisher unter 5 s. Liegt M2 darüber, entscheidet Pascal, ob das Ziel steigt (der Hook hat 30 s).
5. **IT-Workspace-Projekte ohne `_arbeitsstand`:** Die Spec-Zeile „Arbeitsdatei eines Projekts im IT Workspace“ ist für den Bestand nur für `_arbeitsstand` als Muster umgesetzt. Andere Arbeitsdateien kommen auf die Liste.
6. **Specs in der Inbox** wie die Spec zu diesem Plan haben laut Hub-Tabelle das Projekt-README als Hub, liegen aber außerhalb eines Projekts. Die Regel sagt dazu nichts. Vorschlag: bei Abnahme in den Projektordner verschieben oder aus der Tagesnotiz verlinken.
## Abschluss 2026-10-05
| Fertig heißt | Ergebnis | Beleg |
|---|---|---|
| 1, 2 Zähler | erfüllt | `faelle_waisen.py` 109 von 109, Rotlauf 33 Mutanten getötet; `--echt` 2,1 s |
| 3 Sitzungsstart | erfüllt | `faelle_sitzungsstart.py` 75 von 75; `--echt` 5,0 s, Ziel seit 2026-10-05 8 s (Entscheidung in `.vault-tools/session-hooks/ENTWURF.md`) |
| 4 Bestandsskript | erfüllt | `faelle_waisen_bestand.py` 31 von 31, Rotlauf 18 Mutanten getötet |
| 5 Linkcheck-Fälle | erfüllt | `faelle.py` 27 von 27 |
| 6 regelwerk_check | erfüllt | nur der alte Befund zu `frontmatter.md` |
| 7 Byte-Vergleich | erfüllt | je Welle alle Zieldateien byte-gleich; die übrigen Meldungen von `welle_vergleich.py` waren fremde oder nicht committete Dateien |
| 8 Phase A | erfüllt | 164 Ziele, 7 übersprungen, 52 auf der Liste; keine Taskdatei und keine Ausnahme als Ziel |
| 9 Phase B | erfüllt bis auf die Abnahme | Probelauf und echter Lauf gleich, 164 Dateien byte-genau Sicherung plus Anhang, Sicherung unter `D:/Claude-Sicherung-2026-10-05-waisen`; Abnahme per Bildschirmfoto des Graphen steht bei Pascal |
| 10 bis 13 | erfüllt | `faelle_nach_edit.py` 58 von 58; kein neuer toter Link aus den Zieldateien; M4 ohne Befund |
- **Prüfungen:** Task 01 durch einen `pruefer` (2 wichtige Befunde, umgesetzt in Task 06), Tasks 02, 03, 04 und 06 kritisch mit `xhigh`: 0 blockierend, 0 wichtig, 10 klein. Sechs kleine Befunde zum Wortlaut sind in `navigation.md`, Stamm und `seo.md` behoben, das Laufzeitziel ist entschieden.
- **Bewusst offen (klein):** Zwei Schutzprüfungen in `waisen_bestand.py` (Byte-Vergleich der Sicherung, Kontrolle nach dem Anhängen) sind laut Prüfer richtig, aber durch keinen Fall abgesichert. Ein verwaister Git-Arbeitsbaum sperrt alle Ziele seines Repos.
- **Handliste:** 34 von 37 Dateien einzeln angebunden; Programm- und Vorlagendateien als Muster in `waisen-ausnahmen.txt` (Skills, Plugins, Testfixtures, Programmausgaben, Vorlagen).
- **Ergebnis:** 880 Waisen vor dem Lauf, 45 danach. Die 45 sind gesperrt durch offene Zweige und Arbeitsbäume, nicht versionierte Dateien oder gemischte Zeilenenden; nach dem Merge der betroffenen Zweige `waisen_bestand.py` noch einmal fahren.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,492 @@
---
type: plan-task
title: "Task 02 — Zeile „Waisen außerhalb der Ausnahmen“ im SessionStart-Hook"
status: draft
summary: "sitzungsstart.py ruft waisen.py --zahl als eigenen Prozess mit 15 s Grenze und meldet die Zahl als Zeile; der Treiber bekommt eine Grundlinie mit .obsidian/app.json, neue Fälle und angepasste Erwartungen; ENTWURF.md bekommt einen Nachtrag."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-30
modell: haiku
modell_begruendung: "Fünfzehn Aus-wird-Blöcke und ein angehängter Abschnitt stehen wörtlich im Plan und werden per Skript angewendet; keine typografischen Zeichen in den Codeblöcken."
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 02 — Zeile im SessionStart-Hook
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“.
Voraussetzung: Task 01 ist umgesetzt, `waisen.py` und `waisen-ausnahmen.txt` liegen unter `.vault-tools/linkcheck/`.
## Zieldateien
- `D:\Claude\.vault-tools\session-hooks\sitzungsstart.py` (geändert)
- `D:\Claude\.vault-tools\session-hooks\faelle_sitzungsstart.py` (geändert)
- `D:\Claude\.vault-tools\session-hooks\ENTWURF.md` (geändert)
## Verbote
- `.claude/settings.json` bleibt, wie es ist. Der Timeout von 30 s wird nicht geändert.
- `regelwerk_nach_edit.py` und `faelle_nach_edit.py` bleiben unverändert.
- `ZIEL_LAUFZEIT_S` in `faelle_sitzungsstart.py` bleibt bei 5.0, auch wenn die Messung darüber liegt.
- Nichts unter `.vault-tools/linkcheck/` ändern.
## 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-02-sitzungsstart.md
```
Erwartet: eine Zeile mit `'ersetzt': 15` und `'angehaengt': 1` und die Schlusszeile `0 Befund(e).`
### Geltungsbereich im Kopf von `.vault-tools/session-hooks/sitzungsstart.py`
Aus:
```
Die Punkte aus dem Abschnitt Sitzungsprotokoll in CLAUDE.md, gezählt und
nicht bewertet: Datum laut Maschinenuhr, Tagesnotiz, Inbox-Notizen ohne
type: daily, Notizen mit Tag needs-review, Notizen mit Tag review ohne
review-done, Regelwerk-Dateien mit status: pending-review, heutige
Sitzungsdateien. Die Vault-Wurzel ist ausschließlich der Ordner zwei Ebenen
```
wird:
```
Die Punkte aus dem Abschnitt Sitzungsprotokoll in CLAUDE.md, gezählt und
nicht bewertet: Datum laut Maschinenuhr, Tagesnotiz, Inbox-Notizen ohne
type: daily, Notizen mit Tag needs-review, Notizen mit Tag review ohne
review-done, Regelwerk-Dateien mit status: pending-review, heutige
Sitzungsdateien, dazu die Zahl der Waisen außerhalb der Ausnahmen aus
.vault-tools/linkcheck/waisen.py (eigener Prozess, nach 15 s abgebrochen;
Regeln und Lücken stehen im Kopf von waisen.py). Die Vault-Wurzel ist
ausschließlich der Ordner zwei Ebenen
```
### Importe in `.vault-tools/session-hooks/sitzungsstart.py`
Aus:
```
import re
import sys
```
wird:
```
import re
import subprocess
import sys
```
### Konstanten in `.vault-tools/session-hooks/sitzungsstart.py`
Aus:
```
SKRIPT = Path(__file__).resolve()
LINKCHECK = SKRIPT.parent.parent / "linkcheck"
```
wird:
```
SKRIPT = Path(__file__).resolve()
LINKCHECK = SKRIPT.parent.parent / "linkcheck"
WAISEN = LINKCHECK / "waisen.py"
# Das Budget des Hooks in .claude/settings.json sind 30 s für alles.
ZEITGRENZE_WAISEN_S = 15.0
```
### Neuer Punkt in `.vault-tools/session-hooks/sitzungsstart.py`
Aus:
```
PUNKTE = [
("Datum", punkt_datum),
("Tagesnotiz", punkt_tagesnotiz),
("Inbox", punkt_inbox),
("needs-review/review", punkt_tags),
("Regelwerk", punkt_regelwerk),
("Sitzungen", punkt_sitzungen),
]
```
wird:
```
def punkt_waisen(wurzel: Path, heute: str) -> list[str]:
if not WAISEN.is_file():
raise RuntimeError(f"waisen.py fehlt unter {WAISEN}")
umgebung = dict(os.environ)
umgebung["PYTHONIOENCODING"] = "utf-8"
try:
lauf = subprocess.run(
[sys.executable, str(WAISEN), "--vault", str(wurzel), "--zahl"],
capture_output=True,
stdin=subprocess.DEVNULL,
timeout=ZEITGRENZE_WAISEN_S,
env=umgebung,
)
except subprocess.TimeoutExpired:
raise RuntimeError(
f"waisen.py nach {ZEITGRENZE_WAISEN_S:g} s abgebrochen, nicht gezählt"
) from None
ausgabe = lauf.stdout.decode("utf-8", errors="replace").strip()
if lauf.returncode in (0, 1) and ausgabe.isdigit():
return [f"- Waisen außerhalb der Ausnahmen: {ausgabe}"]
erste = ausgabe or lauf.stderr.decode("utf-8", errors="replace").strip()
raise RuntimeError(f"waisen.py Exit {lauf.returncode}: {erste.split(chr(10))[0].strip()}")
PUNKTE = [
("Datum", punkt_datum),
("Tagesnotiz", punkt_tagesnotiz),
("Inbox", punkt_inbox),
("needs-review/review", punkt_tags),
("Regelwerk", punkt_regelwerk),
("Sitzungen", punkt_sitzungen),
("Waisen", punkt_waisen),
]
```
### Konstanten in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: "
FEHLERZEILE = "Sitzungsstart-Hook:"
```
wird:
```
SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: "
WA = "- Waisen außerhalb der Ausnahmen: "
FEHLERZEILE = "Sitzungsstart-Hook:"
WAISEN = HIER.parent / "linkcheck" / "waisen.py"
AUSNAHMEN = HIER.parent / "linkcheck" / "waisen-ausnahmen.txt"
```
### Grundlinie in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
schreibe(v, "Notizen/sauber.md",
"---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n")
```
wird:
```
schreibe(v, "Notizen/sauber.md",
"---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n")
# waisen.py braucht einen Obsidian-Vault; ohne Filter hat die Grundlinie sechs Waisen.
schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": []}')
```
### Erwartete Zeilen der Grundlinie in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
SI + f"1 ({HEUTE}-10.md)",
]
```
wird:
```
SI + f"1 ({HEUTE}-10.md)",
WA + "6",
]
```
### Kopf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
def kopiere_hook(v: Path, linkcheck_kopieren: bool = True) -> Path:
```
wird:
```
def kopiere_hook(v: Path, linkcheck_kopieren: bool = True, waisen_kopieren: bool = True) -> Path:
```
### Rumpf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
if linkcheck_kopieren:
lc_ziel = v / ".vault-tools" / "linkcheck"
lc_ziel.mkdir(parents=True, exist_ok=True)
shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py")
return skript
```
wird:
```
lc_ziel = v / ".vault-tools" / "linkcheck"
lc_ziel.mkdir(parents=True, exist_ok=True)
if linkcheck_kopieren:
shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py")
if waisen_kopieren:
shutil.copy2(WAISEN, lc_ziel / "waisen.py")
shutil.copy2(AUSNAHMEN, lc_ziel / "waisen-ausnahmen.txt")
return skript
```
### Feld im Fall in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
stdin: Callable[[Path], bytes] | None = None
linkcheck_kopieren: bool = True
```
wird:
```
stdin: Callable[[Path], bytes] | None = None
linkcheck_kopieren: bool = True
waisen_kopieren: bool = True
```
### Fall ohne jeden Ordner in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
Fall("leer | Vault ohne jeden Ordner",
lambda v: [shutil.rmtree(p) for p in list(v.iterdir())],
[TN + "fehlt", IN + "Ordner Inbox/ fehlt", NR + "0", RV + "0",
PR + "Ordner AI/Regelwerk/ fehlt", SI + "Ordner AI/sessions/ fehlt"]),
```
wird:
```
Fall("leer | Vault ohne jeden Ordner",
lambda v: [shutil.rmtree(p) for p in list(v.iterdir())],
[TN + "fehlt", IN + "Ordner Inbox/ fehlt", NR + "0", RV + "0",
PR + "Ordner AI/Regelwerk/ fehlt", SI + "Ordner AI/sessions/ fehlt"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: kein Obsidian-Vault"]),
```
### Fall ohne linkcheck.py und neue Fälle in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
verboten=[NR, RV],
praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar"],
stdin=lambda v: b"{}",
linkcheck_kopieren=False),
]
```
wird:
```
verboten=[NR, RV, WA],
praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar",
"Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: linkcheck.py nicht importierbar"],
stdin=lambda v: b"{}",
linkcheck_kopieren=False),
Fall("waisen | verlinkte Notiz senkt die Zahl",
lambda v: schreibe(v, "Notizen/verweis.md", "[[Notizen/sauber]]\n"),
[WA + "5"]),
Fall("waisen | Waise an erster und an letzter Stelle",
lambda v: (schreibe(v, "0-erste/a.md", "x\n"), schreibe(v, "zz-letzte/z.md", "x\n")),
[WA + "8"]),
Fall("waisen | Muster aus der Ausnahmedatei greift",
lambda v: schreibe(v, "Bookstack/archiv/seite.md", "x\n"),
[WA + "6"]),
Fall("waisen | waisen.py fehlt", nichts,
[DA, TN + "vorhanden", IN + "1 (offen.md)", NR + "0", RV + "0", PR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py fehlt"],
waisen_kopieren=False),
Fall("waisen | kaputte app.json meldet Fehler statt Zahl",
lambda v: schreibe(v, ".obsidian/app.json", "{kaputt"),
[DA, TN + "vorhanden", NR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: .obsidian/app.json nicht lesbar"]),
Fall("umgebung | ohne PYTHONIOENCODING zaehlt Waisen mit Umlaut im Namen",
lambda v: schreibe(v, "Notizen/Übersicht 🚀.md", "x\n"),
[WA + "7"],
umgebung=ohne_pythonioencoding),
]
```
### Aufruf von kopiere_hook in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
skript = kopiere_hook(v, fall.linkcheck_kopieren)
```
wird:
```
skript = kopiere_hook(v, fall.linkcheck_kopieren, fall.waisen_kopieren)
```
### Gesperrte Datei in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
erwartet = grundzeilen(v) + ["Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)"]
```
wird:
```
# gesperrt.md ist für waisen.py lesbar und die siebte Waise.
erwartet = [z for z in grundzeilen(v) if not z.startswith(WA)] + [WA + "7"]
erwartet.append("Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)")
```
### Zeitgrenze im Prozess in `.vault-tools/session-hooks/faelle_sitzungsstart.py`
Aus:
```
IM_PROZESS = [
("absturz | ein Punkt wirft", ip_punkt_wirft),
("absturz | Hauptablauf wirft", ip_hauptablauf_wirft),
("lesen | Datei nicht lesbar", ip_datei_gesperrt),
("lesen | Ordner nicht lesbar", ip_ordner_gesperrt),
]
```
wird:
```
def ip_waisen_zeitgrenze(v: Path) -> tuple[bool, str]:
"""Die Zeitgrenze lässt sich von außen nicht billig überschreiten:
im Prozess auf eine Millisekunde gesetzt, startet waisen.py nicht rechtzeitig."""
skript = kopiere_hook(v)
modul = lade_hook("hook_waisen_zeitgrenze", skript)
modul.ZEITGRENZE_WAISEN_S = 0.001
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = [z for z in grundzeilen(v) if not z.startswith(WA)]
erwartet.append("Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py nach 0.001 s abgebrochen, nicht gezählt")
probleme = bewerte(zeilen, erwartet, [WA], [])
return not probleme, "\n".join(" " + p for p in probleme)
IM_PROZESS = [
("absturz | ein Punkt wirft", ip_punkt_wirft),
("absturz | Hauptablauf wirft", ip_hauptablauf_wirft),
("lesen | Datei nicht lesbar", ip_datei_gesperrt),
("lesen | Ordner nicht lesbar", ip_ordner_gesperrt),
("waisen | Zeitgrenze ueberschritten", ip_waisen_zeitgrenze),
]
```
### Nachtrag am Ende von `.vault-tools/session-hooks/ENTWURF.md`
Häng diesen Block ans Ende:
````markdown
## Nachtrag 2026-09-29: Zahl der Waisen
**Anlass.** Die Spec Waisen im Graphen vom 2026-09-29 verlangt beim Sitzungsstart eine Zeile mit der Zahl der Waisen außerhalb der Ausnahmen, nur die Zahl, keine Liste. Plan: `.vault-tools/linkcheck/docs/plans/2026-09-29-waisen/`.
| # | Frage | Antwort | Herkunft |
|---|---|---|---|
| 16 | Woher kommt die Zahl? | `waisen.py --zahl` aus `.vault-tools/linkcheck/`, als eigener Prozess mit `sys.executable`, stdin geschlossen, `PYTHONIOENCODING=utf-8`. Die Regeln des Zählers stehen in `.vault-tools/linkcheck/ENTWURF-waisen.md`. | **Projektvorgabe**: Plan |
| 17 | Wie lange darf sie dauern? | 15 s, dann bricht der Hook den Zähler ab und meldet eine Fehlerzeile. Das Budget des Hooks in `.claude/settings.json` sind 30 s für alles. | **Projektvorgabe** |
| 18 | Was, wenn `waisen.py` fehlt, abbricht oder keine Zahl liefert? | Fehlerzeile `Sitzungsstart-Hook: Waisen:` mit der ersten Ausgabezeile des Zählers, keine Zahl. | **Projektvorgabe**: `pruefwerkzeuge.md` Abschnitt 6 |
**Treiber.** Die Grundlinie trägt jetzt `.obsidian/app.json` und hat sechs Waisen. `kopiere_hook()` kopiert `waisen.py` und `waisen-ausnahmen.txt` mit. Neue Fälle: ein Link senkt die Zahl, Waisen an erster und letzter Stelle, ein Muster aus der Ausnahmedatei greift, `waisen.py` fehlt, kaputte `app.json`, Umlaut und Emoji im Namen ohne `PYTHONIOENCODING`, und im Prozess die überschrittene Zeitgrenze. Zwei bestehende Fälle erwarten zusätzlich die Fehlerzeile des Zählers: Vault ohne jeden Ordner (dann fehlt `.obsidian`) und `linkcheck.py` fehlt. Dass der Hook die Zahl wirklich vom Zähler übernimmt, zeigen die verschiedenen erwarteten Zahlen 5, 6, 7 und 8 in den neuen Fällen.
**Laufzeit.** Frage 15 peilt unter 5 s an. Ob das mit dem Zähler hält, misst `faelle_sitzungsstart.py --echt`.
````
## Schritt 2 — Rot zuerst
Die neuen und geänderten Fälle müssen gegen den Hook ohne die Änderung aus Schritt 1 rot werden. Der Stand ohne Änderung ist `sitzungsstart.py` aus `HEAD` des Vault-Repos; dieser Task committet nicht. Alles in einem einzigen Bash-Aufruf, damit die neue Fassung sicher zurückkommt:
```
H=D:/Claude/.vault-tools/session-hooks
mv "$H/sitzungsstart.py" "$H/sitzungsstart.py.neu" && git -C D:/Claude show HEAD:.vault-tools/session-hooks/sitzungsstart.py > "$H/sitzungsstart.py"
grep -c punkt_waisen "$H/sitzungsstart.py"
PYTHONIOENCODING=utf-8 python -B "$H/faelle_sitzungsstart.py"; echo "exit=$?"
PYTHONIOENCODING=utf-8 python -B -c '
import shutil, sys, tempfile
from pathlib import Path
sys.path.insert(0, "D:/Claude/.vault-tools/session-hooks")
import faelle_sitzungsstart as f
arbeit = Path(tempfile.mkdtemp(prefix="sitzungsstart-rot-")).resolve()
basis = arbeit / "grundlinie"
f.baue_grundlinie(basis)
auswahl = [(x.name, lambda x=x: f.lauf_fall(x, basis, arbeit)) for x in f.FAELLE
if "aisen" in x.name or "ohne jeden Ordner" in x.name or "linkcheck.py fehlt" in x.name]
auswahl += [(n, lambda fn=fn: fn(f.frische_kopie(basis, arbeit))) for n, fn in f.IM_PROZESS
if "aisen" in n or "Datei nicht lesbar" in n]
for name, lauf in auswahl:
try:
ok, text = lauf()
except Exception as exc:
ok, text = None, "ABSTURZ " + type(exc).__name__ + ": " + str(exc)
print({True: "GRUEN", False: "ROT", None: "ABSTURZ"}[ok].ljust(8) + name + "\n" + text)
shutil.rmtree(arbeit, ignore_errors=True)
'; echo "exit=$?"
mv -f "$H/sitzungsstart.py.neu" "$H/sitzungsstart.py"
grep -c punkt_waisen "$H/sitzungsstart.py"
ls "$H"
```
Erwartet, in dieser Reihenfolge:
- Das erste `grep` gibt `0`. Steht dort eine andere Zahl, enthält `HEAD` schon die Änderung, und der Schritt misst nichts: melden.
- Der Treiber endet mit `ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.` und `exit=2`. Unter `erwartet:` steht als letzte Zeile `- Waisen außerhalb der Ausnahmen: 6`, unter `bekommen (stdout):` fehlt sie.
- Die Einzelfälle: je Fall ein Block, der mit `ROT` beginnt, für genau diese Fälle: `leer | Vault ohne jeden Ordner`, `umgebung | linkcheck.py fehlt`, die fünf Fälle, die mit `waisen |` beginnen, `umgebung | ohne PYTHONIOENCODING zaehlt Waisen mit Umlaut im Namen`, `lesen | Datei nicht lesbar` und `waisen | Zeitgrenze ueberschritten`. Jeder Block nennt eine Zeile `fehlt:` mit der Waisenzeile oder eine Zeile `fehlt Zeile mit Anfang: Sitzungsstart-Hook: Waisen:`. Kein Block beginnt mit `GRUEN` oder `ABSTURZ`. Danach `exit=0`.
- Das zweite `grep` gibt `2`: `sitzungsstart.py` ist wieder die Fassung aus Schritt 1.
- `ls` zeigt keine Datei `sitzungsstart.py.neu`.
## Schritt 3 — Angriffsliste
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py; echo "exit=$?"
```
Erwartet: `Grundlinie gruen`, keine Zeile, die mit `FEHLER` beginnt, die Fälle mit `waisen` im Namen als `BESTANDEN`, Schlusszeile `N von N Faellen bestanden.` mit zweimal derselben Zahl, `exit=0`.
## Schritt 4 — Messung M2 über den echten Vault
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py --echt; echo "exit=$?"
```
Erwartet: die Kontextzeilen des echten Vaults, darunter eine Zeile `- Waisen außerhalb der Ausnahmen: ` mit einer Zahl und keine Zeile `Sitzungsstart-Hook: Waisen:`. Steht dort `LAUFZEIT UEBER ZIEL` und `exit=1`: nichts ändern, die Laufzeit melden. Das ist Offener Punkt 4 der Übersicht, kein Fehler dieses Tasks.
## Schritt 5 — Nachbarn unverändert grün
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_nach_edit.py; echo "exit=$?"
```
Erwartet: `exit=0`.
## Bericht
Melde die vollständige Ausgabe von Schritt 1, 2, 4 und 5, von Schritt 3 die Schlusszeile und jede Zeile, die nicht mit `BESTANDEN` beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.
@@ -0,0 +1,218 @@
---
type: plan-task
title: "Task 03 — Hub-Regel in navigation.md und ihre Verweise in Stamm, Planer, Codedoku und SEO"
status: draft
summary: "navigation.md bekommt die Hub-Regel mit Tabelle als einzigen Ort der Regel und einen Abschnitt zu waisen.py; CLAUDE.md, planer.md, codedoku.md und seo.md bekommen je einen knappen Punkt mit Verweis."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-29
modell: sonnet
effort: low
modell_begruendung: "Reine Abschrift per Skript, aber die Codeblöcke enthalten typografische Anführungszeichen, die Haiku still ersetzt."
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 03 — Hub-Regel und Verweise
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“.
Voraussetzung: Pascal hat Prüfschritt P1 aus der Übersicht in Obsidian mit Ja beantwortet. Ohne dieses Ja startet der Task nicht.
## Zieldateien
- `D:\Claude\AI\Regelwerk\navigation.md` (geändert)
- `D:\Claude\CLAUDE.md` (geändert)
- `D:\Claude\.claude\agents\planer.md` (geändert)
- `D:\Claude\AI\Regelwerk\codedoku.md` (geändert)
- `D:\Claude\AI\Regelwerk\seo.md` (geändert)
## Verbote
- Frontmatter der Zieldateien nicht anfassen, auch nicht `updated` oder `status`. Das macht der Koordinator.
- Kein Skill, kein Workflow, keine andere Regelwerk-Datei.
- Keine Formulierung glätten oder ergänzen. Steht ein Aus-Block nicht genau einmal in der Datei, bricht das Skript mit Befund ab: dann melden, nicht von Hand einfügen.
## Schritt 0 — Stand vorher festhalten
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/regelwerk-check/regelwerk_check.py --vault D:/Claude > /tmp/regelwerk-vorher.txt; echo "exit=$?"; PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/linkcheck.py --vault D:/Claude --alle > /tmp/linkcheck-vorher.txt; echo "exit=$?"
```
Der Exit-Code ist hier egal, er wird nur mitgemeldet.
## 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-03-regeltexte.md
```
Erwartet: eine Zeile mit `'ersetzt': 7` und die Schlusszeile `0 Befund(e).`
### Abschnitt zu waisen.py in `AI/Regelwerk/navigation.md`
Aus:
```
Der erste Befehl meldet die echten toten Wikilinks, weil er Codeblöcke ausnimmt. Der zweite fährt die Angriffsfälle gegen die Codeblock-Erkennung. Entwurf, Herkunft je Regel und benannte Lücken: `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md`.
```
wird:
````
Der erste Befehl meldet die echten toten Wikilinks, weil er Codeblöcke ausnimmt. Der zweite fährt die Angriffsfälle gegen die Codeblock-Erkennung. Entwurf, Herkunft je Regel und benannte Lücken: `.vault-tools/linkcheck/ENTWURF-codeblock-erkennung.md`.
## Waisen: waisen.py
Eine Waise ist eine Notiz ohne Kante im Graphen, weder eingehend noch ausgehend. `waisen.py` zählt sie so, wie Obsidian den Graphen baut, und zieht die Muster aus `waisen-ausnahmen.txt` ab.
```bash
PYTHONIOENCODING=utf-8 python .vault-tools/linkcheck/waisen.py --vault . --liste
python .vault-tools/linkcheck/faelle_waisen.py
```
- Der Sitzungsstart zeigt dieselbe Zahl als Zeile „Waisen außerhalb der Ausnahmen“. Steigt sie, hat eine neue Datei ihren Hub nicht verlinkt (Regel unten unter „Links schreiben und Dateien verschieben“).
- In `waisen-ausnahmen.txt` stehen nur allgemeine Muster, keine Kunden- oder Projektnamen, weil `.vault-tools/` veröffentlicht wird.
- Entwurf, Herkunft je Regel und benannte Lücken: `.vault-tools/linkcheck/ENTWURF-waisen.md`. Ein Fangnetz, kein Nachweis: Wie der Graph aussieht, zeigt nur Obsidian.
````
### Sitzungsstart in `AI/Regelwerk/navigation.md`
Aus:
```
- **`sitzungsstart.py`** (SessionStart) gibt beim Start nur Zahlen in den Kontext: ob die Tagesnotiz existiert, offene Inbox-Notizen, Notizen mit `#needs-review`, `#review` ohne `#review-done`, Regelwerk-Dateien auf `pending-review` und die Sitzungsdateien von heute, damit eine Namenskollision auffällt.
```
wird:
```
- **`sitzungsstart.py`** (SessionStart) gibt beim Start nur Zahlen in den Kontext: ob die Tagesnotiz existiert, offene Inbox-Notizen, Notizen mit `#needs-review`, `#review` ohne `#review-done`, Regelwerk-Dateien auf `pending-review` und die Sitzungsdateien von heute, damit eine Namenskollision auffällt. Dazu kommt die Zahl der Waisen außerhalb der Ausnahmen aus `waisen.py`, nur die Zahl, keine Liste.
```
### Hub-Regel in `AI/Regelwerk/navigation.md`
Aus:
```
## Links schreiben und Dateien verschieben
- **Wikilinks mit vollem Vault-Pfad:** `[[AI/Regelwerk/navigation|navigation.md]]`. In Tabellenzellen schreibst du den Alias-Trenner als `\|`.
```
wird:
```
## Links schreiben und Dateien verschieben
### Jede neue Datei hängt an ihrem Hub
**Wer eine Datei anlegt, verlinkt darin ihren Hub, und zwar nach oben.** Das gilt für dich, für jeden Subagenten, Skill und Workflow. Der Erzeuger kennt den Hub beim Anlegen, der Hub selbst braucht keine Pflege, und seine Rückverweise zeigen alle Kinder. Ein Pfad in Backticks ist kein Link.
| Datei | Hub | Linkform |
|---|---|---|
| Plan-Übersicht `00-uebersicht.md`, Einzeldatei-Plan in einem Code-Projekt | Projekt-README | relativer Markdown-Link |
| Plan oder Spec unter `docs/` an der Vault-Wurzel | `README.md` der Vault-Wurzel | relativer Markdown-Link |
| alles andere im Planordner (Task, Anforderung, Prüfbericht, Nachbesserung) | `00-uebersicht.md` desselben Ordners | relativer Markdown-Link |
| Codedoku `docs/code/*.md`, Spec, Quorum-Bericht, CHANGELOG, Administrator-Anleitung | Projekt-README | relativer Markdown-Link |
| SEO-Einzelbefund | Bericht des Laufs | Wikilink mit vollem Pfad |
| Bericht eines SEO-Laufs | SEO-Seed | Wikilink mit vollem Pfad |
| Arbeitsdatei oder Plan-Übersicht eines Projekts im IT Workspace, auch unter `_arbeitsstand/` | Übersicht (`_MOC-*.md`) bzw. README des Projekts | Wikilink mit vollem Pfad |
| Dump | Tagesnotiz | Wikilink `[[Inbox/JJJJ-MM-TT]]` |
| Notiz oder Spec in der Inbox | Tagesnotiz des Tages, an dem sie entsteht | Wikilink aus dem Agentenprotokoll der Tagesnotiz, also nach unten |
- **Wo die Zeile steht:** direkt unter dem Frontmatter, ohne Frontmatter in der ersten Zeile. In einer Taskdatei lautet sie `Übersicht: [00-uebersicht.md](00-uebersicht.md)`, in einer Übersicht `Projekt: [README](../../../README.md)`, die Tiefe passend zum Ort.
- **Relative Markdown-Links in Code-Projekten und unter `docs/` an der Vault-Wurzel, entgegen der Vault-Gewohnheit.** Der Projektstatus steht im Ordnerpräfix, und aus `active - X` wird irgendwann `inactive - X`. Wikilinks mit vollem Pfad brechen dabei, wenn nicht in Obsidian umbenannt wird. Gitea zeigt Markdown-Links an, Wikilinks nicht. Leerzeichen und Umlaute im Pfad schreibst du URL-kodiert, ein Leerzeichen als `%20`.
- **Überall sonst** bleibt es beim Wikilink mit vollem Pfad, auch in Projekten im IT Workspace, deren `_arbeitsstand` ein eigenes Git-Repo ist: Diese Ordner tragen kein Statuspräfix.
- **Notizen in der Inbox verlinkt die Tagesnotiz nach unten**, weil eine Inbox-Notiz noch keinen festen Ort hat. Wird sie einsortiert, gilt die Zeile ihres neuen Orts.
- **SEO-Befunde verlinkt der Bericht nach unten**, weil der Audit-Skill außerhalb des Vaults liegt und seine Befunddateien ohne Link ablegt. Wie, steht in [[AI/Regelwerk/seo|seo.md]], Abschnitt 1, Punkt 5.
- **Bewusste Ausnahmen** sind `Bookstack/` (Warteschlange und Archiv) und Programmausgaben unter `data/` in Code-Projekten. Sie stehen als Muster in `.vault-tools/linkcheck/waisen-ausnahmen.txt` und zählen nicht als Waisen.
### Links allgemein
- **Wikilinks mit vollem Vault-Pfad:** `[[AI/Regelwerk/navigation|navigation.md]]`. In Tabellenzellen schreibst du den Alias-Trenner als `\|`.
```
### Pflegepunkt in `CLAUDE.md`
Aus:
```
- Neues Projekt in Code Projekte / Security-Compliance → im jeweiligen Oberseed mit Link aufs README eintragen und mit dem passenden Themen-Seed verbinden ([[AI/Regelwerk/projekte|projekte.md]]).
```
wird:
```
- Neues Projekt in Code Projekte / Security-Compliance → im jeweiligen Oberseed mit Link aufs README eintragen und mit dem passenden Themen-Seed verbinden ([[AI/Regelwerk/projekte|projekte.md]]).
- Wer eine Datei anlegt, ob du, ein Subagent, ein Skill oder ein Workflow, verlinkt darin ihren Hub nach oben: Plan, Codedoku und CHANGELOG das Projekt-README, Task die Plan-Übersicht, Dump die Tagesnotiz. Tabelle und Linkform: [[AI/Regelwerk/navigation|navigation.md]], „Links schreiben und Dateien verschieben“. Der Sitzungsstart meldet die Zahl der Waisen.
```
### Hub-Zeile in `.claude/agents/planer.md`
Aus:
```
- **Ablageform hängt am Umfang.** Bis zwei Tasks eine Datei `docs/plans/JJJJ-MM-TT-<thema>.md`. **Ab etwa drei Tasks ein Ordner** `docs/plans/JJJJ-MM-TT-<thema>/` mit `00-uebersicht.md` (Ziel, Architektur, globale Vorgaben, Dateiübersicht, Tasktabelle, Abnahme) und je Task einer Datei `task-NN-<thema>.md`. Jede Taskdatei verweist im Kopf auf die globalen Vorgaben der Übersicht, statt sie zu wiederholen — zwei Fassungen derselben Vorgabe laufen auseinander.
```
wird:
```
- **Ablageform hängt am Umfang.** Bis zwei Tasks eine Datei `docs/plans/JJJJ-MM-TT-<thema>.md`. **Ab etwa drei Tasks ein Ordner** `docs/plans/JJJJ-MM-TT-<thema>/` mit `00-uebersicht.md` (Ziel, Architektur, globale Vorgaben, Dateiübersicht, Tasktabelle, Abnahme) und je Task einer Datei `task-NN-<thema>.md`. Jede Taskdatei verweist im Kopf auf die globalen Vorgaben der Übersicht, statt sie zu wiederholen — zwei Fassungen derselben Vorgabe laufen auseinander.
- **Jede Plandatei verlinkt ihren Hub direkt unter dem Frontmatter**, als relativen Markdown-Link, nicht nur als Pfad in Backticks. Die Übersicht und ein Einzeldatei-Plan verlinken das Projekt-README (`Projekt: [README](../../../README.md)`, die Tiefe passend zum Ort), jede andere Datei im Planordner die Übersicht (`Übersicht: [00-uebersicht.md](00-uebersicht.md)`). Leerzeichen und Umlaute im Pfad schreibst du URL-kodiert. Liegt der Plan unter `docs/` an der Vault-Wurzel, ist der Hub die `README.md` dort, und die Zeile beginnt mit `Vault:` statt `Projekt:`. Liegt er in einem Projekt im IT Workspace, etwa unter `_arbeitsstand/`, verlinkt die Übersicht stattdessen die Projektübersicht (`_MOC-*.md` im Projektordner) als Wikilink mit vollem Pfad. Die Planwerkzeuge vertragen die Zeile an dieser Stelle, weil sie nur Überschriften, Codezäune und den Abschnitt `## Zieldateien` auswerten (Befund im Plan `.vault-tools/linkcheck/docs/plans/2026-09-29-waisen/`). Regel und Begründung: `AI/Regelwerk/navigation.md`, Abschnitt „Links schreiben und Dateien verschieben“.
```
### README-Zeile in `AI/Regelwerk/codedoku.md`
Aus:
```
- Ort: Projektordner unter `docs/code/`, eine Datei je Modul oder Skript, gleicher Name.
```
wird:
```
- Ort: Projektordner unter `docs/code/`, eine Datei je Modul oder Skript, gleicher Name.
- Direkt unter dem Frontmatter, ohne Frontmatter in der ersten Zeile, steht ein relativer Markdown-Link aufs Projekt-README, zum Beispiel `Projekt: [README](../../README.md)`. Der Dateikopf im Code nennt die Codedoku nur als Pfad, ohne diese Zeile ist sie im Graphen eine Waise. Regel: [[AI/Regelwerk/navigation|navigation.md]], Abschnitt „Links schreiben und Dateien verschieben“.
```
### Befundliste im Bericht in `AI/Regelwerk/seo.md`
Aus:
```
`findings/`, Rohdaten und die Preisliste bekommen keinen Seed-Eintrag. Befunde früherer Läufe sind oft längst behoben (Punkt 1) und sollen nicht prominent verlinkt sein.
```
wird:
```
`findings/`, Rohdaten und die Preisliste bekommen keinen Seed-Eintrag. Befunde früherer Läufe sind oft längst behoben (Punkt 1) und sollen nicht prominent verlinkt sein. Der Bericht verlinkt sie trotzdem, damit sie im Graphen nicht lose hängen: Nach jedem Lauf bekommt `ABSCHLUSSBERICHT.md` am Ende einen Abschnitt `## Befunddateien dieses Laufs` mit einem Wikilink mit vollem Pfad je Datei unter `findings/`. Der Audit-Skill liegt außerhalb des Vaults und legt die Befunddateien ohne Link ab ([[AI/Regelwerk/navigation|navigation.md]], „Links schreiben und Dateien verschieben“).
```
## Schritt 2 — Stand nachher vergleichen
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/regelwerk-check/regelwerk_check.py --vault D:/Claude > /tmp/regelwerk-nachher.txt; echo "exit=$?"; diff <(grep '^\[' /tmp/regelwerk-vorher.txt) <(grep '^\[' /tmp/regelwerk-nachher.txt) && echo "keine neue Befundzeile"
```
Erwartet: `keine neue Befundzeile`. Zeigt `diff` eine Zeile, die mit `>` beginnt, meldest du sie wörtlich. Ein Befund dieses Tasks ist sie nur, wenn sie eine der fünf Zieldateien nennt. Zwei andere Sitzungen können parallel an `CLAUDE.md` und `AI/Regelwerk/` arbeiten, eine Zeile zu einer anderen Datei stammt deshalb vermutlich von dort. Das entscheidet der Koordinator, nicht du.
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/linkcheck.py --vault D:/Claude --alle > /tmp/linkcheck-nachher.txt; echo "exit=$?"; diff <(grep '^ - ' /tmp/linkcheck-vorher.txt) <(grep '^ - ' /tmp/linkcheck-nachher.txt) && echo "keine neuen toten Links"
```
Erwartet: `keine neuen toten Links`. Für eine neue Zeile gilt dasselbe wie oben: wörtlich melden, ein Befund dieses Tasks nur, wenn sie aus einer der fünf Zieldateien stammt.
## Schritt 3 — Planablage-Prüfung
```
grep -c "Jede Plandatei verlinkt ihren Hub" D:/Claude/.claude/agents/planer.md; grep -c "### Jede neue Datei hängt an ihrem Hub" D:/Claude/AI/Regelwerk/navigation.md
```
Erwartet: zweimal `1`.
## Bericht
Melde die vollständige Ausgabe der Schritte 0 bis 3. Läuft etwas anders als erwartet: nichts anpassen, melden.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,144 @@
---
type: plan-task
title: "Task 05 — Bestandslauf: Probelauf, Halt, echter Lauf nach Freigabe"
status: draft
summary: "Fährt Zähler und Bestandsskript gegen den echten Vault: Phase A nur Probelauf mit Auszählung und Halt, Phase B erst nach ausdrücklicher Freigabe mit Sicherung außerhalb des Vaults; kein Commit."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-29
modell: sonnet
modell_begruendung: "Keine Codeänderung, aber Befehle gegen den echten Vault mit einem harten Halt, einem Datumswert aus der Laufzeit und Ausgaben, die vor dem Weitermachen gelesen werden müssen."
---
Übersicht: [00-uebersicht.md](00-uebersicht.md)
# Task 05 — Bestandslauf
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“.
Voraussetzungen: Tasks 01, 02 und 04 sind umgesetzt und geprüft. Pascal hat die Prüfschritte P1 bis P3 aus der Übersicht in Obsidian beantwortet.
Der Task hat zwei Phasen. **Phase A** fährst du, wenn der Auftrag „Phase A“ sagt. **Phase B** fährst du nur, wenn im Auftrag wörtlich „Phase B freigegeben“ steht. Steht das nicht da, endet dein Lauf nach Phase A.
## Zieldateien
Im Vault und in den Projekt-Repos ändert nur `waisen_bestand.py` selbst Dateien, und nur in Phase B. Du legst von Hand nur an:
- `D:\Claude-waisen-protokoll\` (neu, außerhalb des Vaults, Ausgaben der Läufe)
- `D:\Claude-Sicherung-<Datum>-waisen\` (neu, außerhalb des Vaults, legt das Skript in Phase B selbst an)
## Verbote
- Kein Commit, kein `git add`, kein `git stash`, kein `git checkout` in irgendeinem Repo.
- Keine Datei im Vault von Hand anfassen, auch nicht, um einen Befund „schnell“ zu beheben.
- Phase B nie ohne die wörtliche Freigabe im Auftrag. Bricht ein Lauf ab, nicht wiederholen und nichts zurückkopieren: melden.
- Befehle im Bash-Werkzeug, nicht in der PowerShell. Dort schreibt `>` UTF-16.
## Phase A — Probelauf
### A1 — Treiber im echten Baum
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py > /dev/null; echo "faelle_waisen exit=$?"; PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py > /dev/null; echo "faelle_waisen_bestand exit=$?"
```
Erwartet: zweimal `exit=0`. Sonst Halt und melden.
### A2 — Protokollordner
```
mkdir -p D:/Claude-waisen-protokoll && test ! -e D:/Claude-waisen-protokoll/probelauf.txt && echo "angelegt"
```
Erwartet: `angelegt`. Den Ordner kann Task 06 schon angelegt haben (Unterordner `nachbesserung-06/`); liegt dort aber schon eine `probelauf.txt`, Halt und melden.
### A3 — Zählung vorher
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen.py --vault D:/Claude --liste > D:/Claude-waisen-protokoll/waisen-vorher.txt; echo "exit=$?"; grep -E '^(Notizen im Graphen|Einträge in userIgnoreFilters|Ausgenommene Waisen|Muster ohne Treffer|Waisen außerhalb)' D:/Claude-waisen-protokoll/waisen-vorher.txt
```
Erwartet: `exit=1` und die Zahlenzeilen. `exit=2` heißt abgebrochen: Halt und melden.
### A4 — Probelauf
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude > D:/Claude-waisen-protokoll/probelauf.txt; echo "exit=$?"
```
Erwartet: `exit=0`.
### A5 — Auszählung (Messschritt M3)
```
P=D:/Claude-waisen-protokoll/probelauf.txt; echo "ZIEL gesamt: $(grep -c '^ZIEL ' "$P")"; echo "davon Plan-Uebersichten: $(grep -c '^ZIEL .*/00-uebersicht\.md \[' "$P")"; echo "davon Code Projekte: $(grep -c '^ZIEL Code Projekte/' "$P")"; echo "davon IT Workspace: $(grep -c '^ZIEL IT Workspace/' "$P")"; echo "davon AI/secondbrain: $(grep -c '^ZIEL AI/secondbrain/' "$P")"; echo "UEBERSPRUNGEN: $(grep -c '^UEBERSPRUNGEN ' "$P")"; echo "REST: $(grep -c '^REST ' "$P")"; grep -E '^(Waisen außerhalb|Durch Anhänge)' "$P"
```
### A6 — Stichproben gegen die Verbote
```
P=D:/Claude-waisen-protokoll/probelauf.txt; echo "Taskdateien als Ziel:"; grep -E '^ZIEL .*/task-[^/]*\.md \[' "$P"; echo "Ausnahmen als Ziel:"; grep -E '^ZIEL (Bookstack/|Code Projekte/[^/]*/data/)' "$P"; echo "Ende der Stichprobe"
```
Erwartet: zwischen den Überschriften und `Ende der Stichprobe` steht nichts. Steht dort eine Zeile, Halt und melden.
### A7 — Halt
Melde die vollständige Ausgabe von A1 bis A6 und den Pfad `D:/Claude-waisen-protokoll/probelauf.txt`. Dann endet dein Lauf. Den Probelauf liest der Koordinator, die Freigabe für Phase B gibt Pascal.
## Phase B — echter Lauf, nur mit „Phase B freigegeben“
### B1 — Vault seit Phase A unverändert?
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude > D:/Claude-waisen-protokoll/probelauf-2.txt; echo "exit=$?"; diff <(grep -E '^(ZIEL | \| |UEBERSPRUNGEN |REST )' D:/Claude-waisen-protokoll/probelauf.txt) <(grep -E '^(ZIEL | \| |UEBERSPRUNGEN |REST )' D:/Claude-waisen-protokoll/probelauf-2.txt) && echo "GLEICH"
```
Erwartet: `exit=0` und `GLEICH`. Zeigt `diff` Unterschiede, hat sich der Vault seit Phase A geändert: Halt, die `diff`-Ausgabe melden, nichts anwenden.
### B1b — Git-Stand der berührten Repos vorher
Jedes Ziel liegt in einem Git-Repo: in einem Code-Projekt, im `_arbeitsstand` eines Projekts im IT Workspace oder im Repo der Vault-Wurzel. Manche Repos tragen schon vorher fremde Änderungen. Deshalb wird der Stand vorher festgehalten und nachher verglichen. `GIT_OPTIONAL_LOCKS=0` verhindert, dass `git status` den Index auffrischt.
```
D=D:/Claude-waisen-protokoll; sed -nE 's/^ZIEL (.*) \[.*$/\1/p' "$D/probelauf-2.txt" | while IFS= read -r f; do git -C "D:/Claude/$(dirname "$f")" rev-parse --show-toplevel 2>/dev/null; done | sort -u > "$D/repos.txt"; echo "Repos: $(wc -l < "$D/repos.txt")"; cat "$D/repos.txt"; while IFS= read -r r; do echo "== $r"; GIT_OPTIONAL_LOCKS=0 git -C "$r" status --short --untracked-files=all; done < "$D/repos.txt" > "$D/git-vorher.txt"; echo "exit=$?"
```
Erwartet: eine Zeile `Repos: <Zahl>`, die Liste der Repos und `exit=0`.
### B2 — Anwenden
```
S="D:/Claude-Sicherung-$(date +%F)-waisen"; echo "Sicherung: $S"; test ! -e "$S" && PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude --anwenden --sicherung "$S" > D:/Claude-waisen-protokoll/anwenden.txt; echo "exit=$?"
```
Erwartet: die Zeile `Sicherung: D:/Claude-Sicherung-<Datum>-waisen` und `exit=0`. Prüfe, ob das Datum mit dem Sitzungsdatum aus deinem Kontext übereinstimmt, und melde es. Bei `exit=2` die ganze Datei `anwenden.txt` melden, besonders die Zeilen unter `Vor dem Abbruch schon angehängt`, und nichts weiter tun.
### B3 — Anwenden hat getan, was der Probelauf zeigte
```
diff <(grep -E '^(ZIEL | \| |UEBERSPRUNGEN |REST )' D:/Claude-waisen-protokoll/probelauf-2.txt) <(grep -E '^(ZIEL | \| |UEBERSPRUNGEN |REST )' D:/Claude-waisen-protokoll/anwenden.txt) && echo "GLEICH"; grep -E '^(Angehängt an|Waisen außerhalb der Ausnahmen)' D:/Claude-waisen-protokoll/anwenden.txt
```
Erwartet: `GLEICH`, dazu die Zeilen mit „vorher“ und „nachher“.
### B4 — Zählung nachher
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen.py --vault D:/Claude --liste > D:/Claude-waisen-protokoll/waisen-nachher.txt; echo "exit=$?"; grep '^Waisen außerhalb' D:/Claude-waisen-protokoll/waisen-nachher.txt
```
Erwartet: dieselbe Zahl wie in der Zeile „nachher“ aus B3.
### B5 — Stand der Projekt-Repos
Setze `S` auf den Pfad aus B2, Zeile `Sicherung:`, und fahre:
```
D=D:/Claude-waisen-protokoll; while IFS= read -r r; do echo "== $r"; GIT_OPTIONAL_LOCKS=0 git -C "$r" status --short --untracked-files=all; done < "$D/repos.txt" > "$D/git-nachher.txt"; diff "$D/git-vorher.txt" "$D/git-nachher.txt"; echo "diff-exit=$?"; echo "Zeilen in GEAENDERT.txt: $(wc -l < "$S/GEAENDERT.txt")"
```
Erwartet: `diff` zeigt nur Zeilen, die mit `> ` und danach ` M` beginnen, eine je geänderter Datei eines Code-Projekts oder eines `_arbeitsstand`. Die Pfade darin sind relativ zum Repo, in `GEAENDERT.txt` relativ zum Vault. Dateien unter `AI/`, `Inbox/` und `IT Workspace/` außerhalb eines `_arbeitsstand` ignoriert das Repo der Vault-Wurzel, sie erscheinen nicht. Eine Zeile, die mit `< ` beginnt, oder eine neue Zeile zu einer Datei, die nicht in `GEAENDERT.txt` steht, meldest du wörtlich: Sie kann von einer parallelen Sitzung stammen. Committen tut der Koordinator, je Repo einzeln und nur die Dateien aus `GEAENDERT.txt`.
### B6 — Bericht
Melde die vollständige Ausgabe von B1 bis B5, den Sicherungspfad und die Pfade der vier Protokolldateien. Nichts committen, nichts zurückkopieren.
+115
View File
@@ -0,0 +1,115 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Angriffsfaelle fuer strip_code() aus linkcheck.py.
Vorgehen nach AI/Regelwerk/pruefwerkzeuge.md Abschnitt 3: je Fall ein
eigener Durchlauf, Grundlinie zuerst. Faellt die Grundlinie, misst der
Rest nichts.
Der Fall "Reihenfolge" (Link an erster UND letzter Position) ist Nr. 3 -
laut Regelwerk der Fall, dessen Fehlen am teuersten war.
Aufruf: python .vault-tools/linkcheck/faelle.py
"""
import os
import re
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from linkcheck import strip_code, wikilinks # noqa: E402
_LINK = re.compile(r"\[\[([^\]\|#\^]+)")
class _V:
"""Minimaler Ersatz fuer vaultlib in den Faellen."""
_WIKILINK_RE = _LINK
def links(text):
return wikilinks(_V, text)
# (Nummer, Name, Text, erwartete Links)
FAELLE = [
(0, "GRUNDLINIE: einfacher Link", "Siehe [[Zielnotiz]] dazu.", ["Zielnotiz"]),
(1, "leerer Text", "", []),
(2, "nur Fliesstext mit Link", "Ein [[A/B]] mitten im Satz.", ["A/B"]),
(3, "Reihenfolge: Link vor UND nach einem Zaun",
"[[Vorher]]\n\n```bash\n[[ $EUID -eq 0 ]]\n```\n\n[[Nachher]]",
["Vorher", "Nachher"]),
(4, "Zaun mit Sprachangabe", "```bash\nif [[ -f x ]]; then :; fi\n```", []),
(5, "Tilde-Zaun", "~~~\n[[drin]]\n~~~", []),
(6, "Zaun ohne Abschluss", "Text\n```\n[[drin]]\nnoch drin", []),
(7, "Inline-Code", "Der Ausdruck `[[:space:]]` trifft Leerzeichen.", []),
(8, "doppelte Backticks", "Siehe ``[[roh]]`` im Text.", []),
(9, "einzelner Backtick ohne Partner",
"Ein ` einzelner Backtick und danach [[Echt]].", ["Echt"]),
(10, "vier Backticks als Zaun", "````\n[[drin]]\n````", []),
(11, "eingerueckter Zaun in einer Liste",
"- Punkt:\n ```\n [[drin]]\n ```\n- [[Danach]]", ["Danach"]),
(12, "Link unmittelbar an der Zaunzeile",
"[[Davor]]\n```\ncode\n```\n[[Danach]]", ["Davor", "Danach"]),
(13, "Bestandsfall EUID im Block",
"```bash\nif [[ $EUID -eq 0 ]]; then exit 1; fi\n```", []),
(14, "Bestandsfall Zeichenklasse inline",
"Mit `grep -o '[[:space:]]'` filtern.", []),
(15, "escaptes Pipe in einer Tabellenzelle",
r"| Datei | [[Pfad/Zur/Notiz\|Alias]] | Rest |", ["Pfad/Zur/Notiz"]),
(16, "escaptes Pipe am Zeilenende ohne Alias",
r"Siehe [[Ziel\|]] dort.", ["Ziel"]),
(17, "Dreier-Zaun mit Info-String in Vierer-Zaun",
"````markdown\n```bash\ngrep '[[:space:]]' datei\n```\n````\n[[Danach]]",
["Danach"]),
(18, "Dreier-Schliesszaun schliesst Vierer nicht",
"````\n```\n[[drin]]\n````\n[[Danach]]", ["Danach"]),
(19, "Vierer-Zaun schliesst Vierer-Zaun",
"````\n[[drin]]\n````\n[[Danach]]", ["Danach"]),
(20, "```bash in einem ```-Block schliesst nicht",
"```\n```bash\n[[drin]]\n```\n[[Danach]]", ["Danach"]),
(21, "Schliesszaun mit Leerraum dahinter",
"```\n[[drin]]\n``` \t\n[[Danach]]", ["Danach"]),
(22, "Schliesszaun mit CR am Zeilenende",
"```\r\n[[drin]]\r\n```\r\n[[Danach]]\r\n", ["Danach"]),
(23, "anderes Zaunzeichen schliesst nicht (Backtick vs. Tilde)",
"```\n[[drin]]\n~~~\n[[Danach]]", []),
(24, "Schliesszaun mit vier Leerzeichen davor schliesst nicht",
"```\n[[drin]]\n ```\n[[Danach]]", []),
(25, "Schliesszaun mit Tab davor schliesst (symmetrische Regel, benannte Luecke oberste Ebene)",
"```\n[[drin]]\n\t```\n[[Danach]]", ["Danach"]),
(26, "Liste: mit Tab eingerueckter Block schliesst mit Tab-Zaun",
"- Punkt:\n\t```\n\t[[drin]]\n\t```\n- [[Danach]]", ["Danach"]),
]
def main():
grundlinie = FAELLE[0]
ist = links(grundlinie[2])
if ist != grundlinie[3]:
sys.stdout.write("GRUNDLINIE FAELLT: erwartet %r, bekommen %r\n"
% (grundlinie[3], ist))
sys.stdout.write("Der Rest der Faelle misst damit nichts. Abbruch.\n")
return 2
fehler = 0
for nr, name, text, erwartet in FAELLE:
try:
ist = links(text)
except Exception as exc: # ein Absturz ist ein Befund
sys.stdout.write(" FALL %-2d ABSTURZ %s -> %s\n" % (nr, name, exc))
fehler += 1
continue
ok = ist == erwartet
if not ok:
fehler += 1
sys.stdout.write(" FALL %-2d %-7s %-42s erwartet %r, bekommen %r\n"
% (nr, "ok" if ok else "FEHLER", name, erwartet, ist))
gepruefte = len(FAELLE)
sys.stdout.write("\n%d von %d Faellen bestanden.\n" % (gepruefte - fehler, gepruefte))
return 1 if fehler else 0
if __name__ == "__main__":
sys.exit(main())
+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())
@@ -0,0 +1,735 @@
"""Fährt die Angriffsliste gegen waisen_bestand.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-29
Plan: docs/plans/2026-09-29-waisen/ im selben Ordner
Grundlinie zuerst: Der Probelauf über den unbeschädigten Wegwerf-Vault muss
genau die erwarteten Anhänge und genau die erwartete Liste liefern und darf
nichts ändern. Sonst misst der Rest nichts.
Je Fall eine frische Kopie der Grundlinie, genau eine Änderung, das Skript als
eigener Prozess. Die Git-Fälle legen im Wegwerf-Vault ein Repo mit Zweig oder
Arbeitsbaum an und brauchen git im PATH. Verglichen wird jede Datei des
Wegwerf-Vaults vor und nach dem Lauf, Byte für Byte.
Zwei Fälle laufen über HUELLE: Sie lädt waisen_bestand.py in einen eigenen
Prozess und setzt entweder einen fremden Schreiber ein, der nach der Sicherung
an CHANGELOG.md anhängt, oder schreibt je git-Aufruf den Wert von
GIT_OPTIONAL_LOCKS mit.
--rotlauf baut jeden Mutanten aus MUTANTEN in eine Kopie des Skripts und fährt
die genannten Fälle dagegen. Getötet heißt: jeder genannte Fall schlägt fehl.
Aufruf:
python .vault-tools/linkcheck/faelle_waisen_bestand.py
python .vault-tools/linkcheck/faelle_waisen_bestand.py --rotlauf
"""
import os
import shutil
import stat
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable, Optional
HIER = Path(__file__).resolve().parent
BESTAND = HIER / "waisen_bestand.py"
BEGLEITER = [HIER / "waisen.py", HIER / "linkcheck.py", HIER / "waisen-ausnahmen.txt"]
P = "Code Projekte/active - Alpha"
E = P + "/docs/plans/2026-01-01-eins"
S = "IT Workspace/SEO/kunde-audit/15012026"
X = "IT Workspace/Thema/Projekt X"
XP = X + "/_arbeitsstand/docs/plans/2026-01-03-p"
W = "docs/plans/2026-01-04-w"
SPEC = "docs/specs/2026-01-04-spec.md"
NEU = P + "/docs/plans/2026-01-09-neu"
DUMP = "AI/secondbrain/alpha-memory/2026-01-01-thema.md"
DUMP_OHNE = "AI/secondbrain/alpha-memory/2026-01-05-ohne.md"
UMLAUT = "task-09-prüfung x.md"
UMLAUT_LINK = "task-09-pr%C3%BCfung%20x.md"
ORDNER_KOPF = "## Dateien in diesem Ordner"
BEFUND_KOPF = "## Befunddateien dieses Laufs"
ZIELE = {
P + "/CHANGELOG.md": ["Projekt: [README](README.md)"],
P + "/docs/code/modul.md": ["Projekt: [README](../../README.md)"],
P + "/docs/lose.md": ["Projekt: [README](../README.md)"],
P + "/docs/plans/2026-01-02-einzel.md": ["Projekt: [README](../../README.md)"],
E + "/00-uebersicht.md": ["Projekt: [README](../../../README.md)", "", ORDNER_KOPF, "",
"- [task-01-a.md](task-01-a.md)", f"- [{UMLAUT}]({UMLAUT_LINK})"],
DUMP: ["Tagesnotiz: [[Inbox/2026-01-01]]"],
S + "/ABSCHLUSSBERICHT.md": [BEFUND_KOPF, "", f"- [[{S}/findings/a]]", f"- [[{S}/findings/b]]"],
X + "/_arbeitsstand/notiz.md": [f"Projekt: [[{X}/_MOC-projekt-x]]"],
XP + "/00-uebersicht.md": [f"Projekt: [[{X}/_MOC-projekt-x]]", "", ORDNER_KOPF, "",
"- [task-01-t.md](task-01-t.md)"],
W + "/00-uebersicht.md": ["Vault: [README](../../../README.md)", "", ORDNER_KOPF, "",
"- [task-01-w.md](task-01-w.md)"],
SPEC: ["Vault: [README](../../README.md)"],
}
REST = {
P + "/notizen.md": "keine Regel passt",
DUMP_OHNE: "Tagesnotiz Inbox/2026-01-05.md fehlt",
W + "/befunde/b1.md": "Plandatei tiefer als ein Ordner unter docs/plans",
}
HUELLE = '''"""Huelle aus faelle_waisen_bestand.py: laedt waisen_bestand.py und biegt zwei Stellen um."""
import importlib.util
import os
import subprocess
import sys
from pathlib import Path
skript, art, *rest = sys.argv[1:]
# Die Variable darf nicht schon von aussen kommen, sonst misst der Fall die Shell.
os.environ.pop("GIT_OPTIONAL_LOCKS", None)
spec = importlib.util.spec_from_file_location("waisen_bestand_huelle", skript)
modul = importlib.util.module_from_spec(spec)
# Vor exec_module eintragen: dataclasses sucht das Modul in sys.modules.
sys.modules["waisen_bestand_huelle"] = modul
spec.loader.exec_module(modul)
aufrufe = []
echt_run = subprocess.run
echt_copy2 = modul.shutil.copy2
def run(befehl, *a, **k):
# Git-Double: merkt sich je git-Aufruf den Wert von GIT_OPTIONAL_LOCKS.
if befehl and befehl[0] == "git":
aufrufe.append((k.get("env") or os.environ).get("GIT_OPTIONAL_LOCKS"))
return echt_run(befehl, *a, **k)
def copy2(quelle, ziel, *a, **k):
# Fremder Schreiber: haengt nach der Sicherung von CHANGELOG.md etwas an.
ergebnis = echt_copy2(quelle, ziel, *a, **k)
if art == "fremd" and Path(quelle).name == "CHANGELOG.md":
with open(quelle, "ab") as datei:
datei.write(b"fremd")
return ergebnis
subprocess.run = run
modul.shutil.copy2 = copy2
sys.stdout.reconfigure(encoding="utf-8")
code = modul.main(rest)
ohne = [wert for wert in aufrufe if wert != "0"]
if aufrufe and not ohne:
print("HUELLE git ok")
else:
print(f"HUELLE git: {len(aufrufe)} Aufrufe, {len(ohne)} ohne GIT_OPTIONAL_LOCKS=0")
raise SystemExit(code)
'''
def schreibe(v: Path, rel: str, inhalt) -> None:
# Immer Bytes: write_text würde unter Windows jedes LF zu CRLF machen.
pfad = v / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(inhalt if isinstance(inhalt, bytes) else inhalt.encode("utf-8"))
def haenge_an(v: Path, rel: str, inhalt: bytes) -> None:
with open(v / rel, "ab") as datei:
datei.write(inhalt)
def _schreibbar(funktion, pfad, _info) -> None:
# Git legt Objektdateien schreibgeschützt an; rmtree scheitert sonst unter Windows.
os.chmod(pfad, stat.S_IWRITE)
funktion(pfad)
def entferne(pfad: Path) -> None:
if not pfad.exists():
return
if sys.version_info >= (3, 12):
shutil.rmtree(pfad, onexc=_schreibbar)
else:
shutil.rmtree(pfad, onerror=_schreibbar)
def nichts(v: Path) -> None:
return None
def baue_grundlinie(v: Path) -> None:
schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": ["_index/"]}')
schreibe(v, "Wissen/start.md",
f"[[{P}/README]]\n[[IT Workspace/SEO/_MOC-seo]]\n[[{X}/_MOC-projekt-x]]\n[[README]]\n")
schreibe(v, "README.md", "# Vault\n")
schreibe(v, P + "/README.md", "# Alpha\n")
schreibe(v, P + "/CHANGELOG.md", "# Changelog")
schreibe(v, P + "/notizen.md", "# Notizen\n")
schreibe(v, P + "/data/2026-01-01.md", "# Auswertung\n")
schreibe(v, P + "/docs/code/modul.md", b"# Modul\r\n\r\nText\r\n")
schreibe(v, P + "/docs/lose.md", "# Lose\n")
schreibe(v, P + "/docs/plans/2026-01-02-einzel.md", "# Einzel\n")
schreibe(v, E + "/00-uebersicht.md", "# Eins\n")
schreibe(v, E + "/task-01-a.md", "# A\n")
schreibe(v, E + "/" + UMLAUT, "# Z\n")
schreibe(v, DUMP, "# Dump\n")
schreibe(v, DUMP_OHNE, "# Dump\n")
schreibe(v, "Inbox/2026-01-01.md", "# Tag\n")
schreibe(v, "IT Workspace/SEO/_MOC-seo.md", f"[[{S}/ABSCHLUSSBERICHT]]\n")
schreibe(v, S + "/ABSCHLUSSBERICHT.md", "# Bericht\n")
schreibe(v, S + "/findings/a.md", "# A\n")
schreibe(v, S + "/findings/b.md", "# B\n")
schreibe(v, X + "/_MOC-projekt-x.md", "# X\n")
schreibe(v, X + "/_arbeitsstand/notiz.md", "# N\n")
schreibe(v, XP + "/00-uebersicht.md", "# P\n")
schreibe(v, XP + "/task-01-t.md", "# T\n")
schreibe(v, W + "/00-uebersicht.md", "# W\n")
schreibe(v, W + "/task-01-w.md", "# T\n")
schreibe(v, W + "/befunde/b1.md", "# B\n")
schreibe(v, SPEC, "# Spec\n")
schreibe(v, "Bookstack/archiv/seite.md", "# S\n")
schreibe(v, "_index/INDEX.md", "# I\n")
def git(ordner: Path, *argumente: str) -> None:
lauf = subprocess.run(
["git", "-C", str(ordner), "-c", "user.name=Faelle", "-c", "[email protected]",
"-c", "commit.gpgsign=false", *argumente],
capture_output=True,
stdin=subprocess.DEVNULL,
)
if lauf.returncode != 0:
raise RuntimeError(f"git {' '.join(argumente)}: {lauf.stderr.decode('utf-8', errors='replace')}")
def repo_anlegen(v: Path, ohne: Optional[str] = None) -> Path:
repo = v / P
git(repo, "init", "-q", "-b", "main")
git(repo, "config", "core.autocrlf", "false")
git(repo, "add", "-A")
if ohne:
git(repo, "rm", "-q", "--cached", ohne)
git(repo, "commit", "-q", "-m", "Stand")
return repo
def zweig_aendert(v: Path) -> None:
repo = repo_anlegen(v)
git(repo, "checkout", "-q", "-b", "neben")
schreibe(v, E + "/00-uebersicht.md", "# Eins auf dem Zweig\n")
git(repo, "commit", "-q", "-am", "Zweig")
git(repo, "checkout", "-q", "main")
def arbeitsbaum_aendert(v: Path) -> None:
repo = repo_anlegen(v)
baum = v / ".arbeitsbaeume" / "wb"
git(repo, "worktree", "add", "-q", "-b", "wb", str(baum))
haenge_an(baum, "docs/plans/2026-01-01-eins/00-uebersicht.md", b"offen\n")
def eigener_baum_aendert(v: Path) -> None:
repo_anlegen(v)
haenge_an(v, E + "/00-uebersicht.md", b"offen\n")
def nie_committet(v: Path) -> None:
repo_anlegen(v, ohne="CHANGELOG.md")
def sauberes_repo(v: Path) -> None:
repo_anlegen(v)
def neuer_planordner(v: Path) -> None:
# Ganz ungetrackter Ordner: im Modus normal nennt git nur den Ordner.
repo_anlegen(v)
schreibe(v, NEU + "/00-uebersicht.md", "# Neu\n")
schreibe(v, NEU + "/task-01-x.md", "# X\n")
def umbenannt_im_index(v: Path) -> None:
# Der alte Pfad ab/CHANGELOG.md ohne seine ersten drei Zeichen wäre CHANGELOG.md.
schreibe(v, P + "/ab/CHANGELOG.md", "# Alt\n")
repo = repo_anlegen(v)
git(repo, "mv", "ab/CHANGELOG.md", "ab/neu.md")
# Ohne erkannte Umbenennung misst der Fall nichts.
status = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"],
capture_output=True, stdin=subprocess.DEVNULL).stdout
if not status.startswith(b"R "):
raise RuntimeError(f"git meldet keine Umbenennung: {status!r}")
def wurzel_repo(v: Path, ignorieren: str) -> None:
schreibe(v, ".gitignore", ignorieren)
git(v, "init", "-q", "-b", "main")
git(v, "config", "core.autocrlf", "false")
git(v, "add", ".gitignore")
git(v, "commit", "-q", "-m", "Stand")
def wurzel_ignoriert_alles(v: Path) -> None:
# Wie die Positivliste der Vault-Wurzel: alles ignoriert außer .gitignore.
wurzel_repo(v, "/*\n!/.gitignore\n")
def wurzel_docs_offen(v: Path) -> None:
# docs/ ist nicht ignoriert, seine Dateien sind also neu im Repo der Wurzel.
wurzel_repo(v, "/*\n!/.gitignore\n!/docs/\n")
def abbild(v: Path) -> dict:
ergebnis = {}
for pfad in sorted(v.rglob("*")):
rel = pfad.relative_to(v)
if ".git" in rel.parts or not pfad.is_file():
continue
ergebnis[rel.as_posix()] = pfad.read_bytes()
return ergebnis
@dataclass
class Fall:
name: str
aendern: Callable = nichts
# probe, anwenden, zweimal, ohne_sicherung, sicherung_da, sicherung_im_vault, anwenden_abbruch, anwenden_fremd
modus: str = "probe"
code: int = 0
ziele: Optional[dict] = None
ziel_zeilen: dict = field(default_factory=dict)
mit_ziel: list = field(default_factory=list)
ohne_ziel: list = field(default_factory=list)
rest: Optional[dict] = None
mit_rest: dict = field(default_factory=dict)
ueber: dict = field(default_factory=dict)
texte: list = field(default_factory=list)
nachher: dict = field(default_factory=dict)
# leer: Skript direkt; fremd oder sperre: über HUELLE
huelle: str = ""
GRUNDLINIE = Fall("GRUNDLINIE", ziele=ZIELE, rest=REST,
texte=["Waisen außerhalb der Ausnahmen vorher: 20",
"Durch Anhänge mitverbunden, ohne eigene Regel: 1"])
FAELLE = [
Fall("anwenden | schreibt genau die erwarteten Bytes", modus="anwenden", ziele=ZIELE, rest=REST,
texte=["Waisen außerhalb der Ausnahmen nachher: 3"],
nachher={
P + "/CHANGELOG.md": b"# Changelog\n\nProjekt: [README](README.md)\n",
P + "/docs/code/modul.md": b"# Modul\r\n\r\nText\r\n\r\nProjekt: [README](../../README.md)\r\n",
E + "/00-uebersicht.md": (
"# Eins\n\nProjekt: [README](../../../README.md)\n\n## Dateien in diesem Ordner\n\n"
"- [task-01-a.md](task-01-a.md)\n- [task-09-prüfung x.md](task-09-pr%C3%BCfung%20x.md)\n"
).encode("utf-8"),
}),
Fall("anwenden | zweiter Lauf findet nichts mehr", modus="zweimal", ziele=ZIELE, rest=REST),
Fall("anwenden | ohne --sicherung bricht ab", modus="ohne_sicherung", code=2,
texte=["braucht --sicherung"]),
Fall("anwenden | Sicherungsordner existiert schon", modus="sicherung_da", code=2,
texte=["existiert schon"]),
Fall("anwenden | Sicherungsordner im Vault", modus="sicherung_im_vault", code=2,
texte=["liegt im Vault"]),
Fall("anwenden | Befund des Zaehlers bricht ohne Schreiben ab",
lambda v: entferne(v / ".obsidian"), modus="anwenden_abbruch", code=2,
texte=["Bestandslauf - ABGEBROCHEN", "Ordner .obsidian fehlt"]),
Fall("anwenden | fremder Schreiber zwischen Lesen und Anhaengen bricht ab", modus="anwenden_fremd",
huelle="fremd", code=2,
texte=["hat sich seit dem Lesen geändert", "Vor dem Abbruch schon angehängt (1)"],
nachher={
DUMP: b"# Dump\n\nTagesnotiz: [[Inbox/2026-01-01]]\n",
P + "/CHANGELOG.md": b"# Changelogfremd",
}),
Fall("zeilenende | gemischte Zeilenenden werden uebersprungen",
lambda v: schreibe(v, P + "/docs/code/modul.md", b"# Modul\r\nText\n"),
ueber={P + "/docs/code/modul.md": "gemischte Zeilenenden"}, ohne_ziel=[P + "/docs/code/modul.md"]),
Fall("kodierung | kein UTF-8 wird uebersprungen",
lambda v: schreibe(v, P + "/docs/plans/2026-01-02-einzel.md", b"# Einzel \xff\n"),
ueber={P + "/docs/plans/2026-01-02-einzel.md": "nicht als UTF-8 lesbar"}),
Fall("git | anderer Zweig hat die Uebersicht geaendert", zweig_aendert,
ueber={E + "/00-uebersicht.md": "Zweig neben"}, mit_ziel=[P + "/CHANGELOG.md"]),
Fall("git | anderer Arbeitsbaum hat die Uebersicht uncommittet geaendert", arbeitsbaum_aendert,
ueber={E + "/00-uebersicht.md": "Arbeitsbaum"}, mit_ziel=[P + "/CHANGELOG.md"]),
Fall("git | Uebersicht im eigenen Arbeitsbaum geaendert", eigener_baum_aendert,
ueber={E + "/00-uebersicht.md": "Arbeitsbaum"}),
Fall("git | nie committete Datei zaehlt als geaendert", nie_committet,
ueber={P + "/CHANGELOG.md": "Arbeitsbaum"}),
Fall("git | sauberes Repo ohne Zweig wird angehaengt", sauberes_repo, ziele=ZIELE, rest=REST),
Fall("git | ungetrackter Planordner zaehlt je Datei", neuer_planordner,
ueber={NEU + "/00-uebersicht.md": "Arbeitsbaum"}, mit_ziel=[P + "/CHANGELOG.md"]),
Fall("git | Umbenennung: alter Pfad ist kein eigener Eintrag", umbenannt_im_index,
mit_ziel=[P + "/CHANGELOG.md"]),
Fall("git | jeder git-Aufruf mit GIT_OPTIONAL_LOCKS=0", sauberes_repo, huelle="sperre",
ziele=ZIELE, rest=REST, texte=["HUELLE git ok"]),
Fall("git | Vault-Wurzel als Repo, ignorierte Dateien zaehlen nicht", wurzel_ignoriert_alles,
ziele=ZIELE, rest=REST),
Fall("git | Vault-Wurzel als Repo, docs/ nicht ignoriert", wurzel_docs_offen,
ueber={W + "/00-uebersicht.md": "Arbeitsbaum", SPEC: "Arbeitsbaum"},
mit_ziel=[DUMP, P + "/CHANGELOG.md"]),
Fall("regel | Planordner ohne 00-uebersicht.md",
lambda v: (v / E / "00-uebersicht.md").unlink(),
mit_rest={E + "/task-01-a.md": "Planordner ohne 00-uebersicht.md",
E + "/" + UMLAUT: "Planordner ohne 00-uebersicht.md"}),
Fall("regel | Planordner nur mit Uebersicht",
lambda v: ((v / E / "task-01-a.md").unlink(), (v / E / UMLAUT).unlink()),
ziel_zeilen={E + "/00-uebersicht.md": ["Projekt: [README](../../../README.md)"]}),
Fall("regel | Projekt ohne README",
lambda v: (v / P / "README.md").unlink(),
mit_rest={P + "/CHANGELOG.md": "README.md fehlt", E + "/task-01-a.md": "README.md fehlt"},
ohne_ziel=[P + "/CHANGELOG.md", E + "/00-uebersicht.md"]),
Fall("regel | Befunde ohne Bericht",
lambda v: (v / S / "ABSCHLUSSBERICHT.md").unlink(),
mit_rest={S + "/findings/a.md": "kein Bericht", S + "/findings/b.md": "kein Bericht"}),
Fall("regel | FULL-AUDIT-REPORT als Bericht",
lambda v: (v / S / "ABSCHLUSSBERICHT.md").rename(v / S / "FULL-AUDIT-REPORT.md"),
ziel_zeilen={S + "/FULL-AUDIT-REPORT.md": [BEFUND_KOPF, "", f"- [[{S}/findings/a]]",
f"- [[{S}/findings/b]]"]},
ohne_ziel=[S + "/ABSCHLUSSBERICHT.md"]),
Fall("regel | zwei MOC-Dateien ueber _arbeitsstand",
lambda v: schreibe(v, X + "/_MOC-zwei.md", "# Z\n"),
mit_rest={X + "/_arbeitsstand/notiz.md": "2 _MOC-Dateien"}),
Fall("regel | Abschnitt schon vorhanden",
lambda v: schreibe(v, E + "/00-uebersicht.md", "# Eins\n\n## Dateien in diesem Ordner\n\n- alt\n"),
ueber={E + "/00-uebersicht.md": "enthält schon"}),
Fall("regel | Uebersicht verlinkt das README schon",
lambda v: schreibe(v, E + "/00-uebersicht.md", "# Eins\n\n[README](../../../README.md)\n"),
ziel_zeilen={E + "/00-uebersicht.md": [ORDNER_KOPF, "", "- [task-01-a.md](task-01-a.md)",
f"- [{UMLAUT}]({UMLAUT_LINK})"]}),
Fall("regel | Dump tiefer als ein Ordner",
lambda v: schreibe(v, "AI/secondbrain/alpha-memory/drehbuecher/2026-01-01-x.md", "# D\n"),
mit_rest={"AI/secondbrain/alpha-memory/drehbuecher/2026-01-01-x.md": "keine Regel passt"}),
Fall("regel | Vault-Wurzel ohne README",
lambda v: (v / "README.md").unlink(),
mit_rest={W + "/task-01-w.md": "README.md der Vault-Wurzel fehlt",
SPEC: "README.md der Vault-Wurzel fehlt"},
ohne_ziel=[W + "/00-uebersicht.md", SPEC]),
Fall("regel | Wurzel-Planordner ohne 00-uebersicht.md",
lambda v: (v / W / "00-uebersicht.md").unlink(),
mit_rest={W + "/task-01-w.md": "Planordner ohne 00-uebersicht.md"}),
]
ALLE = [GRUNDLINIE] + FAELLE
# (Name, Zeile in waisen_bestand.py, Ersatzzeile, Fälle, die rot werden müssen)
MUTANTEN = [
("Zeilenende immer LF",
r' ende = "\r\n" if crlf else "\n"',
r' ende = "\n"',
["anwenden | schreibt genau die erwarteten Bytes"]),
("Git-Pruefung aus",
" repo = repo_von(vault, rel)",
" repo = None",
["git | anderer Zweig hat die Uebersicht geaendert",
"git | anderer Arbeitsbaum hat die Uebersicht uncommittet geaendert",
"git | Uebersicht im eigenen Arbeitsbaum geaendert",
"git | nie committete Datei zaehlt als geaendert"]),
("vorhandene Zeile nicht erkannt",
" if zeile in vorhanden:",
" if False:",
["regel | Abschnitt schon vorhanden"]),
("Sicherung leer",
" shutil.copy2(vault / ziel.rel, kopie)",
" kopie.write_bytes(b\"\")",
["anwenden | schreibt genau die erwarteten Bytes"]),
("Liste nicht gefiltert",
" rest = [(rel, grund) for rel, grund in rest if rel not in verbunden]",
" rest = list(rest)",
["GRUNDLINIE"]),
("Sicherung im Vault erlaubt",
" if sicherung_pfad == vault or vault in sicherung_pfad.parents:",
" if False:",
["anwenden | Sicherungsordner im Vault"]),
("fremde Aenderung vor dem Anhaengen nicht erkannt",
" if pfad.read_bytes() != alt:",
" if False:",
["anwenden | fremder Schreiber zwischen Lesen und Anhaengen bricht ab"]),
("GEAENDERT.txt unter anderem Namen",
r' (sicherung / "GEAENDERT.txt").write_text("\n".join(GESCHRIEBEN) + "\n", encoding="utf-8", newline="\n")',
r' (sicherung / "ANDERS.txt").write_text("\n".join(GESCHRIEBEN) + "\n", encoding="utf-8", newline="\n")',
["anwenden | schreibt genau die erwarteten Bytes"]),
("ungetrackte Ordner nur als Ordner",
' roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all")',
' roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=normal")',
["git | ungetrackter Planordner zaehlt je Datei"]),
("ignorierte Dateien zaehlen als geaendert",
' roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all")',
' roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all", "--ignored")',
["git | Vault-Wurzel als Repo, ignorierte Dateien zaehlen nicht"]),
("Umbenennung als zwei Eintraege gelesen",
' if "R" in eintrag[:2] or "C" in eintrag[:2]:',
" if False:",
["git | Umbenennung: alter Pfad ist kein eigener Eintrag"]),
("git ohne GIT_OPTIONAL_LOCKS",
' umgebung["GIT_OPTIONAL_LOCKS"] = "0"',
" pass",
["git | jeder git-Aufruf mit GIT_OPTIONAL_LOCKS=0"]),
("Vault-Wurzel nicht als Repo geprueft",
" while ordner == vault or vault in ordner.parents:",
" while ordner != vault and vault in ordner.parents:",
["git | Vault-Wurzel als Repo, docs/ nicht ignoriert"]),
("docs/ an der Wurzel ohne Regel",
' if teile[0] == "docs" and len(teile) >= 2:',
" if False:",
["GRUNDLINIE"]),
("Wurzel-README nicht geprueft",
" if not existiert(vault, hub):",
" if False:",
["regel | Vault-Wurzel ohne README"]),
("Wurzel-Planordner ohne Uebersicht nicht erkannt",
" if not existiert(vault, kopf):",
" if False:",
["regel | Wurzel-Planordner ohne 00-uebersicht.md"]),
("Wurzel-Plandatei zu tief angehaengt",
' if teile[1] == "plans" and len(teile) > 4:',
" if False:",
["GRUNDLINIE"]),
("Datei direkt unter docs/ ohne Regel",
' if innen == ["CHANGELOG.md"] or (innen[:1] == ["docs"] and len(innen) >= 2):',
' if innen == ["CHANGELOG.md"] or (innen[:1] == ["docs"] and len(innen) >= 3):',
["GRUNDLINIE"]),
]
def streng_utf8(roh: bytes) -> str:
try:
return roh.decode("utf-8")
except UnicodeDecodeError as exc:
return f"UNGUELTIGES-UTF-8: {exc}"
def fahre(skript: Path, argumente: list) -> tuple:
start = time.perf_counter()
lauf = subprocess.run(
[sys.executable, str(skript), *argumente],
capture_output=True,
stdin=subprocess.DEVNULL,
timeout=300,
)
dauer = time.perf_counter() - start
return lauf.returncode, streng_utf8(lauf.stdout), lauf.stderr.decode("utf-8", errors="replace"), dauer
def lies_ausgabe(out: str) -> tuple:
ziele, rest, ueber = {}, {}, {}
aktuell = None
for zeile in out.replace("\r\n", "\n").split("\n"):
if zeile.startswith("ZIEL "):
aktuell = zeile[5:].split(" [", 1)[0]
ziele[aktuell] = []
elif zeile.startswith(" | ") and aktuell is not None:
ziele[aktuell].append(zeile[6:])
else:
aktuell = None
if zeile.startswith("REST "):
rel, _, grund = zeile[5:].partition(": ")
rest[rel] = grund
elif zeile.startswith("UEBERSPRUNGEN "):
rel, _, grund = zeile[14:].partition(": ")
ueber[rel] = grund
return ziele, rest, ueber
def bewerte(fall: Fall, code: int, out: str, v: Path, vorher: dict, sicherung: Path) -> list:
probleme = []
if code != fall.code:
probleme.append(f"Exit {code} statt {fall.code}")
ziele, rest, ueber = lies_ausgabe(out)
if fall.ziele is not None:
if sorted(ziele) != sorted(fall.ziele):
probleme.append(f"Ziele {sorted(ziele)} statt {sorted(fall.ziele)}")
for rel, zeilen in fall.ziele.items():
if rel in ziele and ziele[rel] != zeilen:
probleme.append(f"{rel}: Zeilen {ziele[rel]!r} statt {zeilen!r}")
for rel, zeilen in fall.ziel_zeilen.items():
if ziele.get(rel) != zeilen:
probleme.append(f"{rel}: Zeilen {ziele.get(rel)!r} statt {zeilen!r}")
for rel in fall.mit_ziel:
if rel not in ziele:
probleme.append(f"Ziel fehlt: {rel}")
for rel in fall.ohne_ziel:
if rel in ziele:
probleme.append(f"Ziel unerwartet: {rel}")
if fall.rest is not None and sorted(rest) != sorted(fall.rest):
probleme.append(f"Liste {sorted(rest)} statt {sorted(fall.rest)}")
for rel, teil in list((fall.rest or {}).items()) + list(fall.mit_rest.items()):
if rel not in rest:
probleme.append(f"nicht auf der Liste: {rel}")
elif teil not in rest[rel]:
probleme.append(f"Liste {rel}: {rest[rel]!r} ohne {teil!r}")
for rel, teil in fall.ueber.items():
if rel not in ueber:
probleme.append(f"nicht uebersprungen: {rel}")
elif teil not in ueber[rel]:
probleme.append(f"uebersprungen {rel}: {ueber[rel]!r} ohne {teil!r}")
for text in fall.texte:
if text not in out:
probleme.append(f"fehlt in der Ausgabe: {text}")
nachher = abbild(v)
geaendert = sorted(r for r in set(vorher) | set(nachher) if vorher.get(r) != nachher.get(r))
if fall.modus in ("anwenden", "zweimal"):
erwartet = sorted(fall.ziele or {})
if geaendert != erwartet:
probleme.append(f"geaendert {geaendert} statt {erwartet}")
liste = sicherung / "GEAENDERT.txt"
if not liste.is_file() or liste.read_bytes() != ("\n".join(erwartet) + "\n").encode("utf-8"):
probleme.append(f"GEAENDERT.txt fehlt oder nennt nicht genau {erwartet}")
for rel in erwartet:
kopie = sicherung / rel
if not kopie.is_file() or kopie.read_bytes() != vorher.get(rel):
probleme.append(f"Sicherung fehlt oder weicht ab: {rel}")
if rel in fall.nachher or rel not in nachher:
continue
ende = "\r\n" if b"\r\n" in vorher.get(rel, b"") else "\n"
schwanz = (ende.join(fall.ziele[rel]) + ende).encode("utf-8")
if not nachher[rel].startswith(vorher.get(rel, b"")) or not nachher[rel].endswith(schwanz):
probleme.append(f"{rel}: nicht genau alter Inhalt plus Anhang")
for rel, soll in fall.nachher.items():
if nachher.get(rel) != soll:
probleme.append(f"{rel}: {nachher.get(rel)!r} statt {soll!r}")
elif fall.modus == "anwenden_fremd":
erwartet = sorted(fall.nachher)
if geaendert != erwartet:
probleme.append(f"geaendert {geaendert} statt {erwartet}")
for rel, soll in fall.nachher.items():
if nachher.get(rel) != soll:
probleme.append(f"{rel}: {nachher.get(rel)!r} statt {soll!r}")
elif geaendert:
probleme.append(f"hat Dateien geaendert: {geaendert}")
if fall.modus == "anwenden_abbruch" and sicherung.exists():
probleme.append("Sicherungsordner trotz Abbruch angelegt")
return probleme
def lauf_fall(fall: Fall, basis: Path, arbeit: Path, skript: Path) -> tuple:
v = arbeit / "fall"
entferne(v)
for alt in arbeit.glob("sicherung*"):
entferne(alt)
shutil.copytree(basis, v)
fall.aendern(v)
vorher = abbild(v)
sicherung = arbeit / "sicherung"
argumente = ["--vault", str(v)]
if fall.modus in ("anwenden", "zweimal", "anwenden_abbruch", "anwenden_fremd"):
argumente += ["--anwenden", "--sicherung", str(sicherung)]
elif fall.modus == "ohne_sicherung":
argumente += ["--anwenden"]
elif fall.modus == "sicherung_da":
sicherung.mkdir()
argumente += ["--anwenden", "--sicherung", str(sicherung)]
elif fall.modus == "sicherung_im_vault":
argumente += ["--anwenden", "--sicherung", str(v / "Sicherung")]
ausfuehren = skript
if fall.huelle:
ausfuehren = arbeit / "huelle.py"
ausfuehren.write_bytes(HUELLE.encode("utf-8"))
argumente = [str(skript), fall.huelle] + argumente
code, out, err, dauer = fahre(ausfuehren, argumente)
probleme = bewerte(fall, code, out, v, vorher, sicherung)
if fall.modus == "zweimal" and not probleme:
erster = abbild(v)
code2, out2, err2, _ = fahre(skript, ["--vault", str(v), "--anwenden", "--sicherung",
str(arbeit / "sicherung-2")])
ziele2, _, _ = lies_ausgabe(out2)
if code2 != 0:
probleme.append(f"zweiter Lauf Exit {code2}")
if ziele2:
probleme.append(f"zweiter Lauf will anhaengen: {sorted(ziele2)}")
if abbild(v) != erster:
probleme.append("zweiter Lauf hat Dateien geaendert")
out, err = out + out2, err + err2
kopf = f"exit={code} {dauer:.2f}s"
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
return False, text + f"\n stdout: {out[:2000]!r}\n stderr: {err[:800]!r}"
def fahre_liste(faelle: list, skript: Path, arbeit: Path, ausgabe: bool) -> dict:
basis = arbeit / "grundlinie"
if not basis.exists():
baue_grundlinie(basis)
ergebnis = {}
for fall in faelle:
try:
ok, text = lauf_fall(fall, basis, arbeit, skript)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
ergebnis[fall.name] = ok
if ausgabe:
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
if fall is GRUNDLINIE and not ok:
break
return ergebnis
def rotlauf() -> int:
quelle = BESTAND.read_text(encoding="utf-8")
nach_name = {f.name: f for f in ALLE}
alle_ok = True
for name, aus, wird, rot_bei in MUTANTEN:
anzahl = quelle.count(aus + "\n")
unbekannt = [n for n in rot_bei if n not in nach_name]
if anzahl != 1 or unbekannt:
print(f"MUSTER {name}: steht {anzahl}-mal in waisen_bestand.py, unbekannte Faelle {unbekannt}")
alle_ok = False
continue
arbeit = Path(tempfile.mkdtemp(prefix="bestand-rot-")).resolve()
try:
ordner = arbeit / "skript"
ordner.mkdir()
for begleiter in BEGLEITER:
shutil.copy2(begleiter, ordner / begleiter.name)
skript = ordner / BESTAND.name
with open(skript, "w", encoding="utf-8", newline="\n") as datei:
datei.write(quelle.replace(aus + "\n", wird + "\n"))
ergebnis = fahre_liste([nach_name[n] for n in rot_bei], skript, arbeit, ausgabe=False)
finally:
entferne(arbeit)
gruen = [n for n in rot_bei if ergebnis.get(n)]
if gruen:
alle_ok = False
print(f"UEBERLEBT {name}: gruen blieben {gruen}")
else:
print(f"GETOETET {name}")
if alle_ok:
print("Rotlauf: alle Mutanten getoetet.")
return 0
print("Rotlauf: mindestens ein Mutant nicht getoetet oder ein Muster falsch.")
return 1
def main() -> int:
for pfad in [BESTAND] + BEGLEITER:
if not pfad.is_file():
print(f"ABBRUCH: {pfad} fehlt")
return 2
if shutil.which("git") is None:
print("ABBRUCH: git fehlt im PATH, die Git-Faelle messen sonst nichts")
return 2
namen = [f.name for f in ALLE]
if len(set(namen)) != len(namen):
print("ABBRUCH: ein Fallname steht doppelt")
return 2
if "--rotlauf" in sys.argv[1:]:
return rotlauf()
arbeit = Path(tempfile.mkdtemp(prefix="bestand-faelle-")).resolve()
try:
ergebnis = fahre_liste(ALLE, BESTAND, arbeit, ausgabe=True)
if not ergebnis.get(GRUNDLINIE.name):
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
bestanden = sum(1 for ok in ergebnis.values() if ok)
print(f"\n{bestanden} von {len(ergebnis)} Faellen bestanden.")
return 0 if bestanden == len(ergebnis) else 1
finally:
entferne(arbeit)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())
+236
View File
@@ -0,0 +1,236 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Findet tote Wikilinks im Vault - ohne die Fehlalarme aus Codebloecken.
WARUM ES DIESES WERKZEUG GIBT
-----------------------------
`vault_doctor.py` aus dem mitgelieferten Navigator-Toolkit meldete am
2026-09-03 92 tote Wikilinks. Die Stichprobe bestand fast ausschliesslich
aus Rauschen: `[[ $EUID -eq 0 ]]` (Bash-Test), `[[:space:]]` (POSIX-
Zeichenklasse), `[[bild.png]]` und `[[Ziel]]` (Platzhalter in der Doku des
Bookstack-Upload-Skills). Ursache ist `_WIKILINK_RE` in `vaultlib.py`
Zeile 61: der Ausdruck laeuft ueber den gesamten Text, das Wort "fence"
kommt in der Datei kein einziges Mal vor.
Das Navigator-Toolkit ist schreibgeschuetzt und stammt aus einem fremden
Skill (siehe AI/Regelwerk/navigation.md). Ein Patch dort waere beim
naechsten Update verloren. Deshalb liegt die Korrektur hier daneben.
HERKUNFT DER REGELN
-------------------
- Wikilink-Syntax: QUELLE - `_WIKILINK_RE`, vaultlib.py Zeile 61.
- Zielaufloesung (lookup-Menge und Treffer-Bedingung): QUELLE - woertlich
uebernommen aus vault_doctor.py Zeilen 66-74 und 102-107.
- Codebloecke und Code-Spans zaehlen nicht: QUELLE - CommonMark; Obsidian
rendert dort keine Links.
- Frontmatter zaehlt nicht mit: QUELLE - vaultlib.py Zeile 117 trennt es ab.
- Ungeschlossener Zaun gilt bis Dateiende: PROJEKTVORGABE - die
konservative Wahl, lieber einen echten Link uebersehen als Rauschen
erzeugen.
- Schliesszaun: QUELLE - dasselbe Zeichen wie der Oeffner, mindestens so
lang, danach nur Leerzeichen oder Tab. Abgeschrieben aus markdown-it-py
4.2.0, rules_block/fence.py Zeilen 42-43, 106 und 119-123; Referenz ist
CommonMark 0.31.2, Abschnitt 4.5 Fenced code blocks. Fundstellen und der
offene Abgleich mit dem Spezifikationstext: ENTWURF-codeblock-erkennung.md.
- Einrueckung vor dem Schliesszaun: wie beim Oeffner hoechstens drei
Leerraumzeichen, ein Tab eingeschlossen. Die Regel ist bewusst symmetrisch
(Entscheidung nach Pruefrunde 2): Eine strengere Schliessregel liess einen
in einer Liste mit Tab eingerueckten Block bis zum Dateiende offen, dort
schliesst markdown-it-py ihn aber.
BEWUSSTE LUECKEN - nicht fuer erledigt halten
---------------------------------------------
- Eingerueckte Codebloecke (vier Leerzeichen) werden NICHT erkannt. Sie
wuerden eingerueckte Listenpunkte mit echten Links verschlucken.
- Ein Tab vor dem Schliesszaun auf oberster Ebene schliesst hier den Block.
markdown-it-py zaehlt den Tab dort als vier Spalten und liest die Zeile als
eingerueckten Code (state_block.py Zeilen 79-80 und 257-261). In Listen
schliesst er auch dort. Listen-Einrueckung bildet dieses Werkzeug nicht nach.
- Ein Oeffner mit Backtick im Info-String gilt hier als Zaun. CommonMark
liest so eine Zeile als Inline-Code. Nicht behandelt.
- `extract_headings()` im Toolkit hat denselben Fehler: `# Kommentar` aus
einem Bash-Block zaehlt dort als Ueberschrift. Hier nicht behandelt.
- Dieses Werkzeug ist ein Fangnetz gegen offensichtliche Fehler. Es belegt
nicht, dass alle Wikilinks stimmen - nur, dass die gemeldeten nicht aus
Codebloecken stammen.
Entwurf und Angriffsliste: ENTWURF-codeblock-erkennung.md im selben Ordner.
Aufruf:
python .vault-tools/linkcheck/linkcheck.py --vault .
python .vault-tools/linkcheck/linkcheck.py --vault . --alle
"""
import argparse
import os
import re
import sys
import traceback
SKRIPT = os.path.dirname(os.path.abspath(__file__))
TOOLKIT = os.path.join(os.path.dirname(SKRIPT), "obsidian-vault-navigator", "scripts")
_FENCE_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})")
# Schliesszaun: dieselbe Einrueckung wie beim Oeffner (symmetrisch, siehe
# Kopfkommentar), danach nur Leerzeichen, Tab und ein CR. Eine Zeile mit
# Info-String im Block ist deshalb Inhalt, kein Ende.
_SCHLUSS_RE = re.compile(r"^\s{0,3}(`{3,}|~{3,})[ \t]*\r?$")
_INLINE_CODE_RE = re.compile(r"(`+)(?:(?!\1).)*?\1")
def strip_code(body):
"""Ersetzt Codebloecke und Code-Spans durch Leerzeichen.
Laenge und Zeilenzahl bleiben erhalten (Ersetzen statt Loeschen), damit
nichts ueber eine entfernte Stelle hinweg zusammenwaechst.
"""
if not body:
return ""
zaun = None
aus = []
for zeile in body.split("\n"):
m = _FENCE_RE.match(zeile)
if zaun is None:
if m:
zaun = m.group(1)
aus.append(" " * len(zeile))
continue
aus.append(_INLINE_CODE_RE.sub(lambda t: " " * len(t.group(0)), zeile))
else:
s = _SCHLUSS_RE.match(zeile)
# Gleiches Zeichen und mindestens so lang wie der Oeffner: ein
# Dreier-Zaun schliesst keinen Vierer-Zaun.
if s and s.group(1)[0] == zaun[0] and len(s.group(1)) >= len(zaun):
zaun = None
aus.append(" " * len(zeile))
return "\n".join(aus)
def lade_toolkit():
"""Importiert vaultlib aus dem schreibgeschuetzten Toolkit.
Fehlt es, ist das ein BEFUND und kein Grund zu schweigen.
"""
if TOOLKIT not in sys.path:
sys.path.insert(0, TOOLKIT)
try:
import vaultlib as V
return V
except ImportError as exc:
raise RuntimeError(
"vaultlib.py nicht gefunden oder nicht importierbar unter %s (%s). "
"Ohne das Toolkit kann diese Pruefung nicht arbeiten." % (TOOLKIT, exc))
def sammle(V, vault):
"""Notizen und Anhaenge getrennt einsammeln.
Anhaenge braucht es, weil `![[Pasted image 123.png]]` ein gueltiger
Obsidian-Link auf eine Nicht-Markdown-Datei ist. vault_doctor.py baut
seine lookup-Menge nur aus .md-Dateien und meldet solche Einbettungen
deshalb als tot.
"""
notizen, anhaenge = [], set()
for ap, rp in V.walk_vault(vault):
if not rp.endswith(".md"):
anhaenge |= {rp, os.path.basename(rp)}
continue
with open(ap, "r", encoding="utf-8", errors="replace") as fh:
text = fh.read()
fm, body = V.parse_frontmatter(text)
notizen.append({"abs": ap, "rel": rp, "fm": fm, "body": body})
return notizen, anhaenge
def baue_lookup(V, notizen):
"""Woertlich aus vault_doctor.py Zeilen 66-74."""
lookup = set()
for n in notizen:
rel = n["rel"]
lookup |= {rel, os.path.splitext(rel)[0], os.path.basename(rel),
os.path.splitext(os.path.basename(rel))[0]}
if n["fm"].get("title"):
lookup.add(str(n["fm"]["title"]))
if n["fm"].get("uid"):
lookup.add(str(n["fm"]["uid"]))
return lookup
def wikilinks(V, body):
"""Wie V.extract_links, aber mit ausgeblendetem Code.
Zusaetzlich wird ein abschliessender Backslash entfernt. In Obsidian-
Tabellenzellen muss das Pipe eines Alias als `\\|` geschrieben werden,
sonst bricht die Tabelle. `_WIKILINK_RE` schneidet am Pipe, laesst den
Backslash aber stehen - das Ziel hiesse dann `Pfad\\` und faende nichts.
Ein Dateiname kann unter Windows nicht auf Backslash enden, das
Abschneiden ist also gefahrlos.
"""
sauber = strip_code(body)
treffer = []
for m in V._WIKILINK_RE.finditer(sauber):
t = m.group(1).strip().rstrip("\\").strip()
if t and t not in treffer:
treffer.append(t)
return treffer
def lauf(vault, alle):
V = lade_toolkit()
notizen, anhaenge = sammle(V, vault)
if not notizen:
raise RuntimeError("Keine Markdown-Dateien unter %s gefunden." % vault)
lookup = baue_lookup(V, notizen)
kaputt = []
roh_gesamt = 0
for n in notizen:
roh_gesamt += len(V.extract_links(n["body"]))
for raw in wikilinks(V, n["body"]):
t = raw.strip().lstrip("./")
# Treffer-Bedingung woertlich aus vault_doctor.py Zeile 104,
# erweitert um Anhaenge (Bilder, PDF) - siehe sammle().
if t in lookup or t + ".md" in lookup or os.path.basename(t) in lookup:
continue
if t in anhaenge or os.path.basename(t) in anhaenge:
continue
kaputt.append((n["rel"], raw))
return notizen, kaputt, roh_gesamt
def main():
p = argparse.ArgumentParser(description="Tote Wikilinks ohne Codeblock-Fehlalarme")
p.add_argument("--vault", default=".")
p.add_argument("--alle", action="store_true", help="alle Befunde statt der ersten 40")
args = p.parse_args()
vault = os.path.abspath(os.path.expanduser(args.vault))
befunde = []
try:
notizen, befunde, roh = lauf(vault, args.alle)
except Exception:
# Absturz ist selbst ein Befund - und die bis dahin gesammelten
# Befunde werden trotzdem ausgegeben.
sys.stdout.write("# Linkcheck - ABGEBROCHEN\n\n")
sys.stdout.write(traceback.format_exc())
for rel, raw in befunde:
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
return 2
sys.stdout.write("# Linkcheck - %d Notizen in %s\n" % (len(notizen), vault))
sys.stdout.write("\nWikilink-Vorkommen ohne Codeblock-Filter: %d\n" % roh)
if not befunde:
sys.stdout.write("\nKeine toten Wikilinks ausserhalb von Codebloecken.\n")
return 0
grenze = len(befunde) if args.alle else 40
sys.stdout.write("\n## Tote Wikilinks (%d)\n" % len(befunde))
for rel, raw in befunde[:grenze]:
sys.stdout.write(" - %s -> [[%s]]\n" % (rel, raw))
if len(befunde) > grenze:
sys.stdout.write(" ... %d weitere (--alle)\n" % (len(befunde) - grenze))
return 1
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,22 @@
# 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/**
# 2026-10-05: Programm- und Vorlagendateien, keine Notizen. Skill- und Plugin-Definitionen,
# Testfixtures und Programmausgaben bekommen keine Hub-Zeile; in einer Vorlage wuerde sie
# in jede neue Notiz wandern.
Code Projekte/*/skill/**
Code Projekte/*/plugin/**
Code Projekte/*/tools/testdaten/**
Code Projekte/*/artifacts/**
*/Vorlagen/**
+439
View File
@@ -0,0 +1,439 @@
"""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 folgt getLinkpathDest aus Obsidian 1.13.7,
nachgebaut, nicht abgeschrieben. Abweichungen: Das Namensverzeichnis kennt
nur Notizen, die Knoten sind; Obsidian führt dort auch Anhänge und
ausgeblendete Dateien. Bei gleich langen Kandidaten entscheidet hier die
Reihenfolge des Durchlaufs (alphabetisch je Ordner), in Obsidian die
Reihenfolge seines Verzeichnisses. Ob Obsidian Backslashes im Linkziel vor
der Auflösung umwandelt, ist nicht belegt; hier bleiben sie stehen.
* 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 folgen updateUserIgnoreFilters und isUserIgnored aus
Obsidian 1.13.7, werden aber mit Python-re ausgewertet, nicht mit
JavaScript. Ein Ausdruck, den nur einer der beiden Dialekte versteht,
wirkt verschieden. 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.
* Strenger als Obsidian: Ist userIgnoreFilters null oder keine Liste oder
ein Eintrag kein Text, bricht das Skript ab. Obsidian nimmt null als
keinen Filter.
* 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 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:
# Dateiname mit Endung, klein geschrieben -> Notizen in der Reihenfolge des Durchlaufs.
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
filter_uebersprungen: list
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) -> tuple:
"""Liest userIgnoreFilters nach den Regeln von Obsidian 1.13.7.
Rückgabe: die wirksamen Filter als kompilierte Ausdrücke und je
übersprungenem ungültigem Ausdruck ein Text für die Ausgabe.
"""
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 = []
uebersprungen = []
for eintrag in roh:
if not isinstance(eintrag, str):
raise Abbruch(f"userIgnoreFilters: ein Eintrag ist kein Text: {eintrag!r}")
# Getrimmt, leer übersprungen, Ausdruck erst ab drei Zeichen, Schreibung egal.
text = eintrag.strip()
if not text:
continue
if len(text) > 2 and text.startswith("/") and text.endswith("/"):
try:
liste.append(re.compile(text[1:-1], re.IGNORECASE))
except re.error as fehler:
uebersprungen.append(f"{eintrag!r} ({fehler})")
else:
liste.append(re.compile("^" + re.escape(text), re.IGNORECASE))
return liste, uebersprungen
def ausgeblendet(rel: str, filter_liste: list) -> bool:
# Wie isUserIgnored: ausgeblendet, sobald ein Filter im relativen Pfad trifft.
return any(muster.search(rel) for muster in filter_liste)
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 _name(pfad: str) -> str:
return pfad[pfad.rfind("/") + 1:]
def _eltern(pfad: str) -> str:
stelle = pfad.rfind("/")
return "" if stelle == -1 else pfad[:stelle]
def baue_index(notizen: list) -> Index:
index = Index()
for rel in notizen:
index.namen.setdefault(_name(rel).lower(), []).append(rel)
return index
def aufloesen(ziel: str, quelle: str, index: Index):
# Nachgebaut nach getLinkpathDest aus Obsidian 1.13.7; gezählt wird der erste Treffer.
ziel = ziel.strip()
if not ziel:
return None
pfad = ziel.lower()
name = _name(pfad)
kandidaten = index.namen.get(name) if "." in name else None
if not kandidaten:
pfad = (ziel + ".md").lower()
name = _name(pfad)
kandidaten = index.namen.get(name)
if not kandidaten:
return None
if name == pfad and len(kandidaten) == 1:
return kandidaten[0]
ordner = _eltern(quelle).lower()
if pfad.startswith("./") or pfad.startswith("../"):
if pfad.startswith("./../"):
pfad = pfad[2:]
if pfad.startswith("./"):
if ordner:
ordner += "/"
pfad = ordner + pfad[2:]
else:
while pfad.startswith("../"):
pfad = pfad[3:]
ordner = _eltern(ordner)
if ordner:
ordner += "/"
pfad = ordner + pfad
for rel in kandidaten:
if rel.lower() == pfad:
return rel
if pfad.startswith("/"):
pfad = pfad[1:]
for rel in kandidaten:
if rel.lower() == pfad:
return rel
if ziel.startswith("/"):
return None
# Pfadende ohne Ordnergrenze; erst der Teilbaum der Quelle, je Gruppe der kürzere Pfad.
passend = [rel for rel in kandidaten if rel.lower().endswith(pfad)]
nah = sorted((rel for rel in passend if rel.lower().startswith(ordner)), key=len)
fern = sorted((rel for rel in passend if not rel.lower().startswith(ordner)), key=len)
treffer = nah + fern
return treffer[0] if treffer else None
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, filter_uebersprungen = 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),
filter_uebersprungen)
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:
# Auf stderr, damit stdout nur die Zahl trägt; der Sitzungsstart liest stdout.
for text in ergebnis.filter_uebersprungen:
print(f"Filter übersprungen, ungültiger regulärer Ausdruck: {text}", file=sys.stderr)
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}")
for text in ergebnis.filter_uebersprungen:
print(f"Filter übersprungen, ungültiger regulärer Ausdruck: {text}")
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")
sys.stderr.reconfigure(encoding="utf-8")
except AttributeError:
pass
sys.exit(main())
+490
View File
@@ -0,0 +1,490 @@
"""Hängt verwaiste Notizen einmalig an ihren Hub an: nur anhängen, nie ersetzen.
Ersteller: Pascal Michel
Erstellt: 2026-09-29
Plan: docs/plans/2026-09-29-waisen/ im selben Ordner
Ohne --anwenden ist es ein Probelauf: Er listet je Zieldatei den Text, der
angehängt würde, und schreibt nichts. Mit --anwenden kopiert das Skript jede
Zieldatei zuerst in den Sicherungsordner außerhalb des Vaults, prüft jede
Kopie Byte für Byte und hängt erst danach an. Das Zeilenende jeder Datei
bleibt, wie es war. Kein Commit, kein git add.
REGELN, alle über Muster, ohne Namen einzelner Projekte oder Kunden
Code Projekte/<p>/docs/plans/<ordner>/<datei>.md
00-uebersicht.md desselben Ordners bekommt eine Zeile mit relativem Link
aufs Projekt-README, wenn sie es nicht schon verlinkt, und den Abschnitt
"Dateien in diesem Ordner" mit relativen Links auf jede .md im Ordner.
Taskdateien fasst das Skript nie an.
Code Projekte/<p>/CHANGELOG.md und jede Datei unter Code Projekte/<p>/docs/
außerhalb von Planordnern
eine Zeile mit relativem Link aufs Projekt-README.
docs/plans/<ordner>/<datei>.md an der Vault-Wurzel
wie ein Planordner in Code Projekte, aber die Zeile heißt "Vault:" und
verlinkt README.md der Vault-Wurzel.
jede andere Datei unter docs/ an der Vault-Wurzel
eine Zeile mit relativem Link auf README.md der Vault-Wurzel.
IT Workspace/**/_arbeitsstand/**
Planordner wie oben, aber der Link nach oben ist ein Wikilink auf die
einzige _MOC-*.md im Ordner über _arbeitsstand; jede andere Datei
bekommt eine Zeile mit diesem Wikilink.
IT Workspace/**/findings/<datei>.md
der Bericht im Ordner über findings (ABSCHLUSSBERICHT.md, sonst
FULL-AUDIT-REPORT.md) bekommt den Abschnitt "Befunddateien dieses Laufs"
mit einem Wikilink mit vollem Pfad je Datei.
AI/secondbrain/<ordner>/JJJJ-MM-TT-<thema>.md
eine Zeile "Tagesnotiz: [[Inbox/JJJJ-MM-TT]]", nur wenn die Tagesnotiz
existiert.
Alles andere kommt auf die Liste für den Koordinator. Geraten wird nichts.
ÜBERSPRUNGEN UND AUFGELISTET wird eine Zieldatei, die
* in einem Git-Repo im Vault liegt, auch dem der Vault-Wurzel, und dort in
irgendeinem Arbeitsbaum uncommittet geändert oder neu ist oder auf einem
anderen lokalen Zweig seit dem gemeinsamen Stand geändert wurde; was das
Repo ignoriert, zählt nicht als geändert,
* nicht als UTF-8 lesbar ist oder gemischte Zeilenenden hat,
* die anzuhängende Überschrift oder Linkzeile schon enthält.
Ein Git-Repo, das sich nicht prüfen lässt, sperrt alle seine Zieldateien.
BENANNTE LÜCKEN
* Welche Notiz eine Waise ist, entscheidet waisen.py mit dessen Lücken.
* Nur lokale Zweige zählen, entfernte nicht.
* Eine Datei, die zwischen dem Lesen und dem Anhängen geändert wird, bricht
den Lauf ab; was bis dahin angehängt war, nennt die Ausgabe.
Exit 0: Lauf durch. Exit 2: abgebrochen, die Ursache steht in der Ausgabe.
Aufruf:
python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude
python .vault-tools/linkcheck/waisen_bestand.py --vault D:/Claude --anwenden --sicherung <ordner außerhalb des Vaults>
"""
import argparse
import os
import re
import shutil
import subprocess
import sys
import traceback
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
from urllib.parse import quote
HIER = Path(__file__).resolve().parent
if str(HIER) not in sys.path:
sys.path.insert(0, str(HIER))
try:
import waisen
_IMPORTFEHLER = ""
except Exception as _fehler:
waisen = None
_IMPORTFEHLER = f"{type(_fehler).__name__}: {_fehler}"
ORDNER_KOPF = "## Dateien in diesem Ordner"
BEFUND_KOPF = "## Befunddateien dieses Laufs"
BERICHTE = ("ABSCHLUSSBERICHT.md", "FULL-AUDIT-REPORT.md")
_DATUM = re.compile(r"^(\d{4}-\d{2}-\d{2})")
GESCHRIEBEN = []
class Abbruch(Exception):
"""Der Lauf kann nicht sicher weiter."""
@dataclass
class Ziel:
rel: str
zeilen: list = field(default_factory=list)
ordner: Optional[str] = None
befunde: Optional[str] = None
ausloeser: list = field(default_factory=list)
hubs: set = field(default_factory=set)
gelistet: list = field(default_factory=list)
def existiert(vault: Path, rel: str) -> bool:
return (vault / rel).is_file()
def rel_link(von: str, zu: str) -> str:
start = von.split("/")[:-1]
ziel = zu.split("/")
gemeinsam = 0
while gemeinsam < min(len(start), len(ziel) - 1) and start[gemeinsam] == ziel[gemeinsam]:
gemeinsam += 1
teile = [".."] * (len(start) - gemeinsam) + ziel[gemeinsam:]
return quote("/".join(teile), safe="/")
def _code_projekt(rel: str, teile: list, vault: Path) -> tuple:
readme = "/".join(teile[:2]) + "/README.md"
innen = teile[2:]
if innen[:2] == ["docs", "plans"] and len(innen) == 4:
uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md"
if not existiert(vault, uebersicht):
return ("rest", "", "Planordner ohne 00-uebersicht.md", "")
if not existiert(vault, readme):
return ("rest", "", f"{readme} fehlt", "")
return ("ordner", uebersicht, f"Projekt: [README]({rel_link(uebersicht, readme)})", readme)
if innen[:2] == ["docs", "plans"] and len(innen) > 4:
return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "")
if innen == ["CHANGELOG.md"] or (innen[:1] == ["docs"] and len(innen) >= 2):
if not existiert(vault, readme):
return ("rest", "", f"{readme} fehlt", "")
return ("zeile", rel, f"Projekt: [README]({rel_link(rel, readme)})", readme)
return ("rest", "", "keine Regel passt", "")
def _arbeitsstand(rel: str, teile: list, vault: Path) -> tuple:
stelle = teile.index("_arbeitsstand")
projekt = "/".join(teile[:stelle])
try:
mocs = sorted(
name for name in os.listdir(vault / projekt)
if name.startswith("_MOC-") and name.lower().endswith(".md") and (vault / projekt / name).is_file()
)
except OSError as fehler:
return ("rest", "", f"Projektordner nicht lesbar ({fehler})", "")
if len(mocs) != 1:
return ("rest", "", f"{len(mocs)} _MOC-Dateien in {projekt}, erwartet genau eine", "")
moc = f"{projekt}/{mocs[0]}"
zeile = f"Projekt: [[{moc[:-3]}]]"
innen = teile[stelle + 1:]
if innen[:2] == ["docs", "plans"] and len(innen) == 4:
uebersicht = "/".join(teile[:-1]) + "/00-uebersicht.md"
if not existiert(vault, uebersicht):
return ("rest", "", "Planordner ohne 00-uebersicht.md", "")
return ("ordner", uebersicht, zeile, moc)
if innen[:2] == ["docs", "plans"] and len(innen) > 4:
return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "")
return ("zeile", rel, zeile, moc)
def _befund(rel: str, teile: list, vault: Path) -> tuple:
lauf = "/".join(teile[:-2])
for name in BERICHTE:
if existiert(vault, f"{lauf}/{name}"):
bericht = f"{lauf}/{name}"
return ("befund", bericht, "/".join(teile[:-1]), bericht)
return ("rest", "", f"kein Bericht ({' oder '.join(BERICHTE)}) in {lauf}", "")
def _dump(rel: str, teile: list, vault: Path) -> tuple:
treffer = _DATUM.match(teile[3])
if not treffer:
return ("rest", "", "Dump ohne Datum am Anfang des Dateinamens", "")
tag = treffer.group(1)
if not existiert(vault, f"Inbox/{tag}.md"):
return ("rest", "", f"Tagesnotiz Inbox/{tag}.md fehlt", "")
return ("zeile", rel, f"Tagesnotiz: [[Inbox/{tag}]]", f"Inbox/{tag}.md")
def _wurzel_docs(rel: str, teile: list, vault: Path) -> tuple:
# Pläne und Specs unter docs/ an der Vault-Wurzel: Hub ist README.md dort.
hub = "README.md"
if not existiert(vault, hub):
return ("rest", "", "README.md der Vault-Wurzel fehlt", "")
if teile[1] == "plans" and len(teile) == 4:
kopf = "/".join(teile[:-1]) + "/00-uebersicht.md"
if not existiert(vault, kopf):
return ("rest", "", "Planordner ohne 00-uebersicht.md", "")
return ("ordner", kopf, f"Vault: [README]({rel_link(kopf, hub)})", hub)
if teile[1] == "plans" and len(teile) > 4:
return ("rest", "", "Plandatei tiefer als ein Ordner unter docs/plans", "")
return ("zeile", rel, f"Vault: [README]({rel_link(rel, hub)})", hub)
def klassifiziere(rel: str, vault: Path) -> tuple:
"""Liefert (art, ziel, wert, hub). art ist zeile, ordner, befund oder rest."""
teile = rel.split("/")
if teile[0] == "Code Projekte" and len(teile) >= 3:
return _code_projekt(rel, teile, vault)
if teile[0] == "IT Workspace" and "_arbeitsstand" in teile[1:-1]:
return _arbeitsstand(rel, teile, vault)
if teile[0] == "IT Workspace" and len(teile) >= 3 and teile[-2] == "findings":
return _befund(rel, teile, vault)
if teile[:2] == ["AI", "secondbrain"] and len(teile) == 4:
return _dump(rel, teile, vault)
if teile[0] == "docs" and len(teile) >= 2:
return _wurzel_docs(rel, teile, vault)
return ("rest", "", "keine Regel passt", "")
def plane(vault: Path, ergebnis) -> tuple:
ziele = {}
rest = []
for rel in ergebnis.waisen:
art, ziel_rel, wert, hub = klassifiziere(rel, vault)
if art == "rest":
rest.append((rel, wert))
continue
ziel = ziele.setdefault(ziel_rel, Ziel(ziel_rel))
ziel.ausloeser.append(rel)
ziel.hubs.add(hub)
if art == "befund":
ziel.befunde = wert
continue
if art == "ordner":
ziel.ordner = ziel_rel.rsplit("/", 1)[0]
if hub in ergebnis.ausgehend.get(ziel_rel, set()):
continue
if wert not in ziel.zeilen:
ziel.zeilen.append(wert)
return ziele, rest
def md_dateien(vault: Path, ordner: str) -> list:
pfad = vault / ordner
return sorted(
name for name in os.listdir(pfad)
if name.lower().endswith(".md") and not name.startswith(".") and (pfad / name).is_file()
)
def anhang(vault: Path, ziel: Ziel) -> list:
zeilen = list(ziel.zeilen)
if ziel.ordner is not None:
namen = [n for n in md_dateien(vault, ziel.ordner) if n != "00-uebersicht.md"]
if namen:
if zeilen:
zeilen.append("")
zeilen += [ORDNER_KOPF, ""]
zeilen += [f"- [{n}]({quote(n, safe='')})" for n in namen]
ziel.gelistet += [f"{ziel.ordner}/{n}" for n in namen]
if ziel.befunde is not None:
namen = md_dateien(vault, ziel.befunde)
if namen:
if zeilen:
zeilen.append("")
zeilen += [BEFUND_KOPF, ""]
zeilen += [f"- [[{ziel.befunde}/{n[:-3]}]]" for n in namen]
ziel.gelistet += [f"{ziel.befunde}/{n}" for n in namen]
return zeilen
def pruefe_ziel(vault: Path, rel: str, zeilen: list) -> tuple:
"""Liefert (Grund zum Überspringen oder None, alter Inhalt, Zeilenende)."""
alt = (vault / rel).read_bytes()
try:
text = alt.decode("utf-8")
except UnicodeDecodeError:
return "nicht als UTF-8 lesbar", alt, ""
crlf = alt.count(b"\r\n")
lf = alt.count(b"\n") - crlf
if crlf and lf:
return "gemischte Zeilenenden", alt, ""
ende = "\r\n" if crlf else "\n"
vorhanden = text.replace("\r\n", "\n").split("\n")
for zeile in zeilen:
if zeile and not zeile.startswith("- "):
if zeile in vorhanden:
return f"enthält schon die Zeile {zeile!r}", alt, ende
return None, alt, ende
def anhang_bytes(alt: bytes, zeilen: list, ende: str) -> bytes:
vorne = ende if alt and not alt.endswith(b"\n") else ""
return (vorne + ende + ende.join(zeilen) + ende).encode("utf-8")
def repo_von(vault: Path, rel: str) -> Optional[Path]:
ordner = (vault / rel).parent
# Auch die Vault-Wurzel kann ein Repo sein; über ihr wird nicht gesucht.
while ordner == vault or vault in ordner.parents:
if (ordner / ".git").exists():
return ordner
if ordner == vault:
return None
ordner = ordner.parent
return None
def git(ordner: Path, *argumente: str) -> bytes:
umgebung = dict(os.environ)
# Kein Index-Auffrischen: der Probelauf schreibt auch in .git nichts.
umgebung["GIT_OPTIONAL_LOCKS"] = "0"
try:
lauf = subprocess.run(
["git", "-C", str(ordner), *argumente],
capture_output=True,
stdin=subprocess.DEVNULL,
timeout=120,
env=umgebung,
)
except (OSError, subprocess.TimeoutExpired) as fehler:
raise Abbruch(f"git {' '.join(argumente)} in {ordner}: {fehler}") from fehler
if lauf.returncode != 0:
meldung = lauf.stderr.decode("utf-8", errors="replace").strip()
raise Abbruch(f"git {' '.join(argumente)} in {ordner}: Exit {lauf.returncode}: {meldung}")
return lauf.stdout
def geaenderte_pfade(repo: Path) -> dict:
"""Pfad relativ zum Repo, klein geschrieben, und wo er geändert ist."""
wo = {}
liste = git(repo, "worktree", "list", "--porcelain").decode("utf-8", errors="replace")
baeume = [z[len("worktree "):].strip() for z in liste.split("\n") if z.startswith("worktree ")]
if not baeume:
raise Abbruch("git worktree list nennt keinen Arbeitsbaum")
for baum in baeume:
roh = git(Path(baum), "status", "--porcelain", "-z", "--untracked-files=all")
eintraege = roh.decode("utf-8", errors="replace").split("\0")
i = 0
while i < len(eintraege):
eintrag = eintraege[i]
i += 1
if len(eintrag) < 4:
continue
wo.setdefault(eintrag[3:].lower(), f"Arbeitsbaum {baum}")
if "R" in eintrag[:2] or "C" in eintrag[:2]:
if i < len(eintraege) and eintraege[i]:
wo.setdefault(eintraege[i].lower(), f"Arbeitsbaum {baum}")
i += 1
aktuell = git(repo, "rev-parse", "--abbrev-ref", "HEAD").decode("utf-8", errors="replace").strip()
zweige = git(repo, "for-each-ref", "--format=%(refname:short)", "refs/heads/").decode("utf-8", errors="replace")
for zweig in (z.strip() for z in zweige.split("\n")):
if not zweig or zweig == aktuell:
continue
diff = git(repo, "diff", "--name-only", "-z", f"HEAD...{zweig}", "--")
for pfad in diff.decode("utf-8", errors="replace").split("\0"):
if pfad:
wo.setdefault(pfad.lower(), f"Zweig {zweig}")
return wo
def pruefe_alle(vault: Path, ziele: dict) -> tuple:
repos = {}
fertig = []
uebersprungen = []
for rel in sorted(ziele):
ziel = ziele[rel]
zeilen = anhang(vault, ziel)
repo = repo_von(vault, rel)
if repo is not None:
if repo not in repos:
try:
repos[repo] = geaenderte_pfade(repo)
except Abbruch as fehler:
repos[repo] = str(fehler)
stand = repos[repo]
if isinstance(stand, str):
uebersprungen.append((ziel, f"Git-Repo nicht prüfbar: {stand}"))
continue
innen = (vault / rel).relative_to(repo).as_posix().lower()
if innen in stand:
uebersprungen.append((ziel, f"in Git geändert ({stand[innen]})"))
continue
grund, alt, ende = pruefe_ziel(vault, rel, zeilen)
if grund:
uebersprungen.append((ziel, grund))
continue
fertig.append((ziel, zeilen, alt, ende))
return fertig, uebersprungen
def zeige(vault: Path, anwenden: bool, vorher: int, fertig: list, uebersprungen: list, rest: list, mitverbunden: int) -> None:
print(f"# Bestandslauf - {'Anwenden' if anwenden else 'Probelauf'} - {vault}")
print(f"Waisen außerhalb der Ausnahmen vorher: {vorher}")
print()
print(f"## Anhänge ({len(fertig)} Dateien)")
for ziel, zeilen, _alt, ende in fertig:
art = "CRLF" if ende == "\r\n" else "LF"
print(f"ZIEL {ziel.rel} [{art}] wegen {len(ziel.ausloeser)} Waise(n)")
for zeile in zeilen:
print(f" | {zeile}")
print()
print(f"## Übersprungen ({len(uebersprungen)})")
for ziel, grund in uebersprungen:
print(f"UEBERSPRUNGEN {ziel.rel}: {grund} (betrifft {len(ziel.ausloeser)} Waise(n))")
print()
print(f"## Liste für den Koordinator ({len(rest)})")
for rel, grund in rest:
print(f"REST {rel}: {grund}")
print(f"Durch Anhänge mitverbunden, ohne eigene Regel: {mitverbunden}")
def sichern_und_anhaengen(vault: Path, sicherung: Path, fertig: list) -> None:
sicherung.mkdir(parents=True)
for ziel, _zeilen, alt, _ende in fertig:
kopie = sicherung / ziel.rel
kopie.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(vault / ziel.rel, kopie)
if kopie.read_bytes() != alt:
raise Abbruch(f"Sicherung weicht vom Original ab: {kopie}")
for ziel, zeilen, alt, ende in fertig:
pfad = vault / ziel.rel
if pfad.read_bytes() != alt:
raise Abbruch(f"{ziel.rel} hat sich seit dem Lesen geändert")
neu = anhang_bytes(alt, zeilen, ende)
with open(pfad, "ab") as datei:
datei.write(neu)
GESCHRIEBEN.append(ziel.rel)
if pfad.read_bytes() != alt + neu:
raise Abbruch(f"{ziel.rel}: Inhalt nach dem Anhängen weicht ab")
(sicherung / "GEAENDERT.txt").write_text("\n".join(GESCHRIEBEN) + "\n", encoding="utf-8", newline="\n")
def lauf(vault: Path, ausnahmen: Path, anwenden: bool, sicherung) -> int:
GESCHRIEBEN.clear()
if waisen is None:
raise Abbruch(f"waisen.py nicht importierbar ({_IMPORTFEHLER})")
sicherung_pfad = None
if anwenden:
if not sicherung:
raise Abbruch("--anwenden braucht --sicherung mit einem Ordner außerhalb des Vaults")
sicherung_pfad = Path(os.path.abspath(sicherung)).resolve()
if sicherung_pfad == vault or vault in sicherung_pfad.parents:
raise Abbruch(f"Sicherungsordner liegt im Vault: {sicherung_pfad}")
if sicherung_pfad.exists():
raise Abbruch(f"Sicherungsordner existiert schon: {sicherung_pfad}")
ergebnis = waisen.analysiere(vault, ausnahmen)
ziele, rest = plane(vault, ergebnis)
fertig, uebersprungen = pruefe_alle(vault, ziele)
verbunden = set()
for ziel, _zeilen, _alt, _ende in fertig:
verbunden.add(ziel.rel)
verbunden.update(ziel.hubs)
verbunden.update(ziel.gelistet)
anzahl = len(rest)
rest = [(rel, grund) for rel, grund in rest if rel not in verbunden]
zeige(vault, anwenden, len(ergebnis.waisen), fertig, uebersprungen, rest, anzahl - len(rest))
if anwenden:
sichern_und_anhaengen(vault, sicherung_pfad, fertig)
nachher = waisen.analysiere(vault, ausnahmen)
print()
print(f"Angehängt an {len(GESCHRIEBEN)} Dateien. Sicherung und GEAENDERT.txt: {sicherung_pfad}")
print(f"Waisen außerhalb der Ausnahmen nachher: {len(nachher.waisen)}")
return 0
def main(argv=None) -> int:
zerleger = argparse.ArgumentParser(description="Hängt verwaiste Notizen einmalig an ihren Hub an")
zerleger.add_argument("--vault", required=True)
zerleger.add_argument("--ausnahmen", default=str(HIER / "waisen-ausnahmen.txt"))
zerleger.add_argument("--anwenden", action="store_true")
zerleger.add_argument("--sicherung")
args = zerleger.parse_args(argv)
vault = Path(os.path.abspath(args.vault)).resolve()
try:
return lauf(vault, Path(args.ausnahmen), args.anwenden, args.sicherung)
except Exception as fehler:
erwartet = isinstance(fehler, Abbruch) or (waisen is not None and isinstance(fehler, waisen.Abbruch))
art = "" if erwartet else f"{type(fehler).__name__}: "
print(f"Bestandslauf - ABGEBROCHEN: {art}{fehler}")
if not erwartet:
print(traceback.format_exc())
if GESCHRIEBEN:
print(f"Vor dem Abbruch schon angehängt ({len(GESCHRIEBEN)}), die Sicherung liegt vor:")
for rel in GESCHRIEBEN:
print(f" - {rel}")
return 2
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
sys.exit(main())