Files
claude-regelwerk/.vault-tools/session-hooks/faelle_sitzungsstart.py
T
Pascal MichelandClaude Opus 5 1edbd7ebe5 Automatischer Push: 7 Datei(en) geändert
- .vault-tools/session-hooks/ENTWURF.md
- .vault-tools/session-hooks/faelle_sitzungsstart.py
- .vault-tools/workflow-pruefung/faelle_workflows.mjs
- .vault-tools/workflow-pruefung/mutanten_workflows.py
- AI/Regelwerk/navigation.md
- AI/Regelwerk/seo.md
- CLAUDE.md

Erzeugt vom PostToolUse-Hook .vault-tools/auto-push/auto_push.py.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-05 04:00:02 +02:00

696 lines
30 KiB
Python

"""Fährt die Angriffsliste gegen sitzungsstart.py, je Fall auf einem Wegwerf-Vault.
Ersteller: Pascal Michel
Erstellt: 2026-09-13
Entwurf: ENTWURF.md im selben Ordner
Grundlinie zuerst: Liefert der unbeschädigte Wegwerf-Vault nicht genau die
erwarteten Zeilen, misst der Rest nichts und der Treiber bricht ab.
Je Fall eine frische Kopie der Grundlinie, genau eine Änderung und der Hook als
eigener Prozess. Der Hook wird dafür immer zuerst in den Wegwerf-Vault kopiert:
seit der Nachbesserung Runde 2 bestimmt sitzungsstart.py seine Vault-Wurzel
ausschließlich aus dem eigenen Skriptpfad, das Feld cwd der Hook-Eingabe zählt
nicht mehr (Prüferbefund Runde 2, Koordinatorentscheidung). Nur eine Kopie im
Wegwerf-Vault sorgt dafür, dass diese Wurzel auch tatsächlich der Wegwerf-Vault
ist statt des echten Vaults, in dem das Original liegt. Vier Fehlerpfade laufen
im Prozess, weil sie sich von außen nicht auslösen lassen. Protokolliert werden
Exit-Code, Laufzeit und bei Abweichung der ganze Kontext. Bestanden heißt:
Exit 0, stdout ist genau ein JSON-Objekt der erwarteten Form, jede erwartete
Zeile steht da, keine verbotene.
Aufruf:
python .vault-tools/session-hooks/faelle_sitzungsstart.py
python .vault-tools/session-hooks/faelle_sitzungsstart.py --echt
"""
from __future__ import annotations
import contextlib
import datetime
import importlib.util
import io
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable
HIER = Path(__file__).resolve().parent
HOOK = HIER / "sitzungsstart.py"
LINKCHECK = HIER.parent / "linkcheck" / "linkcheck.py"
HEUTE = datetime.date.today().isoformat()
GESTERN = (datetime.date.today() - datetime.timedelta(days=1)).isoformat()
ZIEL_LAUFZEIT_S = 8.0
DA = f"- Datum laut Maschinenuhr: {HEUTE} (das Sitzungsdatum aus dem Kontext hat Vorrang)"
TN = f"- Tagesnotiz Inbox/{HEUTE}.md: "
IN = "- Inbox-Notizen ohne type: daily: "
NR = "- Notizen mit Tag needs-review: "
RV = "- Notizen mit Tag review ohne review-done: "
PR = "- Regelwerk-Dateien mit status: pending-review: "
SI = f"- Sitzungsdateien AI/sessions/{HEUTE}-*.md: "
WA = "- Waisen außerhalb der Ausnahmen: "
FEHLERZEILE = "Sitzungsstart-Hook:"
WAISEN = HIER.parent / "linkcheck" / "waisen.py"
AUSNAHMEN = HIER.parent / "linkcheck" / "waisen-ausnahmen.txt"
def schreibe(vault: Path, rel: str, inhalt: str | bytes) -> None:
# Immer Bytes: write_text würde unter Windows jedes LF zu CRLF machen.
pfad = vault / rel
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(inhalt if isinstance(inhalt, bytes) else inhalt.encode("utf-8"))
def baue_grundlinie(v: Path) -> None:
schreibe(v, f"Inbox/{HEUTE}.md",
"---\ntype: daily\ntitle: \"Heute\"\nstatus: open\ntags: [daily]\n---\n\n# Heute\n")
schreibe(v, "Inbox/offen.md", "---\ntype: note\ntags: [inbox]\n---\n\nUnverarbeitet.\n")
schreibe(v, "AI/Regelwerk/regel.md", "---\ntype: resource\nstatus: reference\n---\n\n# Regel\n")
schreibe(v, f"AI/sessions/{HEUTE}-10.md", "---\ntype: session\n---\n")
schreibe(v, f"AI/sessions/{GESTERN}-10.md", "---\ntype: session\n---\n")
schreibe(v, "Notizen/sauber.md",
"---\ntype: note\ntags: [thema]\n---\n\nKein Tag hier, nur das Wort review ohne Raute.\n")
# waisen.py braucht einen Obsidian-Vault; ohne Filter hat die Grundlinie sechs Waisen.
schreibe(v, ".obsidian/app.json", '{"userIgnoreFilters": []}')
def grundzeilen(v: Path) -> list[str]:
return [
f"Sitzungsstart-Hook (Vault: {v})",
DA,
TN + "vorhanden",
IN + "1 (offen.md)",
NR + "0",
RV + "0",
PR + "0",
SI + f"1 ({HEUTE}-10.md)",
WA + "6",
]
def kopiere_hook(v: Path, linkcheck_kopieren: bool = True, waisen_kopieren: bool = True) -> Path:
"""Kopiert den Hook (und optional linkcheck.py) in den Wegwerf-Vault.
Seit der Nachbesserung Runde 2 bestimmt sitzungsstart.py seine Vault-
Wurzel ausschließlich aus dem eigenen Skriptpfad (zwei Ebenen darüber,
siehe bestimme_wurzel()). Nur eine Kopie im Wegwerf-Vault sorgt dafür,
dass diese Wurzel auch tatsächlich der Wegwerf-Vault ist statt des echten
Vaults, in dem das Original unter HIER liegt.
"""
ziel = v / ".vault-tools" / "session-hooks"
ziel.mkdir(parents=True, exist_ok=True)
skript = ziel / HOOK.name
shutil.copy2(HOOK, skript)
lc_ziel = v / ".vault-tools" / "linkcheck"
lc_ziel.mkdir(parents=True, exist_ok=True)
if linkcheck_kopieren:
shutil.copy2(LINKCHECK, lc_ziel / "linkcheck.py")
if waisen_kopieren:
shutil.copy2(WAISEN, lc_ziel / "waisen.py")
shutil.copy2(AUSNAHMEN, lc_ziel / "waisen-ausnahmen.txt")
return skript
def ohne_pythonioencoding(_: Path) -> dict[str, str]:
"""Claude Code setzt PYTHONIOENCODING beim Hook-Aufruf nicht; nur die
globale Planvorgabe setzt sie vor dem Aufruf dieses Treibers selbst
(Prüferbefund Runde 2)."""
umgebung = dict(os.environ)
umgebung.pop("PYTHONIOENCODING", None)
return umgebung
def _baue_anderswo(v: Path) -> Path:
"""Ein echter, leerer Ordner neben v. Würde cwd doch noch gelesen, wäre
das am fehlenden Inhalt sofort sichtbar (Prüferbefund Runde 2)."""
anderswo = v.parent / "anderswo"
anderswo.mkdir(parents=True, exist_ok=True)
return anderswo
@dataclass
class Fall:
name: str
aendern: Callable[[Path], object]
# None heisst: genau die Zeilen der Grundlinie, Kopfzeile eingeschlossen.
erwartet: list[str] | None
verboten: list[str] = field(default_factory=lambda: [FEHLERZEILE])
praefixe: list[str] = field(default_factory=list)
# Ueberschreibt die Standard-Eingabe (cwd auf den Wegwerf-Vault). Der Hook
# liest cwd seit Runde 2 nicht mehr; das Feld bleibt hier nur fuer Faelle,
# die gezielt eine kaputte oder ungewoehnliche stdin-Form pruefen wollen.
stdin: Callable[[Path], bytes] | None = None
linkcheck_kopieren: bool = True
waisen_kopieren: bool = True
# None: Subprozess erbt die Umgebung dieses Treibers unveraendert. Gesetzt:
# ersetzt die Umgebung vollstaendig (Pruefbefund Runde 2).
umgebung: Callable[[Path], dict[str, str]] | None = None
def nichts(v: Path) -> None:
return None
FAELLE: list[Fall] = [
Fall("umgebung | Inbox-Ordner fehlt",
lambda v: shutil.rmtree(v / "Inbox"),
[TN + "fehlt", IN + "Ordner Inbox/ fehlt"]),
Fall("fehlt | Tagesnotiz fehlt",
lambda v: (v / "Inbox" / f"{HEUTE}.md").unlink(),
[TN + "fehlt", IN + "1 (offen.md)"]),
Fall("wertebereich| nur die Tagesnotiz von gestern",
lambda v: (v / "Inbox" / f"{HEUTE}.md").rename(v / "Inbox" / f"{GESTERN}.md"),
[TN + "fehlt", IN + "1 (offen.md)"]),
Fall("leer | leere Datei in der Inbox",
lambda v: schreibe(v, "Inbox/leer.md", b""),
[IN + "2 (leer.md, offen.md)"]),
Fall("leer | leere Notiz im Vault",
lambda v: schreibe(v, "Notizen/leer.md", b""),
[NR + "0", RV + "0"]),
Fall("typ | Tagesnotiz ohne Frontmatter",
lambda v: schreibe(v, f"Inbox/{HEUTE}.md", "# Heute\n"),
[TN + "vorhanden", IN + f"2 ({HEUTE}.md, offen.md)"]),
Fall("typ | type in Anfuehrungszeichen",
lambda v: schreibe(v, "Inbox/offen.md", "---\ntype: \"daily\"\n---\n"),
[IN + "0"]),
Fall("CRLF | Frontmatter-Tag mit CRLF",
lambda v: schreibe(v, "Notizen/crlf.md",
b"---\r\ntype: note\r\ntags: [needs-review]\r\n---\r\n\r\nText\r\n"),
[NR + "1 (Notizen/crlf.md)"]),
Fall("CRLF | type: daily mit CRLF",
lambda v: schreibe(v, "Inbox/offen.md", b"---\r\ntype: daily\r\n---\r\n"),
[IN + "0"]),
Fall("BOM | BOM vor dem Frontmatter mit Tag",
lambda v: schreibe(v, "Notizen/bom.md", b"\xef\xbb\xbf---\ntags: [needs-review]\n---\n"),
[NR + "1 (Notizen/bom.md)"]),
Fall("BOM | BOM vor type: daily",
lambda v: schreibe(v, "Inbox/offen.md", b"\xef\xbb\xbf---\ntype: daily\n---\n"),
[IN + "0"]),
Fall("kodierung | ungueltiges UTF-8 vor dem Tag",
lambda v: schreibe(v, "Notizen/kaputt.md", b"Text \xff\xfe kaputt\n#needs-review\n"),
[NR + "1 (Notizen/kaputt.md)"]),
Fall("yaml | Tag-Liste nicht geschlossen",
lambda v: schreibe(v, "Notizen/yaml.md", "---\ntags: [needs-review\ntype: note\n---\n"),
[NR + "1 (Notizen/yaml.md)"]),
Fall("yaml | Frontmatter ohne Schlusszeile",
lambda v: schreibe(v, "Notizen/ohne-schluss.md", "---\ntags: [needs-review]\ntype: note\n"),
[NR + "0"]),
Fall("yaml | Block-Liste, Doppelpunkt im Titel",
lambda v: schreibe(v, "Notizen/block.md",
"---\ntitle: \"A: B\"\ntags:\n - thema\n - needs-review\n---\n"),
[NR + "1 (Notizen/block.md)"]),
Fall("yaml | Tag mit Raute in Anfuehrungszeichen",
lambda v: schreibe(v, "Notizen/raute.md", "---\ntags: [\"#needs-review\"]\n---\n"),
[NR + "1 (Notizen/raute.md)"]),
Fall("falscher Typ| tags als Skalar statt Liste",
lambda v: schreibe(v, "Notizen/skalar.md", "---\ntags: needs-review\n---\n"),
[NR + "1 (Notizen/skalar.md)"]),
Fall("code | Tags im Fenced-Codeblock",
lambda v: schreibe(v, "Notizen/code.md", "Text\n\n```\n#needs-review\n#review\n```\n"),
[NR + "0", RV + "0"]),
Fall("code | Tag im Tilde-Codeblock",
lambda v: schreibe(v, "Notizen/tilde.md", "~~~\n#needs-review\n~~~\n"),
[NR + "0"]),
Fall("code | Tags im Inline-Code",
lambda v: schreibe(v, "Notizen/inline.md",
"Das Wort `#needs-review` und `#review` nur erwaehnt.\n"),
[NR + "0", RV + "0"]),
Fall("reihenfolge | Tag an erster Stelle der Datei",
lambda v: schreibe(v, "Notizen/erste.md", "#needs-review am Anfang\n\nText\n"),
[NR + "1 (Notizen/erste.md)"]),
Fall("reihenfolge | Tag an letzter Stelle, ohne Zeilenende",
lambda v: schreibe(v, "Notizen/letzte.md", "Text\n\nzuletzt #needs-review"),
[NR + "1 (Notizen/letzte.md)"]),
Fall("reihenfolge | Tag direkt nach einem Codeblock",
lambda v: schreibe(v, "Notizen/nach-code.md", "```\ncode\n```\n#needs-review\n"),
[NR + "1 (Notizen/nach-code.md)"]),
Fall("grenze | aehnliche Tags zaehlen nicht",
lambda v: schreibe(v, "Notizen/aehnlich.md",
"#needs-review-done #needs-reviewer abc#needs-review #needs-review/unter\n"),
[NR + "0"]),
Fall("grenze | Grossschreibung zaehlt",
lambda v: schreibe(v, "Notizen/gross.md", "Bitte #Needs-Review.\n"),
[NR + "1 (Notizen/gross.md)"]),
Fall("grenze | Frontmatter-Tag in Grossschreibung zaehlt",
lambda v: schreibe(v, "Notizen/frontmatter-gross.md", "---\ntags: [Needs-Review]\n---\n"),
[NR + "1 (Notizen/frontmatter-gross.md)"]),
Fall("review | review ohne review-done",
lambda v: schreibe(v, "Notizen/r.md", "Text #review\n"),
[RV + "1"]),
Fall("review | review-done neben review",
lambda v: schreibe(v, "Notizen/r.md", "#review\n\nspaeter #review-done\n"),
[RV + "0"]),
Fall("review | review im Frontmatter, review-done im Text",
lambda v: schreibe(v, "Notizen/r.md", "---\ntags: [review]\n---\n\nAbgenommen #review-done\n"),
[RV + "0"]),
Fall("review | review-done allein",
lambda v: schreibe(v, "Notizen/r.md", "#review-done\n"),
[RV + "0"]),
Fall("review | needs-review ist kein review",
lambda v: schreibe(v, "Notizen/nur-needs.md", "#needs-review\n"),
[NR + "1 (Notizen/nur-needs.md)", RV + "0"]),
Fall("grenze | aehnliche review-Tags zaehlen nicht",
lambda v: schreibe(v, "Notizen/aehnlich-review.md",
"#reviewer abc#review #review/x\n"),
[RV + "0"]),
Fall("grenze | aehnlicher review-done-Tag zaehlt nicht als review-done",
lambda v: schreibe(v, "Notizen/aehnlich-review-done.md",
"#review\n\n#review-done-x\n"),
[RV + "1"]),
Fall("grenze | abc#review-done zaehlt nicht als review-done",
lambda v: schreibe(v, "Notizen/eingebettet-done.md", "#review\n\nabc#review-done\n"),
[RV + "1"]),
Fall("ausschluss | Punktdatei ausserhalb von Punktordnern",
lambda v: schreibe(v, "Notizen/.versteckt.md", "#needs-review\n"),
[NR + "0"]),
Fall("reihenfolge | review an erster und an letzter Stelle",
lambda v: (schreibe(v, "Notizen/a-erste.md", "#review\n\nText\n"),
schreibe(v, "Notizen/z-letzte.md", "Text\n\n#review")),
[RV + "2"]),
Fall("ausschluss | Import/ unter der Wurzel",
lambda v: schreibe(v, "Import/x.md", "#needs-review\n#review\n"),
[NR + "0", RV + "0"]),
Fall("ausschluss | _index/ unter der Wurzel",
lambda v: schreibe(v, "_index/INDEX.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | Punktordner",
lambda v: schreibe(v, ".obsidian/x.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | node_modules in der Tiefe",
lambda v: schreibe(v, "Code/app/node_modules/pkg/README.md", "#needs-review\n"),
[NR + "0"]),
Fall("ausschluss | Import/ in der Tiefe zaehlt",
lambda v: schreibe(v, "Projekt/Import/x.md", "#needs-review\n"),
[NR + "1 (Projekt/Import/x.md)"]),
Fall("ausschluss | Punktordner in der Inbox",
lambda v: schreibe(v, "Inbox/.trash/versteckt.md", "x\n"),
[IN + "1 (offen.md)"]),
Fall("zusaetzlich | sieben weitere Inbox-Notizen, Namen gekuerzt",
lambda v: [schreibe(v, f"Inbox/n0{i}.md", "---\ntype: note\n---\n") for i in range(1, 8)],
[IN + "8 (n01.md, n02.md, n03.md, n04.md, n05.md, +3 weitere)"]),
Fall("zusaetzlich | Notiz in einem Inbox-Unterordner",
lambda v: schreibe(v, "Inbox/sub/tief.md", "x\n"),
[IN + "2 (offen.md, sub/tief.md)"]),
Fall("regelwerk | status: pending-review",
lambda v: schreibe(v, "AI/Regelwerk/b.md", "---\ntype: resource\nstatus: pending-review\n---\n"),
[PR + "1 (b.md)"]),
Fall("regelwerk | status in Anfuehrungszeichen",
lambda v: schreibe(v, "AI/Regelwerk/c.md", "---\nstatus: \"pending-review\"\n---\n"),
[PR + "1 (c.md)"]),
Fall("regelwerk | pending-review nur im Text",
lambda v: schreibe(v, "AI/Regelwerk/d.md", "---\nstatus: reference\n---\nstatus: pending-review\n"),
[PR + "0"]),
Fall("umgebung | AI/Regelwerk fehlt",
lambda v: shutil.rmtree(v / "AI" / "Regelwerk"),
[PR + "Ordner AI/Regelwerk/ fehlt"]),
Fall("doppelt | zwei Sitzungsdateien von heute",
lambda v: schreibe(v, f"AI/sessions/{HEUTE}-10b.md", "x\n"),
[SI + f"2 ({HEUTE}-10.md, {HEUTE}-10b.md)"]),
Fall("umgebung | AI/sessions fehlt",
lambda v: shutil.rmtree(v / "AI" / "sessions"),
[SI + "Ordner AI/sessions/ fehlt"]),
Fall("leer | Vault ohne jeden Ordner",
lambda v: [shutil.rmtree(p) for p in list(v.iterdir())],
[TN + "fehlt", IN + "Ordner Inbox/ fehlt", NR + "0", RV + "0",
PR + "Ordner AI/Regelwerk/ fehlt", SI + "Ordner AI/sessions/ fehlt"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: kein Obsidian-Vault"]),
Fall("leer | stdin leer", nichts, None, stdin=lambda v: b""),
Fall("falscher Typ| stdin kein JSON", nichts, None, stdin=lambda v: b"kein json"),
Fall("kodierung | stdin ungueltiges UTF-8", nichts, None, stdin=lambda v: b"\xff\xfe{"),
Fall("falscher Typ| stdin ist eine Liste", nichts, None, stdin=lambda v: b'[{"cwd": "x"}]'),
Fall("fehlt | cwd fehlt", nichts, None,
stdin=lambda v: b'{"hook_event_name": "SessionStart"}'),
Fall("null | cwd ist null", nichts, None, stdin=lambda v: b'{"cwd": null}'),
Fall("falscher Typ| cwd ist eine Zahl", nichts, None, stdin=lambda v: b'{"cwd": 5}'),
Fall("leer | cwd ist leer", nichts, None, stdin=lambda v: b'{"cwd": ""}'),
Fall("verweis | cwd existiert nicht", nichts, None,
stdin=lambda v: json.dumps({"cwd": str(v / "gibtsnicht")}).encode("utf-8")),
Fall("falscher Typ| cwd zeigt auf eine Datei", nichts, None,
stdin=lambda v: json.dumps({"cwd": str(v / "Inbox" / "offen.md")}).encode("utf-8")),
Fall("verweis | cwd zeigt auf einen anderen Ordner, Ergebnis bleibt der Skript-Vault",
nichts, None,
stdin=lambda v: json.dumps({"cwd": str(_baue_anderswo(v))}).encode("utf-8")),
Fall("umgebung | ohne PYTHONIOENCODING mit Nicht-ASCII-Inhalt bleibt gueltiges UTF-8-JSON",
lambda v: schreibe(v, "Notizen/Übersicht.md", "Prüfung #needs-review äöü 🚀\n"),
[NR + "1 (Notizen/Übersicht.md)"],
umgebung=ohne_pythonioencoding),
Fall("umgebung | linkcheck.py fehlt", nichts,
[DA, TN + "vorhanden", IN + "1 (offen.md)", PR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[NR, RV, WA],
praefixe=["Sitzungsstart-Hook: needs-review/review: RuntimeError: linkcheck.py nicht importierbar",
"Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: linkcheck.py nicht importierbar"],
stdin=lambda v: b"{}",
linkcheck_kopieren=False),
Fall("waisen | verlinkte Notiz senkt die Zahl",
lambda v: schreibe(v, "Notizen/verweis.md", "[[Notizen/sauber]]\n"),
[WA + "5"]),
Fall("waisen | Waise an erster und an letzter Stelle",
lambda v: (schreibe(v, "0-erste/a.md", "x\n"), schreibe(v, "zz-letzte/z.md", "x\n")),
[WA + "8"]),
Fall("waisen | Muster aus der Ausnahmedatei greift",
lambda v: schreibe(v, "Bookstack/archiv/seite.md", "x\n"),
[WA + "6"]),
Fall("waisen | waisen.py fehlt", nichts,
[DA, TN + "vorhanden", IN + "1 (offen.md)", NR + "0", RV + "0", PR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py fehlt"],
waisen_kopieren=False),
Fall("waisen | kaputte app.json meldet Fehler statt Zahl",
lambda v: schreibe(v, ".obsidian/app.json", "{kaputt"),
[DA, TN + "vorhanden", NR + "0", SI + f"1 ({HEUTE}-10.md)"],
verboten=[WA],
praefixe=["Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py Exit 2: Waisen - ABGEBROCHEN: .obsidian/app.json nicht lesbar"]),
Fall("umgebung | ohne PYTHONIOENCODING zaehlt Waisen mit Umlaut im Namen",
lambda v: schreibe(v, "Notizen/Übersicht 🚀.md", "x\n"),
[WA + "7"],
umgebung=ohne_pythonioencoding),
]
def streng_utf8(roh: bytes) -> str:
# stdout muss gueltiges UTF-8 sein. Ein ungueltiges Byte darf nicht still
# ersetzt werden, sonst besteht der Umgebungsfall auch dann, wenn der Hook
# cp1252 schreibt (Pruefrunde 2, Befund zur Dekodierung).
try:
return roh.decode("utf-8")
except UnicodeDecodeError as exc:
return "UNGUELTIGES-UTF-8: %s" % exc
def fahre(
skript: Path,
eingabe: bytes,
arbeitsordner: Path,
umgebung: dict[str, str] | None = None,
) -> tuple[int, str, str, float]:
start = time.perf_counter()
lauf = subprocess.run(
[sys.executable, str(skript)],
input=eingabe,
capture_output=True,
cwd=str(arbeitsordner),
timeout=60,
env=umgebung,
)
dauer = time.perf_counter() - start
return (
lauf.returncode,
streng_utf8(lauf.stdout),
lauf.stderr.decode("utf-8", errors="replace"),
dauer,
)
def lies_kontext(code: int, out: str) -> tuple[list[str] | None, str]:
if code != 0:
return None, f"Exit {code} statt 0"
roh = out.strip()
if not (roh.startswith("{") and roh.endswith("}")):
return None, "stdout ist kein JSON-Objekt"
try:
daten = json.loads(roh)
except ValueError as exc:
return None, f"stdout nicht parsebar: {exc}"
if not isinstance(daten, dict) or set(daten) != {"hookSpecificOutput"}:
return None, "oberste Ebene ist nicht genau {hookSpecificOutput}"
innen = daten["hookSpecificOutput"]
if (not isinstance(innen, dict)
or innen.get("hookEventName") != "SessionStart"
or not isinstance(innen.get("additionalContext"), str)):
return None, "hookSpecificOutput hat nicht die erwartete Form"
return innen["additionalContext"].split("\n"), ""
def bewerte(zeilen: list[str], erwartet: list[str], verboten: list[str], praefixe: list[str]) -> list[str]:
probleme = [f"fehlt: {z}" for z in erwartet if z not in zeilen]
probleme += [f"fehlt Zeile mit Anfang: {p}" for p in praefixe
if not any(z.startswith(p) for z in zeilen)]
probleme += [f"verboten: {z}" for z in zeilen if any(z.startswith(p) for p in verboten)]
return probleme
def frische_kopie(basis: Path, arbeit: Path) -> Path:
v = arbeit / "fall"
if v.exists():
shutil.rmtree(v)
shutil.copytree(basis, v)
return v
def lauf_fall(fall: Fall, basis: Path, arbeit: Path) -> tuple[bool, str]:
v = frische_kopie(basis, arbeit)
fall.aendern(v)
skript = kopiere_hook(v, fall.linkcheck_kopieren, fall.waisen_kopieren)
if fall.stdin is None:
eingabe = json.dumps({"session_id": "faelle", "cwd": str(v),
"hook_event_name": "SessionStart", "source": "startup"}).encode("utf-8")
else:
eingabe = fall.stdin(v)
umgebung = fall.umgebung(v) if fall.umgebung is not None else None
code, out, err, dauer = fahre(skript, eingabe, arbeit, umgebung)
kopf = f"exit={code} {dauer:.2f}s"
zeilen, problem = lies_kontext(code, out)
if zeilen is None:
return False, f"{kopf}\n FORM: {problem}\n stdout: {out[:500]!r}\n stderr: {err[:800]!r}"
if fall.erwartet is None:
erwartet = grundzeilen(v)
else:
erwartet = [f"Sitzungsstart-Hook (Vault: {v})"] + fall.erwartet
probleme = bewerte(zeilen, erwartet, fall.verboten, fall.praefixe)
if not probleme:
return True, kopf
text = kopf + "\n" + "\n".join(" " + p for p in probleme)
text += "\n Kontext:\n" + "\n".join(" " + z for z in zeilen)
return False, text
def lade_hook(name: str, skript_pfad: Path):
spec = importlib.util.spec_from_file_location(name, skript_pfad)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def im_prozess(modul, v: Path) -> list[str] | None:
puffer = io.StringIO()
with contextlib.redirect_stdout(puffer):
code = modul.main(json.dumps({"cwd": str(v)}))
zeilen, _ = lies_kontext(code, puffer.getvalue())
return zeilen
def ip_punkt_wirft(v: Path) -> tuple[bool, str]:
skript = kopiere_hook(v)
modul = lade_hook("hook_punkt_wirft", skript)
def wirft(wurzel: Path, heute: str) -> list[str]:
raise RuntimeError("Testfehler")
for i, (name, _) in enumerate(modul.PUNKTE):
if name == "Sitzungen":
modul.PUNKTE[i] = (name, wirft)
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = [z for z in grundzeilen(v) if not z.startswith(SI)]
erwartet.append("Sitzungsstart-Hook: Sitzungen: RuntimeError: Testfehler")
probleme = bewerte(zeilen, erwartet, [SI], [])
return not probleme, "\n".join(" " + p for p in probleme)
def ip_hauptablauf_wirft(v: Path) -> tuple[bool, str]:
skript = kopiere_hook(v)
modul = lade_hook("hook_hauptablauf_wirft", skript)
def kaputt(wurzel: Path, heute: str, zeilen: list[str]) -> None:
zeilen.append("- vorher gesammelt")
raise RuntimeError("Testfehler Hauptablauf")
modul.baue_kontext = kaputt
zeilen = im_prozess(modul, v)
erwartet = ["- vorher gesammelt", "Sitzungsstart-Hook: RuntimeError: Testfehler Hauptablauf"]
if zeilen != erwartet:
return False, f" erwartet {erwartet!r}, bekommen {zeilen!r}"
return True, ""
def ip_datei_gesperrt(v: Path) -> tuple[bool, str]:
schreibe(v, "Notizen/gesperrt.md", "#needs-review\n")
skript = kopiere_hook(v)
modul = lade_hook("hook_datei_gesperrt", skript)
original = modul.lies_text
def gesperrt(pfad: Path) -> str:
if Path(pfad).name == "gesperrt.md":
raise PermissionError("Testsperre")
return original(pfad)
modul.lies_text = gesperrt
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
# gesperrt.md ist für waisen.py lesbar und die siebte Waise.
erwartet = [z for z in grundzeilen(v) if not z.startswith(WA)] + [WA + "7"]
erwartet.append("Sitzungsstart-Hook: 1 Datei(en) nicht lesbar (erste: Notizen/gesperrt.md)")
probleme = bewerte(zeilen, erwartet, [], [])
return not probleme, "\n".join(" " + p for p in probleme)
class _WalkPatch:
"""Ersetzt os.walk nur im Namensraum des geladenen Hook-Moduls (modul.os),
ohne das echte, global geteilte os-Modul anzufassen."""
def __init__(self, echt_os, fehler_pfad: Path) -> None:
self._echt = echt_os
self._fehler_pfad = fehler_pfad
def __getattr__(self, name):
return getattr(self._echt, name)
def walk(self, start, onerror=None, **kwargs):
if onerror is not None:
fehler = OSError(13, "Testsperre")
fehler.filename = str(self._fehler_pfad)
onerror(fehler)
yield from self._echt.walk(start, onerror=onerror, **kwargs)
def ip_ordner_gesperrt(v: Path) -> tuple[bool, str]:
"""Der onerror-Pfad von os.walk in durchlaufe(): von aussen nicht ohne
echte Rechteaenderung ausloesbar, deshalb im Prozess."""
skript = kopiere_hook(v)
modul = lade_hook("hook_ordner_gesperrt", skript)
fehler_pfad = v / "gesperrt"
modul.os = _WalkPatch(modul.os, fehler_pfad)
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = grundzeilen(v) + [f"Sitzungsstart-Hook: 1 Ordner nicht lesbar (erster: {fehler_pfad})"]
probleme = bewerte(zeilen, erwartet, [], [])
return not probleme, "\n".join(" " + p for p in probleme)
def ip_waisen_zeitgrenze(v: Path) -> tuple[bool, str]:
"""Die Zeitgrenze lässt sich von außen nicht billig überschreiten:
im Prozess auf eine Millisekunde gesetzt, startet waisen.py nicht rechtzeitig."""
skript = kopiere_hook(v)
modul = lade_hook("hook_waisen_zeitgrenze", skript)
modul.ZEITGRENZE_WAISEN_S = 0.001
zeilen = im_prozess(modul, v)
if zeilen is None:
return False, " keine gueltige Ausgabe"
erwartet = [z for z in grundzeilen(v) if not z.startswith(WA)]
erwartet.append("Sitzungsstart-Hook: Waisen: RuntimeError: waisen.py nach 0.001 s abgebrochen, nicht gezählt")
probleme = bewerte(zeilen, erwartet, [WA], [])
return not probleme, "\n".join(" " + p for p in probleme)
IM_PROZESS = [
("absturz | ein Punkt wirft", ip_punkt_wirft),
("absturz | Hauptablauf wirft", ip_hauptablauf_wirft),
("lesen | Datei nicht lesbar", ip_datei_gesperrt),
("lesen | Ordner nicht lesbar", ip_ordner_gesperrt),
("waisen | Zeitgrenze ueberschritten", ip_waisen_zeitgrenze),
]
def echter_lauf() -> int:
vault = HIER.parent.parent
eingabe = json.dumps({"session_id": "echt", "cwd": str(vault),
"hook_event_name": "SessionStart", "source": "startup"}).encode("utf-8")
code, out, err, dauer = fahre(HOOK, eingabe, HIER)
print(f"exit={code} Laufzeit {dauer:.2f} s (Ziel: unter {ZIEL_LAUFZEIT_S:.0f} s)")
zeilen, problem = lies_kontext(code, out)
if zeilen is None:
print(f"FORMFEHLER: {problem}")
print(out)
print(err)
return 1
print("\n".join(zeilen))
if err.strip():
print("--- stderr ---")
print(err)
if dauer >= ZIEL_LAUFZEIT_S:
print("LAUFZEIT UEBER ZIEL")
return 1
return 0
def main() -> int:
if not HOOK.is_file():
print(f"ABBRUCH: {HOOK} fehlt")
return 2
if "--echt" in sys.argv[1:]:
return echter_lauf()
arbeit = Path(tempfile.mkdtemp(prefix="sitzungsstart-faelle-")).resolve()
try:
basis = arbeit / "grundlinie"
baue_grundlinie(basis)
print("=" * 78)
print("GRUNDLINIE")
print("=" * 78)
v = frische_kopie(basis, arbeit)
skript = kopiere_hook(v)
eingabe = json.dumps({"cwd": str(v), "hook_event_name": "SessionStart"}).encode("utf-8")
code, out, err, dauer = fahre(skript, eingabe, arbeit)
zeilen, problem = lies_kontext(code, out)
if zeilen != grundzeilen(v):
print(f"exit={code} {problem}")
print("erwartet:")
print("\n".join(grundzeilen(v)))
print("bekommen (stdout):")
print(out)
print("stderr:")
print(err)
print("ABBRUCH: Die Grundlinie ist nicht gruen. Der Rest misst nichts.")
return 2
print(f"Grundlinie gruen (exit 0, {dauer:.2f}s).")
print()
print("=" * 78)
print("ANGRIFFSLISTE")
print("=" * 78)
bestanden = 0
gesamt = 0
for fall in FAELLE:
gesamt += 1
try:
ok, text = lauf_fall(fall, basis, arbeit)
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
bestanden += int(ok)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {fall.name} {text}")
for name, funktion in IM_PROZESS:
gesamt += 1
try:
ok, text = funktion(frische_kopie(basis, arbeit))
except Exception as exc:
ok, text = False, f"ABSTURZ im Treiber: {type(exc).__name__}: {exc}"
bestanden += int(ok)
print(f"{'BESTANDEN' if ok else 'FEHLER':10} {name} (im Prozess) {text}")
print()
print(f"{bestanden} von {gesamt} Faellen bestanden.")
return 0 if bestanden == gesamt else 1
finally:
shutil.rmtree(arbeit, ignore_errors=True)
if __name__ == "__main__":
try:
sys.stdout.reconfigure(encoding="utf-8")
except AttributeError:
pass
raise SystemExit(main())