init
This commit is contained in:
279
backend/viewer.py
Normal file
279
backend/viewer.py
Normal 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
|
||||
Reference in New Issue
Block a user