"""Mechanische Gates (tokenfrei): prüfen LLM-Scan-Ausgaben gegen den echten Code. Port von phase0/pruefe.py als Bibliothek. Phase-0-validiert: fängt erfundene Anker, umgeschriebene Zitate (auch Minimal-Umformungen) und Format-Drift; Escaping-Rauschen der Modelle wird vor dem Vergleich normalisiert. """ import difflib import re from pathlib import Path KANTEN_TYPEN = {"ruft-auf", "nutzt", "wird-genutzt-von", "löst-aus", "wird-ausgelöst-von"} SCHLUESSEL = ("beschreibung", "input", "output") # Pflichtfelder je Einheit def entschaerfe(s: str) -> str: """Escaping-Rauschen normalisieren: \\" → \" und \\\\ → \\ (Erkenntnis 11).""" return s.replace('\\"', '"').replace("\\\\", "\\") def _beleg_text(rest: str) -> str: """Beleg-Inhalt aus dem Zeilenrest: äußere Anführungszeichen abstreifen, Rest wörtlich. Kein Quote-Delimiter-Parsing — Belege enthalten selbst Anführungszeichen/Regex (Abnahme-Befund 17), die Zeile gehört ab `beleg:` komplett dem Zitat.""" rest = rest.strip() if len(rest) >= 2 and rest.startswith('"') and rest.endswith('"'): rest = rest[1:-1] elif rest.startswith('"'): rest = rest[1:] return entschaerfe(rest) def parse_einheiten(text: str) -> tuple[list[dict], list[str], list[str]]: """Einheiten-Markdown → (einheiten, zusatz_anker, struktur_fehler). Einheit: {id, zeile, felder: {beschreibung/input/output}, belege: [(nr, zitat)], kanten: [(nr, typ, ziel)], anker: [ids]}. Streng schreiben, tolerant parsen.""" einheiten: list[dict] = [] zusatz_anker: list[str] = [] fehler: list[str] = [] aktuelle = None modus = None for nr, zeile in enumerate(text.splitlines(), 1): # Einheiten-Kopf: `## datei::symbol` oder Datei-Einheit `## pfad.py` (Skripte # ohne def/class — Befund 18) if zeile.startswith("## ") and ("::" in zeile or re.fullmatch(r"## \S+\.\w+", zeile.strip())): aktuelle = {"id": zeile[3:].strip(), "zeile": nr, "felder": {}, "belege": [], "kanten": [], "anker": []} einheiten.append(aktuelle) modus = None elif zeile.startswith(("anker:", "ergaenze-anker:")): aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip() zusatz_anker.append(aid) if aktuelle is not None and zeile.startswith("anker:"): aktuelle["anker"].append(aid) elif aktuelle is None: continue elif m := re.match(r"(beschreibung|input|output):(.*)$", zeile): aktuelle["felder"][m.group(1)] = m.group(2).strip() modus = m.group(1) elif zeile.strip() in ("entscheidungen:", "fakten:"): modus = "punkte" elif zeile.strip() == "kanten:": modus = "kanten" elif m := re.match(r"\s+beleg:\s*(.+)$", zeile): aktuelle["belege"].append((nr, _beleg_text(m.group(1)))) elif modus == "punkte" and (m := re.search(r"\bbeleg:\s*(.+)$", zeile)): aktuelle["belege"].append((nr, _beleg_text(m.group(1)))) # tolerant: beleg inline elif modus == "kanten" and (m := re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile)): aktuelle["kanten"].append((nr, m.group(1), m.group(2))) elif modus == "kanten" and zeile.startswith("- "): fehler.append(f"Z{nr}: Kante unlesbar (Zusatztext?): {zeile.strip()!r}") return einheiten, zusatz_anker, fehler def _symbol_in_datei(symbol: str, quelle: str, top_level_nur: bool) -> bool: if "." in symbol: # qualifizierte Methode `Klasse.methode` (Abnahme-Befund) klasse, _, methode = symbol.partition(".") return (_symbol_in_datei(klasse, quelle, top_level_nur) and re.search(rf"def\s+{re.escape(methode)}\b", quelle) is not None) if top_level_nur: muster = rf"^(?:async\s+)?(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]" else: # Zusatz-Anker dürfen verschachtelt sein (Innenfunktionen, Erkenntnis M3-Lauf) muster = rf"(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]" return re.search(muster, quelle, re.M) is not None def pruefe_fragment(text: str, wurzel: Path, chunk_datei: str, quelle_cache: dict | None = None) -> tuple[list[str], list[str], list[dict]]: """Gates für EIN Scan-Fragment: Struktur, Anker-Existenz, Zitat-Treue, Pflichtfelder, Kanten-Grammatik. → (fehler, warnungen, einheiten). Kanten-ZIELE in fremden Dateien werden nur auf Datei-Existenz geprüft (das Symbol prüft der Scan der Zieldatei).""" cache = quelle_cache if quelle_cache is not None else {} def quelle(datei: str) -> str | None: if datei not in cache: p = wurzel / datei cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None return cache[datei] einheiten, zusatz_anker, fehler = parse_einheiten(text) warnungen: list[str] = [] if not einheiten: fehler.append("keine Einheit geparst (Format-Drift oder leere Antwort)") def pruefe_id(eid: str, kontext: str, top_level: bool): datei, _, symbol = eid.partition("::") q = quelle(datei) if q is None: fehler.append(f"{kontext}: Datei fehlt: {datei}") elif symbol and not _symbol_in_datei(symbol, q, top_level): fehler.append(f"{kontext}: Symbol '{symbol}' nicht in {datei}") for e in einheiten: kontext = e["id"] if not e["id"].startswith(chunk_datei + "::") and e["id"] != chunk_datei: fehler.append(f"{kontext}: Einheit gehört nicht zur gescannten Datei {chunk_datei}") continue pruefe_id(e["id"], f"Z{e['zeile']} einheit", top_level=True) for feld in SCHLUESSEL: if not e["felder"].get(feld): (fehler if feld == "beschreibung" else warnungen).append(f"{kontext}: {feld}: fehlt") q = quelle(chunk_datei) or "" for nr, beleg in e["belege"]: if beleg not in q: fehler.append(f"{kontext} Z{nr}: Zitat nicht wörtlich in {chunk_datei}: \"{beleg[:50]}…\"") if not e["belege"]: warnungen.append(f"{kontext}: keine Belege") for aid in e["anker"]: pruefe_id(aid, f"{kontext} anker", top_level=False) for nr, typ, ziel in e["kanten"]: if typ not in KANTEN_TYPEN: fehler.append(f"{kontext} Z{nr}: unbekannter Kanten-Typ '{typ}'") ziel_datei = ziel.partition("::")[0] if ziel_datei == chunk_datei: pruefe_id(ziel, f"{kontext} Z{nr} kante", top_level=False) elif quelle(ziel_datei) is None: fehler.append(f"{kontext} Z{nr} kante: Datei fehlt: {ziel_datei}") for aid in zusatz_anker: if not aid.startswith(chunk_datei + "::"): fehler.append(f"ergaenze-anker außerhalb der gescannten Datei: {aid}") return fehler, warnungen, einheiten def fehlende_symbole(chunk_symbole: list[str], einheiten: list[dict], zusatz_anker: list[str]) -> list[str]: """Abdeckung eines Chunks: welche seiner Top-Level-Symbole sind keiner Einheit zugeordnet (weder als ID noch als Anker)?""" zugeordnet = {e["id"].partition("::")[2] for e in einheiten} zugeordnet |= {a.partition("::")[2] for a in zusatz_anker} zugeordnet |= {a.partition("::")[2] for e in einheiten for a in e["anker"]} return [s for s in chunk_symbole if s not in zugeordnet] def repariere_kanten(fragment: str, wurzel: Path, chunk_datei: str, quelle_cache: dict | None = None) -> tuple[str, list[str]]: """Mechanische Reparatur VOR den Gates: Kanten mit ungültigem Ziel (erfundene Datei, fehlendes Symbol, fehlender datei::-Prefix) werden GESTRICHEN statt das ganze Fragment scheitern zu lassen — Kanten sind Zusatz-Metadaten, keine Belege. → (bereinigtes_fragment, gestrichene_zeilen).""" cache = quelle_cache if quelle_cache is not None else {} def quelle(datei: str) -> str | None: if datei not in cache: p = wurzel / datei cache[datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None return cache[datei] def ziel_ok(ziel: str) -> bool: if "::" not in ziel: return False datei, _, symbol = ziel.partition("::") q = quelle(datei) if q is None: return False return datei != chunk_datei or _symbol_in_datei(symbol, q, top_level_nur=False) gestrichen: list[str] = [] zeilen_neu: list[str] = [] for zeile in fragment.splitlines(): m = re.match(r"- ([\wäöü-]+):\s*(\S+)\s*$", zeile) if m and m.group(1) in KANTEN_TYPEN and not ziel_ok(m.group(2)): gestrichen.append(zeile.strip()) continue # Freitext hinter einem Kanten-Typ ("- löst-aus: SystemExit bei …") → streichen m_frei = re.match(r"- ([\wäöü-]+):\s+\S+\s+\S", zeile) if m_frei and m_frei.group(1) in KANTEN_TYPEN: gestrichen.append(zeile.strip()) continue if zeile.startswith(("anker:", "ergaenze-anker:")): # kaputte Anker-Zeilen ebenso aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip() datei, _, symbol = aid.partition("::") q = quelle(datei) if datei else None if datei != chunk_datei or not symbol or q is None \ or not _symbol_in_datei(symbol, q, top_level_nur=False): gestrichen.append(zeile.strip()) continue zeilen_neu.append(zeile) return "\n".join(zeilen_neu), gestrichen _BELEG_RE = re.compile(r"(\bbeleg:\s*)(.+)$") def repariere_belege(fragment: str, wurzel: Path, chunk_datei: str, quelle_cache: dict | None = None) -> tuple[str, list[str]]: """Mechanische Zitat-Reparatur: ein Beleg, der nicht wörtlich in der Quelle steht, wird per Fuzzy-Match auf die ähnlichste Quellzeile GESCHNAPPT und durch deren wörtlichen Text ersetzt (Abnahme-Befund: Modelle schreiben z. B. `:=` zu `=` um — ein Zeichen daneben). Unter der Ähnlichkeitsschwelle bleibt der Beleg unverändert und fällt im Gate durch → Befund-Feedback. → (fragment, korrigierte_belege).""" cache = quelle_cache if quelle_cache is not None else {} if chunk_datei not in cache: p = wurzel / chunk_datei cache[chunk_datei] = p.read_text(encoding="utf-8", errors="replace") if p.is_file() else None quelle = cache[chunk_datei] if quelle is None: return fragment, [] zeilen = [z.strip() for z in quelle.splitlines() if z.strip()] korrigiert: list[str] = [] def snap(m: re.Match) -> str: zitat = _beleg_text(m.group(2)) if not zitat or zitat in quelle: return m.group(0) # Fenster-Matching: Abdeckung des Zitats durch gemeinsame Blöcke mit einer # Quellzeile — funktioniert auch für kurze Zitate aus langen Zeilen. beste, beste_deckung = None, 0.0 for z in zeilen: sm = difflib.SequenceMatcher(None, zitat.lower(), z.lower(), autojunk=False) if sm.real_quick_ratio() * max(len(zitat), len(z)) < len(zitat) * 0.8: continue bloecke = [b for b in sm.get_matching_blocks() if b.size > 0] deckung = sum(b.size for b in bloecke) / len(zitat) if deckung > beste_deckung: beste_deckung = deckung start = min(b.b for b in bloecke) ende = max(b.b + b.size for b in bloecke) beste = z[start:ende] # wörtliches Quell-Fenster if beste is None or beste_deckung < 0.8 or beste not in quelle: return m.group(0) korrigiert.append(f"{zitat!r} → {beste[:60]!r}") return m.group(1) + '"' + beste[:78] + '"' neu = "\n".join(_BELEG_RE.sub(snap, zeile) for zeile in fragment.splitlines()) return neu, korrigiert def repariere_referenzen(sichten_text: str, bekannte_ids: set[str]) -> tuple[str, list[str]]: """Mechanische Reparatur einer Sicht: unbekannte Einheiten-Referenzen werden aus `einheiten:`-/`tragende-einheiten:`-Zeilen und Flow-Schritten GESTRICHEN; verliert ein Teilfeature/Schritt alle Referenzen, fällt die Zeile bzw. der `###`-Block weg. (Abnahme-Befund: Sichten referenzieren Einheiten toter Chunks — per Feedback nicht heilbar, weil die IDs schlicht nicht existieren.) → (text, gestrichene_refs).""" gestrichen: list[str] = [] # Symbol → IDs: falscher Datei-Prefix bei eindeutigem Symbol wird KORRIGIERT statt # gestrichen (Befund 19: repair.py-Einheit als board_inventory.py referenziert) je_symbol: dict[str, list[str]] = {} for eid in bekannte_ids: je_symbol.setdefault(eid.partition("::")[2], []).append(eid) def filtere(refs: str) -> list[str]: gueltig = [] for ref in refs.split(","): ref = ref.strip() if not ref: continue if ref in bekannte_ids: gueltig.append(ref) continue kandidaten = je_symbol.get(ref.partition("::")[2], []) if len(kandidaten) == 1: gueltig.append(kandidaten[0]) else: gestrichen.append(ref) return gueltig zeilen_neu: list[str] = [] for zeile in sichten_text.splitlines(): if m := re.match(r"^(einheiten|tragende-einheiten):\s*(.+)$", zeile): gueltig = filtere(m.group(2)) if gueltig: zeilen_neu.append(f"{m.group(1)}: {', '.join(gueltig)}") elif m.group(1) == "einheiten" and zeilen_neu and zeilen_neu[-1].startswith("### "): zeilen_neu.pop() # Teilfeature ohne einzige gültige Referenz fällt weg else: zeilen_neu.append(f"{m.group(1)}:") elif m := re.match(r"^(\d+\.\s.+\s—\s)(.+)$", zeile): gueltig = filtere(m.group(2)) if gueltig: zeilen_neu.append(m.group(1) + ", ".join(gueltig)) # Schritt ohne gültige Referenz fällt weg else: zeilen_neu.append(zeile) return "\n".join(zeilen_neu), gestrichen def pruefe_referenzen(sichten_text: str, bekannte_ids: set[str]) -> list[str]: """Referenz-Integrität einer Sicht: `einheiten:`-/`tragende-einheiten:`-Zeilen und Flow-Schritte (`1. … — id, id`) dürfen nur bekannte Einheiten-IDs nennen.""" fehler = [] referenz_zeilen = [m.group(1) for m in re.finditer(r"^(?:einheiten|tragende-einheiten):\s*(.+)$", sichten_text, re.M)] referenz_zeilen += [m.group(1) for m in re.finditer(r"^\d+\.\s.+\s—\s(.+)$", sichten_text, re.M)] for zeile in referenz_zeilen: for ref in zeile.split(","): ref = ref.strip() if ref and ref not in bekannte_ids: fehler.append(f"unbekannte Einheiten-Referenz: {ref}") return fehler