From 556d568a63f85f3784f9d8b262e16da5552480c4 Mon Sep 17 00:00:00 2001 From: Thore Cimbal Date: Fri, 21 Aug 2026 12:00:00 +0000 Subject: [PATCH] feat: the drift check now contradicts a forgotten reconciliation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitlab-ci.yml | 5 + FRAMEWORK-BEFUNDE.md | 30 +- .../upstream/neckbeard-v0.3.1/ABGLEICH.tsv | 3 + scripts/pruefe_upstream_drift.py | 293 ++++++++++++++++-- 4 files changed, 300 insertions(+), 31 deletions(-) create mode 100644 docs/sources/upstream/neckbeard-v0.3.1/ABGLEICH.tsv diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index ad01373..508718b 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -66,6 +66,11 @@ validate: - python3 scripts/validate.py - python3 scripts/gen_status.py --check - python3 scripts/pruefe_upstream_drift.py + # Positivkontrolle der Drift-Pruefung: sie traegt seit dem 2026-08-21 + # die Vollstaendigkeits- und die Abgleichspruefung (FB-12), und ihr + # erster Entwurf der letzteren war wirkungslos. Ein Tor, das nur gruen + # gesehen wurde, ist eine Vermutung. + - python3 scripts/pruefe_upstream_drift.py --selbsttest - python3 scripts/pruefe_prosa.py # Sperrliste (FRAMEWORK-BEFUNDE FB-10): angenommene ADRs und docs/sources # sind unveraenderlich. Braucht den Vorgaenger-Commit im Klon. diff --git a/FRAMEWORK-BEFUNDE.md b/FRAMEWORK-BEFUNDE.md index cd58e38..f6fcdb3 100644 --- a/FRAMEWORK-BEFUNDE.md +++ b/FRAMEWORK-BEFUNDE.md @@ -450,13 +450,29 @@ Prüfungen macht. Löschung bleiben gesperrt, nur der Zugang ist erlaubt. Vier Kontrollen belegt. Upstream `check_locked.py` zieht dieselbe Grenze und sichert sie zu — unsere Fassung wusste es nur nicht. -- **Punkt 3 ist entschieden, aber nicht behoben.** Der Abgleich ist gemacht und in - `HERKUNFT.md` protokolliert; seit dem 2026-08-21 trägt - [ADR-0024](docs/adr/0024-drei-framework-dateien-erklaert-erweitert.md) die Ausnahme als - Entscheidung statt nur als Beschreibung und nimmt den Abgleich ausdrücklich in die - Upgrade-Pflicht auf. **Der maschinelle Widerspruch fehlt weiterhin** — beim nächsten - Upgrade erinnert nur noch diese ADR daran, und das ist genau die Sorte Gedächtnis, der - FB-11 nicht traut. +- **Punkt 3 ist behoben.** + [ADR-0024](docs/adr/0024-drei-framework-dateien-erklaert-erweitert.md) trägt die + Ausnahme seit dem 2026-08-21 als Entscheidung, und `pruefe_upstream_drift.py` hat seit + demselben Tag den maschinellen Widerspruch: Hat upstream zwischen der vorigen und der + aktuellen Baseline eine erklärt-erweiterte Datei angefasst, verlangt es eine Quittung in + `ABGLEICH.tsv` der neuen Baseline. Dieselbe Prüfung stellt außerdem sicher, dass **jede** + vendorierte Datei genau einer Klasse zugeordnet ist — byte-identisch, AGENTS-Präfix, + erklärt erweitert oder bewusst nicht übernommen. Eine vendorierte Datei ohne Zuordnung + war bis dahin unsichtbar; beim Upgrade waren es prompt zwei (`check_harvest.py`, + `judge.py`). + + ⚠️ **Der erste Entwurf dieser Prüfung war wirkungslos** und ist es fast geblieben: Er + suchte den Dateinamen irgendwo in der Prosa der `HERKUNFT.md` — und dort steht er + *immer*, in der Inventartabelle. Aufgefallen ist es nur an einer kontrafaktischen Probe + gegen die echte Historie, nicht am Selbsttest, dessen Fixture eine unrealistisch leere + Notiz war. **Das ist FB-02 im Werkzeug gegen FB-12**, und es steht hier, weil es die + Klasse ein weiteres Mal belegt: Eine Prüfung, die man nur grün gesehen hat, ist eine + Vermutung — auch dann, wenn sie einen Selbsttest hat. + + Endstand: zehn Zusicherungen, zwölf absichtliche Brüche, keine Zusicherung unbewacht. + Zwei der Brüche haben dabei zwei **untaugliche Zusicherungen** entlarvt — eine, die auf + eine Phrase prüfte, die in zwei verschiedenen Meldungen vorkommt, und eine, die schon aus + einem anderen Grund erfüllt war. **Vorschlag für die nächste Iteration.** Zwei Dinge, beide klein: diff --git a/docs/sources/upstream/neckbeard-v0.3.1/ABGLEICH.tsv b/docs/sources/upstream/neckbeard-v0.3.1/ABGLEICH.tsv new file mode 100644 index 0000000..f1c97e9 --- /dev/null +++ b/docs/sources/upstream/neckbeard-v0.3.1/ABGLEICH.tsv @@ -0,0 +1,3 @@ +# datei vorige baseline was nachgezogen wurde +scripts/validate.py neckbeard-v0.1.1 check_vendored_portable samt Aufruf uebernommen; eigene Regeln unberuehrt +schema.yaml neckbeard-v0.1.1 vendored, project_section_marker, judged, Typen ledger und verdict uebernommen diff --git a/scripts/pruefe_upstream_drift.py b/scripts/pruefe_upstream_drift.py index 81a9301..d1574ca 100644 --- a/scripts/pruefe_upstream_drift.py +++ b/scripts/pruefe_upstream_drift.py @@ -9,21 +9,53 @@ Framework-Upgrade aktualisiert Baseline und Arbeitskopie im selben, bewussten Commit. Prüfungen (Fehler, Exit 1): - * byte-identische Paare laut PAARE - * AGENTS.md beginnt byte-identisch mit der Baseline-AGENTS.md und - trägt direkt danach die Marke des Projektabschnitts + 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 + `\t\t`. + +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 -BASELINE = "docs/sources/upstream/neckbeard-v0.3.1" +UPSTREAM = "docs/sources/upstream" +BASELINE = f"{UPSTREAM}/neckbeard-v0.3.1" MARKE = "" +HERKUNFT = "HERKUNFT.md" +ABGLEICH = "ABGLEICH.tsv" # (Arbeitskopie, Baseline-Datei) — byte-identisch PAARE = [ @@ -35,42 +67,255 @@ PAARE = [ ("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", +) -def main() -> int: - root = Path(sys.argv[1]) if len(sys.argv) > 1 else Path.cwd() - base = root / BASELINE - fehler: list[str] = [] +# 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}") - continue - if not a.is_file(): + elif not a.is_file(): fehler.append(f"Arbeitskopie fehlt: {arbeit}") - continue - if a.read_bytes() != b.read_bytes(): + 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") - elif not agents.is_file(): + return + if not agents.is_file(): fehler.append("Arbeitskopie fehlt: AGENTS.md") - else: - upstream = agents_base.read_bytes() - arbeit = agents.read_bytes() - if not arbeit.startswith(upstream): - fehler.append("DRIFT: AGENTS.md — Upstream-Teil (§1–5) ist " - "nicht mehr byte-identisch mit der Baseline") - else: - 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") + 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")