Files
claude-regelwerk/.vault-tools/plan-kopie/plan_materialisiere.py
T
Pascal MichelandClaude Opus 5.5 5cbe75c5e3 plan-kopie: Zieldatei ohne Endung über ## Zieldateien, sonst Befund statt stillem Fehlziel
Anlass: Plankritik Paket R1 im Edge-Stack, Befunde 1 und 5. Die Überschrift
"Stelle 30 — `host/logrotate/edge-traefik`" setzte kein Ziel; der Block
"Die ganze Datei ist neu" überschrieb tests/systemd/test-units.sh mit Exit 0.

- ordne() in plan_materialisiere.py, von plan_bloecke.py importiert: Ziel ist
  der letzte PFAD-Treffer, ohne ihn der letzte Token, der unter ## Zieldateien
  steht (absolut relativ zu --projekt bzw. zum Ordner vor docs/plans).
- Überschrift ohne Ziel, die nach einer Datei aussieht (relativer Pfad mit /,
  Datei in Kopie/Projekt, Dateiname eines Eintrags): jeder schreibende Block
  danach ist Befund, Exit 2, nichts geschrieben.
- plan_ziel_treiber.py: 33 Fälle; Ursprung 22 rot, jetzt 33 grün;
  21 eingebaute Fehler gefangen.
- Vorher/nachher über 2 223 Plandateien: Abweichung nur R1 task-01 und
  Trading Task 90, beide jetzt ins richtige Ziel.
- LIESMICH und planer.md nachgezogen.

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

301 lines
14 KiB
Python

"""Wendet die Codebloecke der Taskdateien eines Plans in Wellenreihenfolge auf eine Kopie des Projekts an.
Koordinator-Werkzeug (entstanden 2026-09-17, Trading Plattform Teil 3b). Es tut etwas und beurteilt
nichts; das Urteil faellt danach mit Suite, Mutanten und welle_vergleich.py.
Geltungsbereich:
- Zieldatei ist der letzte Pfad in Backticks in einer Ueberschrift (## bis ####) oberhalb des Blocks, den
PFAD erkennt: mit Schraegstrich und Endung, ohne Schraegstrich nur mit py, ts, tsx, toml, md, css, json.
Erkennt PFAD in der Ueberschrift nichts, ist Ziel der letzte Token, der (ohne "./") genau so unter
"## Zieldateien" derselben Taskdatei steht (seit 2026-10-17): So wird eine Datei ohne Endung
(`host/logrotate/edge-traefik`, `Dockerfile`, `.gitignore`) planbar. Ein absoluter Eintrag zaehlt
relativ zum Ordner vor "docs/plans" im Pfad des Planordners; ohne diesen zaehlen nur relative Eintraege.
Ordner, Muster mit "*" und Pfade mit ".." unter Zieldateien sind kein Ziel.
- Befund statt stillem Ziel (seit 2026-10-17): Nennt eine Ueberschrift kein Ziel, aber einen Token, der
nach einer Datei aussieht, ist jeder schreibende Block danach ein Befund, auch unter Ueberschriften
ohne Pfad, bis eine Ueberschrift mit Ziel kommt; nichts wird geschrieben. Nach einer Datei sehen aus:
ein relativer Pfad mit Schraegstrich und ein Name ohne Schraegstrich, der direkt in der Kopie als Datei
liegt oder Dateiname eines Eintrags unter Zieldateien ist. Vorher ging der Block still ins vorige Ziel.
Kein Befund sind Ordner (hinten "/"), absolute Pfade (vorn "/"), Token mit Leerraum und Bezeichner
ohne gleichnamige Datei: Bestehende Plaene nennen sie in Ueberschriften als Kontext und meinen das
vorige Ziel. Nicht gesehen: eine neue Datei ohne Endung, ohne Schraegstrich und ohne Eintrag unter
Zieldateien, ein absoluter Pfad ohne Endung und eine Ueberschrift, die neben einem PFAD-Treffer eine
Datei ohne Endung nennt (dann gilt der PFAD-Treffer). Gleiche Regel in plan_bloecke.py (dort mit
--projekt statt Kopie und Planordner); Regressionsnachweis: plan_ziel_treiber.py.
- Dateien, die "## Zieldateien" als "(gelöscht)" fuehrt, werden in der Kopie geloescht.
- Bloecke mit Einrueckung (Befehle in Listen) und Prosa-Vorgaben ("wird: ein neuer Abschnitt ...")
werden uebergangen: Ein Doku-Task mit frei zu formulierendem Text laesst sich nicht anwenden.
- Marke ist die letzte nicht leere Zeile vor dem Zaun:
"Aus:" -> merkt den Block als alten Text
"wird:" -> ersetzt den gemerkten Text (muss genau einmal vorkommen)
enthaelt "Die ganze Datei ist neu" -> schreibt die Datei
beginnt mit "Häng diesen Block ans Ende" -> haengt an
Alles andere (Befehle, erwartete Ausgaben, Prosa-"wird: ...") wird uebergangen und gezaehlt.
- Zaeune aus n Backticks schliessen erst mit n Backticks.
- Geschrieben wird immer mit LF.
Aufruf: python plan_materialisiere.py <planordner> <kopie> <taskdatei> [<taskdatei> ...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import re
import sys
from pathlib import Path, PureWindowsPath
UEBERSCHRIFT = re.compile(r"^#{2,4} ")
PFAD = re.compile(r"`([^`\s]+/[^`\s]+\.[A-Za-z0-9]+|[^`\s/]+\.(?:py|ts|tsx|toml|md|css|json))`")
TOKEN = re.compile(r"`([^`\s]+)`")
# Eintrag unter "## Zieldateien", dieselbe Form wie in welle_vergleich.py
ZIEL = re.compile(r"^- `(.+?)`(?:\s*\((.*?)\))?")
def wurzeln(planordner: Path) -> list[PureWindowsPath]:
"""Der Projektordner vor "docs/plans" im Pfad des Planordners, wie uebergeben und aufgeloest."""
aus = []
for pfad in (planordner.absolute(), planordner.resolve()):
teile = pfad.parts
for i in range(len(teile) - 2, 0, -1):
if teile[i] == "docs" and teile[i + 1] == "plans":
aus.append(PureWindowsPath(*teile[:i]))
break
return aus
def zieldateien(zeilen: list[str], wurzeln: list[PureWindowsPath]) -> set[str]:
"""Relative Pfade unter "## Zieldateien"; ein absoluter zaehlt relativ zu einer der wurzeln.
Ordner (hinten "/"), Muster mit "*" und Pfade mit ".." sind kein Ziel.
"""
drin, menge = False, set()
for zeile in zeilen:
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
treffer = ZIEL.match(zeile) if drin else None
if not treffer or treffer.group(1).endswith(("/", "\\")) or "*" in treffer.group(1):
continue
pfad = PureWindowsPath(treffer.group(1))
if pfad.drive or pfad.root:
relativ = None
for wurzel in wurzeln:
try:
relativ = pfad.relative_to(wurzel)
break
except ValueError:
continue
if relativ is None:
continue
pfad = relativ
if ".." not in pfad.parts:
menge.add(pfad.as_posix())
return menge
def ist_datei(ordner: Path | None, name: str) -> bool:
"""Liegt `name` als Datei direkt im ordner? Nie fuer einen Namen mit Laufwerk oder Wurzel."""
if ordner is None or PureWindowsPath(name).drive or PureWindowsPath(name).root:
return False
try:
return (ordner / name).is_file()
except (OSError, ValueError):
return False
def ordne(zeile: str, geplant: set[str], ordner: Path | None) -> tuple[str | None, list[str]]:
"""Zieldatei einer Ueberschrift und die Token, die nach einer Datei aussehen, ohne erkannt zu sein.
Ziel ist der letzte PFAD-Treffer, ohne ihn der letzte Token, der (ohne "./") unter "## Zieldateien"
steht. Ohne Ziel gelten als offen: relative Pfade mit Schraegstrich (kein Ordner, nicht absolut) und
Namen ohne Schraegstrich, die direkt im ordner als Datei liegen oder Dateiname eines Eintrags sind.
Nachbesserung 2026-10-17 (Plankritik Paket R1 im Edge-Stack, Befunde 1 und 5): "Stelle 30 —
`host/logrotate/edge-traefik`" setzte kein Ziel, der Block "Die ganze Datei ist neu" ueberschrieb
`tests/systemd/test-units.sh` mit Exit 0; `Dockerfile` ging ebenso still ins vorige Ziel. `config/`
und `/proc` stehen in bestehenden Plaenen ueber Bloecken, die das vorige Ziel meinen.
"""
treffer = PFAD.findall(zeile)
if treffer:
return treffer[-1], []
token = TOKEN.findall(zeile)
ziele = [PureWindowsPath(t).as_posix() for t in token if PureWindowsPath(t).as_posix() in geplant]
if ziele:
return ziele[-1], []
namen = {eintrag.rsplit("/", 1)[-1] for eintrag in geplant}
offen = [t for t in token if ("/" in t and not t.startswith("/") and not t.endswith("/"))
or ("/" not in t and (t in namen or ist_datei(ordner, t)))]
return None, offen
def schreibt(marke: str) -> bool:
"""Marken, nach denen main() in die Zieldatei schreibt; bei einer neuen Marke hier mitziehen."""
return (marke == "wird:" or "Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke
or marke.startswith("Häng diesen Block ans Ende"))
def normal(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def bloecke(taskdatei: Path, kopie: Path | None = None):
zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n")
geplant = zieldateien(zeilen, wurzeln(taskdatei.parent))
ziel = None
# (Zeile, Token) der letzten Ueberschrift ohne Ziel, die nach einer Datei aussieht; None, sobald ein Ziel kommt
ungeklaert = None
i = 0
while i < len(zeilen):
zeile = zeilen[i]
if UEBERSCHRIFT.match(zeile):
# Der Generator laeuft verzahnt mit main(): Die Kopie traegt hier schon die Bloecke davor
neu, offen = ordne(zeile, geplant, kopie)
if neu:
ziel, ungeklaert = neu, None
elif offen:
ungeklaert = (i + 1, offen)
if zeile.lstrip().startswith("```"):
einzug = len(zeile) - len(zeile.lstrip())
kern = zeile.lstrip()
laenge = len(kern) - len(kern.lstrip("`"))
start = i
i += 1
inhalt = []
while i < len(zeilen):
kandidat = zeilen[i].lstrip()
if kandidat.startswith("`" * laenge) and not kandidat.strip("`").strip():
break
inhalt.append(zeilen[i][einzug:] if zeilen[i][:einzug].strip() == "" else zeilen[i])
i += 1
k = start - 1
while k >= 0 and not zeilen[k].strip():
k -= 1
marke = zeilen[k].strip() if k >= 0 else ""
yield {"ziel": ziel, "marke": marke, "zeile": start + 1, "text": "\n".join(inhalt), "einzug": einzug,
"ungeklaert": ungeklaert}
i += 1
def in_kopie(kopie: Path, ziel: str) -> Path | None:
"""Der Zielpfad in der Kopie, oder None, wenn er aus ihr hinausfuehrt.
Nachbesserung 2026-10-11 (Paket E9 der Trading Plattform): Ein absoluter Pfad in einer
Ueberschrift schlug unter Windows die Kopie (kopie / "D:/..." ist der absolute Pfad), und das
Skript schrieb mit Exit 0 in die echte Datei; gemessen im Scratchpad an einer Probe-Datei.
Seitdem ist ein Ziel ausserhalb der Kopie (absolut oder mit "..") ein Befund, und nichts wird
geschrieben.
"""
pfad = (kopie / ziel).resolve()
try:
pfad.relative_to(kopie.resolve())
except ValueError:
return None
return pfad
LOESCHZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def loesche(taskdatei: Path, kopie: Path) -> int:
"""Loescht, was der Abschnitt "## Zieldateien" als "(gelöscht)" fuehrt.
Der Pfad dort ist absolut und zeigt ins echte Projekt. Geloescht wird in der Kopie die
Datei am laengsten Pfadende, das dort existiert -- nie etwas ausserhalb der Kopie.
"""
drin = False
anzahl = 0
for zeile in normal(taskdatei.read_text(encoding="utf-8")).split("\n"):
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
treffer = LOESCHZIEL.match(zeile) if drin else None
if not treffer or "gelöscht" not in treffer.group(2):
continue
teile = treffer.group(1).replace("\\", "/").split("/")
for anfang in range(len(teile)):
kandidat = kopie.joinpath(*teile[anfang:])
if ":" not in teile[anfang] and kandidat.is_file():
kandidat.unlink()
anzahl += 1
break
return anzahl
def main() -> int:
plan, kopie = Path(sys.argv[1]), Path(sys.argv[2])
befunde = 0
for name in sys.argv[3:]:
taskdatei = plan / name
alt = None
zaehler = {"neu": 0, "ersetzt": 0, "angehaengt": 0, "uebergangen": 0}
for block in bloecke(taskdatei, kopie):
marke, ziel = block["marke"], block["ziel"]
if block["einzug"] > 0:
zaehler["uebergangen"] += 1
continue
if marke == "Aus:" or marke.startswith("Aus, Zeile"):
alt = block
continue
if block["ungeklaert"] and schreibt(marke):
zeile_u, pfade = block["ungeklaert"]
print(f"BEFUND {name}:{block['zeile']}: Ueberschrift in Zeile {zeile_u} nennt "
f"{', '.join(f'`{p}`' for p in pfade)} und keine erkannte Zieldatei (fehlt sie unter ## Zieldateien?); "
f"der Block ginge nach {ziel or 'keinem Ziel'}, nichts geschrieben")
befunde += 1
alt = None
continue
if marke == "wird:":
if alt is None or ziel is None:
print(f"BEFUND {name}:{block['zeile']}: wird-Block ohne Aus-Block oder ohne Zieldatei")
befunde += 1
continue
pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
alt = None
continue
if not pfad.exists():
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} fehlt in der Kopie")
befunde += 1
alt = None
continue
text = normal(pfad.read_text(encoding="utf-8"))
anzahl = text.count(alt["text"])
if anzahl != 1:
print(f"BEFUND {name}:{alt['zeile']}: Aus-Block steht {anzahl}-mal in {ziel}")
befunde += 1
else:
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.replace(alt["text"], block["text"]))
zaehler["ersetzt"] += 1
alt = None
continue
alt = None
if ("Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke) and ziel is not None:
pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
continue
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(block["text"].rstrip("\n") + "\n")
zaehler["neu"] += 1
continue
if marke.startswith("Häng diesen Block ans Ende") and ziel is not None:
pfad = in_kopie(kopie, ziel)
if pfad is None:
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} liegt ausserhalb der Kopie, nichts geschrieben")
befunde += 1
continue
text = normal(pfad.read_text(encoding="utf-8"))
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.rstrip("\n") + "\n" + block["text"].rstrip("\n") + "\n")
zaehler["angehaengt"] += 1
continue
zaehler["uebergangen"] += 1
zaehler["geloescht"] = loesche(taskdatei, kopie)
print(f"{name}: {zaehler}")
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())