Files
management/scripts/pruefe_upstream_drift.py
T
Thore Cimbal 556d568a63 feat: the drift check now contradicts a forgotten reconciliation
FB-12 point 3: the declared-extended files are deliberately outside the
byte comparison, so nothing reported when upstream changed them. Two of
three were touched between v0.1.1 and v0.3.1 and it only surfaced because
someone thought of it.

Two checks added to the script that already owns the subject, rather than
a second script beside it:

  * completeness — every vendored file belongs to exactly one class.
    check_harvest.py and judge.py were adopted byte-for-byte during the
    upgrade and never entered the pair list; nothing compared them.
  * reconciliation — if upstream touched an extended file between the
    previous and the current baseline, ABGLEICH.tsv of the new baseline
    must acknowledge it.

The acknowledgement lives in its own file on purpose. The first draft
searched HERKUNFT.md's prose for the filename, which is always there in
the inventory table: strict-looking and inert. Its selftest missed that
because the fixture was an unrealistically empty note; the counterfactual
against real history caught it.

Ten assertions, twelve deliberate breaks, none uncovered. Two of the
breaks exposed two useless assertions.
2026-08-21 12:00:00 +00:00

327 lines
14 KiB
Python

#!/usr/bin/env python3
"""pruefe_upstream_drift.py — Byte-Vergleich gegen die gepinnte Baseline.
Schützt die übernommenen Framework-Dateien vor stillem Umschreiben
(Entscheidung 8 im Design 2026-08-11, Frage von sorb: „wird die
AGENTS.md ggf. durch Agenten umgeschrieben?"). Die Baseline liegt unter
docs/sources/upstream/neckbeard-v0.3.1/ (siehe HERKUNFT.md dort); ein
Framework-Upgrade aktualisiert Baseline und Arbeitskopie im selben,
bewussten Commit.
Prüfungen (Fehler, Exit 1):
1. byte-identische Paare laut PAARE
2. AGENTS.md beginnt byte-identisch mit der Baseline-AGENTS.md und
trägt direkt danach die Marke des Projektabschnitts
3. **Vollständigkeit:** jede Datei der Baseline ist genau einer Klasse
zugeordnet — byte-identisch, AGENTS-Präfix, erklärt erweitert
(ADR-0024) oder bewusst nicht übernommen. Eine vendorierte Datei,
die in keiner Liste steht, wird von nichts verglichen und fällt
sonst durchs Raster.
4. **Abgleich beim Upgrade:** hat upstream zwischen der vorigen und der
aktuellen Baseline eine erklärt-erweiterte Datei angefasst, muss die
ABGLEICH.tsv der aktuellen Baseline sie quittieren — eine Zeile
`<datei>\t<vorige-baseline>\t<was nachgezogen wurde>`.
Zu 4, weil es die ungewöhnlichste ist: Die erweiterten Dateien werden
absichtlich nicht byte-verglichen (ADR-0024), also meldet auch nichts,
wenn upstream sie ändert. Beim Upgrade v0.1.1 → v0.3.1 waren zwei von
drei betroffen, und gemeldet hat es nichts — sichtbar wurde es nur, weil
jemand daran dachte. Diese Prüfung kann nicht beurteilen, ob richtig
nachgezogen wurde; sie kann sagen, **dass** etwas nachzuziehen war. Der
Fehler ist Vergessen, nicht Falschmachen (FRAMEWORK-BEFUNDE, FB-12).
⚠️ Die Quittung steht bewusst in einer eigenen Datei und nicht in der
Prosa der HERKUNFT.md: Der erste Entwurf suchte den Dateinamen irgendwo im
Text — und der steht dort *immer*, in der Inventartabelle. Die Prüfung war
damit nicht streng, sondern wirkungslos, und ihr eigener Selbsttest hat es
verdeckt, weil sein Fixture eine leere Notiz war. Eine Zusicherung deckt
den Fall jetzt ab.
Fehlt eine Baseline-Datei, ist das ein Fehler, kein Skip
(Stillstandsprüfungs-Regel: eine Prüfung ohne Gegenseite ist ungeprüft).
Usage: python scripts/pruefe_upstream_drift.py [repo-root]
python scripts/pruefe_upstream_drift.py --selbsttest
"""
from __future__ import annotations
import re
import shutil
import sys
import tempfile
from pathlib import Path
UPSTREAM = "docs/sources/upstream"
BASELINE = f"{UPSTREAM}/neckbeard-v0.3.1"
MARKE = "<!-- projektabschnitt -->"
HERKUNFT = "HERKUNFT.md"
ABGLEICH = "ABGLEICH.tsv"
# (Arbeitskopie, Baseline-Datei) — byte-identisch
PAARE = [
("CLAUDE.md", "CLAUDE.md"),
("WORKFLOW.md", "WORKFLOW.md"),
("docs/adr/template.md", "templates/adr-template.md"),
("docs/design/template.md", "templates/design-template.md"),
("docs/aar/template.md", "templates/aar-template.md"),
("docs/issues/template.md", "templates/issue-template.md"),
("docs/ledger/template.md", "templates/ledger-template.md"),
("docs/verdict/template.md", "templates/verdict-template.md"),
("scripts/check_harvest.py", "scripts/check_harvest.py"),
("scripts/judge.py", "scripts/judge.py"),
]
# Erklärt erweitert (ADR-0024): kein Byte-Vergleich, nur Diff-Referenz.
ERWEITERT = (
"schema.yaml",
"scripts/validate.py",
"scripts/gen_status.py",
)
# Vendoriert, aber bewusst nicht übernommen — es gibt keine Arbeitskopie.
# check_locked.py: pruefe_sperrliste.py tut dasselbe seit dem 2026-08-20.
NICHT_UEBERNOMMEN = (
"scripts/check_locked.py",
)
# Von der Zuordnung ausgenommen: die Herkunftsnotiz selbst und AGENTS.md,
# das über den Präfix-Vergleich läuft.
SONDERFALL = (HERKUNFT, ABGLEICH, "AGENTS.md")
def baseline_dateien(base: Path) -> list[str]:
return sorted(str(p.relative_to(base)) for p in base.rglob("*") if p.is_file())
def version_schluessel(name: str) -> tuple:
return tuple(int(t) for t in re.findall(r"\d+", name)) or (0,)
def baselines(root: Path) -> list[Path]:
"""Alle Baselines, aelteste zuerst."""
verzeichnis = root / UPSTREAM
if not verzeichnis.is_dir():
return []
return sorted((p for p in verzeichnis.iterdir() if p.is_dir()),
key=lambda p: version_schluessel(p.name))
def pruefe_paare(root: Path, base: Path, fehler: list[str]) -> None:
for arbeit, original in PAARE:
a, b = root / arbeit, base / original
if not b.is_file():
fehler.append(f"Baseline fehlt: {BASELINE}/{original}")
elif not a.is_file():
fehler.append(f"Arbeitskopie fehlt: {arbeit}")
elif a.read_bytes() != b.read_bytes():
fehler.append(f"DRIFT: {arbeit} weicht von {BASELINE}/{original} ab")
def pruefe_agents(root: Path, base: Path, fehler: list[str]) -> None:
agents, agents_base = root / "AGENTS.md", base / "AGENTS.md"
if not agents_base.is_file():
fehler.append(f"Baseline fehlt: {BASELINE}/AGENTS.md")
return
if not agents.is_file():
fehler.append("Arbeitskopie fehlt: AGENTS.md")
return
upstream = agents_base.read_bytes()
arbeit = agents.read_bytes()
if not arbeit.startswith(upstream):
fehler.append("DRIFT: AGENTS.md — Upstream-Teil ist nicht mehr "
"byte-identisch mit der Baseline")
return
rest = arbeit[len(upstream):].decode("utf-8", "replace")
if MARKE not in rest.splitlines()[0:3]:
fehler.append(f"AGENTS.md: Marke '{MARKE}' fehlt direkt nach dem "
"Upstream-Teil")
def pruefe_vollstaendigkeit(base: Path, fehler: list[str]) -> None:
"""Jede vendorierte Datei gehoert in genau eine Klasse."""
zugeordnet = {o for _, o in PAARE} | set(ERWEITERT) | set(NICHT_UEBERNOMMEN) \
| set(SONDERFALL)
for datei in baseline_dateien(base):
if datei not in zugeordnet:
fehler.append(
f"UNZUGEORDNET: {BASELINE}/{datei} ist vendoriert, steht aber "
f"in keiner Liste — es vergleicht sie nichts. Nach PAARE, "
f"ERWEITERT oder NICHT_UEBERNOMMEN eintragen.")
for datei in ERWEITERT + NICHT_UEBERNOMMEN:
if not (base / datei).is_file():
fehler.append(f"Baseline fehlt: {BASELINE}/{datei} (deklariert, "
f"aber nicht vendoriert)")
def pruefe_abgleich(root: Path, fehler: list[str]) -> None:
"""Hat upstream eine erweiterte Datei angefasst, muss HERKUNFT.md sie nennen."""
stände = baselines(root)
if len(stände) < 2:
return # nichts zu vergleichen, kein Befund
vorher, jetzt = stände[-2], stände[-1]
if jetzt.name != Path(BASELINE).name:
fehler.append(f"BASELINE zeigt auf {Path(BASELINE).name}, neuester "
f"vendorierter Stand ist {jetzt.name} — welcher gilt?")
return
geaendert = []
for datei in ERWEITERT:
alt, neu = vorher / datei, jetzt / datei
if not alt.is_file() or not neu.is_file():
continue # neu hinzugekommen oder entfallen
if alt.read_bytes() != neu.read_bytes():
geaendert.append(datei)
if not geaendert:
return
quittung = jetzt / ABGLEICH
if not quittung.is_file():
fehler.append(
f"{jetzt.name}/{ABGLEICH} fehlt, upstream hat aber "
f"{len(geaendert)} erweiterte Datei(en) angefasst — ungeprueft "
f"ist nicht bestanden.")
return
quittiert = set()
for zeile in quittung.read_text(encoding="utf-8", errors="replace").splitlines():
if not zeile.strip() or zeile.lstrip().startswith("#"):
continue
quittiert.add(zeile.split("\t")[0].strip())
for datei in geaendert:
if datei not in quittiert:
fehler.append(
f"ABGLEICH: upstream hat {datei} zwischen {vorher.name} und "
f"{jetzt.name} geaendert, {jetzt.name}/{ABGLEICH} quittiert "
f"sie nicht. Nachziehen und dort eintragen.")
def pruefe(root: Path) -> list[str]:
base = root / BASELINE
fehler: list[str] = []
pruefe_paare(root, base, fehler)
pruefe_agents(root, base, fehler)
pruefe_vollstaendigkeit(base, fehler)
pruefe_abgleich(root, fehler)
return fehler
# ---------------------------------------------------------------------------
# Positivkontrolle. Ein Tor, das nur gruen gesehen wurde, ist eine Vermutung
# (WORKFLOW.md, Gate 4).
# ---------------------------------------------------------------------------
def selbsttest() -> int:
bestanden, gefallen = 0, []
def pruefe_dass(name: str, bedingung: bool) -> None:
nonlocal bestanden
if bedingung:
bestanden += 1
print(f" ok {bestanden + len(gefallen)} {name}")
else:
gefallen.append(name)
print(f" FEHL {bestanden + len(gefallen)} {name}")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
alt = root / UPSTREAM / "neckbeard-v0.1.1"
neu = root / BASELINE
for b in (alt, neu):
(b / "scripts").mkdir(parents=True)
(b / "templates").mkdir(parents=True)
(b / "AGENTS.md").write_text("upstream\n", encoding="utf-8")
# Realistisch: die Inventartabelle nennt jede Datei beim Namen.
# Der erste Entwurf der Abgleichspruefung suchte genau darin und
# war deshalb wirkungslos - Zusicherung 7 haelt das fest.
tabelle = "\n".join(f"| `{d}` | erklaert erweitert |"
for d in ERWEITERT + NICHT_UEBERNOMMEN)
(b / HERKUNFT).write_text(f"# Herkunft\n\n{tabelle}\n",
encoding="utf-8")
for _, original in PAARE:
(b / original).parent.mkdir(parents=True, exist_ok=True)
(b / original).write_text("gleich\n", encoding="utf-8")
for datei in ERWEITERT + NICHT_UEBERNOMMEN:
(b / datei).parent.mkdir(parents=True, exist_ok=True)
(b / datei).write_text("upstream-stand\n", encoding="utf-8")
(root / "AGENTS.md").write_text(f"upstream\n{MARKE}\neigenes\n",
encoding="utf-8")
for arbeit, _ in PAARE:
(root / arbeit).parent.mkdir(parents=True, exist_ok=True)
(root / arbeit).write_text("gleich\n", encoding="utf-8")
for datei in ERWEITERT:
(root / datei).parent.mkdir(parents=True, exist_ok=True)
(root / datei).write_text("unser erweiterter stand\n", encoding="utf-8")
pruefe_dass("ein sauberer Stand meldet nichts", pruefe(root) == [])
(root / "WORKFLOW.md").write_text("veraendert\n", encoding="utf-8")
pruefe_dass("eine abweichende Arbeitskopie wird gemeldet",
any(f.startswith("DRIFT: WORKFLOW.md") for f in pruefe(root)))
(root / "WORKFLOW.md").write_text("gleich\n", encoding="utf-8")
(root / "AGENTS.md").write_text("anders\n", encoding="utf-8")
pruefe_dass("ein veraenderter AGENTS-Praefix wird gemeldet",
any(f.startswith("DRIFT: AGENTS.md") for f in pruefe(root)))
(root / "AGENTS.md").write_text(f"upstream\n{MARKE}\neigenes\n",
encoding="utf-8")
(neu / "scripts" / "fremd.py").write_text("x\n", encoding="utf-8")
pruefe_dass("eine vendorierte Datei ohne Zuordnung wird gemeldet",
any(f.startswith("UNZUGEORDNET:") for f in pruefe(root)))
(neu / "scripts" / "fremd.py").unlink()
(neu / ERWEITERT[0]).write_text("upstream hat angefasst\n", encoding="utf-8")
pruefe_dass("ohne Quittungsdatei ist eine oben geaenderte erweiterte "
"Datei ein Fehler, kein Skip",
any(ABGLEICH in f and "fehlt" in f for f in pruefe(root)))
# Der Fall, an dem der erste Entwurf wirkungslos war: Die Datei steht
# laengst in der Inventartabelle der HERKUNFT.md. Das ist keine
# Quittung, und eine leere Quittungsdatei aendert daran nichts.
(neu / ABGLEICH).write_text("# datei\tvorige\twas\n", encoding="utf-8")
pruefe_dass("die Zeile in der Inventartabelle quittiert nicht",
any(f.startswith("ABGLEICH:") for f in pruefe(root)))
(neu / ABGLEICH).write_text(f"# datei\tvorige\twas\n"
f"{ERWEITERT[1]}\t{alt.name}\tandere Datei\n",
encoding="utf-8")
pruefe_dass("die Quittung fuer eine andere Datei quittiert diese nicht",
any(f.startswith("ABGLEICH:") for f in pruefe(root)))
(neu / ABGLEICH).write_text(f"# datei\tvorige\twas\n"
f"{ERWEITERT[0]}\t{alt.name}\tnachgezogen\n",
encoding="utf-8")
pruefe_dass("dieselbe Aenderung ist still, sobald ABGLEICH.tsv quittiert",
not any(f.startswith("ABGLEICH:") for f in pruefe(root)))
(neu / ERWEITERT[0]).write_text("upstream-stand\n", encoding="utf-8")
(neu / ABGLEICH).unlink()
pruefe_dass("ohne Aenderung oben wird ueberhaupt keine Quittung verlangt",
not any(ABGLEICH in f for f in pruefe(root)))
shutil.rmtree(alt)
pruefe_dass("mit nur einer Baseline gibt es nichts zu vergleichen",
not any(f.startswith("ABGLEICH:") for f in pruefe(root)))
gesamt = bestanden + len(gefallen)
print()
if gefallen:
print(f"selbsttest: {len(gefallen)} von {gesamt} Zusicherungen GEFALLEN")
for name in gefallen:
print(f" - {name}")
return 1
print(f"selbsttest: {gesamt} Zusicherungen bestanden")
return 0
def main() -> int:
if "--selbsttest" in sys.argv[1:]:
return selbsttest()
root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd()
fehler = pruefe(root)
for f in fehler:
print(f"FEHLER {f}")
print(f"pruefe_upstream_drift: {len(fehler)} Fehler")
return 1 if fehler else 0
if __name__ == "__main__":
sys.exit(main())