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")