.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]>
172 lines
6.8 KiB
Python
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())
|