update
This commit is contained in:
160
backend/quellcode.py
Normal file
160
backend/quellcode.py
Normal file
@@ -0,0 +1,160 @@
|
||||
"""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:""")
|
||||
_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.
|
||||
|
||||
Backend-Routen stehen bewusst nicht drin. Sie sind die Verdrahtung hinter den
|
||||
Knöpfen, nicht die Bedienung — als eigene Einträge erzeugten sie nur Meldungen
|
||||
wie „Prüfungsstatus abrufen", die niemand auf einer Karte sehen will.
|
||||
"""
|
||||
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)
|
||||
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())
|
||||
Reference in New Issue
Block a user