Files
Pascal Michel ae3ec3ca89 Abgleich mit dem Vault-Stand 22761eb
11 Datei(en) geändert.
Erzeugt von .vault-tools/auto-push/werkzeuge_spiegel.py.
2026-11-03 03:00:04 +01:00

397 lines
21 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, 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 <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 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())