- .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]>
696 lines
30 KiB
Python
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())
|