"""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 `## `.""" 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")