2026-08-11 12:00:00 +00:00
|
|
|
#!/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
|
2026-08-21 12:00:00 +00:00
|
|
|
docs/sources/upstream/neckbeard-v0.3.1/ (siehe HERKUNFT.md dort); ein
|
2026-08-11 12:00:00 +00:00
|
|
|
Framework-Upgrade aktualisiert Baseline und Arbeitskopie im selben,
|
|
|
|
|
bewussten Commit.
|
|
|
|
|
|
|
|
|
|
Prüfungen (Fehler, Exit 1):
|
2026-08-21 12:00:00 +00:00
|
|
|
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.
|
|
|
|
|
|
2026-08-11 12:00:00 +00:00
|
|
|
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]
|
2026-08-21 12:00:00 +00:00
|
|
|
python scripts/pruefe_upstream_drift.py --selbsttest
|
2026-08-11 12:00:00 +00:00
|
|
|
"""
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
import re
|
|
|
|
|
import shutil
|
2026-08-11 12:00:00 +00:00
|
|
|
import sys
|
2026-08-21 12:00:00 +00:00
|
|
|
import tempfile
|
2026-08-11 12:00:00 +00:00
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
try:
|
|
|
|
|
import yaml
|
|
|
|
|
except ImportError: # pragma: no cover
|
|
|
|
|
sys.exit("pruefe_upstream_drift.py braucht PyYAML: pip install pyyaml")
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
UPSTREAM = "docs/sources/upstream"
|
|
|
|
|
BASELINE = f"{UPSTREAM}/neckbeard-v0.3.1"
|
2026-08-11 12:00:00 +00:00
|
|
|
MARKE = "<!-- projektabschnitt -->"
|
2026-08-21 12:00:00 +00:00
|
|
|
HERKUNFT = "HERKUNFT.md"
|
|
|
|
|
ABGLEICH = "ABGLEICH.tsv"
|
2026-08-11 12:00:00 +00:00
|
|
|
|
|
|
|
|
# (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"),
|
2026-08-21 12:00:00 +00:00
|
|
|
("docs/ledger/template.md", "templates/ledger-template.md"),
|
|
|
|
|
("docs/verdict/template.md", "templates/verdict-template.md"),
|
2026-08-21 12:00:00 +00:00
|
|
|
("scripts/check_harvest.py", "scripts/check_harvest.py"),
|
|
|
|
|
("scripts/judge.py", "scripts/judge.py"),
|
2026-08-11 12:00:00 +00:00
|
|
|
]
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
# Erklärt erweitert (ADR-0024): kein Byte-Vergleich, nur Diff-Referenz.
|
|
|
|
|
ERWEITERT = (
|
|
|
|
|
"schema.yaml",
|
|
|
|
|
"scripts/validate.py",
|
|
|
|
|
"scripts/gen_status.py",
|
|
|
|
|
)
|
2026-08-11 12:00:00 +00:00
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
# 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",
|
|
|
|
|
)
|
2026-08-11 12:00:00 +00:00
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
# Von der Zuordnung ausgenommen: die Herkunftsnotiz selbst und AGENTS.md,
|
|
|
|
|
# das über den Präfix-Vergleich läuft.
|
|
|
|
|
SONDERFALL = (HERKUNFT, ABGLEICH, "AGENTS.md")
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
# Fixture fuer den Selbsttest: echtes, minimales YAML in der Form, die die
|
|
|
|
|
# Deckungspruefung erwartet.
|
|
|
|
|
SCHEMA_FIXTURE = """link_fields: [related, judged]
|
|
|
|
|
types:
|
|
|
|
|
wiki-page:
|
|
|
|
|
required: [type, area]
|
|
|
|
|
fields:
|
|
|
|
|
status: { enum: [open, harvested] }
|
|
|
|
|
harvested_in: { kind: str }
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
# Unsere Fassung: alles aus der Baseline plus eigene Zusaetze - ein vierter
|
|
|
|
|
# Enum-Wert und ein eigener Typ. Beides darf NICHT gemeldet werden.
|
|
|
|
|
SCHEMA_FIXTURE_UNSER = SCHEMA_FIXTURE.replace(
|
|
|
|
|
"{ enum: [open, harvested] }", "{ enum: [open, harvested, declined] }"
|
|
|
|
|
) + " eigener_typ:\n required: [type]\n"
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
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:
|
2026-08-11 12:00:00 +00:00
|
|
|
for arbeit, original in PAARE:
|
|
|
|
|
a, b = root / arbeit, base / original
|
|
|
|
|
if not b.is_file():
|
|
|
|
|
fehler.append(f"Baseline fehlt: {BASELINE}/{original}")
|
2026-08-21 12:00:00 +00:00
|
|
|
elif not a.is_file():
|
2026-08-11 12:00:00 +00:00
|
|
|
fehler.append(f"Arbeitskopie fehlt: {arbeit}")
|
2026-08-21 12:00:00 +00:00
|
|
|
elif a.read_bytes() != b.read_bytes():
|
2026-08-11 12:00:00 +00:00
|
|
|
fehler.append(f"DRIFT: {arbeit} weicht von {BASELINE}/{original} ab")
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
def pruefe_agents(root: Path, base: Path, fehler: list[str]) -> None:
|
2026-08-11 12:00:00 +00:00
|
|
|
agents, agents_base = root / "AGENTS.md", base / "AGENTS.md"
|
|
|
|
|
if not agents_base.is_file():
|
|
|
|
|
fehler.append(f"Baseline fehlt: {BASELINE}/AGENTS.md")
|
2026-08-21 12:00:00 +00:00
|
|
|
return
|
|
|
|
|
if not agents.is_file():
|
2026-08-11 12:00:00 +00:00
|
|
|
fehler.append("Arbeitskopie fehlt: AGENTS.md")
|
2026-08-21 12:00:00 +00:00
|
|
|
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")
|
2026-08-11 12:00:00 +00:00
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
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.")
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
def pruefe_schema_deckung(root: Path, base: Path, fehler: list[str]) -> None:
|
|
|
|
|
"""Deckt unsere schema.yaml noch alles ab, was die Baseline fuehrt?
|
|
|
|
|
|
|
|
|
|
⚠️ Anlass: Beim Upgrade auf v0.3.1 wurde der Handabgleich quittiert —
|
|
|
|
|
wahrheitsgemaess — und verlor trotzdem zwei Felder (wiki-page.status,
|
|
|
|
|
wiki-page.harvested_in). Die Quittungspflicht aus ADR-0024 fragt, OB
|
|
|
|
|
nachgezogen wurde, nicht OB VOLLSTAENDIG. Sie wurde in der Annahme
|
|
|
|
|
gebaut, der Fehler sei Vergessen und nicht Falschmachen; dieselbe
|
|
|
|
|
Aufgabe hat das an ihrem ersten Tag widerlegt.
|
|
|
|
|
|
|
|
|
|
Diese Pruefung schliesst die Luecke nur fuer schema.yaml, weil YAML
|
|
|
|
|
strukturell vergleichbar ist. Fuer die beiden Python-Dateien gibt es
|
|
|
|
|
keine Entsprechung — das bleibt offen und ist als Muster notiert.
|
|
|
|
|
|
|
|
|
|
Erlaubt ist Hinzufuegen (wir erweitern erklaert, ADR-0024), verboten
|
|
|
|
|
ist Verlieren.
|
|
|
|
|
"""
|
|
|
|
|
eigen_pfad, basis_pfad = root / "schema.yaml", base / "schema.yaml"
|
|
|
|
|
if not (eigen_pfad.is_file() and basis_pfad.is_file()):
|
|
|
|
|
fehler.append("schema.yaml fehlt in Arbeitskopie oder Baseline")
|
|
|
|
|
return
|
|
|
|
|
try:
|
|
|
|
|
eigen = yaml.safe_load(eigen_pfad.read_text(encoding="utf-8")) or {}
|
|
|
|
|
basis = yaml.safe_load(basis_pfad.read_text(encoding="utf-8")) or {}
|
|
|
|
|
except yaml.YAMLError as ex:
|
|
|
|
|
fehler.append(f"schema.yaml nicht lesbar: {str(ex)[:80]}")
|
|
|
|
|
return
|
|
|
|
|
# Ein Schema, das keine Abbildung ist, ist ein Fehler - kein Absturz und
|
|
|
|
|
# erst recht kein stilles Bestanden.
|
|
|
|
|
if not isinstance(eigen, dict) or not isinstance(basis, dict):
|
|
|
|
|
fehler.append("schema.yaml ist keine YAML-Abbildung "
|
|
|
|
|
"(Arbeitskopie oder Baseline)")
|
|
|
|
|
return
|
|
|
|
|
|
|
|
|
|
for schluessel in ("link_fields", "vendored"):
|
|
|
|
|
fehlt = set(basis.get(schluessel) or []) - set(eigen.get(schluessel) or [])
|
|
|
|
|
if fehlt:
|
|
|
|
|
fehler.append(f"schema.yaml: {schluessel} verliert {sorted(fehlt)} "
|
|
|
|
|
f"gegenueber {base.name}")
|
|
|
|
|
|
|
|
|
|
for typ, spec in (basis.get("types") or {}).items():
|
|
|
|
|
eigener = (eigen.get("types") or {}).get(typ)
|
|
|
|
|
if eigener is None:
|
|
|
|
|
fehler.append(f"schema.yaml: Typ '{typ}' aus {base.name} fehlt")
|
|
|
|
|
continue
|
|
|
|
|
for teil in ("fields", "required", "rules"):
|
|
|
|
|
hier = eigener.get(teil) or ([] if teil != "fields" else {})
|
|
|
|
|
dort = spec.get(teil) or ([] if teil != "fields" else {})
|
|
|
|
|
fehlt = set(dort) - set(hier)
|
|
|
|
|
if fehlt:
|
|
|
|
|
fehler.append(f"schema.yaml: {typ}.{teil} verliert "
|
|
|
|
|
f"{sorted(fehlt)} gegenueber {base.name}")
|
|
|
|
|
for feld, regel in (spec.get("fields") or {}).items():
|
|
|
|
|
werte = (regel or {}).get("enum")
|
|
|
|
|
unser = ((eigener.get("fields") or {}).get(feld) or {}).get("enum")
|
|
|
|
|
if werte and unser is not None:
|
|
|
|
|
fehlt = set(werte) - set(unser)
|
|
|
|
|
if fehlt:
|
|
|
|
|
fehler.append(f"schema.yaml: {typ}.{feld} Enum verliert "
|
|
|
|
|
f"{sorted(fehlt)} gegenueber {base.name}")
|
|
|
|
|
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
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)
|
2026-08-21 12:00:00 +00:00
|
|
|
pruefe_schema_deckung(root, base, fehler)
|
2026-08-21 12:00:00 +00:00
|
|
|
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")
|
2026-08-21 12:00:00 +00:00
|
|
|
# ⚠️ schema.yaml MUSS echtes YAML sein. Eine Prosa-Fixture laesst
|
|
|
|
|
# die Deckungspruefung abstuerzen statt pruefen - dieselbe Klasse,
|
|
|
|
|
# die bei diesem Skript schon einmal eine Zusicherung wertlos
|
|
|
|
|
# gemacht hat (FB-12, Punkt 3).
|
|
|
|
|
(b / "schema.yaml").write_text(SCHEMA_FIXTURE, encoding="utf-8")
|
2026-08-21 12:00:00 +00:00
|
|
|
(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")
|
2026-08-21 12:00:00 +00:00
|
|
|
(root / "schema.yaml").write_text(SCHEMA_FIXTURE_UNSER,
|
|
|
|
|
encoding="utf-8")
|
2026-08-21 12:00:00 +00:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
(neu / ERWEITERT[0]).write_text(SCHEMA_FIXTURE + "# oben angefasst\n",
|
|
|
|
|
encoding="utf-8")
|
2026-08-21 12:00:00 +00:00
|
|
|
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)))
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
(neu / ERWEITERT[0]).write_text(SCHEMA_FIXTURE, encoding="utf-8")
|
2026-08-21 12:00:00 +00:00
|
|
|
(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)
|
2026-08-21 12:00:00 +00:00
|
|
|
# --- Deckung der erklaert-erweiterten schema.yaml -------------------
|
|
|
|
|
# Anlass: Der Handabgleich beim v0.3.1-Upgrade wurde quittiert und
|
|
|
|
|
# verlor trotzdem zwei Felder. Die Quittungspflicht fragt OB, nicht
|
|
|
|
|
# OB VOLLSTAENDIG.
|
|
|
|
|
eigen = root / "schema.yaml"
|
|
|
|
|
eigen.write_text(SCHEMA_FIXTURE_UNSER.replace(
|
|
|
|
|
" harvested_in: { kind: str }\n", ""), encoding="utf-8")
|
|
|
|
|
pruefe_dass("ein verlorenes Feld der Baseline wird gemeldet",
|
|
|
|
|
any("wiki-page.fields verliert" in f for f in pruefe(root)))
|
|
|
|
|
|
|
|
|
|
eigen.write_text(SCHEMA_FIXTURE_UNSER.replace(
|
|
|
|
|
"[open, harvested, declined]", "[open, declined]"), encoding="utf-8")
|
|
|
|
|
pruefe_dass("ein verlorener Enum-Wert wird gemeldet",
|
|
|
|
|
any("Enum verliert" in f for f in pruefe(root)))
|
|
|
|
|
|
|
|
|
|
eigen.write_text(SCHEMA_FIXTURE_UNSER.replace(
|
|
|
|
|
" wiki-page:", " andere-seite:"), encoding="utf-8")
|
|
|
|
|
pruefe_dass("ein verlorener Typ wird gemeldet",
|
|
|
|
|
any("Typ 'wiki-page'" in f for f in pruefe(root)))
|
|
|
|
|
|
|
|
|
|
eigen.write_text(SCHEMA_FIXTURE_UNSER.replace(
|
|
|
|
|
"link_fields: [related, judged]", "link_fields: [related]"),
|
|
|
|
|
encoding="utf-8")
|
|
|
|
|
pruefe_dass("ein verlorenes link_fields wird gemeldet",
|
|
|
|
|
any("link_fields verliert" in f for f in pruefe(root)))
|
|
|
|
|
|
|
|
|
|
eigen.write_text("nur prosa, kein schema\n", encoding="utf-8")
|
|
|
|
|
befunde = pruefe(root)
|
|
|
|
|
pruefe_dass("ein Schema ohne Abbildung ist ein Fehler, kein Absturz",
|
|
|
|
|
any("keine YAML-Abbildung" in f for f in befunde))
|
|
|
|
|
|
|
|
|
|
eigen.write_text(SCHEMA_FIXTURE_UNSER, encoding="utf-8")
|
|
|
|
|
pruefe_dass("eigene Zusaetze - vierter Enum-Wert, eigener Typ - sind still",
|
|
|
|
|
pruefe(root) == [])
|
|
|
|
|
|
2026-08-21 12:00:00 +00:00
|
|
|
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)
|
2026-08-11 12:00:00 +00:00
|
|
|
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())
|