This commit is contained in:
Team3
2026-07-22 16:12:23 +02:00
commit f07ef9653d
851 changed files with 501480 additions and 0 deletions

279
backend/viewer.py Normal file
View File

@@ -0,0 +1,279 @@
"""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