From d8f88df0bad94da45bc1e43527c37ba288e250a5 Mon Sep 17 00:00:00 2001 From: Daniel Date: Mon, 14 Sep 2026 12:21:13 +0200 Subject: [PATCH] Umlaut-Pruefskript ergaenzt und in Fallstricke dokumentiert (#42) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit scripts/pruefe_umlaute.py findet String-Inhalte und Kommentare mit Leerzeichen, die einen bekannten ASCII-Wortstamm statt Umlaut/ss enthalten (gueltig, verfuegbar, fuer, ueber, ...). Technische Bezeichner ohne Leerzeichen (aktion-Codes, Variablen-Keys, WFS-Feldnamen) werden von der Heuristik nicht erfasst. Aktuell ueber den gesamten Workspace grün. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01MneDjzrjmbpQt8udFYxN9e --- docs/wissen/fallstricke.md | 10 +++ scripts/pruefe_umlaute.py | 152 +++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/pruefe_umlaute.py diff --git a/docs/wissen/fallstricke.md b/docs/wissen/fallstricke.md index b04b242..4f72e25 100644 --- a/docs/wissen/fallstricke.md +++ b/docs/wissen/fallstricke.md @@ -55,6 +55,16 @@ Kurz, konkret, mit Datum und Quelle. Neue Einträge oben in der passenden Rubrik - **Bezeichner (Layernamen, Dateinamen) nie mit eigenem Regex bereinigen.** `sanitize_bezeichner` aus `sn_basis.functions.text_wrapper` verwenden – Umlaute, `ß`, `_` und `-` wurden in mehreren KI-generierten Regexen vergessen (Plugin_SN_Basis #42). +- **Deutscher Text ohne Umlaute/ß ist ein eigenständiges Muster, kein Einzelfall.** Bei der Aufarbeitung von + #42 fanden sich über alle Fachplugins verteilt MessageBox-Texte, Labels und Docstrings, die wortwörtlich als + ASCII generiert wurden (`gueltig` statt `gültig`, `ueber` statt `über`, `Massstab`/`Groesse` als Anzeigetext + statt `Maßstab`/`Größe`) – typisch für KI-generierten Text, der Sonderzeichen vermeidet. Neuen deutschen Text + (Meldungen, Labels, Kommentare, Docstrings) immer mit echten Umlauten/ß schreiben, nicht mit `ae`/`oe`/`ue`/`ss` + behelfen. Vor dem Commit `python scripts/pruefe_umlaute.py []` laufen lassen – findet genau dieses + Muster (String-Inhalte und Kommentare mit Leerzeichen, die einen bekannten ASCII-Wortstamm enthalten; + technische Bezeichner ohne Leerzeichen werden nicht angetastet). Die Wortstammliste im Skript ist bewusst + konservativ (keine Fehlalarme wichtiger als Vollständigkeit) – Ersatz fürs Lesen des eigenen Textes ist sie + nicht. ## Zusammenarbeit diff --git a/scripts/pruefe_umlaute.py b/scripts/pruefe_umlaute.py new file mode 100644 index 0000000..b48a0b8 --- /dev/null +++ b/scripts/pruefe_umlaute.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +"""Findet deutschen Text in .py-Dateien, der offenbar ohne Umlaute/ß geschrieben wurde. + +Hintergrund: 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 (MessageBox, +zeige_hinweis, QLabel) und Docstrings/Kommentare. + +Technische Bezeichner (Variablennamen, "aktion"-Codes, WFS-Feldnamen, +Projektvariablen-Keys) 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 Repos aus workspace.toml mit Paketnamen + python scripts/pruefe_umlaute.py Basis # nur ein Repo (Kurzname genügt) + +Exit-Code: 0 = keine Funde, 1 = mindestens ein Fund. +""" + +from __future__ import annotations + +import argparse +import re +import sys +import tomllib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +MANIFEST = ROOT / "workspace.toml" + +# 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("repos", nargs="*", help="Kurzname(n) aus workspace.toml (Standard: alle mit Paketnamen)") + args = ap.parse_args() + + with MANIFEST.open("rb") as f: + manifest = tomllib.load(f) + alle_repos = [r for r in manifest["repos"] if r.get("paket")] + + if args.repos: + gesucht = {r.lower() for r in args.repos} + repos = [ + r for r in alle_repos + if r["name"].lower() in gesucht + or r["name"].lower().replace("plugin_sn_", "") in gesucht + or r["paket"].lower() in gesucht + ] + unbekannt = gesucht - { + n for r in repos for n in (r["name"].lower(), r["name"].lower().replace("plugin_sn_", ""), r["paket"].lower()) + } + if unbekannt: + print(f"Unbekannte Repo-Angabe: {', '.join(sorted(unbekannt))}", file=sys.stderr) + return 2 + else: + repos = alle_repos + + gesamt_funde = 0 + for repo in repos: + basis = ROOT / repo["name"] + if not basis.is_dir(): + print(f"[{repo['paket']}] Ordner fehlt ({basis}) - übersprungen") + continue + repo_funde = 0 + for py_datei in sorted(basis.rglob("*.py")): + if any(teil in (".git", "__pycache__") for teil in py_datei.parts): + continue + relativ = py_datei.relative_to(ROOT).as_posix() + for zeile, stamm, kontext in pruefe_datei(py_datei): + if (relativ, zeile) in ALLOWLIST: + continue + print(f"{relativ}:{zeile}: '{stamm}' in: {kontext}") + repo_funde += 1 + status = "GRÜN" if repo_funde == 0 else "ROT" + print(f"[{repo['paket']}] {status}: {repo_funde} Fund/e") + gesamt_funde += repo_funde + + print(f"\nGesamt: {gesamt_funde} Fund/e in {len(repos)} Repo(s).") + return 1 if gesamt_funde else 0 + + +if __name__ == "__main__": + raise SystemExit(main())