- .claude/agents/executer.md - .claude/agents/recherche.md - .claude/skills/anleitung-straffen/SKILL.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/00-uebersicht.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-01-zaehler.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-02-sitzungsstart.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-03-regeltexte.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-04-bestandsskript.md - .vault-tools/linkcheck/docs/plans/2026-09-29-waisen/task-05-bestandslauf.md - .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/00-uebersicht.md - .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-01-quorum-werkzeug.md - .vault-tools/workflow-pruefung/docs/plans/2026-09-29-quorum/task-02-quorum-workflow.md - … und 13 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
1094 lines
45 KiB
Markdown
1094 lines
45 KiB
Markdown
---
|
|
type: plan-task
|
|
title: "Task 04 — Bestandsskript waisen_bestand.py mit Treiber"
|
|
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
|
|
modell: haiku
|
|
modell_begruendung: "Zwei neue Dateien stehen wörtlich im Plan und werden per Skript angelegt; keine typografischen Zeichen in den Codeblöcken."
|
|
---
|
|
Übersicht: [00-uebersicht.md](00-uebersicht.md)
|
|
|
|
# Task 04 — Bestandsskript
|
|
|
|
Globale Vorgaben: `00-uebersicht.md` im selben Ordner, Abschnitt „Globale Vorgaben“.
|
|
|
|
Voraussetzung: Task 01 ist umgesetzt. `waisen_bestand.py` importiert `waisen.py` aus demselben Ordner.
|
|
|
|
## Zieldateien
|
|
|
|
- `D:\Claude\.vault-tools\linkcheck\waisen_bestand.py` (neu)
|
|
- `D:\Claude\.vault-tools\linkcheck\faelle_waisen_bestand.py` (neu)
|
|
|
|
## Verbote
|
|
|
|
- `waisen_bestand.py` in diesem Task **nie** gegen den echten Vault fahren, auch nicht als Probelauf. Das ist Task 05.
|
|
- `waisen.py`, `linkcheck.py` und `waisen-ausnahmen.txt` bleiben unverändert.
|
|
- Kein `git` im echten Vault oder in einem echten Projekt-Repo. Die Git-Fälle des Treibers arbeiten nur im Wegwerfordner.
|
|
|
|
## Schritt 1 — Blöcke anwenden
|
|
|
|
```
|
|
python D:/Claude/.vault-tools/plan-kopie/plan_materialisiere.py D:/Claude/.vault-tools/linkcheck/docs/plans/2026-09-29-waisen D:/Claude task-04-bestandsskript.md
|
|
```
|
|
|
|
Erwartet: eine Zeile mit `'neu': 2` und die Schlusszeile `0 Befund(e).`
|
|
|
|
### `.vault-tools/linkcheck/waisen_bestand.py`
|
|
|
|
Die ganze Datei ist neu:
|
|
|
|
````python
|
|
"""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.
|
|
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 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,
|
|
* 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 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)
|
|
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
|
|
while ordner != vault and vault in ordner.parents:
|
|
if (ordner / ".git").exists():
|
|
return ordner
|
|
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())
|
|
````
|
|
|
|
### `.vault-tools/linkcheck/faelle_waisen_bestand.py`
|
|
|
|
Die ganze Datei ist neu:
|
|
|
|
````python
|
|
"""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.
|
|
|
|
--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"
|
|
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/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)"],
|
|
}
|
|
REST = {
|
|
P + "/notizen.md": "keine Regel passt",
|
|
DUMP_OHNE: "Tagesnotiz Inbox/2026-01-05.md fehlt",
|
|
}
|
|
|
|
|
|
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")
|
|
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/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, "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 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
|
|
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)
|
|
|
|
|
|
GRUNDLINIE = Fall("GRUNDLINIE", ziele=ZIELE, rest=REST,
|
|
texte=["Waisen außerhalb der Ausnahmen vorher: 15",
|
|
"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"],
|
|
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("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("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"}),
|
|
]
|
|
|
|
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"]),
|
|
]
|
|
|
|
|
|
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}")
|
|
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 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"):
|
|
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")]
|
|
code, out, err, dauer = fahre(skript, 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())
|
|
````
|
|
|
|
## Schritt 2 — Rot zuerst
|
|
|
|
Ohne `waisen_bestand.py` muss der Treiber abbrechen. In einem einzigen Bash-Aufruf:
|
|
|
|
```
|
|
mv D:/Claude/.vault-tools/linkcheck/waisen_bestand.py D:/Claude/.vault-tools/linkcheck/waisen_bestand.py.weg; python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py; echo "exit=$?"; mv D:/Claude/.vault-tools/linkcheck/waisen_bestand.py.weg D:/Claude/.vault-tools/linkcheck/waisen_bestand.py
|
|
```
|
|
|
|
Erwartet: `ABBRUCH: D:\Claude\.vault-tools\linkcheck\waisen_bestand.py fehlt` und `exit=2`. Danach liegt die Datei wieder an ihrem Platz.
|
|
|
|
## Schritt 3 — Angriffsliste
|
|
|
|
```
|
|
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.
|
|
|
|
## Schritt 4 — Rotlauf
|
|
|
|
```
|
|
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen_bestand.py --rotlauf; echo "exit=$?"
|
|
```
|
|
|
|
Erwartet: je Mutant eine Zeile `GETOETET`, keine Zeile `UEBERLEBT` oder `MUSTER`, Schlusszeile `Rotlauf: alle Mutanten getoetet.`, `exit=0`.
|
|
|
|
## Schritt 5 — Zähler unverändert grün
|
|
|
|
```
|
|
PYTHONIOENCODING=utf-8 python D:/Claude/.vault-tools/linkcheck/faelle_waisen.py; echo "exit=$?"
|
|
```
|
|
|
|
Erwartet: `exit=0`.
|
|
|
|
## Bericht
|
|
|
|
Melde die vollständige Ausgabe von Schritt 1, 2, 4 und 5, von Schritt 3 die Schlusszeile und jede Zeile, die nicht mit `BESTANDEN` beginnt. Läuft etwas anders als erwartet: nichts anpassen, melden.
|