Regelwerk, Agenten, Skills und Werkzeuge veröffentlichen

Erster Stand des öffentlichen Ausschnitts aus dem Vault. Die .gitignore
arbeitet als Positivliste: ausgeschlossen ist alles, eingeschlossen nur,
was ausdrücklich wieder aufgenommen wird. Notizen, Projekte, Finanzen,
das Sitzungsgedächtnis und settings.local.json bleiben damit außen vor.

Der Kundenname in AI/Regelwerk/seo.md ist durch eine neutrale
Bezeichnung ersetzt; die Regeln selbst sind unverändert.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Pascal MichelandClaude Opus 5 committed 2026-09-17 22:38:15 +02:00
commit 2c9a4475fc
97 files changed
+14627

No files matched your search

+32
View File
@@ -0,0 +1,32 @@
---
type: resource
status: active
title: "Planwerkzeuge: Plan auf eine Kopie anwenden und jede Welle dagegen vergleichen"
summary: "Fünf Skripte für die Standardschleife bei Plänen mit ausgeschriebenem Code — Plan auf eine Kopie anwenden, je Welle Menge und Bytes vergleichen, Aus-wird-Blöcke prüfen, Codeblöcke auf unsichtbare Zeichen absuchen, Escape-Folgen per Codepunkt setzen. Entstanden 2026-09-17 in der Trading Plattform (Teil 3b)."
tags: [vault-tools, codeworkflow]
created: 2026-09-17
updated: 2026-09-17
---
# Planwerkzeuge für die Kopie mit angewendetem Plan
Die Regel dazu steht in [[AI/Regelwerk/codeworkflow|codeworkflow.md]] („Die Kopie mit dem angewendeten Plan bleibt stehen …"). Hier steht nur, wie man die Skripte benutzt. Alle laufen mit dem Windows-Python: `PYTHONIOENCODING=utf-8 python -X utf8 <skript> …`.
## Ablauf
1. **Kopie bauen — erst, wenn der Planer fertig gemeldet hat.** Den Projektbaum ohne `.git`, ohne Geheimnisdateien und ohne `node_modules` in einen Wegwerfordner kopieren. **Ausschlüsse als Pfade vom Wurzelordner angeben, nicht als Ordnernamen:** Ein Ausschluss von „jedem Ordner namens `data`" hat am 2026-09-17 auch das Paket `trading/data` getroffen, und die Suite der Kopie brach mit Sammelfehlern ab. Nach dem Kopieren die Paketliste gegen das Original halten. Braucht die Kopie `node_modules`, eine Junction setzen (`mklink /J`) — **und sie vor dem Löschen der Kopie mit `cmd //c rmdir <pfad>` wieder lösen**, sonst trifft `rm -rf` das echte `node_modules`. Nie `tsc -b` durch die Junction fahren (schreibt nach `node_modules/.tmp`); `npx tsc -p tsconfig.app.json --noEmit` nehmen.
2. **Plan anwenden:** `plan_materialisiere.py <planordner> <kopie> task-01.md task-02.md …` in Wellenreihenfolge. Erkennt die Marken `Aus:` / `wird:`, „Die ganze Datei ist neu", „vollständig ersetzt" und „Häng diesen Block ans Ende"; Dateien, die `## Zieldateien` als „(gelöscht)" führt, löscht es in der Kopie. Prosa-Tasks („wird: ein neuer Abschnitt, er muss sagen …") lassen sich nicht anwenden.
3. **Auf der Kopie fahren:** Suite, Typprüfung, Mutanten. Ein Mutantenlauf gibt je Mutant die Fehlerzeile aus, nicht nur den Exitcode.
4. **Vor jeder Welle und nach jeder Plankorrektur:** `plan_bloecke.py vor <tasks…> --projekt <projekt>` (jeder Aus-Block steht genau einmal im echten Baum) und `plan_zeichen.py <planordner>` (keine Zeilen- und Absatztrenner, Format- und Steuerzeichen in Codeblöcken).
5. **Nach jeder Welle:** `welle_vergleich.py --projekt <projekt> --mat <kopie> [--prosa <rel> …] -- <taskdateien der Welle>`. Prüft, ob genau die Zieldateien geändert sind und ob jede byte-gleich mit der Kopie ist. Es zeigt je Datei nur die erste Abweichung — danach `diff` auf die ganze Datei. Eigene Plankorrekturen im Baum meldet es zu Recht als „außerhalb der Zieldateien".
6. **Escape-Folgen setzen:** `plan_escape.py <datei> <zeile> <codepunkt-hex> …` ersetzt echte Zeichen in einer Zeile durch ihre Escape-Folge, ohne dass eine Schreibschicht mitredet.
## Was belegt ist
`welle_vergleich_treiber.py` fährt 14 Angriffsfälle gegen `welle_vergleich.py` (Grundlinie zuerst; fremde Datei, unveränderte Zieldatei, Abweichung in erster und letzter Zeile und in der letzten Zieldatei, CRLF, Datei fehlt in der Kopie, Task ohne Zieldateien, Kopie fehlt, Löschung nicht ausgeführt, Prosa-Datei unverändert, Zieldatei außerhalb des Projekts, Datei länger als der Plan). `plan_zeichen.py` hat am 2026-09-17 an einem echten Fund rot gezeigt (U+2028 im Plan). `plan_materialisiere.py` reproduziert Task 108 des Teil-3b-Plans aus dem Stand vor den Wellen byte-genau.
## Was die Skripte nicht sehen
Dateien, die `.gitignore` ausblendet; ob die Kopie selbst sauber ist (eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Grün); **ob die Kopie älter ist als der Plan** — wird eine Taskdatei nach dem Anwenden noch überarbeitet, meldet `welle_vergleich.py` eine Abweichung, die keine ist (2026-09-17, Teil 4c: der Planer lief noch, als die Kopie entstand). Dann entscheidet `plan_bloecke.py nach` gegen den echten Baum, und die betroffenen Dateien der Kopie werden aus `git show HEAD:<pfad>` neu aufgebaut und der Task erneut angewendet; verwechselbare sichtbare Zeichen (kyrillisches „с"). Sie sind ein Fangnetz, kein Nachweis — den liefert der Lauf mit echten Daten.
Das ältere `.vault-tools/plan-diff/plan_diff.py` vergleicht Ganzdatei-Blöcke einer früheren Plankonvention und bleibt dafür in Gebrauch.
+98
View File
@@ -0,0 +1,98 @@
"""Baut die schlanke Kopie eines Projektbaums fuer den Byte-Vergleich -- Ausschluesse nur als Pfade vom Wurzelordner.
Ersteller: Pascal Michel
Erstellt: 2026-09-17
Doku: .vault-tools/plan-kopie/LIESMICH.md
Aufruf: python kopie_bauen.py <projekt> <ziel> --ohne <relpfad> [<relpfad> ...]
Warum Pfade und keine Namen: Ein Ausschluss von "jedem Ordner namens data" hat am 2026-09-17 auch das Paket
trading/data getroffen. Nach dem Kopieren vergleicht das Skript deshalb die Python-Pakete (Ordner mit __init__.py)
beider Baeume und meldet jedes, das in der Kopie fehlt und nicht unter einem ausgeschlossenen Pfad liegt.
Das Ziel darf nicht existieren; Verknuepfungen und Junctions werden nicht verfolgt, sondern uebersprungen.
"""
import argparse
import os
import shutil
import sys
from pathlib import Path
def _relativ(pfad: Path, wurzel: Path) -> str:
return pfad.relative_to(wurzel).as_posix()
def _pakete(wurzel: Path, ohne: set[str]) -> set[str]:
gefunden = set()
for ordner, unterordner, dateien in os.walk(wurzel):
rel = _relativ(Path(ordner), wurzel)
unterordner[:] = [
name for name in unterordner
if (f"{rel}/{name}" if rel != "." else name) not in ohne
and not Path(ordner, name).is_symlink()
and not Path(ordner, name).is_junction()
]
if "__init__.py" in dateien:
gefunden.add(rel)
return gefunden
def main() -> int:
parser = argparse.ArgumentParser(allow_abbrev=False)
parser.add_argument("projekt", type=Path)
parser.add_argument("ziel", type=Path)
parser.add_argument("--ohne", nargs="*", default=[], help="relative Pfade vom Wurzelordner, mit /")
args = parser.parse_args()
projekt = args.projekt.resolve()
ziel = args.ziel.resolve()
if not projekt.is_dir():
print(f"BEFUND Projekt {projekt} ist kein Ordner")
return 2
if ziel.exists():
print(f"BEFUND Ziel {ziel} existiert schon -- erst ansehen, dann selbst loeschen")
return 2
ohne = {eintrag.replace("\\", "/").strip("/") for eintrag in args.ohne}
befunde = []
for eintrag in sorted(ohne):
if "/" not in eintrag and not (projekt / eintrag).exists():
befunde.append(f"Ausschluss '{eintrag}' gibt es im Wurzelordner nicht -- ein Name statt eines Pfads?")
elif not (projekt / eintrag).exists():
befunde.append(f"Ausschluss '{eintrag}' gibt es nicht")
uebersprungen = []
def ignoriere(ordner: str, namen: list[str]) -> list[str]:
rel = _relativ(Path(ordner), projekt)
raus = []
for name in namen:
pfad = Path(ordner, name)
relpfad = name if rel == "." else f"{rel}/{name}"
if relpfad in ohne or name == "__pycache__":
raus.append(name)
elif pfad.is_symlink() or pfad.is_junction():
uebersprungen.append(relpfad)
raus.append(name)
return raus
shutil.copytree(projekt, ziel, ignore=ignoriere)
fehlend = sorted(_pakete(projekt, ohne) - _pakete(ziel, set()))
for paket in fehlend:
befunde.append(f"Paket '{paket}' fehlt in der Kopie")
dateien = sum(len(d) for _, _, d in os.walk(ziel))
print(f"{dateien} Dateien kopiert, {len(ohne)} Pfade ausgeschlossen, "
f"{len(uebersprungen)} Verknuepfungen uebersprungen, {len(_pakete(ziel, set()))} Pakete in der Kopie.")
for zeile in uebersprungen:
print(f"HINWEIS Verknuepfung uebersprungen: {zeile}")
for zeile in befunde:
print(f"BEFUND {zeile}")
print(f"{len(befunde)} Befund(e).")
return 1 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+125
View File
@@ -0,0 +1,125 @@
"""Prueft die Aus-/wird-Bloecke einer Plan-Taskdatei gegen den Projektbaum.
Koordinator-Werkzeug im Scratchpad. Geltungsbereich (Fangnetz, kein Nachweis):
- Sieht nur Codebloecke, deren letzte nicht leere Zeile vor dem Zaun genau "Aus:" oder
"wird:" ist (Projektkonvention der Planer-Taskdateien seit Teil 5). Die Zieldatei ist der
letzte Pfad in Backticks in einer Ueberschrift (##, ###, ####) oberhalb des Blocks, der
auf eine im Projekt vorhandene oder unter "## Zieldateien" genannte Datei zeigt.
- Modus vor: jeder Aus-Block steht genau einmal in seiner Zieldatei (0 = Befund, >1 = Befund
"mehrdeutig"). Belegt, dass der Plan den heutigen Stand byte-genau zitiert.
- Modus nach: jeder wird-Block steht in der Zieldatei; jeder Aus-Block steht nicht mehr darin,
ausser er ist Teil seines eigenen wird-Blocks (Einfuegung).
- Ganzdatei-Bloecke ("neu anlegen") sieht es nicht; dafuer ist .vault-tools/plan-diff/plan_diff.py da.
- Vergleich nach Normalisierung CRLF -> LF. Eine fehlende Zieldatei ist im Modus vor fuer
Aus-Bloecke ein Befund, im Modus nach fuer beide.
Exit: 0 kein Befund, 2 Befunde, 3 kein einziger Aus-/wird-Block gefunden (nichts geprueft).
"""
import argparse
import re
import sys
from pathlib import Path
ZAUN = re.compile(r"^```")
UEBERSCHRIFT = re.compile(r"^#{2,4} ")
PFAD = re.compile(r"`([^`\s]+/[^`\s]+\.[A-Za-z0-9]+|[^`\s/]+\.(?:py|ts|tsx|toml|md|css|json))`")
def normal(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def lies_bloecke(taskdatei: Path, projekt: Path) -> list[dict]:
zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n")
bloecke: list[dict] = []
ziel: str | None = None
i = 0
while i < len(zeilen):
zeile = zeilen[i]
if UEBERSCHRIFT.match(zeile):
for treffer in PFAD.findall(zeile):
ziel = treffer
if ZAUN.match(zeile):
start = i
# Ein Zaun aus n Backticks schliesst erst mit mindestens n Backticks: Ein
# Vier-Backtick-Block darf einen Drei-Backtick-Block enthalten (Doku-Tasks).
laenge = len(zeile) - len(zeile.lstrip("`"))
i += 1
inhalt: list[str] = []
while i < len(zeilen) and not (
zeilen[i].startswith("`" * laenge) and not zeilen[i].strip("`").strip()
):
inhalt.append(zeilen[i])
i += 1
marke = ""
k = start - 1
while k >= 0 and not zeilen[k].strip():
k -= 1
if k >= 0:
marke = zeilen[k].strip()
if marke in ("Aus:", "wird:"):
bloecke.append({"art": marke[:-1], "ziel": ziel, "zeile": start + 1, "text": "\n".join(inhalt)})
i += 1
return bloecke
def main() -> int:
zerleger = argparse.ArgumentParser()
zerleger.add_argument("modus", choices=["vor", "nach"])
zerleger.add_argument("taskdateien", nargs="+")
zerleger.add_argument("--projekt", required=True)
argumente = zerleger.parse_args()
projekt = Path(argumente.projekt)
befunde = 0
geprueft = 0
for name in argumente.taskdateien:
taskdatei = Path(name)
bloecke = lies_bloecke(taskdatei, projekt)
paare = 0
for nummer, block in enumerate(bloecke):
if block["ziel"] is None:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: {block['art']}-Block ohne Zieldatei in einer Ueberschrift")
befunde += 1
continue
datei = projekt / block["ziel"]
if not datei.exists():
if argumente.modus == "nach" or block["art"] == "Aus":
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Zieldatei {block['ziel']} fehlt")
befunde += 1
continue
inhalt = normal(datei.read_text(encoding="utf-8"))
anzahl = inhalt.count(block["text"])
geprueft += 1
if argumente.modus == "vor":
if block["art"] != "Aus":
continue
paare += 1
if anzahl == 0:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht nicht in {block['ziel']}")
befunde += 1
elif anzahl > 1:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht {anzahl}-mal in {block['ziel']} (mehrdeutig)")
befunde += 1
else:
if block["art"] == "wird":
paare += 1
if anzahl == 0:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: wird-Block steht nicht in {block['ziel']}")
befunde += 1
else:
folge = bloecke[nummer + 1] if nummer + 1 < len(bloecke) else None
einfuegung = folge is not None and folge["art"] == "wird" and block["text"] in folge["text"]
if anzahl > 0 and not einfuegung:
print(f"BEFUND {taskdatei.name}:{block['zeile']}: Aus-Block steht noch in {block['ziel']}")
befunde += 1
print(f"{taskdatei.name}: {len(bloecke)} Aus-/wird-Bloecke, im Modus {argumente.modus} geprueft: {paare}")
if geprueft == 0:
print("Nichts geprueft: kein Aus-/wird-Block gefunden.")
return 3
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
"""Ersetzt in EINER Zeile einer Datei bestimmte echte Zeichen durch ihre sechs ASCII-Zeichen lange Escape-Folge.
Arbeitet mit Codepunkten; im eigenen Quelltext steht keine Escape-Folge, die eine Schicht auswerten koennte.
Aufruf: python plan_escape.py <datei> <zeilennummer> <codepunkt-hex> [<codepunkt-hex> ...]
Beispiel: python plan_escape.py task-107.md 311 2122 0441
Exit 0, wenn mindestens ein Zeichen ersetzt wurde; 2, wenn die Zeile keines der Zeichen traegt.
"""
import sys
from pathlib import Path
datei = Path(sys.argv[1])
nummer = int(sys.argv[2])
punkte = [int(p, 16) for p in sys.argv[3:]]
text = datei.read_bytes().decode("utf-8")
zeilen = text.split("\n")
zeile = zeilen[nummer - 1]
ersetzt = 0
for punkt in punkte:
zeichen = chr(punkt)
folge = chr(92) + "u" + format(punkt, "04x")
ersetzt += zeile.count(zeichen)
zeile = zeile.replace(zeichen, folge)
if ersetzt == 0:
print(f"BEFUND: Zeile {nummer} traegt keines der Zeichen -- nichts geaendert: {zeilen[nummer - 1][:100]!r}")
sys.exit(2)
zeilen[nummer - 1] = zeile
with open(datei, "w", encoding="utf-8", newline="") as strom:
strom.write("\n".join(zeilen))
print(f"{ersetzt} Zeichen ersetzt in Zeile {nummer}: {zeile.strip()[:140]}")
@@ -0,0 +1,153 @@
"""Wendet die Codebloecke der Taskdateien eines Plans in Wellenreihenfolge auf eine Kopie des Projekts an.
Koordinator-Werkzeug (entstanden 2026-09-17, Trading Plattform Teil 3b). Es tut etwas und beurteilt
nichts; das Urteil faellt danach mit Suite, Mutanten und welle_vergleich.py.
Geltungsbereich:
- Zieldatei ist der letzte Pfad in Backticks in einer Ueberschrift (## bis ####) oberhalb des Blocks.
- Dateien, die "## Zieldateien" als "(gelöscht)" fuehrt, werden in der Kopie geloescht.
- Bloecke mit Einrueckung (Befehle in Listen) und Prosa-Vorgaben ("wird: ein neuer Abschnitt ...")
werden uebergangen: Ein Doku-Task mit frei zu formulierendem Text laesst sich nicht anwenden.
- Marke ist die letzte nicht leere Zeile vor dem Zaun:
"Aus:" -> merkt den Block als alten Text
"wird:" -> ersetzt den gemerkten Text (muss genau einmal vorkommen)
enthaelt "Die ganze Datei ist neu" -> schreibt die Datei
beginnt mit "Häng diesen Block ans Ende" -> haengt an
Alles andere (Befehle, erwartete Ausgaben, Prosa-"wird: ...") wird uebergangen und gezaehlt.
- Zaeune aus n Backticks schliessen erst mit n Backticks.
- Geschrieben wird immer mit LF.
Aufruf: python plan_materialisiere.py <planordner> <kopie> <taskdatei> [<taskdatei> ...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import re
import sys
from pathlib import Path
UEBERSCHRIFT = re.compile(r"^#{2,4} ")
PFAD = re.compile(r"`([^`\s]+/[^`\s]+\.[A-Za-z0-9]+|[^`\s/]+\.(?:py|ts|tsx|toml|md|css|json))`")
def normal(text: str) -> str:
return text.replace("\r\n", "\n").replace("\r", "\n")
def bloecke(taskdatei: Path):
zeilen = normal(taskdatei.read_text(encoding="utf-8")).split("\n")
ziel = None
i = 0
while i < len(zeilen):
zeile = zeilen[i]
if UEBERSCHRIFT.match(zeile):
treffer = PFAD.findall(zeile)
if treffer:
ziel = treffer[-1]
if zeile.lstrip().startswith("```"):
einzug = len(zeile) - len(zeile.lstrip())
kern = zeile.lstrip()
laenge = len(kern) - len(kern.lstrip("`"))
start = i
i += 1
inhalt = []
while i < len(zeilen):
kandidat = zeilen[i].lstrip()
if kandidat.startswith("`" * laenge) and not kandidat.strip("`").strip():
break
inhalt.append(zeilen[i][einzug:] if zeilen[i][:einzug].strip() == "" else zeilen[i])
i += 1
k = start - 1
while k >= 0 and not zeilen[k].strip():
k -= 1
marke = zeilen[k].strip() if k >= 0 else ""
yield {"ziel": ziel, "marke": marke, "zeile": start + 1, "text": "\n".join(inhalt), "einzug": einzug}
i += 1
LOESCHZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def loesche(taskdatei: Path, kopie: Path) -> int:
"""Loescht, was der Abschnitt "## Zieldateien" als "(gelöscht)" fuehrt.
Der Pfad dort ist absolut und zeigt ins echte Projekt. Geloescht wird in der Kopie die
Datei am laengsten Pfadende, das dort existiert -- nie etwas ausserhalb der Kopie.
"""
drin = False
anzahl = 0
for zeile in normal(taskdatei.read_text(encoding="utf-8")).split("\n"):
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
treffer = LOESCHZIEL.match(zeile) if drin else None
if not treffer or "gelöscht" not in treffer.group(2):
continue
teile = treffer.group(1).replace("\\", "/").split("/")
for anfang in range(len(teile)):
kandidat = kopie.joinpath(*teile[anfang:])
if ":" not in teile[anfang] and kandidat.is_file():
kandidat.unlink()
anzahl += 1
break
return anzahl
def main() -> int:
plan, kopie = Path(sys.argv[1]), Path(sys.argv[2])
befunde = 0
for name in sys.argv[3:]:
taskdatei = plan / name
alt = None
zaehler = {"neu": 0, "ersetzt": 0, "angehaengt": 0, "uebergangen": 0}
for block in bloecke(taskdatei):
marke, ziel = block["marke"], block["ziel"]
if block["einzug"] > 0:
zaehler["uebergangen"] += 1
continue
if marke == "Aus:" or marke.startswith("Aus, Zeile"):
alt = block
continue
if marke == "wird:":
if alt is None or ziel is None:
print(f"BEFUND {name}:{block['zeile']}: wird-Block ohne Aus-Block oder ohne Zieldatei")
befunde += 1
continue
pfad = kopie / ziel
if not pfad.exists():
print(f"BEFUND {name}:{block['zeile']}: Zieldatei {ziel} fehlt in der Kopie")
befunde += 1
alt = None
continue
text = normal(pfad.read_text(encoding="utf-8"))
anzahl = text.count(alt["text"])
if anzahl != 1:
print(f"BEFUND {name}:{alt['zeile']}: Aus-Block steht {anzahl}-mal in {ziel}")
befunde += 1
else:
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.replace(alt["text"], block["text"]))
zaehler["ersetzt"] += 1
alt = None
continue
alt = None
if ("Die ganze Datei ist neu" in marke or "vollständig ersetzt" in marke) and ziel is not None:
pfad = kopie / ziel
pfad.parent.mkdir(parents=True, exist_ok=True)
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(block["text"].rstrip("\n") + "\n")
zaehler["neu"] += 1
continue
if marke.startswith("Häng diesen Block ans Ende") and ziel is not None:
pfad = kopie / ziel
text = normal(pfad.read_text(encoding="utf-8"))
with open(pfad, "w", encoding="utf-8", newline="\n") as strom:
strom.write(text.rstrip("\n") + "\n" + block["text"].rstrip("\n") + "\n")
zaehler["angehaengt"] += 1
continue
zaehler["uebergangen"] += 1
zaehler["geloescht"] = loesche(taskdatei, kopie)
print(f"{name}: {zaehler}")
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+59
View File
@@ -0,0 +1,59 @@
"""Sucht in den Codebloecken eines Plans nach Zeichen, die ein Executer nicht sehen oder nicht tippen kann.
Koordinator-Werkzeug. Frage: Steht in einem Codeblock ein Zeichen, das beim Abschreiben verloren geht?
Herkunft der Antwort: Unicode-Kategorie des Zeichens (unicodedata, Quelle: Python-Standardbibliothek).
Befund ist jedes Zeichen der Kategorien Zl, Zp, Cf, Cc (ausser \\n und \\t) und Zs ausser dem Leerzeichen --
also Zeilen- und Absatztrenner, Formatzeichen, Steuerzeichen, geschuetzte und schmale Leerzeichen.
Alle uebrigen Nicht-ASCII-Zeichen in Codebloecken werden nur gezaehlt und gezeigt (Umlaute, Gedankenstriche).
Was es nicht sieht: Zeichen ausserhalb von Codezaeunen; verwechselbare sichtbare Zeichen (kyrillisches c).
Aufruf: python plan_zeichen.py <planordner> Exit 0 ohne Befund, 2 mit Befund.
"""
import sys
import unicodedata
from collections import Counter
from pathlib import Path
def main() -> int:
plan = Path(sys.argv[1])
dateien = sorted(plan.glob("*.md"))
if not dateien:
print(f"BEFUND {plan}: keine Markdown-Dateien -- der Lauf misst nichts")
return 2
befunde = 0
sichtbar: Counter[str] = Counter()
bloecke = 0
for datei in dateien:
zeilen = datei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
zaun = 0
for nummer, zeile in enumerate(zeilen, start=1):
kern = zeile.lstrip()
if kern.startswith("```"):
laenge = len(kern) - len(kern.lstrip("`"))
if zaun == 0:
zaun = laenge
bloecke += 1
elif laenge >= zaun and not kern.strip("`").strip():
zaun = 0
continue
if zaun == 0:
continue
for spalte, zeichen in enumerate(zeile, start=1):
if ord(zeichen) < 128 and zeichen not in "\x0b\x0c\x1b" and (zeichen >= " " or zeichen == "\t"):
continue
kategorie = unicodedata.category(zeichen)
if kategorie in ("Zl", "Zp", "Cf", "Cc") or (kategorie == "Zs" and zeichen != " "):
name = unicodedata.name(zeichen, "?")
print(f"BEFUND {datei.name}:{nummer}:{spalte}: U+{ord(zeichen):04X} {name} ({kategorie}) in: {zeile.strip()[:110]!r}")
befunde += 1
else:
sichtbar[zeichen] += 1
print(f"{len(dateien)} Dateien, {bloecke} Codebloecke gelesen.")
print("Sichtbare Nicht-ASCII-Zeichen in Codebloecken: " + " ".join(f"{z}(U+{ord(z):04X})x{n}" for z, n in sichtbar.most_common()))
print(f"{befunde} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
+168
View File
@@ -0,0 +1,168 @@
"""Gegenpruefung nach einer Executer-Welle: Menge der geaenderten Dateien und Byte-Vergleich gegen die Kopie.
Koordinator-Werkzeug im Scratchpad. Fragen und Herkunft der Antwort:
1. Welche Dateien hat die Welle geaendert? Quelle: `git status --porcelain -z` im Projekt.
2. Welche durfte sie aendern? Quelle: Abschnitt "## Zieldateien" der uebergebenen Taskdateien.
3. Ist jede Zieldatei byte-gleich mit dem Plan? Quelle: die Kopie `mat`, auf der plan_materialisiere.py
alle Codebloecke des Plans angewendet hat.
Was es NICHT sieht (Fangnetz, kein Nachweis):
- Dateien, die .gitignore ausblendet (data/, artifacts/, node_modules/): Ein Executer, der dorthin
schreibt, bleibt unsichtbar.
- Ob die Kopie selbst sauber ist. Eine verschmutzte Kopie erzeugt Fehlalarm, kein falsches Gruen.
- Prosa-Dateien (--prosa): nur Mengenpruefung, kein Byte-Vergleich; die liest der Koordinator selbst.
- Bereits committete Aenderungen: Verglichen wird der Arbeitsbaum gegen HEAD.
Eine fehlende Voraussetzung (Kopie fehlt, Taskdatei ohne Zieldateien, git laeuft nicht) ist ein Befund.
Aufruf: python welle_vergleich.py --projekt <pfad> --mat <pfad> [--prosa <rel> ...] <taskdatei> [...]
Exit 0 ohne Befund, 2 mit Befund.
"""
import argparse
import re
import subprocess
import sys
import traceback
from pathlib import Path, PureWindowsPath
ZIEL = re.compile(r"^- `(.+?)`\s*\((.*)\)\s*$")
def zieldateien(taskdatei: Path, projekt: Path, befunde: list[str]) -> dict[str, bool]:
"""Relativer Pfad -> True, wenn die Datei geloescht werden soll."""
try:
zeilen = taskdatei.read_text(encoding="utf-8").replace("\r\n", "\n").split("\n")
except OSError as fehler:
befunde.append(f"{taskdatei.name}: nicht lesbar ({fehler})")
return {}
drin = False
ziele: dict[str, bool] = {}
for zeile in zeilen:
if zeile.startswith("## "):
drin = zeile.startswith("## Zieldateien")
continue
if not drin:
continue
treffer = ZIEL.match(zeile)
if not treffer:
continue
absolut = PureWindowsPath(treffer.group(1))
try:
rel = absolut.relative_to(PureWindowsPath(str(projekt)))
except ValueError:
befunde.append(f"{taskdatei.name}: Zieldatei {absolut} liegt nicht unter dem Projekt {projekt}")
continue
ziele[rel.as_posix()] = "gelöscht" in treffer.group(2) or "geloescht" in treffer.group(2)
if not ziele:
befunde.append(f"{taskdatei.name}: keine Zieldateien gefunden -- ohne sie misst der Lauf nichts")
return ziele
def geaendert(projekt: Path, befunde: list[str]) -> dict[str, str]:
"""Relativer Pfad -> zweistelliger Status aus git."""
try:
ergebnis = subprocess.run(
["git", "-C", str(projekt), "status", "--porcelain", "-z", "--untracked-files=all"],
capture_output=True,
stdin=subprocess.DEVNULL,
)
except OSError as fehler:
befunde.append(f"git laesst sich nicht starten ({fehler})")
return {}
if ergebnis.returncode != 0:
befunde.append(f"git status endet mit {ergebnis.returncode}: {ergebnis.stderr.decode('utf-8', 'replace').strip()}")
return {}
teile = ergebnis.stdout.decode("utf-8", "replace").split("\0")
aus: dict[str, str] = {}
i = 0
while i < len(teile):
eintrag = teile[i]
i += 1
if len(eintrag) < 4:
continue
status, pfad = eintrag[:2], eintrag[3:]
if status[0] in "RC":
alt = teile[i] if i < len(teile) else "?"
i += 1
befunde.append(f"{pfad}: git meldet Umbenennung oder Kopie aus {alt} -- im Plan nicht vorgesehen")
aus[pfad] = status
return aus
def erste_abweichung(echt: bytes, kopie: bytes) -> str:
if echt.replace(b"\r\n", b"\n") == kopie.replace(b"\r\n", b"\n"):
return "nur die Zeilenenden weichen ab (CRLF gegen LF)"
a, b = echt.split(b"\n"), kopie.split(b"\n")
for nummer, (links, rechts) in enumerate(zip(a, b), start=1):
if links != rechts:
return f"Zeile {nummer}: Baum {links[:160]!r} | Plan {rechts[:160]!r}"
return f"Laenge: Baum {len(a)} Zeilen, Plan {len(b)} Zeilen; die gemeinsamen Zeilen sind gleich"
def pruefe(args: argparse.Namespace, befunde: list[str]) -> None:
projekt, mat = Path(args.projekt), Path(args.mat)
if not projekt.is_dir():
befunde.append(f"Projektordner {projekt} fehlt")
return
if not mat.is_dir():
befunde.append(f"Kopie {mat} fehlt -- ohne sie gibt es keinen Byte-Vergleich")
return
prosa = {p.replace("\\", "/") for p in args.prosa}
ziele: dict[str, bool] = {}
for name in args.taskdateien:
ziele.update(zieldateien(Path(name), projekt, befunde))
status = geaendert(projekt, befunde)
for pfad in sorted(set(status) - set(ziele)):
befunde.append(f"{pfad}: geaendert ({status[pfad]!r}), liegt aber ausserhalb der Zieldateien dieser Welle")
for pfad in sorted(prosa - set(ziele)):
befunde.append(f"{pfad}: als Prosa uebergeben, steht aber in keiner Taskdatei als Zieldatei")
gleich = 0
for pfad, loeschen in sorted(ziele.items()):
echt, kopie = projekt / pfad, mat / pfad
if loeschen:
if echt.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch im Baum")
if kopie.exists():
befunde.append(f"{pfad}: soll geloescht sein, steht aber noch in der Kopie")
if not echt.exists() and "D" not in status.get(pfad, ""):
befunde.append(f"{pfad}: fehlt im Baum, git meldet aber keine Loeschung ({status.get(pfad)!r})")
continue
if pfad not in status:
befunde.append(f"{pfad}: Zieldatei ist unveraendert gegenueber HEAD")
continue
if not echt.is_file():
befunde.append(f"{pfad}: Zieldatei fehlt im Baum")
continue
if pfad in prosa:
continue
if not kopie.is_file():
befunde.append(f"{pfad}: fehlt in der Kopie -- kein Byte-Vergleich moeglich")
continue
a, b = echt.read_bytes(), kopie.read_bytes()
if a == b:
gleich += 1
else:
befunde.append(f"{pfad}: weicht vom Plan ab -- {erste_abweichung(a, b)}")
print(f"{len(ziele)} Zieldateien, {len(status)} geaenderte Dateien laut git, {gleich} byte-gleich mit der Kopie, {len(prosa & set(ziele))} nur als Menge geprueft.")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--projekt", required=True)
parser.add_argument("--mat", required=True)
parser.add_argument("--prosa", nargs="*", default=[])
parser.add_argument("taskdateien", nargs="+")
args = parser.parse_args()
befunde: list[str] = []
try:
pruefe(args, befunde)
except Exception: # Ein Absturz ist selbst ein Befund; die gesammelten Befunde gehen trotzdem raus.
befunde.append("unerwarteter Abbruch: " + traceback.format_exc().strip().splitlines()[-1])
for befund in befunde:
print("BEFUND " + befund)
print(f"{len(befunde)} Befund(e).")
return 2 if befunde else 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,168 @@
"""Regressionsnachweis fuer welle_vergleich.py: Grundlinie zuerst, dann jeder Angriffsfall einzeln auf
einer frischen Wegwerfkopie. Je Fall werden Exit-Code und ausloesende Meldung geprueft.
Aufruf: python welle_vergleich_treiber.py Exit 0, wenn jeder Fall so ausgeht wie erwartet.
"""
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
WERKZEUG = Path(__file__).parent / "welle_vergleich.py"
def git(ordner: Path, *args: str) -> None:
subprocess.run(["git", "-C", str(ordner), *args], check=True, capture_output=True, stdin=subprocess.DEVNULL)
def schreibe(pfad: Path, text: str) -> None:
pfad.parent.mkdir(parents=True, exist_ok=True)
pfad.write_bytes(text.encode("utf-8"))
def baue(wurzel: Path) -> tuple[Path, Path, Path]:
"""Repo mit drei committeten Dateien, danach die 'Welle': a.py geaendert, sub/neu.py neu, weg.py geloescht,
doku.md geaendert (Prosa). Die Kopie traegt denselben Stand."""
projekt, mat = wurzel / "projekt", wurzel / "mat"
projekt.mkdir()
git(projekt, "init", "-q")
git(projekt, "config", "user.email", "[email protected]")
git(projekt, "config", "user.name", "Treiber")
git(projekt, "config", "core.autocrlf", "false")
schreibe(projekt / "a.py", "eins\nzwei\ndrei\n")
schreibe(projekt / "b.py", "bleibt\n")
schreibe(projekt / "weg.py", "weg\n")
schreibe(projekt / "doku.md", "alt\n")
git(projekt, "add", "-A")
git(projekt, "commit", "-q", "-m", "basis")
schreibe(projekt / "a.py", "EINS\nzwei\nDREI\n")
schreibe(projekt / "sub" / "neu.py", "neu eins\nneu zwei\n")
(projekt / "weg.py").unlink()
schreibe(projekt / "doku.md", "neue Prosa des Executers\n")
schreibe(mat / "a.py", "EINS\nzwei\nDREI\n")
schreibe(mat / "b.py", "bleibt\n")
schreibe(mat / "sub" / "neu.py", "neu eins\nneu zwei\n")
schreibe(mat / "doku.md", "anderer Stand in der Kopie\n")
task = wurzel / "task-01.md"
schreibe(
task,
"# Task\n\n## Zieldateien\n\n"
f"- `{projekt}\\a.py` (geändert)\n"
f"- `{projekt}\\sub\\neu.py` (neu)\n"
f"- `{projekt}\\weg.py` (gelöscht)\n"
f"- `{projekt}\\doku.md` (geändert)\n\n"
"Keine andere Datei wird angefasst.\n\n## Regeln\n\n- `nicht/gemeint.py` (kein Ziel)\n",
)
return projekt, mat, task
def fall_grundlinie(projekt, mat, task, wurzel):
pass
def fall_fremde_datei(projekt, mat, task, wurzel):
schreibe(projekt / "fremd.py", "x\n")
def fall_ziel_unveraendert(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "eins\nzwei\ndrei\n")
def fall_abweichung_erste_zeile(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "Eins\nzwei\nDREI\n")
def fall_abweichung_letzte_zeile(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\nzwei\nDrei\n")
def fall_abweichung_letzte_zieldatei(projekt, mat, task, wurzel):
schreibe(projekt / "sub" / "neu.py", "neu eins\nneu ZWEI\n")
def fall_crlf(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\r\nzwei\r\nDREI\r\n")
def fall_fehlt_in_kopie(projekt, mat, task, wurzel):
(mat / "sub" / "neu.py").unlink()
def fall_ohne_zieldateien(projekt, mat, task, wurzel):
schreibe(task, "# Task\n\n## Regeln\n\nnichts\n")
def fall_kopie_fehlt(projekt, mat, task, wurzel):
shutil.rmtree(mat)
def fall_nicht_geloescht(projekt, mat, task, wurzel):
schreibe(projekt / "weg.py", "weg\n")
def fall_prosa_unveraendert(projekt, mat, task, wurzel):
schreibe(projekt / "doku.md", "alt\n")
def fall_ziel_ausserhalb(projekt, mat, task, wurzel):
text = task.read_text(encoding="utf-8")
schreibe(task, text.replace("## Regeln", "") .replace("Keine andere Datei", "- `C:\\anderswo\\x.py` (neu)\n\nKeine andere Datei"))
def fall_laengere_datei(projekt, mat, task, wurzel):
schreibe(projekt / "a.py", "EINS\nzwei\nDREI\nvier\n")
FAELLE = [
("Grundlinie", fall_grundlinie, 0, "0 Befund(e)."),
("fremde Datei im Baum", fall_fremde_datei, 2, "fremd.py: geaendert"),
("Zieldatei unveraendert", fall_ziel_unveraendert, 2, "a.py: Zieldatei ist unveraendert"),
("Abweichung in der ersten Zeile", fall_abweichung_erste_zeile, 2, "a.py: weicht vom Plan ab -- Zeile 1"),
("Abweichung in der letzten Zeile", fall_abweichung_letzte_zeile, 2, "a.py: weicht vom Plan ab -- Zeile 3"),
("Abweichung in der letzten Zieldatei", fall_abweichung_letzte_zieldatei, 2, "sub/neu.py: weicht vom Plan ab -- Zeile 2"),
("CRLF statt LF", fall_crlf, 2, "nur die Zeilenenden weichen ab"),
("Datei fehlt in der Kopie", fall_fehlt_in_kopie, 2, "sub/neu.py: fehlt in der Kopie"),
("Taskdatei ohne Zieldateien", fall_ohne_zieldateien, 2, "keine Zieldateien gefunden"),
("Kopie fehlt", fall_kopie_fehlt, 2, "fehlt -- ohne sie gibt es keinen Byte-Vergleich"),
("Loeschung nicht ausgefuehrt", fall_nicht_geloescht, 2, "weg.py: soll geloescht sein, steht aber noch im Baum"),
("Prosa-Datei unveraendert", fall_prosa_unveraendert, 2, "doku.md: Zieldatei ist unveraendert"),
("Zieldatei ausserhalb des Projekts", fall_ziel_ausserhalb, 2, "liegt nicht unter dem Projekt"),
# Die angehaengte Zeile steht der leeren Restzeile der Kopie gegenueber, deshalb "Zeile 4" und nicht "Laenge".
("Datei laenger als der Plan", fall_laengere_datei, 2, "a.py: weicht vom Plan ab -- Zeile 4: Baum b'vier'"),
]
def main() -> int:
fehler = 0
for name, angriff, exit_erwartet, meldung in FAELLE:
wurzel = Path(tempfile.mkdtemp(prefix="wv_"))
try:
projekt, mat, task = baue(wurzel)
angriff(projekt, mat, task, wurzel)
lauf = subprocess.run(
[sys.executable, "-X", "utf8", str(WERKZEUG), "--projekt", str(projekt), "--mat", str(mat),
"--prosa", "doku.md", "--", str(task)],
capture_output=True, text=True, encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
)
ausgabe = lauf.stdout + lauf.stderr
gut = lauf.returncode == exit_erwartet and meldung in ausgabe
if name != "Grundlinie" and gut:
# Genau ein Befund je Fall, ausser wo der Angriff zwangslaeufig zwei ausloest.
pass
print(f"{'ok ' if gut else 'FEHLER'} {name}: Exit {lauf.returncode} (erwartet {exit_erwartet})")
if not gut:
fehler += 1
print(" erwartete Meldung: " + meldung)
print(" " + ausgabe.strip().replace("\n", "\n "))
if name == "Grundlinie":
print("Die Grundlinie ist nicht sauber -- der Rest misst nichts.")
return 2
finally:
shutil.rmtree(wurzel, ignore_errors=True)
print(f"{fehler} Fall/Faelle mit falschem Ausgang.")
return 2 if fehler else 0
if __name__ == "__main__":
sys.exit(main())