Files
Pascal MichelandClaude Opus 5.5 fa1c26b407 Erste Fassung: Claude-Werkzeuge aus dem Vault
.vault-tools (ohne trading-plattform-pruefung und watermarks-remover),
.claude/agents, .claude/skills, .claude/workflows, .claude/settings.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 11:27:59 +02:00

172 lines
6.8 KiB
Python

"""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 <taskdatei.md> --projekt <projektordner> [--nur <pfad> ...]
"""
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())