Files
claude-regelwerk/.vault-tools/plan-kopie/welle_vergleich.py
T
Pascal MichelandClaude Opus 5 2c9a4475fc Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen
Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-09-17 22:38:15 +02:00

169 lines
7.2 KiB
Python

"""Gegenpruefung nach einer Executer-Welle: Menge der geaenderten Dateien und Byte-Vergleich gegen die Kopie.
Koordinator-Werkzeug im Scratchpad. Fragen und Herkunft der Antwort:
1. Welche Dateien hat die Welle geaendert? Quelle: `git status --porcelain -z` im Projekt.
2. Welche durfte sie aendern? Quelle: Abschnitt "## Zieldateien" der uebergebenen Taskdateien.
3. Ist jede Zieldatei byte-gleich mit dem Plan? Quelle: die Kopie `mat`, auf der plan_materialisiere.py
alle Codebloecke des Plans angewendet hat.
Was es NICHT sieht (Fangnetz, kein Nachweis):
- Dateien, die .gitignore ausblendet (data/, artifacts/, node_modules/): Ein Executer, der dorthin
schreibt, bleibt unsichtbar.
- Ob die Kopie selbst sauber ist. Eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Gruen.
- Prosa-Dateien (--prosa): nur Mengenpruefung, kein Byte-Vergleich; die liest der Koordinator selbst.
- Bereits committete Aenderungen: Verglichen wird der Arbeitsbaum gegen HEAD.
Eine fehlende Voraussetzung (Kopie fehlt, Taskdatei ohne Zieldateien, git laeuft nicht) ist ein Befund.
Aufruf: python welle_vergleich.py --projekt <pfad> --mat <pfad> [--prosa <rel> ...] <taskdatei> [...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import argparse
import re
import subprocess
import sys
import traceback
from pathlib import Path, PureWindowsPath
ZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def zieldateien(taskdatei: Path, projekt: Path, befunde: list[str]) -> dict[str, bool]:
"""Relativer Pfad -> True, wenn die Datei geloescht werden soll."""
try:
zeilen = taskdatei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
except OSError as fehler:
befunde.append(f"{taskdatei.name}: nicht lesbar ({fehler})")
return {}
drin = False
ziele: dict[str, bool] = {}
for zeile in zeilen:
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
if not drin:
continue
treffer = ZIEL.match(zeile)
if not treffer:
continue
absolut = PureWindowsPath(treffer.group(1))
try:
rel = absolut.relative_to(PureWindowsPath(str(projekt)))
except ValueError:
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
continue
ziele[rel.as_posix()] = "gelöscht" in treffer.group(2) or "geloescht" in treffer.group(2)
if not ziele:
befunde.append(f"{taskdatei.name}: keine Zieldateien gefunden -- ohne sie misst der Lauf nichts")
return ziele
def geaendert(projekt: Path, befunde: list[str]) -> dict[str, str]:
"""Relativer Pfad -> zweistelliger Status aus git."""
try:
ergebnis = subprocess.run(
["git", "-C", str(projekt), "status", "--porcelain", "-z", "--untracked-files=all"],
capture_output=True,
stdin=subprocess.DEVNULL,
)
except OSError as fehler:
befunde.append(f"git laesst sich nicht starten ({fehler})")
return {}
if ergebnis.returncode != 0:
befunde.append(f"git status endet mit {ergebnis.returncode}: {ergebnis.stderr.decode('utf-8', 'replace').strip()}")
return {}
teile = ergebnis.stdout.decode("utf-8", "replace").split("\0")
aus: dict[str, str] = {}
i = 0
while i < len(teile):
eintrag = teile[i]
i += 1
if len(eintrag) < 4:
continue
status, pfad = eintrag[:2], eintrag[3:]
if status[0] in "RC":
alt = teile[i] if i < len(teile) else "?"
i += 1
befunde.append(f"{pfad}: git meldet Umbenennung oder Kopie aus {alt} -- im Plan nicht vorgesehen")
aus[pfad] = status
return aus
def erste_abweichung(echt: bytes, kopie: bytes) -> str:
if echt.replace(b"\r\n", b"\n") == kopie.replace(b"\r\n", b"\n"):
return "nur die Zeilenenden weichen ab (CRLF gegen LF)"
a, b = echt.split(b"\n"), kopie.split(b"\n")
for nummer, (links, rechts) in enumerate(zip(a, b), start=1):
if links != rechts:
return f"Zeile {nummer}: Baum {links[:160]!r} | Plan {rechts[:160]!r}"
return f"Laenge: Baum {len(a)} Zeilen, Plan {len(b)} Zeilen; die gemeinsamen Zeilen sind gleich"
def pruefe(args: argparse.Namespace, befunde: list[str]) -> None:
projekt, mat = Path(args.projekt), Path(args.mat)
if not projekt.is_dir():
befunde.append(f"Projektordner {projekt} fehlt")
return
if not mat.is_dir():
befunde.append(f"Kopie {mat} fehlt -- ohne sie gibt es keinen Byte-Vergleich")
return
prosa = {p.replace("\\", "/") for p in args.prosa}
ziele: dict[str, bool] = {}
for name in args.taskdateien:
ziele.update(zieldateien(Path(name), projekt, befunde))
status = geaendert(projekt, befunde)
for pfad in sorted(set(status) - set(ziele)):
befunde.append(f"{pfad}: geaendert ({status[pfad]!r}), liegt aber ausserhalb der Zieldateien dieser Welle")
for pfad in sorted(prosa - set(ziele)):
befunde.append(f"{pfad}: als Prosa uebergeben, steht aber in keiner Taskdatei als Zieldatei")
gleich = 0
for pfad, loeschen in sorted(ziele.items()):
echt, kopie = projekt / pfad, mat / pfad
if loeschen:
if echt.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch im Baum")
if kopie.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch in der Kopie")
if not echt.exists() and "D" not in status.get(pfad, ""):
befunde.append(f"{pfad}: fehlt im Baum, git meldet aber keine Loeschung ({status.get(pfad)!r})")
continue
if pfad not in status:
befunde.append(f"{pfad}: Zieldatei ist unveraendert gegenueber HEAD")
continue
if not echt.is_file():
befunde.append(f"{pfad}: Zieldatei fehlt im Baum")
continue
if pfad in prosa:
continue
if not kopie.is_file():
befunde.append(f"{pfad}: fehlt in der Kopie -- kein Byte-Vergleich moeglich")
continue
a, b = echt.read_bytes(), kopie.read_bytes()
if a == b:
gleich += 1
else:
befunde.append(f"{pfad}: weicht vom Plan ab -- {erste_abweichung(a, b)}")
print(f"{len(ziele)} Zieldateien, {len(status)} geaenderte Dateien laut git, {gleich} byte-gleich mit der Kopie, {len(prosa & set(ziele))} nur als Menge geprueft.")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--projekt", required=True)
parser.add_argument("--mat", required=True)
parser.add_argument("--prosa", nargs="*", default=[])
parser.add_argument("taskdateien", nargs="+")
args = parser.parse_args()
befunde: list[str] = []
try:
pruefe(args, befunde)
except Exception: # Ein Absturz ist selbst ein Befund; die gesammelten Befunde gehen trotzdem raus.
befunde.append("unerwarteter Abbruch: " + traceback.format_exc().strip().splitlines()[-1])
for befund in befunde:
print("BEFUND " + befund)
print(f"{len(befunde)} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())