/* ===================================================================== pruef-arbeitsschloss.mjs — hält das Schloss, was es verspricht? WARUM ES DAS SCHLOSS GIBT (01.10.2026): Zwei Claude-Sitzungen arbeiteten gleichzeitig in DogiHompage, ohne voneinander zu wissen. Beide haben `git add -A` benutzt, beide den Stempellauf. Zweimal ist es nur gutgegangen. WARUM ES DIESE PRÜFUNG GIBT: Ein Schloss ist eine Sicherung, und eine Sicherung, die man nie ausgelöst hat, ist eine Hoffnung. Die teuren Fehler liegen bei einem Schloss alle in derselben Richtung — es blockiert zu viel: * Es blockiert das EIGENE Arbeiten -> wird abgeschafft. * Es blockiert nach einem Absturz dauerhaft -> wird umgangen. * Es blockiert, wenn es selbst kaputt ist -> wird entfernt. Deshalb prüft diese Datei NICHT nur, dass es zuschlägt, sondern vor allem, dass es in genau diesen drei Fällen DURCHLÄSST. --------------------------------------------------------------------- ALLES IN EINEM WEGWERF-VERZEICHNIS Die Prüfung legt ein eigenes kleines git-Verzeichnis an, kopiert das Schloss und den Haken hinein und spielt dort. Das echte `.arbeitsschloss` wird nicht angefasst — sonst nähme eine Prüfung jemandem sein Schloss weg, und das wäre genau der Schaden, den sie verhindern soll. Aufrufen mit: node server/pruef-arbeitsschloss.mjs ===================================================================== */ import { mkdtempSync, rmSync, mkdirSync, copyFileSync, writeFileSync, readFileSync, readdirSync, existsSync, chmodSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { execFileSync, spawnSync } from "node:child_process"; let ok_ = 0, fehl = 0, offen = 0; const ok = (b, text) => { if (b) { ok_++; console.log(" ok " + text); } else { fehl++; console.log(" FEHL " + text); } }; const unklar = (text) => { offen++; console.log(" -- " + text); }; const HIER = dirname(fileURLToPath(import.meta.url)); const WURZEL = join(HIER, ".."); const SCHLOSS = join(WURZEL, "tools", "arbeitsschloss.mjs"); const HAKEN = join(WURZEL, "tools", "git-haken", "pre-commit"); /* --------------------------------------------------------------------- 1. Steht überhaupt alles da? --------------------------------------------------------------------- */ console.log("\n=== 1. Die Teile ==="); ok(existsSync(SCHLOSS), "tools/arbeitsschloss.mjs ist da"); ok(existsSync(HAKEN), "tools/git-haken/pre-commit ist da"); /* DAS SCHLOSS DARF NICHT IM VERLAUF LANDEN. Committet wäre es eine Behauptung von gestern: Nach einem Klon stünde dort, dass jemand arbeitet, der längst fertig ist — und dann blockiert es, bis es verfällt. */ { const ignoriert = readFileSync(join(WURZEL, ".gitignore"), "utf8"); ok(/^\.arbeitsschloss$/m.test(ignoriert), ".arbeitsschloss steht in .gitignore (sonst waere es ein Schloss von gestern)"); } /* ==== DER HAKEN BRAUCHT LINUX-ZEILENENDEN ========================= Er hat KEINE Dateiendung, also greift keine der Regeln in .gitattributes von sich aus. Ohne eine eigene Zeile dort wandelt git ihn beim Auschecken auf Windows in CRLF -- und auf Linux ist `#!/bin/sh` mit einem CR dahinter ein Programmname mit einem unsichtbaren Zeichen am Ende („bad interpreter"). NACHGEMESSEN AM 01.10.2026, damit hier keine Behauptung steht: Auf Windows blockiert auch ein CRLF-Haken richtig (Rueckgabe 1, null Commits). Die Regel ist also Vorsorge und keine Reparatur -- aber ein Haken, der still nicht laeuft, ist genau die Sicherung, die aussieht, als waere sie da. UND EINE LEHRE AUS DEM MESSEN SELBST: Meine erste Messung meldete 73 CR-Bytes, wo keines war. Gemessen hatte ich mit einer Rohrkette aus `tr` und `od`, und `tr` nimmt in dieser Shell die Maskierung fuer das Wagenruecklauf-Zeichen nicht als Zeichen -- gezaehlt wurden am Ende Zeilenenden. Byteweise wird deshalb hier gemessen, nicht in einer Rohrkette: `readFileSync` ohne Zeichensatz gibt Bytes, und 13 ist 13. */ { const roh = readFileSync(HAKEN); ok(!roh.includes(13), `der Haken hat reine LF-Zeilenenden (${roh.filter((b) => b === 13).length} CR)`); const regeln = readFileSync(join(WURZEL, ".gitattributes"), "utf8"); ok(/tools\/git-haken\/\*\s+text\s+eol=lf/.test(regeln), ".gitattributes haelt den Haken auf LF (er hat keine Endung)"); } /* DER HAKEN MUSS AUCH GEFUNDEN WERDEN. `.git/hooks` wird nicht versioniert; ohne `core.hooksPath` liegt der Haken im Baum und tut nichts. Das ist die Sorte Sicherung, die aussieht, als wäre sie da. */ { let pfad = ""; try { pfad = execFileSync("git", ["config", "--get", "core.hooksPath"], { cwd: WURZEL, encoding: "utf8" }).trim(); } catch { pfad = ""; } ok(pfad === "tools/git-haken", `core.hooksPath zeigt auf den Haken ("${pfad || "nicht gesetzt"}")` + (pfad === "tools/git-haken" ? "" : " — node tools/arbeitsschloss.mjs einrichten")); } /* DIE STEMPELWERKZEUGE NEHMEN ES SELBST. Abgeleitet aus dem Quelltext, nicht angenommen: Ein Schloss, das man von Hand nehmen muss, wird vergessen — und dann umgangen. */ for (const w of ["workspace-stempel.mjs", "seiten-stempel.mjs"]) { const t = readFileSync(join(WURZEL, "tools", w), "utf8"); ok(/schlossKurz\(/.test(t) && /arbeitsschloss\.mjs/.test(t), `tools/${w} nimmt das Schloss selbst`); } /* --------------------------------------------------------------------- 2. Wer bin ich? --------------------------------------------------------------------- */ console.log("\n=== 2. Die Kennung ==="); const { werIch } = await import("file://" + SCHLOSS.replace(/\\/g, "/")); { const alt = process.env.ARBEITER; delete process.env.ARBEITER; const ohneName = werIch(); ok(ohneName.woher === "Sitzungskennung" && /^claude-[0-9a-f]{8}$/.test(ohneName.name), `ohne ARBEITER kommt die Sitzungskennung ("${ohneName.name}")`); /* DAS IST DER PUNKT, AN DEM RUNONE HÄNGT: Dort fällt es auf den Systembenutzer zurück, und zwei Sitzungen desselben Benutzers sehen damit gleich aus — die Sicherung griff ausgerechnet zwischen den zwei Fällen nicht, für die sie gebaut wurde. */ ok(ohneName.name !== process.env.USERNAME, "und nicht der Benutzername — zwei Sitzungen desselben Benutzers" + " muessen sich unterscheiden"); process.env.ARBEITER = "probe-name"; ok(werIch().name === "probe-name", "ein ausdrueckliches ARBEITER gewinnt"); if (alt === undefined) delete process.env.ARBEITER; else process.env.ARBEITER = alt; } /* --------------------------------------------------------------------- 3. Das Verhalten — im Wegwerf-Verzeichnis --------------------------------------------------------------------- */ console.log("\n=== 3. Nehmen, sehen, freigeben ==="); const spiel = mkdtempSync(join(tmpdir(), "schlossprobe-")); mkdirSync(join(spiel, "tools", "git-haken"), { recursive: true }); copyFileSync(SCHLOSS, join(spiel, "tools", "arbeitsschloss.mjs")); copyFileSync(HAKEN, join(spiel, "tools", "git-haken", "pre-commit")); try { chmodSync(join(spiel, "tools", "git-haken", "pre-commit"), 0o755); } catch { /* egal */ } const SCHLOSSDATEI = join(spiel, ".arbeitsschloss"); const ICH = "claude-probe01"; const ANDERER = "claude-probe02"; /** Ruft das Schloss im Spielverzeichnis auf. */ function rufen(befehl, { wer = ICH, grund = "", verfall } = {}) { const e = spawnSync(process.execPath, [join(spiel, "tools", "arbeitsschloss.mjs"), befehl, grund], { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: wer, ...(verfall ? { SCHLOSS_VERFALL: String(verfall) } : {}) } }); return { stand: e.status, aus: (e.stdout || "") + (e.stderr || "") }; } const schlossSetzen = (wer, vorMinuten = 0, was = "etwas") => writeFileSync(SCHLOSSDATEI, `wer=${wer}\nseit=${new Date(Date.now() - vorMinuten * 60000).toISOString()}\n` + `was=${was}\nrechner=probe\n`); ok(rufen("ansehen").aus.trim() === "frei", "ein leeres Verzeichnis ist frei"); { const r = rufen("nehmen", { grund: "Probe" }); ok(r.stand === 0 && existsSync(SCHLOSSDATEI), `nehmen legt es an (${r.stand})`); ok(/Sitzungskennung|ARBEITER/.test(r.aus), "und sagt, woher die Kennung kommt"); } ok(rufen("ansehen").stand === 1, "danach meldet ansehen: belegt (Stand 1)"); ok(rufen("nehmen").stand === 0, "zweimal nehmen vom selben ist in Ordnung"); /* ---- DER FALL, DER ALLES ENTSCHEIDET ------------------------------- `fremd` muss zwischen „ich halte es" und „ein anderer haelt es" unterscheiden. RunOnes erste Fassung konnte das nicht und hat damit genau das richtige Verhalten bestraft. */ ok(rufen("fremd").stand === 0, "fremd: mein EIGENES Schloss blockiert mich nicht"); schlossSetzen(ANDERER); ok(rufen("fremd").stand === 1, "fremd: ein FREMDES blockiert (Stand 1)"); ok(rufen("nehmen").stand === 1, "nehmen prallt an einem fremden ab"); { const r = rufen("freigeben"); ok(r.stand === 1 && /gehört/.test(r.aus), "und ein fremdes gibt man nicht einfach frei"); } /* ---- ES VERFÄLLT. Sonst blockiert ein Absturz dauerhaft. ---------- */ schlossSetzen(ANDERER, 180); ok(rufen("fremd").stand === 0, "ein verfallenes fremdes Schloss blockiert NICHT mehr"); { const r = rufen("nehmen"); ok(r.stand === 0 && /verfallen/.test(r.aus), "es wird uebernommen — und dass da jemand war, steht dabei"); } schlossSetzen(ANDERER, 180); ok(rufen("fremd", { verfall: 400 }).stand === 1, "Gegenprobe: mit laengerer Frist gilt dasselbe Schloss wieder (SCHLOSS_VERFALL)"); /* ---- UNLESBAR IST NICHT BELEGT ------------------------------------ Ein kaputtes Schloss darf die Arbeit nicht anhalten. */ writeFileSync(SCHLOSSDATEI, "das ist kein Schloss\n"); { const r = rufen("fremd"); ok(r.stand === 0, `ein unbrauchbarer Inhalt blockiert nicht (${r.stand})`); } writeFileSync(SCHLOSSDATEI, `wer=${ANDERER}\nseit=voelliger-unsinn\nwas=x\n`); ok(rufen("fremd").stand === 0, "und ein unlesbarer Zeitstempel gilt als verfallen, nicht als eben genommen"); /* ---- Freigeben ---------------------------------------------------- */ schlossSetzen(ICH); ok(rufen("freigeben").stand === 0 && !existsSync(SCHLOSSDATEI), "das eigene gibt man frei, und danach ist die Datei weg"); ok(rufen("freigeben").stand === 0, "zweimal freigeben ist kein Fehler"); /* --------------------------------------------------------------------- 4. Der git-Haken — am echten git, nicht an der Behauptung --------------------------------------------------------------------- */ console.log("\n=== 4. Der Haken am echten git ==="); { const git = (...a) => spawnSync("git", a, { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: ICH } }); git("init", "-q"); git("config", "user.email", "probe@example.invalid"); git("config", "user.name", "Probe"); git("config", "core.hooksPath", "tools/git-haken"); writeFileSync(join(spiel, "a.txt"), "eins\n"); git("add", "-A"); const erst = git("commit", "-q", "-m", "erster"); ok(erst.status === 0, `ohne Schloss laesst der Haken committen (${erst.status})`); writeFileSync(join(spiel, "a.txt"), "zwei\n"); schlossSetzen(ICH); git("add", "-A"); const eigen = git("commit", "-q", "-m", "mit eigenem Schloss"); ok(eigen.status === 0, `mit dem EIGENEN Schloss ebenfalls (${eigen.status}) — sonst waere ordentliches` + " Abschliessen bestraft"); writeFileSync(join(spiel, "a.txt"), "drei\n"); schlossSetzen(ANDERER, 0, "arbeitet gerade"); git("add", "-A"); const fremd = git("commit", "-q", "-m", "darf nicht entstehen"); const text = (fremd.stdout || "") + (fremd.stderr || ""); ok(fremd.status === 1, `mit einem FREMDEN Schloss bricht der Commit ab (${fremd.status})`); ok(/arbeitet gerade jemand anders/.test(text), "und sagt, warum"); ok(/claude-probe02/.test(text), "und WER es ist"); /* UND DIE ZAHL DER COMMITS BEWEIST ES. Eine Meldung allein könnte auch bei einem erfolgreichen Commit erscheinen. */ const anzahl = Number(spawnSync("git", ["rev-list", "--count", "HEAD"], { cwd: spiel, encoding: "utf8" }).stdout.trim()); ok(anzahl === 2, `es stehen genau zwei Commits da, nicht drei (${anzahl})`); /* DER NOTAUSGANG MUSS GEHEN. Ein Riegel ohne Notausgang wird ausgebaut, nicht benutzt. */ const zwang = spawnSync("git", ["commit", "-q", "-m", "mit Zwang"], { cwd: spiel, encoding: "utf8", env: { ...process.env, ARBEITER: ICH, SCHLOSS_ZWANG: "ja" } }); ok(zwang.status === 0, `SCHLOSS_ZWANG=ja kommt vorbei (${zwang.status})`); /* UND OHNE DAS SCHLOSS-WERKZEUG DARF ER NICHT BLOCKIEREN: Wer den Haken in ein Verzeichnis ohne Schloss mitnimmt, soll arbeiten koennen. */ rmSync(join(spiel, "tools", "arbeitsschloss.mjs")); schlossSetzen(ANDERER); writeFileSync(join(spiel, "a.txt"), "vier\n"); git("add", "-A"); const ohneWerkzeug = git("commit", "-q", "-m", "ohne Werkzeug"); ok(ohneWerkzeug.status === 0, `ohne das Schloss-Werkzeug laesst der Haken durch (${ohneWerkzeug.status})`); } /* --------------------------------------------------------------------- 5. Die Standwache — hat sich der Stand WAEHREND des Laufs geaendert? Das Schloss schuetzt den Commit und die Stempellaeufe. Es schuetzt NICHT die Pruefläufe: Wer misst, waehrend ein anderer speichert, misst einen Stand, den es nicht mehr gibt. Am 06./07.09.2026 sind so fuenf Gesamtlaeufe wertlos geworden, rund zwei Stunden Wartezeit ohne einen einzigen Befund. DIE FRAGE IST EINE MESSUNG, KEINE ZUSTAENDIGKEIT. „Haelt jemand das Schloss" waere die falsche: Sie blockiert zu viel (zwei Stunden Arbeit am Schloss = zwei Stunden ohne Prueflauf) und zu wenig (Filipe am Editor hat kein Schloss). Gefragt wird deshalb: Ist der Stand am Ende noch derselbe? --------------------------------------------------------------------- */ console.log("\n=== 5. Die Standwache ==="); { const WACHE = join(WURZEL, "server", "helfer-standwache.mjs"); ok(existsSync(WACHE), "server/helfer-standwache.mjs ist da"); /* AN DER NOTBREMSE UND NICHT IN 179 DATEIEN. Abgeleitet aus dem Quelltext: Wer eine Notbremse hat, bekommt die Wache mit. */ const bremse = readFileSync(join(WURZEL, "server", "helfer-notbremse.mjs"), "utf8"); ok(/standWache\(/.test(bremse) && /helfer-standwache\.mjs/.test(bremse), "die Notbremse ruft die Standwache — eine Stelle fuer alle Pruefungen"); { const alle = readdirSync(join(WURZEL, "server")) .filter((n) => /^(pruef|mess)-.*\.mjs$/.test(n)); const mit = alle.filter((n) => /notbremse\(/.test(readFileSync(join(WURZEL, "server", n), "utf8"))); ok(mit.length > 150, `${mit.length} von ${alle.length} Pruef- und Messdateien haben damit eine Standwache`); } /* ---- Im Wegwerf-Verzeichnis, mit einem gefaelschten Baum -------- Moeglich, weil die Wache ihre Wurzel aus ihrem EIGENEN Pfad ableitet. Eine festgeschriebene Wurzel waere nicht pruefbar, ohne am echten Haus zu wackeln. */ const spiel2 = mkdtempSync(join(tmpdir(), "standprobe-")); mkdirSync(join(spiel2, "server"), { recursive: true }); mkdirSync(join(spiel2, "workspace"), { recursive: true }); copyFileSync(WACHE, join(spiel2, "server", "helfer-standwache.mjs")); writeFileSync(join(spiel2, "workspace", "a.css"), "a{color:red}\n"); const w = await import("file://" + join(spiel2, "server", "helfer-standwache.mjs").replace(/\\/g, "/")); const eins = w.standNehmen(); /* ZWEI, NICHT EINE: workspace/a.css UND die Kopie der Wache selbst, die im server-Ordner liegt. Der server-Ordner zaehlt mit, und das ist richtig -- eine geaenderte Pruefdatei verschiebt den Stand genauso wie eine geaenderte Seite. (Ich hatte hier 1 erwartet; die Wache hatte recht, nicht ich.) */ ok(eins.anzahl === 2, `der gefaelschte Baum hat zwei zaehlende Dateien (${eins.anzahl})` + " -- die Seite und die Wache selbst"); ok(w.standPruefen(eins, "Probe") === true, "ohne Aenderung meldet sie nichts"); /* ZWEIMAL HINTEREINANDER GLEICH. Ohne das waere jede Meldung wertlos — eine Wache, die bei sich selbst anschlaegt, ist eine Warnung, die immer kommt. */ ok(JSON.stringify({ ...w.standNehmen(), genommen: 0 }) === JSON.stringify({ ...eins, genommen: 0 }), "zwei Aufnahmen hintereinander sind gleich"); /* ---- UND JETZT SPEICHERT JEMAND ------------------------------- */ writeFileSync(join(spiel2, "workspace", "a.css"), "a{color:blue;font-size:2rem}\n"); ok(w.standPruefen(eins, "Probe") === false, "eine geaenderte Datei faellt auf"); const zwei = w.standNehmen(); writeFileSync(join(spiel2, "workspace", "b.js"), "// neu\n"); ok(w.standPruefen(zwei, "Probe") === false, "eine NEUE Datei ebenfalls"); /* ---- BILDER ZAEHLEN NICHT ------------------------------------ Die Messdateien legen selbst welche ab (`server/mess-*.png`). Eine Wache, die bei jedem Bildschirmfoto anschlaegt, wird weggeklickt und nimmt die echte Meldung mit. */ const drei = w.standNehmen(); writeFileSync(join(spiel2, "workspace", "bild.png"), "nicht wirklich ein Bild"); ok(w.standPruefen(drei, "Probe") === true, "ein neues Bild faellt NICHT auf — Messungen legen selbst welche ab"); /* ---- DER RUECKGABEWERT IST 3, NICHT 1 ------------------------ „Nicht in Ordnung" waere eine Aussage ueber den Code, und die hat so ein Lauf nicht gemacht. 3 ist der dritte Ausgang. Geprueft am echten Verhalten: ein eigener Prozess. */ { const script = join(spiel2, "lauf.mjs"); writeFileSync(script, 'import { standWache } from "./server/helfer-standwache.mjs";\n' + 'import { writeFileSync } from "node:fs";\n' + 'import { join, dirname } from "node:path";\n' + 'import { fileURLToPath } from "node:url";\n' + 'standWache("Probelauf");\n' + 'const hier = dirname(fileURLToPath(import.meta.url));\n' + 'writeFileSync(join(hier, "workspace", "c.css"), "c{}\\n");\n'); const e = spawnSync(process.execPath, [script], { cwd: spiel2, encoding: "utf8" }); ok(e.status === 3, `ein Lauf, bei dem sich der Stand aendert, endet mit 3 (${e.status})`); ok(/NICHT NACHSEHBAR/.test(e.stderr || ""), "und sagt NICHT NACHSEHBAR"); ok(/KEIN BEFUND AM CODE/.test(e.stderr || ""), "und ausdruecklich, dass das kein Befund am Code ist"); /* GEGENPROBE: Ohne Aenderung bleibt es bei 0. Ohne diese Zeile waere „endet mit 3" auch dann wahr, wenn sie IMMER 3 meldet. */ writeFileSync(script, 'import { standWache } from "./server/helfer-standwache.mjs";\n' + 'standWache("Probelauf ohne Aenderung");\n'); const e2 = spawnSync(process.execPath, [script], { cwd: spiel2, encoding: "utf8" }); ok(e2.status === 0, `ein Lauf ohne Aenderung endet mit 0 (${e2.status})`); } try { rmSync(spiel2, { recursive: true, force: true }); } catch { /* Rest */ } } try { rmSync(spiel, { recursive: true, force: true }); } catch { /* Aufraeumrest */ } console.log(`\n${ok_} Pruefungen, ${fehl} Fehler, ${offen} nicht nachsehbar`); console.log(fehl ? "NICHT IN ORDNUNG\n" : "ALLES IN ORDNUNG\n"); process.exit(fehl ? 1 : 0);