Sperrliste in der CI: angenommene ADRs und docs/sources sind unveraenderlich

Umsetzung von FRAMEWORK-BEFUNDE FB-10. Anlass: 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. Dieses Skript ist der fehlende Widerspruch.

Erlaubt bleibt genau die Aenderung, die die Vorlage vorschreibt: superseded_by
und status in der abgeloesten ADR setzen. Jede Aenderung am Text ist ein
Verstoss.

Geprueft wird nur der aktuelle Push, nicht die Historie - Altlasten wuerden die
Pipeline dauerhaft rot faerben, ohne dass jemand sie beheben kann (FB-03).

Beide Sperren mit Positivkontrolle belegt: mein realer Verstoss 6652125 wird
rot (23 Zeilen), ein Wegwerf-Commit an docs/sources wird rot, ein sauberer
Commit gruen, ein ungueltiger Bereich rot.

⚠️ Beim Bau ist mir FB-02 prompt wieder unterlaufen: Der erste git-Helfer
verwandelte einen Fehlschlag in einen leeren String - ein ungueltiger Bereich
wurde damit zu 'keine Aenderungen' und bestand stillschweigend. Aufgefallen nur,
weil der Test gegen den Wurzel-Commit lief, der keinen Vorgaenger hat. Jetzt
wirft git() statt zu schweigen, und der Fall ist eine eigene Kontrolle.

GIT_DEPTH 0 fuer den validate-Job: Im flachen Klon faellt die Pruefung auf HEAD~1
zurueck und sagt das - genau messen ist besser als benannt lueckenhaft.
This commit is contained in:
Thore Cimbal
2026-08-20 12:00:00 +00:00
parent 75699c4798
commit 7e7ac29ecb
3 changed files with 217 additions and 4 deletions
+8
View File
@@ -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/,
+24 -4
View File
@@ -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.
---
+185
View File
@@ -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 <alt>..<neu> [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
<wurzel-commit>^..<wurzel-commit>) 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 <alt>..<neu> 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())