- .claude/agents/planer.md - .claude/skills/plan-executer-pruefung/SKILL.md - .claude/workflows/plankritik.js - .claude/workflows/planung.js - .claude/workflows/pruefung.js - .claude/workflows/verbrauch.js - .claude/workflows/welle-mit-pruefung.js - .vault-tools/plan-kopie/LIESMICH.md - .vault-tools/plan-kopie/plan_zeichen.py - .vault-tools/plan-kopie/rot_nachweis.py - .vault-tools/trading-plattform-pruefung/LIESMICH.md - .vault-tools/trading-plattform-pruefung/abendkontrolle.py - … und 34 weitere Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py. Co-Authored-By: Claude Opus 5 <[email protected]>
329 lines
19 KiB
Python
329 lines
19 KiB
Python
"""Rot-Nachweis je Task: die neuen Tests eines Tasks gegen den Produktivcode VOR dem Task, auf Wegwerfkopien.
|
|
|
|
Koordinator-Werkzeug (entstanden 2026-09-18, Trading Plattform). plan_materialisiere.py wendet eine
|
|
Taskdatei immer ganz an; "erst der Test, dann die Umsetzung" laesst sich damit nicht beobachten. Dieses
|
|
Skript stellt den Zustand dazwischen her: Basis + Voraussetzungstasks + der Task selbst, danach die
|
|
PRODUKTIVEN Zieldateien des Tasks zurueck auf den Stand vor dem Task (neue Produktivdateien geloescht).
|
|
Dann laufen die Testdateien des Tasks. Es tut etwas und berichtet; das Urteil faellt der Koordinator:
|
|
ROT mindestens ein Fehlschlag oder Sammelfehler -- die erste Fehlerzeile steht dabei, damit man sieht,
|
|
ob der Test an der gemeinten Stelle faellt und nicht an einem Tippfehler.
|
|
GRUEN kein Fehlschlag ohne die Umsetzung -- ein Test, der so gruen ist, misst die Umsetzung nicht
|
|
(ausser der Plan fuehrt ihn ausdruecklich als Regressionsnetz).
|
|
Geltungsbereich und Luecken:
|
|
- Testdateien sind Zieldateien unter tests/ sowie *.test.ts und *.test.tsx; alles unter tests/fixtures/
|
|
gilt als Testseite und bleibt stehen. Alles andere ist produktiv.
|
|
- tests/conftest.py gilt als PRODUKTIVSEITE und wird zurueckgesetzt (Projektvorgabe seit 2026-09-22):
|
|
Liegt die Korrektur eines Tasks selbst in conftest.py (Trading Plattform, Task 369: die Ersatzfunktionen
|
|
der Netzsperre), wendete das Skript sie im Lauf "ohne Umsetzung" mit an und meldete GRUEN -- der eine
|
|
Zustand, in dem der Nachweis nichts misst, sah aus wie ein sauberer Befund. Liegt dagegen eine neue
|
|
Fixture des Tasks in conftest.py (Task 368), fehlt sie jetzt im Lauf und die neuen Tests enden als
|
|
Sammelfehler -- das ist ROT mit "fixture ... not found" in der ersten Fehlerzeile, also sichtbar.
|
|
Wer diesen Fall hat, ruft mit --conftest-als-test auf; dann gilt conftest.py wie frueher als Testdatei.
|
|
Das Skript sagt je Task, wie es conftest.py behandelt hat.
|
|
- Python-Tests laufen in WSL (Debian, Venv aus --venv), Oberflaechentests unter Windows mit npx vitest im
|
|
Ordner aus --frontend; dafuer setzt das Skript eine Junction auf das node_modules des Projekts und loest
|
|
sie vor dem Loeschen. Die Vorgaben der drei Schalter sind die der Trading Plattform (~/venvs/trading,
|
|
dashboard, trading-plattform/); jedes andere Projekt nennt seine Werte (seit 2026-09-24, Nexhorizons Hub).
|
|
Fehlt das Venv in WSL oder der Frontend-Ordner in der Kopie, steht beim Task VORAUSSETZUNG FEHLT statt
|
|
ROT -- sonst saehe ein Lauf im falschen Venv aus wie ein sauberer Rot-Nachweis. Dasselbe gilt, wenn die
|
|
erste pytest-Fehlerzeile ein Modul vermisst, das weder im Projekt liegt noch vom Task angelegt wird
|
|
(vergessenes --venv, eine Abhaengigkeit, die nur der Stack hat). Das greift nur an der ersten
|
|
Fehlerzeile; ein fremdes Modul, das erst hinter einem Projektmodul fehlt, sieht das Skript nicht.
|
|
- Vorbedingung: Das Projekt ist im Venv NICHT editierbar installiert (pip install -e). Sonst loest der
|
|
Finder von setuptools ein fehlendes Untermodul gegen das echte Projekt auf, und ein Test ohne Umsetzung
|
|
liefe gruen (Pruefung 2026-09-24, setuptools editable_wheel.py).
|
|
- Erzeugte Fixtures (der Plan laesst sie mit altem Code erzeugen) holt --fixturen aus einem Ordner nach,
|
|
wenn sie nach dem Anwenden fehlen.
|
|
- Es sieht nicht, WELCHE Tests rot sein muessten; es zaehlt und zeigt die erste Fehlerzeile.
|
|
- Die Basis ist eine Kopie, die der Aufrufer VOR der ersten Welle gezogen hat; das Skript fasst das
|
|
Projekt selbst nie an.
|
|
|
|
Aufruf: python rot_nachweis.py <basis-kopie> <planordner> <arbeitsordner> <spec.json> [--fixturen <ordner>] [--node-modules <pfad>] [--conftest-als-test] [--venv <wsl-pfad>] [--frontend <ordner>] [--projektname <ordnername>]
|
|
--node-modules nennt das node_modules des Projekts, wenn die Basis-Kopie keines traegt.
|
|
--venv nennt das Venv in WSL (Vorgabe ~/venvs/trading), --frontend den Oberflaechenordner relativ zum
|
|
Projekt (Vorgabe dashboard), --projektname den Ordnernamen, hinter dem absolute Zielpfade abgeschnitten
|
|
werden (Vorgabe trading-plattform).
|
|
--conftest-als-test laesst tests/conftest.py als Testdatei stehen (nur, wenn die neue Fixture des Tasks dort liegt).
|
|
spec.json: [{"task": "task-141-....md", "vor": ["task-...md", ...]}, ...]
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shlex
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
HIER = Path(__file__).resolve().parent
|
|
ZIEL = re.compile(r"^- `(.+?)`")
|
|
PROJEKTNAME = "trading-plattform/"
|
|
VENV = "~/venvs/trading"
|
|
FRONTEND = "dashboard"
|
|
CONFTEST = "tests/conftest.py"
|
|
# Das Venv steht ungequotet im bash-Aufruf, damit ~ expandiert; deshalb nur diese Zeichen.
|
|
VENV_MUSTER = re.compile(r"^~?[A-Za-z0-9._/-]+\Z")
|
|
FRONTEND_MUSTER = re.compile(r"^[A-Za-z0-9._-]+(/[A-Za-z0-9._-]+)*\Z")
|
|
FEHLENDES_MODUL = re.compile(r"No module named '([^']+)'")
|
|
|
|
|
|
def zieldateien(taskdatei: Path, projektname: str = PROJEKTNAME) -> list[str]:
|
|
"""Zieldateien relativ zum Projekt. Absolute Pfade werden hinter dem Projektordner abgeschnitten."""
|
|
drin = False
|
|
ziele: list[str] = []
|
|
for zeile in taskdatei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n"):
|
|
if zeile.startswith("## "):
|
|
drin = zeile.startswith("## Zieldateien")
|
|
continue
|
|
treffer = ZIEL.match(zeile) if drin else None
|
|
if treffer:
|
|
pfad = treffer.group(1).replace("\\", "/").removeprefix("./")
|
|
if re.match(r"^[A-Za-z]:/", pfad):
|
|
stelle = pfad.lower().find(projektname)
|
|
if stelle < 0:
|
|
raise ValueError(f"{taskdatei.name}: absoluter Pfad ausserhalb des Projekts: {pfad}")
|
|
pfad = pfad[stelle + len(projektname) :]
|
|
ziele.append(pfad)
|
|
return ziele
|
|
|
|
|
|
def ist_test(pfad: str, conftest_als_test: bool = False) -> bool:
|
|
if pfad == CONFTEST:
|
|
# Vorgabe: produktiv. Sonst wendet der Lauf "ohne Umsetzung" eine Korrektur an, die in
|
|
# conftest.py liegt, und meldet gruen (siehe Kopfkommentar).
|
|
return conftest_als_test
|
|
return pfad.startswith("tests/") or pfad.endswith((".test.ts", ".test.tsx"))
|
|
|
|
|
|
def wende_an(planordner: Path, kopie: Path, tasks: list[str]) -> str:
|
|
if not tasks:
|
|
return ""
|
|
lauf = subprocess.run(
|
|
[sys.executable, "-X", "utf8", str(HIER / "plan_materialisiere.py"), str(planordner), str(kopie), *tasks],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
|
)
|
|
return "" if lauf.returncode == 0 else (lauf.stdout + lauf.stderr)[-600:]
|
|
|
|
|
|
def wsl_pfad(pfad: Path) -> str:
|
|
text = str(pfad.resolve()).replace("\\", "/")
|
|
return f"/mnt/{text[0].lower()}{text[2:]}"
|
|
|
|
|
|
def loesche(ordner: Path, frontend: str = FRONTEND) -> None:
|
|
junction = ordner / frontend / "node_modules"
|
|
if junction.exists():
|
|
os.rmdir(junction) # loest nur die Verknuepfung, nie das Ziel
|
|
shutil.rmtree(ordner, ignore_errors=True)
|
|
|
|
|
|
def pytest_befehl(rot: Path, python_tests: list[str], venv: str = VENV) -> str:
|
|
# shlex.quote: Ein Arbeitsordner mit Leerzeichen (Benutzername im Temp-Pfad) liess das cd
|
|
# scheitern; jeder Task stand dann als "ROT pytest: keine Summenzeile" da, ohne dass ein Test
|
|
# lief (gefunden 2026-09-20).
|
|
return f"cd {shlex.quote(wsl_pfad(rot))} && {venv}/bin/python -m pytest {' '.join(python_tests)} -q -p no:cacheprovider 2>&1 | tail -60"
|
|
|
|
|
|
def venv_da(venv: str) -> bool:
|
|
lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", f"test -x {venv}/bin/python && echo VENV_DA"],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
return "VENV_DA" in lauf.stdout
|
|
|
|
|
|
def fremdes_modul(fehlerzeile: str, ziele: list[str], kopie: Path) -> str | None:
|
|
"""Das fehlende Modul, wenn es weder im Projekt liegt noch vom Task angelegt wird.
|
|
|
|
Ein solcher Fehler misst das Venv, nicht die fehlende Umsetzung: ein vergessenes --venv
|
|
(No module named 'argon2' im Trading-Venv) oder eine Abhaengigkeit, die nur der Stack hat
|
|
(playwright). Gefunden 2026-09-24 in der Pruefung des Werkzeugs.
|
|
"""
|
|
treffer = FEHLENDES_MODUL.search(fehlerzeile)
|
|
if not treffer:
|
|
return None
|
|
oben = treffer.group(1).split(".")[0]
|
|
eigene = set()
|
|
for ziel in ziele:
|
|
eigene.update(Path(ziel).parts[:-1])
|
|
eigene.add(Path(ziel).stem)
|
|
if oben in eigene or any(kopie.rglob(f"{oben}.py")) or any(p.is_dir() for p in kopie.rglob(oben)):
|
|
return None
|
|
return treffer.group(1)
|
|
|
|
|
|
def nimm_wert(argumente: list[str], schalter: str) -> str | None:
|
|
if schalter not in argumente:
|
|
return None
|
|
stelle = argumente.index(schalter)
|
|
if stelle + 1 >= len(argumente) or argumente[stelle + 1].startswith("--"):
|
|
raise ValueError(f"{schalter} ohne Wert")
|
|
wert = argumente[stelle + 1]
|
|
del argumente[stelle : stelle + 2]
|
|
return wert
|
|
|
|
|
|
def erste_fehlerzeile(ausgabe: str) -> str:
|
|
for zeile in ausgabe.splitlines():
|
|
kern = zeile.strip()
|
|
if kern.startswith(("E ", "FAILED", "ERROR", "AssertionError", "Error:", "TypeError", "ReferenceError")) or " FAIL " in zeile:
|
|
return kern[:220]
|
|
return ""
|
|
|
|
|
|
def main() -> int:
|
|
argumente = sys.argv[1:]
|
|
try:
|
|
fixturen_arg = nimm_wert(argumente, "--fixturen")
|
|
node_modules_wert = nimm_wert(argumente, "--node-modules")
|
|
venv = nimm_wert(argumente, "--venv") or VENV
|
|
frontend = (nimm_wert(argumente, "--frontend") or FRONTEND).replace("\\", "/").strip("/")
|
|
projektname = (nimm_wert(argumente, "--projektname") or PROJEKTNAME).replace("\\", "/").strip("/").lower() + "/"
|
|
except ValueError as fehler:
|
|
print(f"AUFRUF FALSCH: {fehler}")
|
|
return 3
|
|
fixturen = Path(fixturen_arg) if fixturen_arg else None
|
|
node_modules_arg = Path(node_modules_wert) if node_modules_wert else None
|
|
if not VENV_MUSTER.match(venv):
|
|
print(f"AUFRUF FALSCH: --venv {venv!r} -- erlaubt sind nur ~, Buchstaben, Ziffern und ._/-")
|
|
return 3
|
|
if not FRONTEND_MUSTER.match(frontend) or ".." in frontend.split("/"):
|
|
print(f"AUFRUF FALSCH: --frontend {frontend!r} -- ein Ordner relativ zum Projekt, ohne ..")
|
|
return 3
|
|
conftest_als_test = False
|
|
if "--conftest-als-test" in argumente:
|
|
conftest_als_test = True
|
|
argumente.remove("--conftest-als-test")
|
|
if len(argumente) != 4:
|
|
print(__doc__)
|
|
return 3
|
|
print(f"Einstellungen: venv={venv} frontend={frontend} projektname={projektname}")
|
|
basis, planordner, arbeit, spec_pfad = (Path(a) for a in argumente)
|
|
if not basis.is_dir() or not planordner.is_dir() or not spec_pfad.is_file():
|
|
print("VORAUSSETZUNG FEHLT: Basis-Kopie, Planordner oder Spec")
|
|
return 3
|
|
arbeit.mkdir(parents=True, exist_ok=True)
|
|
# Den langen Namen erzwingen: Kommt der Arbeitsordner als Windows-Kurzname (PASCAL~1), startet
|
|
# vitest dort, loest die Dateien aber ueber den langen Namen auf und meldet "Cannot find module"
|
|
# fuer die eigene Testdatei -- ein ROT, das nichts misst (2026-09-24, Hub-Tasks 24, 25, 29, 37
|
|
# und Trading-Task 395).
|
|
arbeit = arbeit.resolve()
|
|
node_modules = None
|
|
for kandidat in (node_modules_arg, basis / frontend / "node_modules"):
|
|
if kandidat is not None and kandidat.exists():
|
|
node_modules = kandidat
|
|
break
|
|
venv_geprueft: bool | None = None # erst pruefen, wenn ein Task Python-Tests hat
|
|
ergebnisse = []
|
|
for eintrag in json.loads(spec_pfad.read_text(encoding="utf-8")):
|
|
task, voraus = eintrag["task"], eintrag.get("vor", [])
|
|
name = task.split("-")[1] if "-" in task else task
|
|
vor, rot = arbeit / f"{name}_vor", arbeit / f"{name}_rot"
|
|
for ordner in (vor, rot):
|
|
loesche(ordner, frontend)
|
|
shutil.copytree(basis, vor, ignore=shutil.ignore_patterns("node_modules", "__pycache__"))
|
|
fehler = wende_an(planordner, vor, voraus)
|
|
shutil.copytree(vor, rot)
|
|
fehler = fehler or wende_an(planordner, rot, [task])
|
|
if fehler:
|
|
ergebnisse.append((task, "ANWENDEN GESCHEITERT", fehler.replace("\n", " | ")))
|
|
print(f"{task}: ANWENDEN GESCHEITERT: {fehler[-300:]}")
|
|
continue
|
|
ziele = zieldateien(planordner / task, projektname)
|
|
tests = [z for z in ziele if ist_test(z, conftest_als_test) and not z.startswith("tests/fixtures/")]
|
|
if CONFTEST in ziele:
|
|
# Sichtbar machen, was mit conftest.py geschah -- der Koordinator liest die erste Fehlerzeile
|
|
# anders, wenn eine Fixture fehlt, als wenn eine Korrektur zurueckgesetzt wurde.
|
|
wie = "als Testdatei stehen gelassen (--conftest-als-test)" if conftest_als_test else "als Produktivseite auf den Stand vor dem Task zurueckgesetzt"
|
|
print(f"{task}: Hinweis: {CONFTEST} {wie}")
|
|
for ziel in ziele:
|
|
if ist_test(ziel, conftest_als_test):
|
|
if not (rot / ziel).exists() and fixturen is not None and (fixturen / Path(ziel).name).is_file():
|
|
(rot / ziel).parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(fixturen / Path(ziel).name, rot / ziel)
|
|
continue
|
|
if (vor / ziel).is_file():
|
|
shutil.copyfile(vor / ziel, rot / ziel)
|
|
elif (rot / ziel).is_file():
|
|
(rot / ziel).unlink()
|
|
# Ordner, die erst der Task angelegt hat, gehen mit, sobald sie leer sind: Ein leeres
|
|
# hub/auth/ liest Python als Namespace-Paket, und die Fehlerzeile nannte dann das
|
|
# Untermodul statt des fehlenden Pakets (2026-09-24, Nexhorizons Hub, Tasks 7, 9, 11).
|
|
ordner = (rot / ziel).parent
|
|
while ordner != rot and not (vor / ordner.relative_to(rot)).exists() and not any(ordner.iterdir()):
|
|
ordner.rmdir()
|
|
ordner = ordner.parent
|
|
python_tests = [t for t in tests if t.endswith(".py")]
|
|
ober_tests = [t for t in tests if not t.endswith(".py")]
|
|
zeilen = []
|
|
if python_tests and venv_geprueft is None:
|
|
venv_geprueft = venv_da(venv)
|
|
if python_tests and not venv_geprueft:
|
|
zeilen.append(f"VORAUSSETZUNG FEHLT: {venv}/bin/python ist in WSL nicht ausfuehrbar")
|
|
elif python_tests:
|
|
befehl = pytest_befehl(rot, python_tests, venv)
|
|
lauf = subprocess.run(["wsl.exe", "-d", "Debian", "--", "bash", "-lc", befehl],
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
ausgabe = lauf.stdout
|
|
summe = next((z.strip() for z in reversed(ausgabe.splitlines()) if re.search(r"\d+ (passed|failed|error)", z)), "keine Summenzeile")
|
|
# Gruen ist nur ein Lauf mit Summenzeile und ohne Fehlschlag. Ohne Summenzeile hat pytest gar
|
|
# nicht gesammelt (etwa ein ImportError in tests/conftest.py) -- das ist rot, nicht gruen.
|
|
# Gefunden am 2026-09-18: Task 172 stand als GRUEN da, obwohl kein einziger Test lief.
|
|
rot_ja = bool(re.search(r"\d+ (failed|error)", summe)) or summe == "keine Summenzeile"
|
|
erste = erste_fehlerzeile(ausgabe)
|
|
fremd = fremdes_modul(erste, ziele, rot) if rot_ja else None
|
|
if fremd:
|
|
zeilen.append(f"VORAUSSETZUNG FEHLT: Modul {fremd!r} liegt weder im Projekt noch im Venv {venv} | pytest: {summe} | {erste}")
|
|
else:
|
|
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" pytest: {summe} | {erste}")
|
|
if ober_tests:
|
|
if node_modules is None:
|
|
zeilen.append("VORAUSSETZUNG FEHLT: node_modules in der Basis-Kopie")
|
|
elif not (rot / frontend).is_dir():
|
|
zeilen.append(f"VORAUSSETZUNG FEHLT: Frontend-Ordner {frontend} fehlt in der Kopie")
|
|
else:
|
|
import _winapi
|
|
_winapi.CreateJunction(str(node_modules.resolve()), str(rot / frontend / "node_modules"))
|
|
relativ = [t.removeprefix(f"{frontend}/") for t in ober_tests]
|
|
lauf = subprocess.run(["npx.cmd", "vitest", "run", *relativ], cwd=rot / frontend,
|
|
capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
ausgabe = re.sub(r"\x1b\[[0-9;]*m", "", lauf.stdout + lauf.stderr)
|
|
summe = next((z.strip() for z in ausgabe.splitlines() if z.strip().startswith("Tests ")), "")
|
|
if not summe:
|
|
# Ohne Summenzeile ist vitest nicht bis zum Ende gekommen (Importfehler, Startfehler):
|
|
# das Ende der Ausgabe zeigen, statt "rot" zu behaupten, ohne zu wissen woran.
|
|
summe = "keine Summenzeile; Ende der Ausgabe: " + " / ".join(z.strip() for z in ausgabe.splitlines()[-6:] if z.strip())[:300]
|
|
rot_ja = "failed" in summe or lauf.returncode != 0
|
|
if "Denied ID" in ausgabe:
|
|
# Vite liefert keine Datei ausserhalb seines Wurzelordners, und durch die Junction
|
|
# liegt node_modules ausserhalb der Kopie: Ein Test mit "?raw" aus node_modules
|
|
# scheitert dann als ganze Datei (2026-09-24, Nexhorizons Hub, Task 42).
|
|
zeilen.append(f"VORAUSSETZUNG FEHLT: Vite verweigert eine Datei hinter der Junction auf node_modules; mit echter Kopie von node_modules fahren | vitest: {summe}")
|
|
else:
|
|
# Bei einer Datei, die schon am Import scheitert, steht vor der Fehlermeldung nur
|
|
# die FAIL-Zeile; die Meldung selbst zeigt erst, ob der Grund der gemeinte ist.
|
|
erste = erste_fehlerzeile(ausgabe)
|
|
meldung = next((z.strip()[:220] for z in ausgabe.splitlines()
|
|
if z.strip().startswith(("Error:", "AssertionError", "TypeError", "ReferenceError", "SyntaxError"))), "")
|
|
zusatz = f" | {meldung}" if meldung and meldung not in erste else ""
|
|
zeilen.append(("ROT " if rot_ja else "GRUEN") + f" vitest: {summe} | {erste}{zusatz}")
|
|
if not tests:
|
|
zeilen.append("ohne Testdatei unter den Zieldateien")
|
|
for zeile in zeilen:
|
|
print(f"{task}: {zeile}")
|
|
ergebnisse.append((task, zeile[:5].strip(), zeile))
|
|
for ordner in (vor, rot):
|
|
loesche(ordner, frontend)
|
|
gruen = [e for e in ergebnisse if e[1] == "GRUEN"]
|
|
ohne_lauf = [e for e in ergebnisse if e[1] in ("VORAU", "ANWENDEN GESCHEITERT")]
|
|
print(f"\n{len(ergebnisse)} Laeufe, davon {len(gruen)} gruen ohne Umsetzung und {len(ohne_lauf)} ohne Lauf "
|
|
"(Voraussetzung fehlt oder Anwenden gescheitert).")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
try:
|
|
sys.exit(main())
|
|
except Exception as ausnahme: # noqa: BLE001 - ein Absturz des Werkzeugs ist selbst ein Befund
|
|
print(f"BEFUND: das Werkzeug ist abgebrochen: {ausnahme!r}")
|
|
sys.exit(2)
|