190 lines
7.9 KiB
Python
190 lines
7.9 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()
|
|
|
|
|
|
def _pyrumpf(zeilen: list, nr: int, hoechstens: int = 14) -> str:
|
|
"""Der eingerückte Block unter einer Zeile — für Python statt geschweifter Klammern."""
|
|
if nr > len(zeilen):
|
|
return "" # Dekorator ohne Rumpf, etwa am Dateiende
|
|
block = [zeilen[nr - 1]]
|
|
for weiter in zeilen[nr:nr + hoechstens]:
|
|
if weiter.strip() and not weiter[:1].isspace() and not weiter.startswith("@"):
|
|
break
|
|
block.append(weiter)
|
|
return "\n".join(block).rstrip()
|
|
|
|
|
|
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.
|
|
|
|
Dazu die Backend-Routen. Sie standen zwischenzeitlich nicht drin, weil sie
|
|
doppelt gemeldet und dabei der falschen Karte zugeordnet wurden. Beides ist
|
|
jetzt aufgefangen: das Gedächtnis hält die Zuordnung fest, und der
|
|
Dublettenabgleich fängt zwei Funktionen für dieselbe Sache. Was hinter einem
|
|
Knopf liegt, ist der einzige Weg an Parser, Dateiverwaltung und Prüflogik —
|
|
ohne die Routen bliebe der ganze Rücken des Programms ungeprüft.
|
|
|
|
Reine Hilfsfunktionen bleiben draußen. Ein Kunde bestellt keine Funktion namens
|
|
`_slug`; sie wird über die Route erreicht, die ihn interessiert. Jedes
|
|
öffentliche Symbol aufzunehmen ergäbe wieder ein Code-Inventar statt einer
|
|
Auftragsliste.
|
|
"""
|
|
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)
|
|
for m in _ROUTE.finditer(zeile):
|
|
punkt = merke(f"route:{m.group(1)} {m.group(2)}", "Schnittstelle",
|
|
pfad, nr, zeilen, zeile)
|
|
if punkt is not None:
|
|
punkt["rumpf"] = _pyrumpf(zeilen, nr + 1)
|
|
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())
|