Files
planer/backend/artefakte.py
2026-07-22 16:12:23 +02:00

102 lines
4.2 KiB
Python

"""Artefakt-Schicht: Scan-Fragmente je Quelldatei zusammensetzen und als
`.planer/`-Dateien ins Ziel-Repo schreiben. Merge arbeitet auf den ROHEN
Markdown-Blöcken (kein Grammatik-Roundtrip — nichts geht verloren).
"""
import re
from pathlib import Path
_ERGAENZE_RE = re.compile(r"^ergaenze-anker:\s*(\S+)\s+zu\s+(\S+)\s*$", re.M)
def datei_pfad(datei_rel: str) -> str:
"""Quelldatei → Pfad der Einheiten-Datei unter .planer/ (`/` → `__`).
Bekannte v1-Schwäche: Quellnamen mit `__` können kollidieren."""
return "einheiten/" + datei_rel.replace("/", "__") + ".md"
def _bloecke(text: str) -> dict[str, str]:
"""Fragment → {einheiten_id: roher_block}. Blöcke beginnen bei `## <id>`."""
out: dict[str, str] = {}
aktuelle_id = None
zeilen: list[str] = []
for zeile in text.splitlines():
if zeile.startswith("## ") and ("::" in zeile or re.fullmatch(r"## \S+\.\w+", zeile.strip())):
if aktuelle_id is not None:
out[aktuelle_id] = "\n".join(zeilen).rstrip()
aktuelle_id = zeile[3:].strip()
zeilen = [zeile]
elif aktuelle_id is not None and not _ERGAENZE_RE.match(zeile):
zeilen.append(zeile)
if aktuelle_id is not None:
out[aktuelle_id] = "\n".join(zeilen).rstrip()
return out
def merge_fragmente(fragmente: list[str]) -> str:
"""Fragmente EINER Quelldatei → Rumpf der Einheiten-Datei. Einheiten in Reihenfolge
des ersten Auftretens, Dubletten (gleiche ID) fallen weg; `ergaenze-anker: X zu Y`
aus Nachfass-Antworten wird als `anker: X` in den Block von Y eingefügt."""
bloecke: dict[str, str] = {}
ergaenzungen: list[tuple[str, str]] = []
for frag in fragmente:
ergaenzungen += _ERGAENZE_RE.findall(frag)
for eid, block in _bloecke(frag).items():
bloecke.setdefault(eid, block)
for anker_id, ziel_id in ergaenzungen:
block = bloecke.get(ziel_id)
if block is None:
continue # Ziel existiert nicht — Gate hat das bereits als Fehler gemeldet
kopf, _, rest = block.partition("\n")
if f"anker: {anker_id}" not in block:
bloecke[ziel_id] = f"{kopf}\nanker: {anker_id}\n{rest}"
return "\n\n".join(bloecke.values())
def einheiten_datei(datei_rel: str, rumpf: str, stand: str) -> str:
return f"# Einheiten: {datei_rel}\nstand: {stand}\n\n{rumpf}\n"
def lese_stand(text: str) -> str | None:
m = re.search(r"^stand:\s*(\S+)", text, re.M)
return m.group(1) if m else None
def alle_ids(text: str) -> set[str]:
"""Alle Einheiten-IDs + Zusatz-Anker einer Einheiten-Datei (für Referenz-Gates)."""
ids = {m.group(1) for m in re.finditer(r"^## (\S+)$", text, re.M)}
ids |= {m.group(1) for m in re.finditer(r"^anker:\s*(\S+)\s*$", text, re.M)}
return ids
def schreibe_planer(ziel_repo: Path, dateien: dict[str, str]) -> list[Path]:
"""Schreibt {relativer_pfad_unter_.planer: inhalt} ins Ziel-Repo. → geschriebene Pfade."""
geschrieben = []
for rel, inhalt in sorted(dateien.items()):
pfad = ziel_repo / ".planer" / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_text(inhalt, encoding="utf-8")
geschrieben.append(pfad)
return geschrieben
def pruefung_geruest(wurzel: Path, projekt: str) -> str:
"""pruefung.md-Gerüst mit einfacher Erkennung (Makefile-Targets, pytest).
Fehlt etwas, steht dort explizit '(keins vorhanden)' — Gates degradieren sichtbar."""
tests = lint = start = "(keins vorhanden)"
makefile = wurzel / "Makefile"
if makefile.is_file():
targets = set(re.findall(r"^([\w-]+):", makefile.read_text(encoding="utf-8"), re.M))
if "test" in targets:
tests = "make test"
if "lint" in targets:
lint = "make lint"
for kandidat in ("dev", "run", "start"):
if kandidat in targets:
start = f"make {kandidat}"
break
elif list(wurzel.rglob("pytest.ini")) or list(wurzel.rglob("conftest.py")):
tests = "pytest"
return (f"# Prüfung: {projekt}\n"
f"tests: {tests}\nlint: {lint}\nstart: {start}\n"
f"hinweise:\n- (vom Scan erzeugtes Gerüst — bitte prüfen und ergänzen)\n")