Files
Pascal MichelandClaude Opus 5 deec79a6ea 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]>
2026-10-01 04:00:02 +02:00

736 lines
32 KiB
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.
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())