Files
planer2/backend/quellcode.py
2026-07-28 08:36:49 +02:00

168 lines
6.7 KiB
Python

"""Der Quelltext als Prüfgrundlage.
Die Inhaltsprüfung vergleicht Karten mit dem, was das Programm wirklich kann.
Damit das nicht auf Behauptungen hinausläuft, muss jede Aussage über den Code
eine wörtlich zitierte Zeile mitbringen — `belegt()` prüft sie nach.
"""
import re
import config
# Was zum lauffähigen Programm gehört. Tests beschreiben keine Nutzerfunktionen.
MUSTER = (
"backend/*.py",
"frontend/index.html",
"frontend/src/*.js",
"frontend/src/**/*.js",
"frontend/src/**/*.vue",
)
AUSGENOMMEN = ("tests/", "/tests/", "node_modules/", "dist/")
def _passt(pfad: str) -> bool:
return not any(a in pfad for a in AUSGENOMMEN)
def dateien() -> dict:
"""Pfad relativ zur Projektwurzel -> Inhalt, in fester Reihenfolge."""
raus = {}
for muster in MUSTER:
for datei in sorted(config.PROJECT_ROOT.glob(muster)):
pfad = str(datei.relative_to(config.PROJECT_ROOT))
if datei.is_file() and _passt(pfad):
raus[pfad] = datei.read_text()
return dict(sorted(raus.items()))
def als_text(quelle: dict = None) -> str:
"""Alle Dateien am Stück, jede mit Überschrift — so kann das Modell zitieren."""
quelle = dateien() if quelle is None else quelle
return "\n\n".join(f"### {pfad}\n{text.rstrip()}" for pfad, text in quelle.items())
def _normal(zeile: str) -> str:
return re.sub(r"\s+", " ", zeile).strip()
def belegt(quelle: dict, pfad: str, schnipsel: str) -> bool:
"""Steht der Schnipsel wörtlich in der Datei?
Verglichen wird ohne Rücksicht auf Einrückung — Modelle geben sie ungenau
wieder. Alles andere muss stimmen, sonst ist der Beleg wertlos.
"""
text = quelle.get(pfad)
if text is None or not schnipsel.strip():
return False
gesucht = [_normal(z) for z in schnipsel.splitlines() if z.strip()]
if not gesucht:
return False
vorhanden = [_normal(z) for z in text.splitlines()]
# Zusammenhängend: eine mehrzeilige Angabe muss auch mehrzeilig dastehen
for i in range(len(vorhanden) - len(gesucht) + 1):
if vorhanden[i:i + len(gesucht)] == gesucht:
return True
return False
# Die Stellen, an denen ein Mensch etwas auslöst. Vorher hat ein Modell die
# Fähigkeiten frei aufgezählt — jede Runde andere, jede Runde anders benannt.
# Hier steht die Liste fest, weil sie aus dem Code fällt.
_EREIGNIS = re.compile(r'@(click|auxclick|contextmenu|keyup|keydown|blur|submit|change)'
r'[\w.]*\s*=\s*"([^"]*)"')
_MENUEPUNKT = re.compile(r"""\{\s*text:\s*['"](.+?)['"]\s*,\s*aktion:""")
_ROUTE = re.compile(r'@router\.(get|post|put|delete)\("([^"]+)"\)')
_LAUSCHER = re.compile(r'(window|document)\.addEventListener\(\s*[\'"](\w+)[\'"]')
_NAME = re.compile(r"[A-Za-zÄÖÜäöü_$][\w$]*")
# Rein technische Aufhänger: Speichern beim Verlassen, Abbrechen mit Escape.
# Sie tragen keine eigene Fähigkeit, sondern gehören zur Bedienung eines Feldes.
_UNINTERESSANT = {"fertig", "abbrechen", "blur", "waehle"}
# emit('auswaehlen') ist eine Fähigkeit, emit('abbrechen') nicht — es zählt das
# Ereignis, nicht der Aufruf. Vorher fiel mit „emit" auch das Auswählen weg.
_EMIT = re.compile(r"""emit\(\s*['"](\w+)['"]""")
_ZUWEISUNG = re.compile(r"^\s*[\w.]+\s*=[^=]")
def _umfeld(zeilen: list, nr: int, rand: int = 5) -> str:
von, bis = max(0, nr - rand - 1), min(len(zeilen), nr + rand)
return "\n".join(zeilen[von:bis]).strip()
_ENDE = re.compile(r"^(function |const |async function |\S)")
def _rumpf(zeilen: list, name: str, hoechstens: int = 14) -> str:
"""Die Umsetzung hinter einem Namen.
Das Markup allein sagt nur, dass ein Knopf etwas aufruft. Was er tut, steht im
Rumpf — ohne den musste der Prüfer raten und urteilte jede Runde anders.
"""
anfang = re.compile(rf"^\s*(async\s+)?function\s+{re.escape(name)}\s*\(|"
rf"^\s*const\s+{re.escape(name)}\s*=")
for i, zeile in enumerate(zeilen):
if not anfang.match(zeile):
continue
block = [zeile]
for weiter in zeilen[i + 1:i + hoechstens]:
block.append(weiter)
if weiter.startswith("}"):
break
return "\n".join(block)
return ""
def interaktionen(quelle: dict = None) -> list[dict]:
"""Auslösbare Stellen im Code, eine je Bedienhandlung.
Zusammengefasst nach dem aufgerufenen Namen: `@click="starte('ziel')"` steht
zwölfmal im Markup, ist aber eine Fähigkeit.
Dazu Fenster-Lauscher: ein Tastaturgriff ist eine Fähigkeit, auch ohne Knopf.
Backend-Routen stehen bewusst nicht drin. Jede nutzbare Route hängt an einem
Knopf, der schon in der Liste steht — als eigene Einträge wurden sie doppelt
gemeldet und dabei der falschen Karte zugeordnet.
"""
quelle = dateien() if quelle is None else quelle
raus, gesehen = [], set()
def merke(schluessel, art, pfad, nr, zeilen, beleg):
if schluessel in gesehen:
return None
gesehen.add(schluessel)
punkt = {"schluessel": schluessel, "art": art, "datei": pfad, "zeile": nr,
"beleg": beleg.strip(), "umfeld": _umfeld(zeilen, nr), "rumpf": ""}
raus.append(punkt)
return punkt
for pfad, text in quelle.items():
zeilen = text.splitlines()
for nr, zeile in enumerate(zeilen, 1):
for m in _MENUEPUNKT.finditer(zeile):
merke(f"menue:{m.group(1)}", "Menüpunkt", pfad, nr, zeilen, zeile)
for m in _LAUSCHER.finditer(zeile):
merke(f"lauscher:{m.group(1)}.{m.group(2)}", "Tastatur oder Maus",
pfad, nr, zeilen, zeile)
if not pfad.endswith(".vue"):
continue
for m in _EREIGNIS.finditer(zeile):
ausdruck = m.group(2)
if _ZUWEISUNG.match(ausdruck) or ausdruck.startswith("$"):
continue # Zuweisung oder Ereignis-Objekt, keine Fähigkeit
treffer = _EMIT.search(ausdruck) or _NAME.search(ausdruck)
if not treffer or treffer.group(1 if _EMIT.search(ausdruck) else 0) \
in _UNINTERESSANT:
continue
name = treffer.group(1) if _EMIT.search(ausdruck) else treffer.group(0)
punkt = merke(f"bedienen:{name}", "Bedienung", pfad, nr, zeilen, zeile)
if punkt is not None:
punkt["rumpf"] = _rumpf(zeilen, name)
return raus
def uebersicht(quelle: dict = None) -> str:
"""Kurze Dateiliste mit Zeilenzahl — für Prompts, die den Code nicht brauchen."""
quelle = dateien() if quelle is None else quelle
return "\n".join(f"- {p} ({len(t.splitlines())} Zeilen)" for p, t in quelle.items())