"""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())