scripts/check_core_purity.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""CI-Wächter: der Kern kennt keine Extension.
Docs: docs/01-ARCHITECTURE.md §1.
Durchsucht `backend/src/nodvard_deck/**/*.py` nach Wörtern, die auf eine konkrete
Extension/Fremdsoftware hindeuten (Proxmox, Shield, Ollama, ntfy, Nextcloud, TrueNAS,
Syncthing, Teleport, Docker als Produktbegriff, dazu der alte Produktname in der
Schreibweise "nexus") und nach dem Präfix `nodvard_deck_ext_` der Extension-Pakete:
der Kern importiert keine Extension. "Shield" gilt nur als eigenes Wort ohne `-` oder
`(` dahinter: `asyncio.shield(...)` und Symbolnamen wie "shield-check" (Icon-Auswahl)
sind keine Abhängigkeit von Nodvard Shield. Kommentare und
String-Literal-Anweisungen (Modul-/Klassen-/Funktions-Docstrings UND die in diesem
Codebase durchgängig genutzten "Attribut-Docstrings" -- ein freistehender
Dreifach-String direkt nach einer Feldzuweisung) sind ausgenommen: verboten ist die
*Abhängigkeit* (ein Import, ein API-Aufruf, ein hartcodierter Connector), nicht die
*Erwähnung* in Prosa, die zum Erklären des generischen Mechanismus oft nötig ist.
Der Standard-Produktname in `branding.py` ("Nodvard Deck") steht nicht auf der Sperrliste.
Exit-Code 0 = sauber, 1 = Verstoss gefunden, 2 = Aufrufproblem.
"""
from __future__ import annotations
import ast
import re
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
CORE_ROOT = REPO_ROOT / "backend" / "src" / "nodvard_deck"
FORBIDDEN_WORDS = [
"proxmox",
"nexus",
"ollama",
"ntfy",
"nextcloud",
"truenas",
"syncthing",
"teleport",
"docker",
]
# Verbotene Muster mit eigener Regel (Name -> Regex-Quelltext), wenn ein einfaches Wort nicht reicht.
FORBIDDEN_PATTERNS: dict[str, str] = {
# Der Name der Extension "Nodvard Shield". Ausgenommen sind `asyncio.shield(...)` (Aufruf, `(` dahinter) und Symbolnamen
# mit Bindestrich wie "shield-check" (Icons in services/custom_apps.py und core/demo_seed.py). Jede dieser Ausnahmen ist
# in backend/tests/test_core_purity.py an der echten Fundstelle belegt.
"shield": r"\bshield\b(?![-(])",
# Python-Pakete der Extensions heißen `nodvard_deck_ext_<id>`: der Kern importiert und nennt keine.
"nodvard_deck_ext_": r"\bnodvard_deck_ext_",
}
# Datei -> Wörter, die dort ausdrücklich erlaubt sind (mit Begründung im Docstring
# der jeweiligen Datei).
ALLOWLIST: dict[str, set[str]] = {}
RULES: tuple[tuple[str, re.Pattern[str]], ...] = (
*((word, re.compile(rf"\b{re.escape(word)}\b", re.IGNORECASE)) for word in FORBIDDEN_WORDS),
*((name, re.compile(pattern, re.IGNORECASE)) for name, pattern in FORBIDDEN_PATTERNS.items()),
)
def _code_only(source: str) -> str:
"""Neutralisiert jede bare String-Literal-Anweisung, damit sie nicht mitgeprüft
wird -- nicht nur klassische Modul-/Klassen-/Funktions-Docstrings, sondern auch die
in diesem Codebase durchgängig genutzten Attribut-Docstrings (`feld: Mapped[...] =
...` gefolgt von einem freistehenden Dreifach-String, wie Sphinx/griffe sie
interpretieren). Eine freistehende String-Anweisung hat nie eine Laufzeitwirkung --
sie ist per Definition Dokumentation, niemals eine echte Abhängigkeit.
"""
try:
tree = ast.parse(source)
except SyntaxError:
return source
string_expr_spans: list[tuple[int, int]] = [
(node.lineno, getattr(node, "end_lineno", node.lineno))
for node in ast.walk(tree)
if isinstance(node, ast.Expr)
and isinstance(node.value, ast.Constant)
and isinstance(node.value.value, str)
]
lines = source.splitlines(keepends=True)
for start, end in string_expr_spans:
for lineno in range(start, end + 1):
idx = lineno - 1
if 0 <= idx < len(lines):
# Zeileninhalt neutralisieren, Zeilenumbruch behalten (Zeilennummern
# in der Fehlerausgabe sollen stimmen).
newline = "\n" if lines[idx].endswith("\n") else ""
lines[idx] = "#" * (len(lines[idx]) - len(newline)) + newline
return "".join(lines)
def _strip_comments(line: str) -> str:
"""Grobe Heuristik: alles ab einem `#` ignorieren. Reicht für diesen Codebase-Stil
(kein `#` in String-Literalen, die relevante Wörter enthalten könnten)."""
return line.split("#", 1)[0]
def check_file(path: Path) -> list[tuple[int, str, str]]:
"""Gibt Liste von (Zeilennummer, Zeile, gefundenes Wort) zurück."""
source = path.read_text(encoding="utf-8")
allowed = ALLOWLIST.get(path.name, set())
code = _code_only(source)
findings: list[tuple[int, str, str]] = []
for lineno, line in enumerate(code.splitlines(), start=1):
stripped = _strip_comments(line)
hits = [
(match.start(), word)
for word, rule in RULES
if word not in allowed
for match in rule.finditer(stripped)
]
findings.extend((lineno, line.rstrip(), word) for _pos, word in sorted(hits))
return findings
def main() -> int:
if not CORE_ROOT.is_dir():
print(f"FEHLER: {CORE_ROOT} existiert nicht.", file=sys.stderr)
return 2
all_findings: list[tuple[Path, int, str, str]] = []
for path in sorted(CORE_ROOT.rglob("*.py")):
for lineno, line, word in check_file(path):
all_findings.append((path.relative_to(REPO_ROOT), lineno, line, word))
if not all_findings:
print(f"OK: Kern ist sauber ({CORE_ROOT.relative_to(REPO_ROOT)}).")
return 0
print("Verstoss gegen die Kern-Reinheit (docs/01-ARCHITECTURE.md Paragraph 1):\n")
for rel_path, lineno, line, word in all_findings:
print(f" {rel_path}:{lineno}: Wort '{word}'\n {line.strip()}")
print(
f"\n{len(all_findings)} Fund(e). Der Kern darf keine konkrete Extension/Software "
f"kennen -- fehlt eine Faehigkeit, gehoert sie als Protokoll in nodvard_sdk, "
f"implementiert von einer Extension."
)
return 1
if __name__ == "__main__":
raise SystemExit(main())