280 lines
12 KiB
Python
280 lines
12 KiB
Python
"""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
|