"""Vergleicht die Codebloecke einer Plan-Taskdatei byteweise mit den Zieldateien im Projekt. Geltungsbereich (Fangnetz, kein Nachweis): - Prueft nur, was der Plan als Codeblock mit vorangestellter Zieldatei-Zeile fuehrt. Konvention der Planer-Taskdateien (Projektvorgabe, Plan 3a und 4): direkt vor dem Zaun steht eine Zeile mit dem Pfad in Backticks, z. B. "`tests/x.py` neu anlegen:". - "neu anlegen" / "ersetzen" = ganze Datei, Gleichheit nach Normalisierung von CRLF und der letzten Leerzeile. "ergaenzen" / "einfuegen" / "Abschnitt" / "Ausschnitt" = Fragment, Pruefung auf Enthaltensein. - Mehrere Bloecke fuer dieselbe Datei: der letzte Ganzdatei-Block gilt (Plan-Reihenfolge). - Nicht gesehen: Bloecke ohne Pfadzeile (werden als "nicht zugeordnet" gemeldet, nicht geprueft), Zieldateien ohne Block (gemeldet), semantische Abweichungen bei gleichem Byteinhalt, Dateien ausserhalb der Liste "## Zieldateien" des Tasks. - Annahme, die im Skript erzwungen wird: jede Zieldatei aus "## Zieldateien" hat einen Block; fehlt er, ist das ein gemeldeter Befund (Exit 3), kein stilles Ueberspringen. Exit-Codes: 0 alles gleich, 1 mindestens eine Abweichung oder fehlende Datei, 3 keine Abweichung, aber Zieldateien ohne Block oder Bloecke ohne Zuordnung. Aufruf: python plan_diff.py --projekt [--nur ...] """ from __future__ import annotations import argparse import difflib import re import sys from pathlib import Path ZAUN = re.compile(r"^```") PFAD_IN_BACKTICKS = re.compile(r"`([^`\s]+\.[A-Za-z0-9]+)`") # Fragmentwoerter werden ausserhalb der Backtick-Pfade gesucht: "Kopfzeile.tsx" traf sonst # "zeile" und wurde still nur auf Enthaltensein statt auf Gleichheit geprueft (2026-09-07). OHNE_PFAD = re.compile(r"`[^`]*`") FRAGMENT_WOERTER = ("ergänz", "ergaenz", "einfüg", "einfueg", "abschnitt", "ausschnitt", "zeile", "ersetzt durch", "wird ersetzt", "nach der zeile", "nach dem absatz") def normalisiere(text: str) -> str: text = text.replace("\r\n", "\n").replace("\r", "\n") return text.rstrip("\n") + "\n" def zieldateien(zeilen: list[str]) -> list[str]: pfade: list[str] = [] aktiv = False for zeile in zeilen: if zeile.startswith("## "): aktiv = zeile.strip().lower().startswith("## zieldateien") continue if aktiv: for treffer in PFAD_IN_BACKTICKS.findall(zeile): if treffer not in pfade: pfade.append(treffer) return pfade def bloecke(zeilen: list[str], kandidaten: list[str]) -> tuple[dict[str, list[tuple[str, bool]]], list[int]]: """Liefert je Pfad die Bloecke (Inhalt, ist_fragment) und die Zeilennummern nicht zugeordneter Bloecke.""" ergebnis: dict[str, list[tuple[str, bool]]] = {} ohne: list[int] = [] i = 0 n = len(zeilen) while i < n: if ZAUN.match(zeilen[i]): start = i i += 1 inhalt: list[str] = [] while i < n and not ZAUN.match(zeilen[i]): inhalt.append(zeilen[i]) i += 1 # Pfadzeile: die naechste Zeile mit einem Zielpfad vor dem Zaun, hoechstens zehn # zurueck und nicht ueber einen anderen Zaun hinweg. Fragment, wenn die Pfadzeile # oder eine Zeile zwischen ihr und dem Zaun ein Fragmentwort traegt. pfad = None fragment = False dazwischen: list[str] = [] for k in range(start - 1, max(start - 11, -1), -1): zeile = zeilen[k].strip() if ZAUN.match(zeile): break treffer = [p for p in PFAD_IN_BACKTICKS.findall(zeile) if p in kandidaten] if treffer: pfad = treffer[0] pruefen = [OHNE_PFAD.sub("", zeile).lower()] + [OHNE_PFAD.sub("", z).lower() for z in dazwischen] fragment = any(w in z for z in pruefen for w in FRAGMENT_WOERTER) break dazwischen.append(zeile) if pfad is None: ohne.append(start + 1) else: ergebnis.setdefault(pfad, []).append(("\n".join(inhalt) + "\n", fragment)) i += 1 return ergebnis, ohne def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("taskdatei") parser.add_argument("--projekt", required=True) parser.add_argument("--nur", nargs="*", default=None) args = parser.parse_args() task = Path(args.taskdatei) projekt = Path(args.projekt) try: zeilen = task.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n") except OSError as fehler: print(f"Taskdatei nicht lesbar: {fehler}") return 1 ziele = zieldateien(zeilen) if args.nur: ziele = [z for z in ziele if z in args.nur] + [z for z in args.nur if z not in ziele] if not ziele: print("Keine Zieldateien im Abschnitt '## Zieldateien' gefunden.") return 3 gefunden, ohne = bloecke(zeilen, ziele) abweichung = False luecke = False for ziel in ziele: eintraege = gefunden.get(ziel, []) datei = projekt / ziel if not eintraege: print(f"KEIN BLOCK {ziel}") luecke = True continue try: ist = normalisiere(datei.read_text(encoding="utf-8")) except FileNotFoundError: print(f"FEHLT {ziel}") abweichung = True continue except UnicodeDecodeError as fehler: print(f"NICHT UTF-8 {ziel}: {fehler}") abweichung = True continue ganze = [inhalt for inhalt, frag in eintraege if not frag] fragmente = [inhalt for inhalt, frag in eintraege if frag] if ganze: soll = normalisiere(ganze[-1]) if soll == ist: print(f"OK {ziel} ({len(ganze)} Ganzblock/-bloecke, {len(fragmente)} Fragment(e))") else: abweichung = True print(f"DIFF {ziel}") diff = difflib.unified_diff(soll.splitlines(), ist.splitlines(), "plan", "datei", lineterm="", n=2) for zeile in list(diff)[:60]: print(" " + zeile) for nr, fragment in enumerate(fragmente, 1): if normalisiere(fragment).rstrip("\n") in ist: print(f"FRAGMENT OK {ziel} #{nr}") else: abweichung = True print(f"FRAGMENT FEHLT {ziel} #{nr}") for zeile in normalisiere(fragment).splitlines()[:8]: print(" | " + zeile) if ohne: luecke = True print(f"NICHT ZUGEORDNET: Bloecke ab Zeile {', '.join(str(z) for z in ohne)} (ohne Pfadzeile oder Pfad nicht in Zieldateien)") if abweichung: return 1 return 3 if luecke else 0 if __name__ == "__main__": sys.exit(main())