Automatischer Push: 45 Datei(en) geändert

- .claude/agents/planer.md
- .claude/agents/quorum-stimme.md
- .claude/skills/bookstack-upload/SKILL.md
- .claude/skills/bookstack-upload/scripts/bookstack/archiv.py
- .claude/skills/bookstack-upload/scripts/bookstack/auswahl.py
- .claude/skills/bookstack-upload/scripts/bookstack/beschreibung.py
- .claude/skills/bookstack-upload/scripts/bookstack/bilder.py
- .claude/skills/bookstack-upload/scripts/bookstack/ergebnis.py
- .claude/skills/bookstack-upload/scripts/bookstack/hochladen.py
- .claude/skills/bookstack-upload/scripts/bookstack/konvertierung.py
- .claude/skills/bookstack-upload/scripts/bookstack/struktur.py
- .claude/skills/bookstack-upload/scripts/bookstack/verweise.py
- … und 33 weitere

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

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-10-01 04:00:02 +02:00
1 parent 60dee0ab68
commit deec79a6ea
45 files changed
+8645 -262

No files matched your search

+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.
@@ -5,7 +5,7 @@ status: draft
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-09-29
updated: 2026-09-30
---
# Waisen im Graphen
@@ -56,6 +56,7 @@ Gelesen am 2026-09-29: sechs gespeicherte Workflows, vier Skills im Vault, vier
| 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) |
@@ -67,7 +68,7 @@ Frage: Vertragen die Planwerkzeuge eine Zeile `Übersicht: [00-uebersicht.md](00
|---|---|---|---|
| `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 |
| `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 |
@@ -116,7 +117,8 @@ Ein Nein bei P1 stoppt Task 03 und Task 05, weil die Hub-Regel dann in Projekt-R
| `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; P1 bis P3 | 3 |
| `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.
@@ -140,17 +142,23 @@ Der Planer hatte keine Shell. Was gemessen werden muss, steht als Schritt in ein
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, Pascal hat Phase B freigegeben.
9. 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.
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.
- **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.
- **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.
@@ -170,6 +178,8 @@ Der Planer hatte keine Shell. Was gemessen werden muss, steht als Schritt in ein
## 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.
@@ -5,7 +5,7 @@ status: draft
summary: "Legt waisen.py, waisen-ausnahmen.txt, faelle_waisen.py und ENTWURF-waisen.md an; der Treiber fährt die Angriffsliste, einen Rotlauf mit eingebauten Mutanten und eine Laufzeitmessung über den echten Vault."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-29
updated: 2026-09-30
modell: haiku
modell_begruendung: "Vier neue Dateien stehen wörtlich im Plan und werden per Skript angelegt; die Codeblöcke enthalten keine typografischen Anführungszeichen, Gedankenstriche oder Auslassungspunkte."
---
@@ -599,7 +599,7 @@ FAELLE = [
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", "---\nverwandt: \"[[C/allein]]\"\n"), []),
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"]),
@@ -652,6 +652,13 @@ FAELLE = [
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"), [],
@@ -676,6 +683,10 @@ FAELLE = [
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"],
@@ -724,14 +735,19 @@ FAELLE = [
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, "C/doppelt.md", "# D\n"), schreibe(v, "E/doppelt.md", "# D\n"),
schreibe(v, "C/verweis.md", "[[doppelt]]\n")),
["C/allein.md", "E/doppelt.md"]),
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, "E/doppelt.md", "# D\n"), schreibe(v, "F/G/doppelt.md", "# D\n"),
schreibe(v, "A/verweis.md", "[[doppelt]]\n")),
["C/allein.md", "F/G/doppelt.md"]),
lambda v: (schreibe(v, "A/B/doppelt.md", "# D\n"), schreibe(v, "Z/doppelt.md", "# D\n"),
schreibe(v, "Q/verweis.md", "[[doppelt]]\n")),
["A/B/doppelt.md", "C/allein.md"]),
Fall("zahl | nur die Zahl", nichts, ["C/allein.md"], zahl=True),
Fall("zahl | null Waisen", neu("A/neu.md", "[[C/allein]]\n"), [], zahl=True),
Fall("zahl | Abbruch ohne Zahl", lambda v: shutil.rmtree(v / ".obsidian"), None,
@@ -968,6 +984,35 @@ MUTANTEN = [
" liste.append((\"regex\", re.compile(eintrag[1:-1])))",
" liste.append((\"praefix\", eintrag))",
["ausblendung | regulaerer Ausdruck in der Tiefe"]),
("Frontmatter ohne Schlusszeile als Kopf",
" return \"\", text",
" return text, \"\"",
["frontmatter | ohne Schlusszeile zaehlt als Text"]),
("Punkt als Pfadteil erlaubt",
" or \"..\" in teile or \".\" in teile or \"\" in teile[:-1]):",
" or \"..\" in teile or \"\" in teile[:-1]):",
["ausnahme | Punkt als Pfadteil ist ungueltig"]),
("leerer Pfadteil erlaubt",
" or \"..\" in teile or \".\" in teile or \"\" in teile[:-1]):",
" or \"..\" in teile or \".\" in teile):",
["ausnahme | leerer Pfadteil ist ungueltig"]),
("Dateifilter ignoriert",
" if not ausgeblendet(praefix + name, filter_liste):",
" if True:",
["ausblendung | Dateifilter blendet eine Notiz aus",
"ausblendung | per Dateifilter ausgeblendete Notiz ist keine Quelle"]),
("gleicher Ordner ignoriert",
" gleich = sorted(r for r in kandidaten if posixpath.dirname(r).lower() == ordner)",
" gleich = []",
["mehrdeutig | gleichnamige Notiz im selben Ordner gewinnt"]),
("kein Kandidat relativ zur Quelle",
" kandidaten = [posixpath.normpath(ziel), posixpath.normpath(posixpath.join(ordner, ziel))]",
" kandidaten = [posixpath.normpath(ziel)]",
["mehrdeutig | relativ zur Quelle vor dem Namen"]),
("mehrdeutig nur alphabetisch",
" return sorted(kandidaten, key=lambda r: (r.count(\"/\"), len(r), r.lower()))[0]",
" return sorted(kandidaten, key=lambda r: r.lower())[0]",
["mehrdeutig | sonst der kuerzeste Pfad"]),
]
@@ -1111,13 +1156,13 @@ Die einzelnen Fälle stehen an genau einer Stelle, in `faelle_waisen.py`. Hier s
| Klasse | Fälle |
|---|---|
| fehlt | `.obsidian`, `app.json`, Schlüssel `userIgnoreFilters`, Ausnahmedatei, `linkcheck.py`, Vault-Ordner |
| fehlt | `.obsidian`, `app.json`, Schlüssel `userIgnoreFilters`, Ausnahmedatei, `linkcheck.py`, Vault-Ordner, Schlusszeile des Frontmatters |
| null | `userIgnoreFilters` ist null |
| falscher Typ | `app.json` als Liste, Filtereintrag als Zahl, `app.json` ohne JSON |
| leer | leere Filterliste, leerer Filtereintrag, leere Ausnahmedatei, nur Kommentare, leere Notiz, Vault ohne Notiz |
| doppelt | doppeltes Muster, gleichnamige Notizen in zwei Ordnern |
| zusätzlich | Punktordner, Punktdatei, andere Endung, Endung in Großbuchstaben |
| Wertebereich | Muster mit Backslash, führendem Schrägstrich, `..`, Laufwerk; Stern über Ordnergrenze |
| doppelt | doppeltes Muster, gleichnamige Notizen in zwei Ordnern: gleicher Ordner, relativ zur Quelle, kürzester Pfad; die richtige Wahl liegt jeweils alphabetisch hinten |
| zusätzlich | Punktordner, Punktdatei, andere Endung, Endung in Großbuchstaben, per Dateifilter ausgeblendete Notiz als Knoten und als Quelle |
| Wertebereich | Muster mit Backslash, führendem Schrägstrich, `..`, `.` als Pfadteil, leerem Pfadteil, Laufwerk; Stern über Ordnergrenze |
| Verweis ins Leere | Wikilink ins Leere, auf Anhang, auf ausgeblendete Notiz, Selbstreferenz, Anker allein |
| Reihenfolge | Waise an erster und letzter Stelle, Muster in erster und letzter Zeile, Filter an erster und letzter Stelle, Link am Dateiende ohne Zeilenumbruch |
| Umgebung | CRLF, BOM, ungültiges UTF-8 in Notiz und Ausnahmedatei, Umlaut und Emoji im Namen ohne `PYTHONIOENCODING` |
@@ -5,7 +5,7 @@ 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-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."
---
@@ -421,7 +421,49 @@ Häng diesen Block ans Ende:
**Laufzeit.** Frage 15 peilt unter 5 s an. Ob das mit dem Zähler hält, misst `faelle_sitzungsstart.py --echt`.
````
## Schritt 2 — Angriffsliste
## 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=$?"
@@ -429,7 +471,7 @@ PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzun
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 3 — Messung M2 über den echten Vault
## Schritt 4 — Messung M2 über den echten Vault
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzungsstart.py --echt; echo "exit=$?"
@@ -437,7 +479,7 @@ PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_sitzun
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 4 — Nachbarn unverändert grün
## Schritt 5 — Nachbarn unverändert grün
```
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/session-hooks/faelle_nach_edit.py; echo "exit=$?"
@@ -447,4 +489,4 @@ Erwartet: `exit=0`.
## Bericht
Melde die vollständige Ausgabe von Schritt 1, 3 und 4, von Schritt 2 die Schlusszeile und jede Zeile, die nicht mit `BESTANDEN` beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.
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.
@@ -110,17 +110,20 @@ wird:
| Datei | Hub | Linkform |
|---|---|---|
| Plan-Übersicht `00-uebersicht.md`, Einzeldatei-Plan | Projekt-README | relativer Markdown-Link |
| 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 eines Projekts im IT Workspace | Übersicht bzw. README des Projekts | 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 Projekt-Repos, 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`.
- **Außerhalb der Projekt-Repos** bleibt es beim Wikilink mit vollem Pfad.
- **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.
@@ -141,7 +144,7 @@ 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]]).
- Jede Datei, die du, ein Subagent, Skill oder Workflow anlegt, verlinkt 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.
- 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`
@@ -156,7 +159,7 @@ 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. Hat das Projekt kein `README.md`, entfällt die README-Zeile. 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“.
- **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`
@@ -194,13 +197,13 @@ wird:
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, ist das ein neuer Befund: melden.
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`.
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
@@ -5,7 +5,7 @@ status: draft
summary: "Legt waisen_bestand.py an, das verwaiste Notizen über Muster ihrem Hub zuordnet, im Probelauf nur auflistet und mit --anwenden erst sichert und dann anhängt, und faelle_waisen_bestand.py mit Git-Fällen und Rotlauf."
tags: [plan-task, vault-tools]
created: 2026-09-29
updated: 2026-09-29
updated: 2026-09-30
modell: haiku
modell_begruendung: "Zwei neue Dateien stehen wörtlich im Plan und werden per Skript angelegt; keine typografischen Zeichen in den Codeblöcken."
---
@@ -62,6 +62,11 @@ REGELN, alle über Muster, ohne Namen einzelner Projekte oder Kunden
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
@@ -76,9 +81,10 @@ REGELN, alle über Muster, ohne Namen einzelner Projekte oder Kunden
Alles andere kommt auf die Liste für den Koordinator. Geraten wird nichts.
ÜBERSPRUNGEN UND AUFGELISTET wird eine Zieldatei, die
* in einem Git-Repo unterhalb des Vaults liegt und dort in irgendeinem
Arbeitsbaum uncommittet geändert oder neu ist oder auf einem anderen
lokalen Zweig seit dem gemeinsamen Stand geändert wurde,
* 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.
@@ -218,6 +224,21 @@ def _dump(rel: str, teile: list, vault: Path) -> tuple:
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("/")
@@ -229,6 +250,8 @@ def klassifiziere(rel: str, vault: Path) -> tuple:
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", "")
@@ -311,9 +334,12 @@ def anhang_bytes(alt: bytes, zeilen: list, ende: str) -> bytes:
def repo_von(vault: Path, rel: str) -> Optional[Path]:
ordner = (vault / rel).parent
while ordner != vault and vault in ordner.parents:
# 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
@@ -527,6 +553,11 @@ 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.
@@ -555,6 +586,9 @@ 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"
@@ -565,6 +599,7 @@ 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})"],
@@ -573,12 +608,64 @@ ZIELE = {
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.
@@ -614,12 +701,14 @@ def nichts(v: Path) -> 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")
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")
@@ -635,6 +724,10 @@ def baue_grundlinie(v: Path) -> None:
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")
@@ -689,6 +782,43 @@ 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("*")):
@@ -703,7 +833,7 @@ def abbild(v: Path) -> dict:
class Fall:
name: str
aendern: Callable = nichts
# probe, anwenden, zweimal, ohne_sicherung, sicherung_da, sicherung_im_vault, anwenden_abbruch
# probe, anwenden, zweimal, ohne_sicherung, sicherung_da, sicherung_im_vault, anwenden_abbruch, anwenden_fremd
modus: str = "probe"
code: int = 0
ziele: Optional[dict] = None
@@ -715,15 +845,17 @@ class Fall:
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: 15",
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: 2"],
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",
@@ -742,6 +874,13 @@ FAELLE = [
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"]),
@@ -757,6 +896,17 @@ FAELLE = [
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",
@@ -789,6 +939,14 @@ FAELLE = [
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
@@ -822,6 +980,54 @@ MUTANTEN = [
" 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"]),
]
@@ -905,6 +1111,9 @@ def bewerte(fall: Fall, code: int, out: str, v: Path, vorher: dict, sicherung: P
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):
@@ -918,6 +1127,13 @@ def bewerte(fall: Fall, code: int, out: str, v: Path, vorher: dict, sicherung: P
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():
@@ -935,7 +1151,7 @@ def lauf_fall(fall: Fall, basis: Path, arbeit: Path, skript: Path) -> tuple:
vorher = abbild(v)
sicherung = arbeit / "sicherung"
argumente = ["--vault", str(v)]
if fall.modus in ("anwenden", "zweimal", "anwenden_abbruch"):
if fall.modus in ("anwenden", "zweimal", "anwenden_abbruch", "anwenden_fremd"):
argumente += ["--anwenden", "--sicherung", str(sicherung)]
elif fall.modus == "ohne_sicherung":
argumente += ["--anwenden"]
@@ -944,7 +1160,12 @@ def lauf_fall(fall: Fall, basis: Path, arbeit: Path, skript: Path) -> tuple:
argumente += ["--anwenden", "--sicherung", str(sicherung)]
elif fall.modus == "sicherung_im_vault":
argumente += ["--anwenden", "--sicherung", str(v / "Sicherung")]
code, out, err, dauer = fahre(skript, argumente)
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)
@@ -1070,7 +1291,7 @@ Erwartet: `ABBRUCH: D:\Claude\.vault-tools\linkcheck\waisen_bestand.py fehlt` un
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py; echo "exit=$?"
```
Erwartet: erste Zeile `BESTANDEN GRUNDLINIE`, keine Zeile, die mit `FEHLER` beginnt, Schlusszeile `N von N Faellen bestanden.` mit zweimal derselben Zahl, `exit=0`. Der Lauf legt Git-Repos im Temp-Ordner an und dauert deshalb länger als der Treiber aus Task 01.
Erwartet: erste Zeile `BESTANDEN GRUNDLINIE`, keine Zeile, die mit `FEHLER` beginnt, Schlusszeile `N von N Faellen bestanden.` mit zweimal derselben Zahl, `exit=0`. Der Lauf legt Git-Repos im Temp-Ordner an und dauert deshalb länger als der Treiber aus Task 01. Meldet der Fall `git | Umbenennung: alter Pfad ist kein eigener Eintrag` die Zeile `ABSTURZ im Treiber: RuntimeError: git meldet keine Umbenennung`, erkennt das installierte git die Umbenennung nicht: nichts anpassen, melden.
## Schritt 4 — Rotlauf
@@ -46,10 +46,10 @@ Erwartet: zweimal `exit=0`. Sonst Halt und melden.
### A2 — Protokollordner
```
test ! -e D:/Claude-waisen-protokoll && mkdir D:/Claude-waisen-protokoll && echo "angelegt"
mkdir -p D:/Claude-waisen-protokoll && test ! -e D:/Claude-waisen-protokoll/probelauf.txt && echo "angelegt"
```
Erwartet: `angelegt`. Gibt es den Ordner schon, Halt und melden.
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
@@ -95,6 +95,16 @@ PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/waisen_bestand.py
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
```
@@ -124,10 +134,10 @@ Erwartet: dieselbe Zahl wie in der Zeile „nachher“ aus B3.
Setze `S` auf den Pfad aus B2, Zeile `Sicherung:`, und fahre:
```
cut -d/ -f1-2 "$S/GEAENDERT.txt" | grep '^Code Projekte/' | sort -u | while IFS= read -r r; do echo "== $r"; git -C "D:/Claude/$r" status --short; done
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: je Repo eine Überschrift `== Code Projekte/<projekt>` und darunter nur Zeilen mit ` M` für Dateien aus `GEAENDERT.txt`. Committen tut der Koordinator nach Pascals Ja, je Repo einzeln.
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
+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())
@@ -0,0 +1,13 @@
# 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/**
+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())