"""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, in einer Zeile ohne weiteren Text und hoechstens drei Spalten tiefer als der Oeffner; tiefer ist es Inhalt. Eine Zeile mit weiterem Backtick hinter dem Zaun oeffnet nichts, sie ist Inline-Code. Beides nach CommonMark 0.31.2, Abschnitt Fenced code blocks, nachgelesen 2026-11-01 ("may be preceded by up to three spaces of indentation", "Info strings for backtick code blocks cannot contain backticks"). Abweichend davon oeffnet ein Zaun bei jeder Einrueckung, und Tilde-Zaeune kennt das Werkzeug nicht. - Zaunpruefung (seit 2026-11-02, Anlass in zaunbefunde()): Vor dem ersten Schreibvorgang liest zaunbefunde() alle uebergebenen Taskdateien. Ein Befund in einer davon, und der ganze Lauf schreibt und loescht nichts, auch nicht die heilen Dateien davor. Befund sind, mit n als Zaunlaenge des Blocks: 1. eine Zeile im Block, die auf mindestens n Backticks endet, ohne zu schliessen (klebender Zaun, auch ein Zaun mehr als drei Spalten tiefer als der Oeffner), 2. eine Zeile im Block, die mit mindestens n Backticks beginnt, ohne zu schliessen (Schlusszaun fehlt, Aussenzaun zu kurz), 3. ein Block, der bis zum Dateiende nicht schliesst, 4. eine Taskdatei, die sich nicht lesen laesst. Herkunft: 1 und 3 stehen im Auftrag zur Haertung. 2 ist Projektvorgabe, denselben stillen Schaden richtet ein fehlender Zaun an. 4 ist Projektvorgabe nach AI/Regelwerk/pruefwerkzeuge.md, vorher stuerzte der Lauf mittendrin ab. Bewusst entschieden: Ein Text im Block, etwa eine Zeichenkette in einem Test, die am Zeilenende auf n Backticks endet oder mit ihnen beginnt, ist derselbe Befund. Von einem klebenden Zaun ist er nicht zu unterscheiden, und die Folgen sind ungleich: Der Fehlalarm kostet einen laengeren Zaun um den Block, der uebersehene Zaun schreibt Planprosa in die Zieldatei. Gemessen am 2026-11-02 ueber alle verschiedenen Taskdateien im Vault und in den Jobordnern: 1 und 3 ohne Treffer, 2 acht Zeilen in drei alten Doku-Tasks, alle echt (Aussenzaun zu kurz, der Block endete zu frueh). Nicht entscheidbar ist ein nackter Zaun als eigene Zeile im Text, hoechstens drei Spalten tiefer als der Oeffner: Er schliesst den Block. Geht die Zahl der Zaeune auf, wird der Rest Prosa, der Block endet dort, und es kommt kein Befund (Treiberfall "Luecke:"). Text mit Zaeunen braucht deshalb immer einen laengeren Zaun um den Block. Kein Befund: laengere Zaeune um Markdown-Vorlagen mit ##-Ueberschriften und kuerzeren Zaeunen darin, Inline-Code und Zaunfolgen mitten in der Zeile oder in Anfuehrungszeichen, Prosa ausserhalb von Bloecken. Nicht gesehen (Fangnetz, kein Nachweis): Ein nackter Zaun als Oeffner ist von einem Schlusszaun nicht zu unterscheiden. Fehlt der Schlusszaun und kommt als naechstes ein nackter Zaun derselben oder groesserer Laenge, schliesst der den Block, und die Rollen tauschen bis zum naechsten Oeffner mit Sprache (das meldet 2) oder bis zum Dateiende (das meldet 3 am letzten Zaun, zwei solche Fehler heben sich auf). Ebenso bei einem Oeffner, der fehlt, an der Zeile davor klebt oder einen Backtick in der Sprachangabe traegt, bei Zaeunen hinter Listenpunkt oder Zitatzeichen und bei Backtick-Zaeunen in Tilde-Bloecken. Nachweis: plan_zaun_treiber.py. plan_bloecke.py nutzt dieselben Funktionen. - Geschrieben wird immer mit LF. Aufruf: python plan_materialisiere.py [ ...] 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 zaun(zeile: str) -> int: """Laenge des Zauns, der in dieser Zeile einen Block oeffnet, sonst 0.""" kern = zeile.lstrip() laenge = len(kern) - len(kern.lstrip("`")) return laenge if laenge >= 3 and "`" not in kern[laenge:] else 0 def schliesst(zeile: str, laenge: int, einzug: int) -> bool: """Schliesst die Zeile einen Block mit Zaun der Laenge laenge und Einzug einzug? Mindestens so viele Backticks, sonst nichts in der Zeile, hoechstens drei Spalten tiefer als der Oeffner. Nachbesserung 2026-11-02 (Pruefung der Zaunpruefung): Vorher schloss jeder Einzug und "``` ```". Ein eingerueckter Zaun in einer Zeichenkette kappte den Block mit 0 Befunden, und plan_bloecke.py hatte das bis dahin gesehen. """ kern = zeile.lstrip() return kern.startswith("`" * laenge) and not kern.rstrip().strip("`") and len(zeile) - len(kern) - einzug <= 3 def zaunbefunde(taskdatei: Path) -> list[tuple[int, str]]: """(Zeile, Meldung) je Stelle, an der die Bloecke der Taskdatei nicht stimmen; leer heisst: bloecke() liest sie richtig. Nachbesserung 2026-11-01 (Kette 050 der Trading Plattform, Auftrag 13): In task-1020 klebte der Zaun an `])`, der Block lief bis in den naechsten Task, es kam nur "Aus-Block steht 0-mal", und ein Aus/wird-Paar ging ohne Meldung verloren. Unter "Die ganze Datei ist neu" schrieb derselbe Fehler Planprosa mit 0 Befunden in die Datei. Fuer die Meldung endet ein Block am klebenden Zaun, und ein Zaun am Zeilenanfang im Block beginnt einen neuen: So steht jeder Fehler einmal da, und die Bloecke dahinter stimmen wieder. War es doch ein Text mit Zaunfolge, oeffnet der echte Schlusszaun fuer diese Suche einen Block, und ein Folgebefund steht weiter hinten; der laengere Zaun um den Block loest beide. """ try: zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n") except (OSError, UnicodeDecodeError) as fehler: return [(0, f"Taskdatei nicht lesbar ({fehler})")] befunde = [] i = 0 while i < len(zeilen): laenge = zaun(zeilen[i]) start = i i += 1 if not laenge: continue spalte = len(zeilen[start]) - len(zeilen[start].lstrip()) while i < len(zeilen) and not schliesst(zeilen[i], laenge, spalte): if zeilen[i].rstrip().endswith("`" * laenge): befunde.append((i + 1, f"Zaun klebt an der Codezeile, Block ab Zeile {start + 1}; der Schlusszaun " "gehoert auf eine eigene Zeile, ein Text mit Zaunfolge in einen laengeren Zaun")) break kern = zeilen[i].lstrip() if kern.startswith("`" * laenge): befunde.append((i + 1, f"hier oeffnet ein Block, der Block ab Zeile {start + 1} ist aber nicht geschlossen; " "es fehlt sein Schlusszaun, oder der Text braucht einen laengeren Zaun")) start, laenge, spalte = i, len(kern) - len(kern.lstrip("`")), len(zeilen[i]) - len(kern) i += 1 if i >= len(zeilen): befunde.append((start + 1, f"Block schliesst nie (Zaun aus {laenge} Backticks)")) i += 1 return befunde 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) laenge = zaun(zeile) if laenge: einzug = len(zeile) - len(zeile.lstrip()) start = i i += 1 inhalt = [] while i < len(zeilen) and not schliesst(zeilen[i], laenge, einzug): 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 # Erst alle Zaeune aller Taskdateien, dann der erste Schreibvorgang: Hinter einem kaputten Zaun stimmt die Datei nicht mehr for name in sys.argv[3:]: for zeile, meldung in zaunbefunde(plan / name): print(f"BEFUND {name}:{zeile}: {meldung}; nichts geschrieben") befunde += 1 if befunde: print(f"{befunde} Befund(e).") return 2 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())