#!/usr/bin/env python3 """Mechanische Prüfung einer Einheiten-Datei (Phase 0). Aufruf: pruefe.py Prüft tokenfrei: 1. Anker-Existenz — jede Einheiten-ID und jedes Kanten-Ziel zeigt auf existierende Datei + darin definiertes Symbol (def/class/Zuweisung). 2. Zitat-Treue — jeder beleg:-String kommt wörtlich in der Anker-Datei vor. 3. Struktur — Schlüssel-Grammatik (beschreibung/fakten/kanten, Kanten-Typen). 4. Abdeckung — Report: welche Top-Level-Symbole der Quelldatei keiner Einheit zugeordnet sind (v1 kein Fehler). """ import re import sys from pathlib import Path KANTEN_TYPEN = {"ruft-auf", "nutzt", "wird-genutzt-von", "löst-aus", "wird-ausgelöst-von"} def entschaerfe(s: str) -> str: """Escaping-Rauschen der Modelle normalisieren: \\" → " und \\\\ → \\.""" return s.replace('\\"', '"').replace("\\\\", "\\") def symbole_in(quelle: str) -> set[str]: """Top-Level-Symbole: def/class/Konstanten-Zuweisung auf Spalte 0 (+ async def).""" out = set() for m in re.finditer(r"^(?:async\s+)?(?:def|class)\s+(\w+)", quelle, re.M): out.add(m.group(1)) for m in re.finditer(r"^(\w+)\s*[:=]", quelle, re.M): out.add(m.group(1)) return out def main(pfad: str, wurzel: str) -> int: text = Path(pfad).read_text(encoding="utf-8") wurzel_p = Path(wurzel) fehler, warnungen = [], [] quell_cache: dict[str, str | None] = {} def quelle(datei: str) -> str | None: if datei not in quell_cache: p = wurzel_p / datei quell_cache[datei] = p.read_text(encoding="utf-8") if p.is_file() else None return quell_cache[datei] def pruefe_id(eid: str, kontext: str): if "::" not in eid: if quelle(eid) is None: fehler.append(f"{kontext}: Datei fehlt: {eid}") return datei, symbol = eid.split("::", 1) q = quelle(datei) if q is None: fehler.append(f"{kontext}: Datei fehlt: {datei}") elif symbol not in symbole_in(q): fehler.append(f"{kontext}: Symbol '{symbol}' nicht top-level in {datei}") # --- parsen --- einheiten: list[dict] = [] zusatz_anker: set[str] = set() aktuelle = None modus = None # None | fakten | kanten for nr, zeile in enumerate(text.splitlines(), 1): if zeile.startswith("## "): aktuelle = {"id": zeile[3:].strip(), "zeile": nr, "belege": [], "kanten": [], "beschreibung": False} einheiten.append(aktuelle) modus = None elif zeile.startswith(("anker:", "ergaenze-anker:")): # Zusatz-Anker dürfen auch verschachtelte Symbole sein (Innenfunktionen); # ergaenze-anker kommt auch außerhalb von Einheiten vor (Nachfass-Ausgabe) aid = zeile.split(":", 1)[1].strip().split(" zu ")[0].strip() zusatz_anker.add(aid) if "::" in aid: datei, symbol = aid.split("::", 1) q = quelle(datei) if q is None: fehler.append(f"Z{nr} anker: Datei fehlt: {datei}") elif not re.search(rf"(?:def|class)\s+{re.escape(symbol)}\b|^{re.escape(symbol)}\s*[:=]", q, re.M): fehler.append(f"Z{nr} anker: Symbol '{symbol}' nicht in {datei}") else: pruefe_id(aid, f"Z{nr} anker") elif aktuelle is None: continue elif zeile.startswith("beschreibung:"): aktuelle["beschreibung"] = True modus = None elif zeile.strip() in ("fakten:", "entscheidungen:"): modus = "fakten" elif zeile.startswith("input:"): aktuelle["input"] = True elif zeile.startswith("output:"): aktuelle["output"] = True elif zeile.strip() == "kanten:": modus = "kanten" elif m := re.match(r'\s+beleg:\s*"(.*)"\s*$', zeile): aktuelle["belege"].append((nr, entschaerfe(m.group(1)))) elif modus == "fakten" and (m := re.search(r'\bbeleg:\s*"(.*)"\s*$', zeile)): # tolerant: beleg inline am Ende der Fakt-Zeile (streng schreiben, tolerant parsen) aktuelle["belege"].append((nr, entschaerfe(m.group(1)))) 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}") # --- prüfen --- for e in einheiten: kontext = e["id"] pruefe_id(e["id"], f"Z{e['zeile']} einheit") if not e["beschreibung"]: fehler.append(f"{kontext}: beschreibung: fehlt") datei = e["id"].split("::")[0] q = quelle(datei) or "" for nr, beleg in e["belege"]: if beleg not in q: fehler.append(f"{kontext} Z{nr}: Zitat nicht wörtlich in {datei}: \"{beleg[:50]}…\"") if not e["belege"]: warnungen.append(f"{kontext}: keine Belege") if not e.get("input") or not e.get("output"): warnungen.append(f"{kontext}: input:/output: fehlt") for nr, typ, ziel in e["kanten"]: if typ not in KANTEN_TYPEN: fehler.append(f"{kontext} Z{nr}: unbekannter Kanten-Typ '{typ}'") pruefe_id(ziel, f"{kontext} Z{nr} kante") # --- Abdeckung der gescannten Datei --- dateien = {e["id"].split("::")[0] for e in einheiten if "::" in e["id"]} for datei in sorted(dateien): q = quelle(datei) if q is None: continue zugeordnet = {e["id"].split("::", 1)[1] for e in einheiten if e["id"].startswith(datei + "::")} zugeordnet |= {a.split("::", 1)[1] for a in zusatz_anker if a.startswith(datei + "::")} oeffentlich = {s for s in symbole_in(q) if re.search(rf"^(?:async\s+)?(?:def|class)\s+{re.escape(s)}\b", q, re.M)} fehlt = oeffentlich - zugeordnet if fehlt: warnungen.append(f"Abdeckung {datei}: nicht zugeordnet: {', '.join(sorted(fehlt))}") print(f"Einheiten: {len(einheiten)}, Belege: {sum(len(e['belege']) for e in einheiten)}, " f"Kanten: {sum(len(e['kanten']) for e in einheiten)}") for f in fehler: print(f"FEHLER {f}") for w in warnungen: print(f"WARNUNG {w}") print(f"\n{len(fehler)} Fehler, {len(warnungen)} Warnungen") return 1 if fehler else 0 if __name__ == "__main__": sys.exit(main(sys.argv[1], sys.argv[2]))