157 lines
6.6 KiB
Python
157 lines
6.6 KiB
Python
#!/usr/bin/env python3
|
|
"""Mechanische Prüfung einer Einheiten-Datei (Phase 0).
|
|
|
|
Aufruf: pruefe.py <einheiten.md> <projekt-wurzel>
|
|
|
|
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]))
|