diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f3445ad..ad01373 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -52,6 +52,11 @@ stillstandspruefung: validate: stage: pruefen image: python:3.12-alpine + variables: + # Volle Historie: pruefe_sperrliste.py vergleicht gegen den Vorgaenger- + # Commit. Im flachen Klon faellt es auf HEAD~1 zurueck und sagt das - + # genau messen ist aber besser als benannt lueckenhaft. Repo ist klein. + GIT_DEPTH: "0" rules: - if: $CI_PIPELINE_SOURCE == "push" before_script: @@ -62,6 +67,9 @@ validate: - python3 scripts/gen_status.py --check - python3 scripts/pruefe_upstream_drift.py - python3 scripts/pruefe_prosa.py + # Sperrliste (FRAMEWORK-BEFUNDE FB-10): angenommene ADRs und docs/sources + # sind unveraenderlich. Braucht den Vorgaenger-Commit im Klon. + - python3 scripts/pruefe_sperrliste.py allow_failure: false # Verbund-Prüfung (ADR-0012/0013): Gruppenliste vs. docs/components/, diff --git a/FRAMEWORK-BEFUNDE.md b/FRAMEWORK-BEFUNDE.md index 48abd15..57efea3 100644 --- a/FRAMEWORK-BEFUNDE.md +++ b/FRAMEWORK-BEFUNDE.md @@ -29,7 +29,7 @@ | FB-07 | Werkzeuge messen unbemerkt die falsche Instanz | Fehlerklasse | offen | | FB-08 | Dokumente werden angelesen, nicht durchgelesen | Arbeitsweise | offen | | FB-09 | Gelesene Anweisungen werden nicht befolgt | Arbeitsweise | offen | -| FB-10 | Gesperrte Dateien werden bearbeitet | Arbeitsweise | offen | +| FB-10 | Gesperrte Dateien werden bearbeitet | Arbeitsweise | **Werkzeug steht** | --- @@ -230,9 +230,29 @@ Zum Vergleich: `STATUS.md` trägt dieselbe Art Verbot („nicht von Hand ändern kein einziges Mal verletzt, weil `gen_status.py --check` widerspricht. Derselbe Befund wie FB-01, an anderem Gegenstand. -**Vorschlag.** Sperrliste in der CI: Änderungen an `docs/adr/*` mit `status: accepted`, -an `docs/sources/**` und an generierten Dateien brechen die Pipeline, sofern der Commit -nicht ausdrücklich eine Ablösung ist (neue ADR mit `supersedes`). +**Umgesetzt 2026-08-20.** `scripts/pruefe_sperrliste.py`, im `validate`-Job bei jedem +Push. Gesperrt sind `docs/sources/**` und jede ADR, die vor dem Push `status: accepted` +trug; erlaubt bleibt genau die von der Vorlage vorgeschriebene Änderung — `superseded_by` +und `status` setzen, wenn eine neue ADR ablöst. + +**Beide Sperren mit Positivkontrolle belegt, nicht nur gebaut:** + +| Fall | Ergebnis | +|---|---| +| Commit `6652125` (mein realer Verstoß an ADR-0022) | rot, 23 beanstandete Zeilen | +| Wegwerf-Commit an `docs/sources/…/AGENTS.md` | rot | +| sauberer Commit | grün | +| ungültiger Vergleichsbereich | rot — „ungeprüft ist nicht bestanden" | + +⚠️ **Beim Bau ist mir dieselbe Klasse prompt wieder unterlaufen:** Der erste `git`-Helfer +verwandelte einen Fehlschlag in einen leeren String — ein ungültiger Bereich wurde damit zu +„keine Änderungen" und **bestand stillschweigend**. Aufgefallen nur, weil der Test gegen +den Wurzel-Commit des Repos lief, der keinen Vorgänger hat. Das ist FB-02 im Werkzeug +gegen FB-10; der Fall steht als vierte Zeile in der Tabelle oben, weil er jetzt geprüft +wird. + +**Nicht abgedeckt:** generierte Dateien. `STATUS.md` bleibt durch `gen_status --check` +geschützt, andere Generate haben heute keine Entsprechung. --- diff --git a/scripts/pruefe_sperrliste.py b/scripts/pruefe_sperrliste.py new file mode 100644 index 0000000..df2c4bc --- /dev/null +++ b/scripts/pruefe_sperrliste.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +"""pruefe_sperrliste.py — Aenderungen an gesperrten Pfaden im Push finden. + +Ergaenzt pruefe_upstream_drift.py: jenes schuetzt die uebernommenen +Framework-Dateien gegen stilles Umschreiben, dieses die im Repo selbst +getroffenen bindenden Entscheidungen. + +Anlass (FRAMEWORK-BEFUNDE FB-09/FB-10): Commit 6652125 hat die angenommene +ADR-0022 veraendert, obwohl AGENTS.md sagt "binding; never edited, only +superseded". Der Verstoss wurde gepusht und erst durch Zufall bemerkt. +STATUS.md traegt dasselbe Verbot und wurde nie verletzt - weil +gen_status --check widerspricht. Diese Pruefung ist der fehlende Widerspruch. + +Gesperrt: + * docs/sources/** unveraenderlich (AGENTS.md: read-only for agents) + * docs/adr/*.md, sofern die Datei VOR dem Push status: accepted trug. + Erlaubt bleibt genau die Aenderung, die die Vorlage vorschreibt: das + Setzen von superseded_by (und der Statuswechsel auf superseded), wenn + eine neue ADR sie abloest. Jede Aenderung am Text ist ein Verstoss. + +Geprueft wird nur der aktuelle Push, nicht die Historie. Altlasten wuerden +sonst die Pipeline dauerhaft rot faerben, ohne dass jemand sie beheben kann - +genau die Klasse, die FB-03 beschreibt. + +⚠️ Laesst sich der Vergleichsbereich nicht bestimmen, ist das ein FEHLER, +kein Skip (Stillstandspruefungs-Regel: eine Pruefung ohne Gegenseite ist +ungeprueft). + +Usage: + python scripts/pruefe_sperrliste.py [repo-root] + python scripts/pruefe_sperrliste.py --bereich .. [repo-root] +""" +from __future__ import annotations + +import os +import re +import subprocess +import sys +from pathlib import Path + +GESPERRT_VOLL = ("docs/sources/",) +NULL_SHA = "0" * 40 +# Genau diese Frontmatter-Schluessel darf eine angenommene ADR noch aendern. +ABLOESE_SCHLUESSEL = {"status", "superseded_by"} + + +class GitFehler(RuntimeError): + """git hat die Frage nicht beantwortet - das ist kein leeres Ergebnis.""" + + +def git(root: Path, *args: str, leer_erlaubt: bool = False) -> str: + """Ruft git auf. Ein Fehlschlag wirft, statt "" zu liefern. + + ⚠️ Das ist der Kern der Pruefung: Ein ungueltiger Bereich (etwa + ^..) laesst git scheitern. Wer den Fehler in + einen leeren String verwandelt, macht daraus "keine Aenderungen" und damit + ein stilles Bestanden - genau die Fehlerklasse, gegen die dieses Skript + antritt (FRAMEWORK-BEFUNDE FB-02). Beim Bau ist mir das prompt passiert. + """ + ergebnis = subprocess.run(["git", "-C", str(root), *args], + capture_output=True, text=True) + if ergebnis.returncode != 0: + if leer_erlaubt: + return "" + raise GitFehler(f"git {' '.join(args)}: {ergebnis.stderr.strip()[:120]}") + return ergebnis.stdout + + +def bereich_bestimmen(root: Path, vorgabe: str | None) -> str: + if vorgabe: + return vorgabe + vorher = os.environ.get("CI_COMMIT_BEFORE_SHA", "") + if vorher and vorher != NULL_SHA: + # Im flachen Klon kann der Vorgaenger fehlen. Dann auf HEAD~1 + # zurueckfallen und das SAGEN - ein Fehlalarm waere hier schlimmer als + # eine benannte Luecke (FRAMEWORK-BEFUNDE FB-03). + if _existiert(root, vorher): + return f"{vorher}..HEAD" + print(f"pruefe_sperrliste: {vorher[:12]} liegt nicht im Klon " + f"(flacher Clone) - pruefe nur den letzten Commit.") + # Kein CI-Kontext oder neuer Branch: der letzte Commit. + if git(root, "rev-parse", "--verify", "HEAD~1", leer_erlaubt=True).strip(): + return "HEAD~1..HEAD" + return "" + + +def _existiert(root: Path, sha: str) -> bool: + ergebnis = subprocess.run(["git", "-C", str(root), "cat-file", "-e", + f"{sha}^{{commit}}"], capture_output=True) + return ergebnis.returncode == 0 + + +def frontmatter_wert(text: str, schluessel: str) -> str | None: + treffer = re.search(rf"^{schluessel}:\s*(.+?)\s*$", text, re.M) + return treffer.group(1).strip().strip('"') if treffer else None + + +def geaenderte_zeilen(root: Path, bereich: str, pfad: str) -> list[str]: + roh = git(root, "diff", "--unified=0", bereich, "--", pfad) + return [z for z in roh.splitlines() + if (z.startswith("+") or z.startswith("-")) + and not z.startswith(("+++", "---"))] + + +def main() -> int: + argv = [a for a in sys.argv[1:] if not a.startswith("--")] + bereich_vorgabe = None + if "--bereich" in sys.argv: + i = sys.argv.index("--bereich") + if i + 1 < len(sys.argv): + bereich_vorgabe = sys.argv[i + 1] + argv = [a for a in argv if a != bereich_vorgabe] + root = Path(argv[0]) if argv else Path.cwd() + + bereich = bereich_bestimmen(root, bereich_vorgabe) + if not bereich: + print("pruefe_sperrliste: Vergleichsbereich nicht bestimmbar - " + "ungeprueft ist nicht bestanden. Mit --bereich .. aufrufen.") + return 1 + + try: + geaendert = [z for z in git(root, "diff", "--name-only", bereich).splitlines() if z] + except GitFehler as fehler: + print(f"pruefe_sperrliste: Bereich {bereich} nicht auswertbar - {fehler}") + print("Ungeprueft ist nicht bestanden.") + return 1 + if not geaendert: + print(f"pruefe_sperrliste: keine Aenderungen im Bereich {bereich}.") + return 0 + + befunde: list[str] = [] + + for pfad in geaendert: + # 1. Unveraenderliche Quellen + if any(pfad.startswith(p) for p in GESPERRT_VOLL): + befunde.append(f"{pfad}: unveraenderlich (AGENTS.md: read-only for agents)") + continue + + # 2. Angenommene ADRs + if not (pfad.startswith("docs/adr/") and pfad.endswith(".md")): + continue + if pfad.endswith("template.md"): + continue + + alt = git(root, "show", f"{bereich.split('..')[0]}:{pfad}", leer_erlaubt=True) + if not alt: + continue # neu angelegt - erlaubt + if frontmatter_wert(alt, "status") != "accepted": + continue # proposed darf noch bearbeitet werden + + zeilen = geaenderte_zeilen(root, bereich, pfad) + verletzend = [] + for zeile in zeilen: + inhalt = zeile[1:].strip() + if not inhalt: + continue + schluessel = inhalt.split(":", 1)[0].strip() if ":" in inhalt else "" + if schluessel in ABLOESE_SCHLUESSEL: + continue + verletzend.append(zeile) + if verletzend: + befunde.append( + f"{pfad}: angenommene ADR veraendert " + f"({len(verletzend)} Zeile(n) ausserhalb von " + f"{'/'.join(sorted(ABLOESE_SCHLUESSEL))})") + for z in verletzend[:3]: + befunde.append(f" {z[:96]}") + + if befunde: + print(f"pruefe_sperrliste: {len([b for b in befunde if not b.startswith(' ')])} " + f"Verstoss/Verstoesse im Bereich {bereich}") + for b in befunde: + print(f" {b}") + print() + print("Eine angenommene ADR wird nicht editiert, sondern durch eine neue") + print("abgeloest (AGENTS.md, docs/adr/template.md). Erlaubt ist nur, in der") + print("alten superseded_by und status zu setzen.") + return 1 + + print(f"pruefe_sperrliste: keine Verstoesse im Bereich {bereich}.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())