Files
dogfather-universe/server/pruef-arbeitsschloss.mjs
T
DogFatherGitandClaude Opus 5 ec0ee61691 Arbeitsschloss: der Haken bleibt auf LF, und ein Messfehler von mir
ZWEI NACHTRAEGE ZUM SCHLOSS.

1. DER HAKEN HAT KEINE DATEIENDUNG

Damit greift keine der Regeln in .gitattributes von sich aus — und
git kuendigt beim Committen selbst an: „LF will be replaced by CRLF
the next time Git touches it". Auf Linux ist `#!/bin/sh` mit einem CR
dahinter ein Programmname mit einem unsichtbaren Zeichen am Ende
(„bad interpreter"), und die Datei hat schon einen Kommentar genau
darueber.

Eigene Zeile dazu: `tools/git-haken/* text eol=lf`.

NACHGEMESSEN, DAMIT HIER KEINE BEHAUPTUNG STEHT: Auf Windows
blockiert auch ein CRLF-Haken richtig — Rueckgabe 1, null Commits,
richtige Meldung. Das ist also Vorsorge und keine Reparatur. Aber ein
Haken, der still nicht laeuft, ist genau die Sicherung, die aussieht,
als waere sie da.

Zwei neue Pruefungen dazu: der Haken hat reine LF-Enden (byteweise
gemessen), und die Regel steht in .gitattributes.

2. EIN MESSFEHLER VON MIR, UND ER GEHOERT AUFGESCHRIEBEN

Ich habe gemeldet, im Verlauf lägen 73 CR-Bytes. Es war keines.
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, und die
Datei hat 73.

Mit Python byteweise nachgemessen: 0 im Arbeitsbaum, 0 im Index. Die
Datei war immer LF; git hat nur VORHERGESAGT, was beim naechsten
Auschecken passiert.

Das ist heute der dritte Messfehler aus Shell-Maskierung — nach zwei
`\b`, die als Steuerzeichen in regulaeren Ausdruecken landeten und
dort je eine Pruefung lahmgelegt haben. Und beim Aufschreiben dieser
Lehre ist sie mir ein viertes Mal passiert: Aus dem Kommentar
`tr -d '\r'` wurde `tr -d ''`, die Aussage hat sich selbst bewiesen
und dabei unlesbar gemacht.

DIE REGEL STEHT JETZT, und zwar im Werkzeug selbst: Alles mit einem
Rueckstrich gehoert in eine DATEI, nie in ein Hier-Dokument. Und wer
Bytes zaehlen will, zaehlt Bytes — `readFileSync` ohne Zeichensatz,
und 13 ist 13.

GEPRUEFT: pruef-arbeitsschloss 33 -> 35 Pruefungen, 0 Fehler.
Steuerzeichen im ganzen Haus: 834 Dateien, keines.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-01 20:07:20 +02:00

290 lines
13 KiB
JavaScript

/* =====================================================================
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, 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", "[email protected]");
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})`);
}
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);