#!/usr/bin/env python3 """Findet deutschen Text in .py-Dateien, der offenbar ohne Umlaute/ß geschrieben wurde. Hintergrund: AG_QGIS, Plugin_SN_Basis #42. KI-generierter Text weicht gelegentlich auf eine ASCII-Ersatzschreibweise aus ("gueltig" statt "gültig", "ueber" statt "über") statt echte Umlaute zu verwenden. Das betrifft vor allem Meldungstexte, Beschriftungen (etwa BPMN-Elementnamen) und Docstrings/Kommentare. Technische Bezeichner (Variablen-, Funktions- und Schlüsselnamen) sind davon ausdrücklich nicht betroffen: Sie sind bewusst ASCII und enthalten kein Leerzeichen, während geschriebener deutscher Text so gut wie immer eines enthält. Deshalb prüft dieses Skript nur String-Inhalte mit mindestens einem Leerzeichen sowie Kommentartext - keine Bezeichner. Kein Ersatz fürs Korrekturlesen: Die Liste verdächtiger Wortstämme ist bewusst kurz gehalten (nur eindeutige Fälle), um keine Fehlalarme zu erzeugen. Neue, hier nicht erfasste Wortstämme ergänzen. Aufrufe (aus dem Workspace-Root): python scripts/pruefe_umlaute.py # alle .py-Dateien im Workspace python scripts/pruefe_umlaute.py .agents/skills # nur unterhalb dieser Ordner Exit-Code: 0 = keine Funde, 1 = mindestens ein Fund. """ from __future__ import annotations import argparse import re from pathlib import Path ROOT = Path(__file__).resolve().parents[1] # Ordner, die nie geprüft werden (Git-Interna, gespiegelte Skills, lokale Entwürfe). AUSGESCHLOSSEN = {".git", ".claude", "__pycache__", "entwuerfe"} # Dateien, die bewusst ASCII-Ersatzschreibweisen enthalten: hallo.py schreibt seine # Tabellenwerte absichtlich ohne Umlaute, dieses Skript nennt die Muster selbst. DATEIEN_OHNE_PRUEFUNG = {"scripts/hallo.py", "scripts/pruefe_umlaute.py"} # Nur eindeutige Fälle: Wortstämme, die als ASCII-Ersatzschreibweise so gut wie nie # etwas anderes als ein fehlender Umlaut/ß sein können. Absichtlich kein "ss" für # "ß" (zu viele legitime Doppel-S-Wörter) und kein "ae/oe/ue" pauschal (zu viele # Fehlalarme in technischen Bezeichnern und Fremdwörtern). VERDAECHTIGE_STAEMME = [ r"gueltig\w*", r"ungueltig\w*", r"verfuegbar\w*", r"ausgewaehlt\w*", r"gewaehlt\w*", r"waehlen\b", r"auswaehlen\b", r"loeschen\b", r"loescht\w*", r"groesse\w*", r"zuruecksetzen\b", r"erlaeuter\w*", r"bestaetig\w*", r"natuerlich\w*", r"moeglich\w*", r"zusaetzlich\w*", r"abhaengig\w*", r"unzulaessig\w*", r"ueberpruef\w*", r"vollstaendig\w*", r"geaendert\w*", r"aenderung\w*", r"einfuegen\b", r"ausfuehr\w*", r"benoetig\w*", r"gewuenscht\w*", r"ueber\w*", r"fuer\b", r"hoehe\w*", r"waere\b", ] _STAMM_PATTERN = re.compile( r"\b(?:" + "|".join(VERDAECHTIGE_STAEMME) + r")\b", re.IGNORECASE ) # Erlaubt genau diese exakten Fundstellen (Datei, Zeile) - für Ausnahmefälle, die # geprüft und bewusst akzeptiert wurden. Aktuell leer: siehe Docstring, warum # technische Bezeichner die Heuristik ohnehin nicht erreichen. ALLOWLIST: set[tuple[str, int]] = set() _STRING_PATTERN = re.compile( r'"""(?P(?:.|\n)*?)"""' r"|'''(?P(?:.|\n)*?)'''" r'|"(?P(?:[^"\\\n]|\\.)*)"' r"|'(?P(?:[^'\\\n]|\\.)*)'" ) _KOMMENTAR_PATTERN = re.compile(r"#(?P.*)$", re.MULTILINE) def _kandidaten(quelltext: str): """Liefert (start_offset, text) für String-Inhalte und Kommentare mit Leerzeichen.""" for m in _STRING_PATTERN.finditer(quelltext): text = next(g for g in m.groups() if g is not None) if " " in text: yield m.start(), text for m in _KOMMENTAR_PATTERN.finditer(quelltext): text = m.group("text") if " " in text: yield m.start("text"), text def pruefe_datei(pfad: Path) -> list[tuple[int, str, str]]: """Liefert Funde als (Zeilennummer, Wortstamm, Kontext) für eine Datei. Ein Kandidat (String-Inhalt oder Kommentar) kann mehrzeilig sein (Docstrings); deshalb wird für jeden Treffer einzeln die Zeile anhand seiner eigenen Position berechnet, nicht anhand des Kandidaten-Anfangs. """ quelltext = pfad.read_text(encoding="utf-8", errors="replace") funde = [] for offset, text in _kandidaten(quelltext): for treffer in _STAMM_PATTERN.finditer(text): zeile = quelltext.count("\n", 0, offset + treffer.start()) + 1 zeilen_text = text.splitlines()[treffer.string.count("\n", 0, treffer.start())] \ if "\n" in text else text kontext = zeilen_text.strip() if len(kontext) > 90: kontext = kontext[:87] + "..." funde.append((zeile, treffer.group(0), kontext)) return funde def main() -> int: ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("ordner", nargs="*", help="Ordner relativ zum Workspace-Root (Standard: alles)") args = ap.parse_args() wurzeln = [ROOT / o for o in args.ordner] if args.ordner else [ROOT] fehlend = [w for w in wurzeln if not w.is_dir()] if fehlend: print("Ordner nicht gefunden: " + ", ".join(str(w) for w in fehlend)) return 2 gesamt_funde = 0 dateien = 0 for wurzel in wurzeln: for py_datei in sorted(wurzel.rglob("*.py")): if any(teil in AUSGESCHLOSSEN for teil in py_datei.relative_to(ROOT).parts): continue relativ = py_datei.relative_to(ROOT).as_posix() if relativ in DATEIEN_OHNE_PRUEFUNG: continue dateien += 1 for zeile, stamm, kontext in pruefe_datei(py_datei): if (relativ, zeile) in ALLOWLIST: continue print(f"{relativ}:{zeile}: '{stamm}' in: {kontext}") gesamt_funde += 1 status = "GRÜN" if gesamt_funde == 0 else "ROT" print(f"\n{status}: {gesamt_funde} Fund/e in {dateien} Datei(en).") return 1 if gesamt_funde else 0 if __name__ == "__main__": raise SystemExit(main())