"""Viewer-Service: liest .planer/-Dateien eines Projekts in Strukturen für die API. Reine Bibliothek (kein FastAPI-Import). Eigener toleranter Lese-Parser — gates.py bleibt unangetastet (scan-kritisch) und verwirft z. B. die Entscheidungs-SÄTZE, die die UI zeigt. Code je Einheit wird LIVE aus dem Anker extrahiert, nie gespeichert (Single Source). """ import re from pathlib import Path import artefakte import repo _EINHEIT_KOPF = re.compile(r"^## (\S+::\S+|\S+\.\w+)$") def lese_einheiten(text: str) -> dict[str, dict]: """Einheiten-Datei → {id: {beschreibung, input, output, entscheidungen, kanten, anker}}.""" out: dict[str, dict] = {} aktuelle = None modus = None for zeile in text.splitlines(): if (m := _EINHEIT_KOPF.match(zeile.strip())) and zeile.startswith("## "): aktuelle = {"id": m.group(1), "beschreibung": "", "input": "", "output": "", "entscheidungen": [], "kanten": [], "anker": []} out[aktuelle["id"]] = aktuelle modus = None elif aktuelle is None: continue elif m := re.match(r"(beschreibung|input|output):(.*)$", zeile): aktuelle[m.group(1)] = m.group(2).strip() modus = m.group(1) elif zeile.startswith("anker:"): aktuelle["anker"].append(zeile.split(":", 1)[1].strip()) elif zeile.strip() in ("entscheidungen:", "fakten:"): modus = "punkte" elif zeile.strip() == "kanten:": modus = "kanten" elif modus == "punkte" and re.match(r"\s+beleg:", zeile): continue # UI zeigt echten Code statt Zitaten elif modus == "punkte" and zeile.startswith("- "): aktuelle["entscheidungen"].append(re.sub(r"\s*\bbeleg:.*$", "", zeile[2:]).strip()) elif modus == "punkte" and zeile.startswith(" ") and aktuelle["entscheidungen"]: aktuelle["entscheidungen"][-1] += " " + zeile.strip() elif modus == "kanten" and (m := re.match(r"- ([\wäöü-]+):\s*(\S+)", zeile)): aktuelle["kanten"].append({"typ": m.group(1), "ziel": m.group(2)}) elif modus in ("beschreibung", "input", "output") and zeile.startswith(" "): aktuelle[modus] += " " + zeile.strip() return out def lese_kern(text: str) -> dict: zeilen = text.splitlines() titel = zeilen[0].removeprefix("# Kern:").strip() if zeilen else "" tragend: list[str] = [] rumpf: list[str] = [] for zeile in zeilen[1:]: if zeile.startswith("tragende-einheiten:"): tragend = [t.strip() for t in zeile.split(":", 1)[1].split(",") if t.strip()] else: rumpf.append(zeile) return {"titel": titel, "text": "\n".join(rumpf).strip(), "tragende_einheiten": tragend} def _einheiten_refs(zeile: str) -> list[str]: return [t.strip() for t in zeile.split(":", 1)[1].split(",") if t.strip()] def lese_features(text: str) -> dict: """Eine Bereichs-Datei → {name, beschreibung, features: [{titel, flag, beschreibung, teilfeatures: [{titel, einheiten}]}]}.""" bereich = {"name": "", "beschreibung": "", "features": []} feature = None for zeile in text.splitlines(): if zeile.startswith("# Bereich:"): bereich["name"] = zeile.split(":", 1)[1].strip() feature = None elif zeile.startswith("## Feature:"): titel = zeile[11:].strip() flag = "kern" if "[kern]" in titel else "rand" feature = {"titel": re.sub(r"\s*\[(kern|rand)\]", "", titel).strip(), "flag": flag, "beschreibung": "", "teilfeatures": []} bereich["features"].append(feature) elif zeile.startswith("### ") and feature is not None: feature["teilfeatures"].append({"titel": zeile[4:].strip(), "einheiten": []}) elif zeile.startswith("einheiten:") and feature and feature["teilfeatures"]: feature["teilfeatures"][-1]["einheiten"] = _einheiten_refs(zeile) elif zeile.startswith("beschreibung:"): ziel = feature if feature is not None else bereich ziel["beschreibung"] = zeile.split(":", 1)[1].strip() return bereich def lese_architektur(text: str) -> list[dict]: komponenten: list[dict] = [] k = None modus = None for zeile in text.splitlines(): if zeile.startswith("## Komponente:"): k = {"name": zeile.split(":", 1)[1].strip(), "beschreibung": "", "einheiten": [], "beziehungen": []} komponenten.append(k) modus = None elif k is None: continue elif zeile.startswith("beschreibung:"): k["beschreibung"] = zeile.split(":", 1)[1].strip() elif zeile.startswith("einheiten:"): k["einheiten"] = _einheiten_refs(zeile) elif zeile.strip() == "beziehungen:": modus = "beziehungen" elif modus == "beziehungen" and (m := re.match(r"- ([\wäöü-]+):\s*(.+)$", zeile)): k["beziehungen"].append({"typ": m.group(1), "ziel": m.group(2).strip()}) return komponenten def lese_flows(text: str) -> list[dict]: flows: list[dict] = [] f = None for zeile in text.splitlines(): if zeile.startswith("## Flow:"): f = {"titel": zeile.split(":", 1)[1].strip(), "schritte": []} flows.append(f) elif f is not None and (m := re.match(r"^\d+\.\s(.+?)(?:\s—\s(.+))?$", zeile)): refs = [t.strip() for t in (m.group(2) or "").split(",") if t.strip()] f["schritte"].append({"text": m.group(1).strip(), "einheiten": refs}) return flows def lese_pruefung(text: str) -> dict: out = {"tests": "", "lint": "", "start": "", "hinweise": []} modus = None for zeile in text.splitlines(): if m := re.match(r"(tests|lint|start):(.*)$", zeile): out[m.group(1)] = m.group(2).strip() modus = None elif zeile.strip() == "hinweise:": modus = "hinweise" elif modus == "hinweise" and zeile.startswith("- "): out["hinweise"].append(zeile[2:].strip()) return out def code_extrahieren(wurzel: Path, eid: str) -> str: """Quellcode eines Ankers (pfad::symbol) LIVE aus der Quelle — nie Exception: fehlende Datei/Symbol → '' (UI zeigt Hinweis).""" try: datei, _, symbol = eid.partition("::") p = wurzel / datei if not p.is_file(): return "" text = p.read_text(encoding="utf-8", errors="replace") if not symbol: return text[:8000] # Datei-Einheit: ganze (gekappte) Datei if "." in symbol: symbol = symbol.split(".", 1)[1] # qualifizierte Methode → Methode suchen zeilen = text.splitlines() start = None for i, z in enumerate(zeilen): if re.match(rf"(\s*)(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b", z): start = i break if re.match(rf"{re.escape(symbol)}\s*[:=]", z): start = i ende = i + 1 while ende < len(zeilen) and (zeilen[ende].startswith((" ", "\t", ")", "]", "}")) or not zeilen[ende].strip()): ende += 1 return "\n".join(zeilen[start:ende]).rstrip() if start is None: return "" einzug = len(zeilen[start]) - len(zeilen[start].lstrip()) ende = start + 1 while ende < len(zeilen): z = zeilen[ende] if z.strip() and (len(z) - len(z.lstrip())) <= einzug: break ende += 1 return "\n".join(zeilen[start:ende]).rstrip() except Exception: return "" def lade_projekt(wurzel: Path) -> dict: """Alle .planer/-Dateien eines Projekts → Struktur für die API. Fehlende Dateien ergeben leere Teile (degradieren, nie werfen).""" planer = wurzel / ".planer" def text(rel: str) -> str: p = planer / rel return p.read_text(encoding="utf-8") if p.is_file() else "" einheiten: dict[str, dict] = {} staende: dict[str, str] = {} # quelldatei → stand for p in sorted((planer / "einheiten").glob("*.md")) if (planer / "einheiten").is_dir() else []: inhalt = p.read_text(encoding="utf-8") einheiten.update(lese_einheiten(inhalt)) kopf = inhalt.splitlines()[0] if inhalt else "" datei = kopf.removeprefix("# Einheiten:").strip() if datei and (stand := artefakte.lese_stand(inhalt)): staende[datei] = stand bereiche = [lese_features((planer / "features" / p.name).read_text(encoding="utf-8")) for p in sorted((planer / "features").glob("*.md"))] \ if (planer / "features").is_dir() else [] return { "kern": lese_kern(text("kern.md")), "bereiche": bereiche, "architektur": lese_architektur(text("architektur.md")), "flows": lese_flows(text("flows.md")), "pruefung": lese_pruefung(text("pruefung.md")), "einheiten": einheiten, "staende": staende, } # --- Drift --------------------------------------------------------------------------- _diff_cache: dict[tuple[str, str, str], set[str]] = {} # (pfad, stand, head) → geändert def drift(wurzel: Path, staende: dict[str, str]) -> dict: """Welche gescannten Quelldateien sind seit ihrem stand: berührt? → {head, veraltet: [quelldatei]}. Cache-Schlüssel enthält HEAD → selbst-invalidierend.""" try: head = repo.head_hash(wurzel) except (RuntimeError, FileNotFoundError): return {"head": "", "veraltet": []} veraltet = [] for datei, stand in sorted(staende.items()): if stand == head: continue schluessel = (str(wurzel), stand, head) if schluessel not in _diff_cache: try: _diff_cache[schluessel] = repo.geaenderte_dateien(wurzel, stand)[0] except RuntimeError: _diff_cache[schluessel] = set() if datei in _diff_cache[schluessel]: veraltet.append(datei) return {"head": head, "veraltet": veraltet} # --- Doku-Leseordnung ------------------------------------------------------------------ def doku_ordnung(projekt: dict) -> list[str]: """Bereichs-Namen in Abhängigkeitsordnung: Kanten der Einheiten (ruft-auf/nutzt) werden zu Bereichs-Kanten aggregiert; viel Genutztes kommt früher (Kahn-Toposort, zyklenfest: bei Zyklus gewinnt der Bereich mit kleinstem In-Degree, dann alphabetisch).""" einheit_zu_bereich: dict[str, str] = {} for bereich in projekt["bereiche"]: for f in bereich["features"]: for tf in f["teilfeatures"]: for eid in tf["einheiten"]: einheit_zu_bereich.setdefault(eid, bereich["name"]) namen = [b["name"] for b in projekt["bereiche"]] eingehend: dict[str, set[str]] = {n: set() for n in namen} for eid, e in projekt["einheiten"].items(): von = einheit_zu_bereich.get(eid) for kante in e["kanten"]: if kante["typ"] not in ("ruft-auf", "nutzt"): continue nach = einheit_zu_bereich.get(kante["ziel"]) if von and nach and von != nach: eingehend[nach].add(von) # "von nutzt nach" → nach ist Fundament → früher # Kahn: Bereiche ohne offene Nutzer-Abhängigkeit zuerst — Fundament vor Nutzern: # ein Bereich kommt, sobald alles, was ER nutzt, schon gelesen wurde. nutzt: dict[str, set[str]] = {n: set() for n in namen} for nach, nutzer in eingehend.items(): for von in nutzer: nutzt[von].add(nach) ordnung: list[str] = [] offen = set(namen) while offen: bereit = sorted(n for n in offen if not (nutzt[n] & offen)) if not bereit: # Zyklus: kleinste offene Abhängigkeitszahl, dann Name bereit = [min(offen, key=lambda n: (len(nutzt[n] & offen), n))] for n in bereit: ordnung.append(n) offen.discard(n) return ordnung