Files
dogfather-universe/server/pruef-struktur.mjs
T
DogFatherGitandClaude Opus 5 2fb92f86f3 Die Routenwache war unvollstaendig -- 13 Routen waren ihr unsichtbar
GEFUNDEN ALS FEHLALARM, GEBLIEBEN IST EIN ECHTER FUND.

pruef-struktur meldete:

    assets/js/manager-ziele.js: Schnittstelle gibt es nicht
      -> /workspace/api/manager-ziele

Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE auf. In
Zeile 31 steht `const BASIS = '/workspace/api/manager-ziele'`, benutzt
wird ausschliesslich `${BASIS}/stand`, `${BASIS}/eintraege`,
`${BASIS}/eintrag/${id}`.

DER EIGENTLICHE FUND LAG EINE EBENE TIEFER. Der SERVER registriert
seine Routen genauso:

    const BASIS = "/workspace/api/manager-ziele";
    managerZieleRouter.get(`${BASIS}/stand`, ...)

Die Sammelregel verlangte aber, dass ein Routenpfad direkt mit
`/workspace/` beginnt. ALLE DREIZEHN Routen dieses Moduls fehlten
damit in der Liste -- gemessen, nicht geschaetzt. Die Wache war also
nicht zu streng, sie war UNVOLLSTAENDIG: Zu diesen Routen konnte sie
gar nichts sagen, weder dass es sie gibt noch dass es sie nicht gibt.
Und weil die Aufrufe dorthin ebenfalls zusammengesetzt sind, ist es
nie aufgefallen -- ausser an dieser einen nackten Konstanten.

    Server-Routen eingelesen:  401 -> 414

GEAENDERT

 1. Einfache Praefix-Konstanten werden je Datei aufgeloest. Mehr
    nicht: Wer seinen Pfad aus drei Variablen zusammensetzt, bleibt
    unauffindbar -- und das ist richtig so, denn geraten wird hier
    nicht. Ohne bekannten Wert wird GAR NICHTS eingetragen; eine
    geratene Route waere schlimmer als eine fehlende, weil sie einen
    toten Aufruf als lebendig durchgehen liesse.

 2. Eine nackte Praefix-Konstante im Browser gilt als Praefix, wenn
    es Routen DARUNTER gibt. Abgeleitet, nicht aufgezaehlt -- eine
    Ausnahmeliste mit „manager-ziele" darin waere die naechste, die
    niemand pflegt.

GEGENPROBEN IN BEIDE RICHTUNGEN, weil die neue Regel etwas
durchlaesst:

    eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)
    eine erfundene Adresse OHNE Routen darunter bleibt ein Fund
    und eine echte Route bleibt eine echte Route, kein Praefix

DAZU VIER SCHLUSSZEICHEN. Die Wache fuer das deutsche
Anfuehrungszeichen meldete vier Stellen in denselben zwei Dateien:
`„${was}" löschen?` und drei Geschwister. Berichtigt auf `“`.

UND EINE KORREKTUR AN MIR: Ich habe pruef-struktur heute dreimal als
„99 ok, Exitcode 0" protokolliert und dabei zwei rote Zeilen
uebersehen -- mein Filter zeigte nur die ersten drei FEHL-Zeilen und
die Zahl der gruenen. Gefunden habe ich es erst, als dieselbe Pruefung
spaeter Exitcode 1 meldete. Nachgemessen mit `git stash`: Die zwei
Funde stecken auch in HEAD, sie sind also nicht aus meiner laufenden
Arbeit gekommen. Eine Zusammenfassung, die nur die gruenen Zeilen
zaehlt, ist keine.

GEPRUEFT: pruef-struktur 102 ok, 0 Fehler.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-10-02 19:25:54 +02:00

1702 lines
79 KiB
JavaScript

/* STRUKTURPRUEFUNG — findet Kaputtes, das kein Browser meldet.
Auftrag vom 04.09.2026: vollstaendiger Audit des Creator Workspace.
WAS DIESE PRUEFUNG SUCHT — drei Sorten stiller Fehler:
1. Eine Seite laedt eine Datei, die es nicht gibt.
Der Browser meldet das nur in der Konsole; die Seite sieht
einfach etwas anders aus. Bei CSS faellt es auf, bei einem
einzelnen JS-Baustein oft monatelang nicht.
2. Ein Verweis fuehrt ins Leere.
Eine Kachel, ein Zurueck-Knopf, ein Menuepunkt — Ziel existiert
nicht. Erst der Klick zeigt es.
3. Die Oberflaeche ruft eine Schnittstelle auf, die der Server
nicht (mehr) hat.
Das ist der gefaehrlichste Fall: Der Knopf ist da, er tut nur
nichts. Genau so verschwindet eine Funktion still, wenn eine
Route umbenannt wird.
DIE ANZAHL GEHOERT IN DIE BEDINGUNG. Faende das Skript keine einzige
Seite, waere "0 kaputte Verweise" das beste Ergebnis ueberhaupt --
und voellig wertlos. Deshalb wird jede Menge gezaehlt und eine leere
Menge ist ein Fehler.
GEGENPROBE am Ende: ein absichtlich falscher Verweis MUSS gemeldet
werden. Eine Pruefung, die immer bestaetigt, bestaetigt nichts. */
import { readFileSync, readdirSync, existsSync, statSync } from "node:fs";
import { sep, join, dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { brotliCompressSync, constants } from "node:zlib";
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO = join(__dirname, "..");
const WS = join(REPO, "workspace");
const SERVER = __dirname;
let fehler = 0;
const ok = (b, t) => { console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; };
/* ---------- Bestand einsammeln ------------------------------------------ */
/* ZWEI SEITEN SIND KEINE SEITEN FUER MENSCHEN.
`buehne.html` und `tafel.html` sind Quellen fuer OBS und TikTok
Studio: Sie laufen in einem Programmfenster, werden nie auf einem
Telefon geoeffnet und nie installiert. Ein Symbol fuer den
Startbildschirm, ein Manifest und eine Leistenfarbe waeren dort
ohne Sinn.
BENANNT UND MIT GRUND, nicht still: Eine Ausnahme ohne Namen ist
die, die spaeter auch die naechste Seite verschluckt. */
const NUR_FUER_OBS = new Set(["buehne.html", "tafel.html"]);
const alleSeiten = readdirSync(WS).filter((n) => n.endsWith(".html"));
const seiten = alleSeiten.filter((n) => !NUR_FUER_OBS.has(n));
console.log(`\n=== Bestand ===`);
ok(seiten.length >= 10, `${seiten.length} Workspace-Seiten gefunden`);
const serverDateien = readdirSync(SERVER).filter((n) => /^workspace.*\.js$/.test(n));
ok(serverDateien.length >= 20, `${serverDateien.length} Server-Module gefunden`);
/* Alle Routen, die der Server wirklich anbietet. Gelesen wird der Code,
nicht eine gepflegte Liste -- eine Liste altert, der Code nicht. */
const ROUTEN = [];
for (const datei of serverDateien) {
const text = readFileSync(join(SERVER, datei), "utf8");
/* AUCH ROUTEN IN ANFUEHRUNGSSTRICHEN UND BACKTICKS (23.09.2026).
Hier stand nur das doppelte Anfuehrungszeichen. Wer eine Route in
einer Schleife registriert, schreibt sie aber als Template:
for (const weg of ["annehmen", "ablehnen"])
router.post(`/workspace/api/vorlagen/bewerbung/${weg}`, ...)
Diese Routen fehlten in der Liste -- und die Aufrufe dorthin
wurden als "Schnittstelle gibt es nicht" gemeldet, obwohl sie
jeden Tag laufen. Ein Fehlalarm, den man zweimal liest und dann
nicht mehr; genau davor warnt der Kommentar weiter unten bei den
sechs angeblich verwaisten Seiten.
Die Erfassung der AUFRUFE kannte alle drei Zeichen schon
(["'`]) -- nur die Erfassung der ROUTEN nicht. Zwei Regeln fuer
dieselbe Frage, und eine davon war aelter. */
/* ==== AUCH ROUTEN HINTER EINER KONSTANTEN (02.10.2026) =========
Gemeldet wurde „Schnittstelle gibt es nicht ->
/workspace/api/manager-ziele". Nachgesehen statt geglaubt: Die
Adresse wird nie aufgerufen; sie ist eine Konstante, und benutzt
wird `${BASIS}/stand`, `${BASIS}/eintraege` und acht weitere.
DER EIGENTLICHE FUND LAG EINE EBENE TIEFER: Der SERVER
registriert sie genauso --
const BASIS = "/workspace/api/manager-ziele";
managerZieleRouter.get(`${BASIS}/stand`, ...)
-- und die Sammelregel hier verlangte, dass der Pfad direkt mit
`/workspace/` beginnt. ALLE ZEHN Routen dieses Moduls fehlten
damit in der Liste. Die Wache war nicht zu streng, sie war
unvollstaendig: Sie konnte zu diesen Routen gar nichts sagen --
weder dass es sie gibt, noch dass es sie nicht gibt.
Jetzt werden einfache Praefix-Konstanten je Datei aufgeloest.
Mehr nicht: Wer seinen Pfad aus drei Variablen zusammensetzt,
bleibt unauffindbar -- und das ist richtig so, denn geraten wird
hier nicht. */
const konstanten = new Map();
for (const k of text.matchAll(
/\bconst\s+([A-Za-z_$][\w$]*)\s*=\s*["'`](\/workspace\/[^"'`]*)["'`]\s*;/g)) {
konstanten.set(k[1], k[2]);
}
const treffer = text.matchAll(
/Router\.(get|post|patch|put|delete|use)\(\s*\n?\s*["'`](\$\{[A-Za-z_$][\w$]*\})?(\/workspace\/[^"'`]*|\/[^"'`]*)/g);
for (const t of treffer) {
let pfad = t[3];
if (t[2]) {
const name = t[2].slice(2, -1);
const wert = konstanten.get(name);
/* OHNE BEKANNTEN WERT WIRD NICHTS EINGETRAGEN. Eine geratene
Route waere schlimmer als eine fehlende: Sie liesse einen
toten Aufruf als lebendig durchgehen. */
if (!wert) continue;
pfad = wert + pfad;
}
if (!pfad.startsWith("/workspace/")) continue;
ROUTEN.push({ art: t[1], pfad, datei });
}
}
ok(ROUTEN.length >= 80, `${ROUTEN.length} Server-Routen eingelesen`);
/** Ein Pfad aus der Oberflaeche gegen die Routenliste halten.
* Platzhalter (:id) und eingesetzte Werte (${...}) gelten als gleich.
*
* WICHTIG — .use() zaehlt NICHT als Gegenstueck. Diese Eintraege sind
* Schranken ("jeder Pfad unter /workspace/api braucht eine Anmeldung"),
* keine Endpunkte. Sie mitzuzaehlen war der erste Entwurf, und die
* Gegenprobe hat ihn sofort ueberfuehrt: /workspace/api/gibtesnicht
* galt als vorhanden, weil aufgabenRouter.use("/workspace/api", …)
* darueber liegt. Damit haette diese ganze Pruefung nie einen einzigen
* toten Aufruf gefunden und trotzdem gruen gemeldet. */
function routeGibtEs(pfad, art) {
const teileA = pfad.split("/").filter(Boolean);
return ROUTEN.some((r) => {
if (r.art === "use") return false;
if (art && r.art !== art) return false;
const teileB = r.pfad.split("/").filter(Boolean);
if (teileA.length !== teileB.length) return false;
/* ${...} IN EINER ROUTE IST EIN PLATZHALTER wie :id -- was dort
eingesetzt wird, entscheidet die Schleife beim Start. Der
Aufrufpfad ist oben schon zu "1" normalisiert worden; ohne diese
Zeile verglichen wir "1" mit "${weg}" und faenden nie etwas. */
return teileB.every((t, i) => t.startsWith(":") || t === "*"
|| t.includes("${") || t === teileA[i]);
});
}
/* ---------- 1. Geladene Dateien ----------------------------------------- */
console.log(`\n=== Geladene Dateien (script/link/img) ===`);
let geprueftAssets = 0;
for (const seite of seiten) {
const text = readFileSync(join(WS, seite), "utf8");
const quellen = [
...text.matchAll(/<script[^>]+src="([^"]+)"/g),
...text.matchAll(/<link[^>]+href="([^"]+\.css[^"]*)"/g),
...text.matchAll(/<img[^>]+src="([^"]+)"/g),
].map((m) => m[1]);
for (const roh of quellen) {
if (/^(https?:|data:|\/\/)/.test(roh)) continue;
const ohneStempel = roh.split("?")[0];
const pfad = ohneStempel.startsWith("/")
? join(REPO, ohneStempel)
: resolve(WS, ohneStempel);
geprueftAssets++;
if (!existsSync(pfad)) ok(false, `${seite}: Datei fehlt -> ${roh}`);
}
}
ok(geprueftAssets > 0, `${geprueftAssets} geladene Dateien geprueft, alle vorhanden`);
/* ---------- 2. Verweise auf andere Seiten -------------------------------- */
console.log(`\n=== Verweise (href) ===`);
let geprueftLinks = 0;
const quellenMitLinks = [
...seiten.map((n) => ({ name: n, pfad: join(WS, n) })),
...readdirSync(join(WS, "assets", "js"))
.filter((n) => n.endsWith(".js"))
.map((n) => ({ name: "assets/js/" + n, pfad: join(WS, "assets", "js", n) })),
];
for (const { name, pfad: quelle } of quellenMitLinks) {
const text = readFileSync(quelle, "utf8");
/* Beide Schreibweisen: absolut (/workspace/aufgaben.html) und relativ
("aufgaben.html"). Der erste Entwurf suchte nur die absolute Form
und meldete deshalb "0 Verweise geprueft" -- die Seiten verlinken
aber fast ausschliesslich relativ. Eine Pruefung, die nichts
findet, prueft nichts; deshalb steht die Anzahl unten in der
Bedingung. */
const ziele = [
...[...text.matchAll(/href\s*=\s*["'`](\/workspace\/[^"'`#?${]*)/g)].map((m) => m[1]),
...[...text.matchAll(/(?:location\.href|location\.assign\()\s*=?\s*["'`](\/workspace\/[^"'`#?${]*)/g)].map((m) => m[1]),
...[...text.matchAll(/href\s*=\s*["'`]([a-z0-9-]+\.html)/g)].map((m) => "/workspace/" + m[1]),
...[...text.matchAll(/(?:location\.href\s*=|location\.assign\()\s*["'`]([a-z0-9-]+\.html)/g)].map((m) => "/workspace/" + m[1]),
];
for (const ziel of ziele) {
if (ziel.startsWith("/workspace/api/")) continue;
geprueftLinks++;
const rel = ziel.replace(/^\/workspace\/?/, "");
const datei = rel === "" ? "index.html" : rel;
const voll = join(WS, datei);
if (!existsSync(voll)) ok(false, `${name}: Verweis ins Leere -> ${ziel}`);
}
}
ok(geprueftLinks > 0, `${geprueftLinks} Verweise geprueft, alle mit Ziel`);
/* ---------- 3. Aufgerufene Schnittstellen -------------------------------- */
console.log(`\n=== Aufgerufene Schnittstellen ===`);
let geprueftApi = 0;
let nurPraefix = 0;
const jsDateien = readdirSync(join(WS, "assets", "js")).filter((n) => n.endsWith(".js"));
for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
const text = readFileSync(join(WS, name), "utf8");
/* Jede Zeichenkette, die mit /workspace/api/ beginnt -- egal ob in
fetch(), in einer Hilfsfunktion oder in einer Variablen. */
for (const m of text.matchAll(/["'`](\/workspace\/api\/[^"'`\s]*)/g)) {
const roh = m[1].replace(/\$\{[^}]*\}/g, "1").split("?")[0];
if (!roh || roh === "/workspace/api" || roh === "/workspace/api/") continue;
/* DER DRITTE AUSGANG. Endet die Zeichenkette auf "/", wurde im Code
per + noch etwas angehaengt ('/workspace/api/ki/' + pfad). Was
dort steht, weiss nur die Laufzeit -- statisch ist der volle Pfad
NICHT zu ermitteln.
Der erste Entwurf schnitt den Schraegstrich einfach weg und
meldete vier Fehler, die keine waren. Genau davor warnt die
eigene Regel: eine Pruefung, die ihre Voraussetzung nicht hat,
muss das SAGEN und nicht raten. Geprueft wird deshalb nur, dass
es unter diesem Praefix ueberhaupt Routen gibt -- die vollen
Pfade deckt der Rundgang im Browser ab. */
if (roh.endsWith("/")) {
const praefix = roh.slice(0, -1);
nurPraefix++;
const gibtEsDarunter = ROUTEN.some(
(r) => r.art !== "use" && r.pfad.startsWith(praefix + "/"));
if (!gibtEsDarunter) {
ok(false, `${name}: unter ${praefix}/ gibt es gar keine Schnittstelle`);
}
continue;
}
/* ==== EINE PRAEFIX-KONSTANTE IST KEIN AUFRUF (02.10.2026) =====
Gemeldet wurde:
assets/js/manager-ziele.js: Schnittstelle gibt es nicht
-> /workspace/api/manager-ziele
Nachgesehen statt geglaubt: Die Seite ruft diese Adresse NIE
auf. In Zeile 31 steht
const BASIS = '/workspace/api/manager-ziele';
und benutzt wird sie ausschliesslich als `${BASIS}/stand`,
`${BASIS}/eintraege`, `${BASIS}/eintrag/${id}` -- zehn Routen
gibt es darunter, alle vorhanden.
Der Zweig darueber faengt nur Praefixe mit Schraegstrich am
Ende ab. Eine Konstante ohne Schraegstrich fiel durch und wurde
als fehlende Schnittstelle gemeldet. Ein Fehlalarm, der bei
jedem Lauf kommt -- und eine Warnung, die immer kommt, nimmt
irgendwann die echte mit.
DIE REGEL IST ABGELEITET, NICHT AUFGEZAEHLT: Gibt es unter
diesem Pfad Routen, aber keine EXAKT darauf, dann ist es ein
Praefix. Eine Ausnahmeliste mit „manager-ziele" darin waere die
naechste, die niemand pflegt. */
if (!routeGibtEs(roh)
&& ROUTEN.some((r) => r.art !== "use" && r.pfad.startsWith(roh + "/"))) {
nurPraefix++;
continue;
}
geprueftApi++;
if (!routeGibtEs(roh)) ok(false, `${name}: Schnittstelle gibt es nicht -> ${roh}`);
}
}
ok(geprueftApi >= 40, `${geprueftApi} vollstaendige Aufrufe geprueft, alle mit Gegenstueck`);
ok(nurPraefix > 0, `${nurPraefix} zusammengesetzte Aufrufe: nur Praefix pruefbar (Rest deckt der Browser-Rundgang)`);
/* ---------- 4. Jede Seite ist erreichbar --------------------------------- */
console.log(`\n=== Erreichbarkeit ===`);
/* WER AUF EINE SEITE ZEIGT, IST NICHT NUR EIN href (16.09.2026).
Diese Zeile meldete sechs Seiten als "von nirgendwo verlinkt":
crew-index, entwicklung, rechte, talente, teamlage und
treff-moderation. Keine davon war verwaist -- man kommt auf jede,
indem man eine KACHEL antippt. Die Kacheln stehen aber im Server
(`ziel: "talente.html"`), und der wurde hier nie durchsucht.
Eine Pruefung, die seit Wochen sechs Fehler meldet, die keine sind,
ist schlimmer als keine: Man liest sie nicht mehr, und dann uebersieht
man die siebte, die echt ist. Der blinde Fleck lag in der Pruefung,
nicht im Haus.
ABGELEITET, NICHT NACHGETRAGEN: Statt die sechs von Hand
auszunehmen, kommen die Dateien dazu, in denen die Ziele wirklich
stehen. Wer morgen eine Kachel anlegt, ist damit automatisch
abgedeckt -- und wer eine Seite anlegt, auf die nichts zeigt, faellt
weiterhin auf. */
const zeigerQuellen = [
...quellenMitLinks,
/* Die Kachellisten -- dort steht `ziel: "talente.html"`. */
{ name: "server/workspace.js", pfad: join(SERVER, "workspace.js") },
/* Die Zugangswaende -- crew-index.html wird dort ausgeliefert. */
{ name: "server/crew-adresse.js", pfad: join(SERVER, "crew-adresse.js") },
/* EIN TEILEN-ZIEL IST EIN VERWEIS (17.09.2026).
teilen.html stand hier als "von nirgendwo verlinkt" -- und das war
falsch: Auf sie zeigt der Eintrag `share_target.action` im
Manifest. Genau dafuer gibt es die Seite; einen Verweis im HTML
hat sie absichtlich nicht, weil man sie nicht aufsucht, sondern
von TikTok aus dort landet.
Die Manifeste kommen deshalb als Quelle dazu -- nicht die eine
Seite als Ausnahme. Eine Ausnahmeliste haette beim naechsten
Teilen-Ziel wieder gefehlt. */
...["app.webmanifest", "crew.webmanifest"]
.map((n) => ({ name: n, pfad: join(WS, n) }))
.filter((q) => existsSync(q.pfad)),
];
const verlinkt = new Set(["index.html", "start.html"]);
for (const { pfad: quelle } of zeigerQuellen) {
const text = readFileSync(quelle, "utf8");
for (const m of text.matchAll(/["'`](?:\/workspace\/)?([a-z0-9-]+\.html)/g)) {
verlinkt.add(m[1]);
}
}
for (const seite of seiten) {
if (!verlinkt.has(seite)) ok(false, `Seite ${seite} ist von nirgendwo verlinkt`);
}
ok(verlinkt.size >= seiten.length, `alle ${seiten.length} Seiten sind verlinkt`);
/* ---------- 5. Versionsstempel ------------------------------------------- */
/* Ohne Stempel haelt Cloudflare bis zu vier Stunden die alte Fassung --
dann sieht niemand die Aenderung, und man sucht den Fehler im Code. */
console.log(`\n=== Versionsstempel an JS und CSS ===`);
let ohneStempel = [];
for (const seite of seiten) {
const text = readFileSync(join(WS, seite), "utf8");
for (const m of text.matchAll(/(?:src|href)="(\/workspace\/assets\/(?:js|css)\/[^"]+)"/g)) {
if (!m[1].includes("?v=")) ohneStempel.push(`${seite}: ${m[1]}`);
}
}
ok(ohneStempel.length === 0,
ohneStempel.length ? `ohne Versionsstempel: ${ohneStempel.slice(0, 8).join(", ")}`
: "jede JS- und CSS-Einbindung traegt einen Stempel");
/* ---------- 6. Reste aus der Entwicklung --------------------------------- */
console.log(`\n=== Produktionsreste ===`);
const resteMuster = [
[/console\.log\(/g, "console.log"],
[/\bdebugger\b/g, "debugger"],
[/\bTODO\b|\bFIXME\b|\bHACK\b/g, "TODO/FIXME"],
[/localhost:\d+/g, "localhost-Adresse"],
[/Lorem ipsum/gi, "Platzhaltertext"],
];
let reste = [];
for (const name of [...jsDateien.map((n) => "assets/js/" + n), ...seiten]) {
const text = readFileSync(join(WS, name), "utf8");
/* Kommentare zaehlen nicht -- dort steht Begruendung, kein Code. */
const ohneKommentar = text
.replace(/\/\*[\s\S]*?\*\//g, "")
.replace(/^\s*\/\/.*$/gm, "")
.replace(/<!--[\s\S]*?-->/g, "");
for (const [muster, was] of resteMuster) {
const t = ohneKommentar.match(muster);
if (t) reste.push(`${name}: ${was} (${t.length}x)`);
}
}
ok(reste.length === 0, reste.length ? reste.join(" · ") : "keine Entwicklungsreste im Auslieferstand");
/* ---------- 7. Groesse der Assets ---------------------------------------- */
console.log(`\n=== Dateigroessen ===`);
/** Ist diese Text-Datei zu schwer? Gibt den Grund oder `null`.
*
* ALS EIGENE FUNKTION, damit die Gegenprobe sie mit erfundenem Inhalt
* aufrufen kann. Eine Messung, die nur ueber die echten Dateien
* laeuft, ist genau so lange gruen, wie zufaellig keine zu gross ist
* -- und beweist nie, dass sie ueberhaupt anschlagen kann. */
function textZuSchwer(roh) {
const gepackt = Math.round(brotliCompressSync(roh, {
params: { [constants.BROTLI_PARAM_QUALITY]: 4 },
}).length / 1024);
if (gepackt > 200) return `${gepackt} KB uebertragen`;
const ohneKommentar = Math.round(
roh.toString("utf8").replace(/\/\*[\s\S]*?\*\//g, "").length / 1024);
if (ohneKommentar > 200) return `${ohneKommentar} KB ohne Kommentare`;
return null;
}
let schwer = [];
for (const unter of ["assets/js", "assets/css", "assets/img"]) {
const ordner = join(WS, unter);
if (!existsSync(ordner)) continue;
for (const n of readdirSync(ordner)) {
const p = join(ordner, n);
if (statSync(p).isDirectory()) continue;
const kb = Math.round(statSync(p).size / 1024);
/* GESTUFTE BILDER DUERFEN GROESSER SEIN -- aber nur die (07.09.2026).
Die Grenze von 200 KB soll verhindern, dass eine Seite unnoetig
viel laedt. Bei einem Bild, das in mehreren Groessen vorliegt und
von dem der Browser IMMER NUR EINE holt, misst sie das Falsche:
Die 2400-px-Fassung einer Buehne wiegt 210 KB, wird aber nur auf
einem Schirm angefordert, der sie auch braucht -- ein Handy laedt
weiterhin 60 KB derselben Szene.
Ohne diese Ausnahme haette ich die Wahl zwischen einer Pruefung,
die bei jedem Lauf grundlos rot ist, und weichen Bildern auf
grossen Schirmen. Beides waere falsch. Die Grenze fuer alles
andere bleibt, wo sie war -- gerade weil sie fuer Skripte und
Stile weiterhin gilt.
ERKANNT AM NAMEN, nicht am Ordner: `-uhd` und `gate-2560` sagen
ausdruecklich "das ist die grosse Stufe". Wer eine Datei so nennt,
ohne dass es Stufen gibt, umgeht die Grenze -- deshalb prueft die
Zeile darunter, dass es die kleineren Stufen auch wirklich gibt. */
/* `-breit` GEHOERT DAZU (07.09.2026, Nachtrag am selben Tag).
Die Ausnahme kannte nur die groesste Stufe. Dann wurden die Bilder
nachgeschaerft -- und Schaerfe ist genau das, was Bytes kostet:
Sie fuegt hohe Frequenzen hinzu, und die kann kein Verfahren
wegrechnen. Die MITTLERE Stufe wuchs damit von rund 170 auf 230
bis 300 KB und lief in eine Grenze, die fuer EINZELDATEIEN gedacht
ist, nicht fuer eine von drei Stufen.
Die Regel bleibt inhaltlich unveraendert: Eine Datei darf gross
sein, WENN es nachweislich eine kleinere Stufe daneben gibt. Fuer
`-breit` ist das `-schmal` (85 bis 130 KB) -- ein Handy holt
weiterhin die kleine. Was wirklich zaehlt, misst ohnehin
pruef-tempo-workspace an der fertigen Seite: Dort liegt die
Grenze bei 1,2 MB, und start.html kommt auf 290 KB. */
const gestuft = /-(uhd|breit)\.webp$|^gate-(1920|2560)\.webp$|^gate-hoch-900\.webp$/.test(n);
if (gestuft) {
/* JE STUFE GENAU EINE naechstkleinere -- nicht verkettet.
`replace(uhd->breit).replace(breit->schmal)` haette bei einer
uhd-Datei BEIDE Ersetzungen ausgefuehrt und am Ende nach
"-schmal" gesucht. Das faellt nicht auf, solange alle drei
Stufen da sind, und genau deshalb waere es ein stiller Fehler:
Fehlte eines Tages `-breit`, meldete die Pruefung trotzdem
"in Ordnung". */
const kleiner = n.endsWith("-uhd.webp") ? n.replace("-uhd.webp", "-breit.webp")
: n.endsWith("-breit.webp") ? n.replace("-breit.webp", "-schmal.webp")
: n
.replace(/^gate-(1920|2560)\.webp$/, "gate-1280.webp")
.replace("gate-hoch-900.webp", "gate-hoch-440.webp");
if (!existsSync(join(ordner, kleiner))) {
schwer.push(`${unter}/${n} ${kb} KB (als Stufe ausgewiesen, aber ${kleiner} fehlt)`);
}
continue;
}
/* BEI TEXT MISST DIE ROHE GROESSE DAS FALSCHE (16.09.2026).
Die Grenze soll verhindern, "dass eine Seite unnoetig viel
laedt" -- so steht es oben. Fuer Bilder ist die Datei genau das,
was geladen wird. Fuer CSS und JS nicht: Caddy komprimiert sie
unterwegs. Nachgemessen auf der echten Seite am 16.09.:
start.css auf der Platte 346 KB uebertragen 110 KB (br)
chat.css 59 KB 16 KB
entwicklung.css 33 KB 8 KB
Die Pruefung meldete start.css mit 347 KB -- davon sind 227 KB
KOMMENTAR (64 %). Sie bestrafte damit genau das, was dieses Haus
absichtlich tut: jede Entscheidung danebenschreiben. Und sie
haette eine Datei mit 199 KB dichtem, unkommentiertem CSS
durchgewunken.
Dasselbe Argument steht drei Absaetze weiter oben schon einmal,
fuer die gestuften Bilder: "Bei einem Bild, das in mehreren
Groessen vorliegt, misst sie das Falsche."
JETZT ZWEI ZAHLEN, weil es zwei verschiedene Fragen sind:
1. WAS DER BESUCHER LAEDT -- brotli, Stufe 4. Die Stufe ist
nicht geraten: Bei 4 liefert node 111 KB, Caddy liefert
110. Jede andere Stufe waere eine Zahl, die mit der
Wirklichkeit nichts zu tun hat.
2. WAS EIN MENSCH PFLEGEN MUSS -- die Datei ohne Kommentare.
Sonst koennte man die erste Zahl klein halten, indem man
immer mehr Prosa schreibt, und niemand merkt, dass das CSS
selbst ausgeufert ist. */
if (/\.(css|js)$/.test(n)) {
const grund = textZuSchwer(readFileSync(p));
if (grund) schwer.push(`${unter}/${n} ${grund}`);
continue;
}
if (kb > 200) schwer.push(`${unter}/${n} ${kb} KB`);
}
}
ok(schwer.length === 0, schwer.length ? `ueber 200 KB: ${schwer.join(", ")}` : "keine Datei ueber 200 KB");
/* GEGENPROBE ZUR GROESSENMESSUNG. Sie ist seit dem 16.09.2026 kein
blosser Blick auf die Dateigroesse mehr, sondern packt den Inhalt
zusammen -- und eine Messung, die niemand je hat anschlagen sehen,
ist eine Behauptung. Drei Faelle, und jeder muss anders ausgehen. */
{
/* 1. Viel echtes CSS, das sich nicht wegpacken laesst. */
let dicht = "";
for (let i = 0; i < 9000; i++) {
dicht += `.k${i.toString(36)}-${(i * 7919).toString(36)} { color: #${
(i * 2654435761 % 16777215).toString(16).padStart(6, "0")}; }\n`;
}
ok(textZuSchwer(Buffer.from(dicht)) !== null,
`dichtes CSS von ${Math.round(dicht.length / 1024)} KB faellt durch `
+ `(${textZuSchwer(Buffer.from(dicht))})`);
/* 2. Dieselbe Menge Text, aber als Prosa im Kommentar -- die packt
sich auf ein Zehntel und darf durchgehen. Das ist der ganze
Grund fuer die Umstellung. */
const prosa = "/* " + "Jede Entscheidung gehoert danebengeschrieben. ".repeat(9000) + " */\n"
+ ".a { color: #fff; }\n";
ok(textZuSchwer(Buffer.from(prosa)) === null,
`${Math.round(prosa.length / 1024)} KB Kommentar gehen durch — sie kosten `
+ "den Besucher fast nichts");
/* 3. UND DIE ZWEITE ZAHL MUSS AUCH ANSCHLAGEN: viel echtes CSS, das
sich zwar gut packt (weil es sich wiederholt), aber trotzdem
niemand mehr pflegen kann. Ohne diesen Fall koennte man die
erste Grenze mit Wiederholung unterlaufen. */
const wiederholt = ".gleich { color: #ffffff; padding: 4px; }\n".repeat(6000);
ok(textZuSchwer(Buffer.from(wiederholt)) !== null,
`${Math.round(wiederholt.length / 1024)} KB sich wiederholendes CSS faellt `
+ `ebenfalls durch (${textZuSchwer(Buffer.from(wiederholt))})`);
}
/* ---------- 8. Totes CSS ------------------------------------------------- */
/* start.css wird auf JEDER Seite geladen und ist 126 KB gross. Was darin
niemand mehr benutzt, laedt trotzdem jeder mit.
ZWEI BEKANNTE AUSNAHMEN, bewusst stehen gelassen: Beide stehen als
Glied einer Selektorliste (".a, .b, .code-feld { … }") und kosten je
rund fuenfzehn Byte. Sie zu entfernen braechte nichts und traegt das
kleine Risiko, dass die Klasse doch irgendwo zur Laufzeit gesetzt
wird. Sie stehen hier namentlich, damit NEUES totes CSS auffaellt --
eine Pruefung, die von Anfang an rot ist, wird weggeklickt.
DER DRITTE AUSGANG: Klassen, die zusammengesetzt werden koennen
('kachel--' + ton), zaehlen nicht als tot. Ihr Stamm wird gesucht;
findet er sich, gilt die Klasse als moeglicherweise dynamisch und
wird getrennt ausgewiesen statt geraten. */
console.log(`\n=== Totes CSS ===`);
const BEKANNT_TOT = [".content-wurzel", ".code-feld"];
{
const cssOrdner = join(WS, "assets", "css");
const jsOrdner = join(WS, "assets", "js");
let quelle = "";
/* HIER `alleSeiten` UND NICHT `seiten` (29.09.2026).
`seiten` laesst `buehne.html` und `tafel.html` aus -- mit gutem
Grund, aber fuer eine ANDERE Frage: Die beiden brauchen kein
Startbildschirm-Symbol und kein Manifest. Beim Suchen nach totem
CSS hat dieselbe Ausnahme eine falsche Antwort gegeben: Eine
Klasse, die nur in einer OBS-Quelle steht, galt als unbenutzt.
Aufgefallen an `.obs-buehne`, das genau dort steht. Der Wachter
haette also verlangt, eine Regel zu loeschen, die den Stream
traegt. Eine Ausnahme wirkt immer auf ALLES, was hinter ihr
kommt -- nicht nur auf die Frage, fuer die sie gemacht wurde. */
for (const n of alleSeiten) quelle += readFileSync(join(WS, n), "utf8");
for (const n of readdirSync(jsOrdner).filter((x) => x.endsWith(".js"))) {
quelle += readFileSync(join(jsOrdner, n), "utf8");
}
let gesamt = 0, dynamisch = 0;
const tot = [];
for (const datei of readdirSync(cssOrdner).filter((n) => n.endsWith(".css"))) {
const text = readFileSync(join(cssOrdner, datei), "utf8").replace(/\/\*[\s\S]*?\*\//g, "");
for (const k of new Set([...text.matchAll(/\.([a-zA-Z][a-zA-Z0-9_-]{2,})/g)].map((m) => m[1]))) {
gesamt++;
if (quelle.includes(k)) continue;
const stamm = k.split(/--|__/)[0];
if (stamm !== k && quelle.includes(stamm)) { dynamisch++; continue; }
if (BEKANNT_TOT.includes("." + k)) continue;
tot.push(`${datei}: .${k}`);
}
}
ok(gesamt > 500, `${gesamt} CSS-Klassen geprueft (${dynamisch} moeglicherweise dynamisch gebaut)`);
ok(tot.length === 0,
tot.length ? `NEUES totes CSS: ${tot.slice(0, 8).join(" · ")}`
: `kein neues totes CSS (${BEKANNT_TOT.length} bekannte Reste stehen bewusst)`);
}
/* ---------- 9. Kommt jedes Manifest auch WIRKLICH an? --------------------
Ein Manifest kann fehlerfrei im Repo liegen und trotzdem nie beim
Browser ankommen -- wenn eine Zugangswand davorsteht und HTML statt
JSON zurueckgibt. Dann bietet der Browser "App installieren" gar
nicht erst an, und niemand merkt etwas: Die Datei ist ja da.
Genau das ist DREIMAL passiert (webdesign/app.webmanifest,
verwaltung.webmanifest, portal.webmanifest). Zweimal stand die
Begruendung danach im Code -- und beim dritten Mal wurde sie trotzdem
uebersehen. Ein Kommentar verhindert nichts; eine Pruefung schon.
Geprueft wird gegen die Ausnahmeliste in webdesign-gate.js: Jedes
Manifest unter /webdesign/ MUSS dort stehen. */
console.log(`
=== Manifeste hinter der Zugangswand ===`);
{
const tor = join(SERVER, "webdesign-gate.js");
const torText = existsSync(tor) ? readFileSync(tor, "utf8") : "";
ok(torText.length > 1000, "webdesign-gate.js gelesen");
const manifeste = [];
const suche = (ordner, vorn) => {
for (const n of readdirSync(ordner, { withFileTypes: true })) {
if (n.name.startsWith(".") || n.name === "node_modules") continue;
const rel = vorn ? `${vorn}/${n.name}` : n.name;
if (n.isDirectory()) { if (rel === "webdesign") suche(join(ordner, n.name), rel); continue; }
if (n.name.endsWith(".webmanifest") || n.name === "manifest.json") manifeste.push(rel);
}
};
suche(REPO, "");
const hinterWand = manifeste.filter((m) => m.startsWith("webdesign/"));
ok(hinterWand.length >= 2, `${hinterWand.length} Manifeste unter /webdesign/ gefunden`);
const fehlend = hinterWand.filter((m) => !torText.includes(`"/${m}"`));
ok(fehlend.length === 0,
fehlend.length
? `NICHT in der Ausnahmeliste von webdesign-gate.js: ${fehlend.join(", ")} `
+ `— der Browser bekaeme HTML statt JSON und boete "App installieren" nicht an`
: `alle ${hinterWand.length} stehen in der Ausnahmeliste`);
}
/* ---------- 10. Sind die Symbole fuer JEDES Geraet richtig gebaut? -------
PC und Handy holen ihr Symbol an drei verschiedenen Stellen, und
jede stellt andere Anforderungen:
Browser-Reiter das normale Symbol, darf runde Ecken haben
Android die maskable-Fassung, davon wird aussen bis zu
ein Fuenftel weggeschnitten
iPhone <link rel="apple-touch-icon"> -- und iOS rundet
SELBST ab und fuellt alles Durchsichtige mit
SCHWARZ
Der letzte Punkt ist der, der auffiel ("auf dem einen klappt es und
auf dem anderen nicht"): Ein apple-touch-icon mit eigener Rundung
bekommt auf dem iPhone schwarze Zipfel. Auf dem PC sieht dieselbe
Datei tadellos aus -- deshalb faellt es nur auf einem der beiden
Geraete auf, und deshalb muss es gemessen werden statt angesehen.
Geprueft werden die Dateinamen, nicht die Bilder selbst -- die
Bildpruefung braucht einen Browser und laeuft in pruef-symbole.mjs.
Hier geht es darum, dass ueberhaupt fuer jede App und jedes Geraet
eine Datei da ist. */
console.log(`
=== Symbole fuer alle Geraete ===`);
{
const symOrdner = join(REPO, "assets", "img", "app-symbole");
if (!existsSync(symOrdner)) {
ok(false, "der Ordner assets/img/app-symbole fehlt");
} else {
const da = readdirSync(symOrdner);
const apps = [...new Set(da.map((n) => n.replace(/-(\d+)(-maskable)?\.png$/, "")))]
.filter((n) => n.endsWith(".png") === false);
ok(apps.length >= 8, `${apps.length} Apps mit eigenen Symbolen`);
const noetig = ["192", "512", "192-maskable", "512-maskable", "180", "32"];
const fehlend = [];
for (const a of apps) {
for (const g of noetig) {
if (!da.includes(`${a}-${g}.png`)) fehlend.push(`${a}-${g}.png`);
}
}
ok(fehlend.length === 0,
fehlend.length ? `fehlende Symbole: ${fehlend.slice(0, 6).join(", ")}`
: `alle ${apps.length * noetig.length} Symbole vorhanden `
+ `(Reiter, Android-maskable, iPhone)`);
/* Jede Seite muss BEIDES nennen: das Reiter-Symbol und das fuers
iPhone. Fehlt das zweite, nimmt iOS einen Bildschirmausschnitt
der Seite -- unbrauchbar und unerkennbar. */
let ohneApple = [];
for (const seite of seiten) {
const text = readFileSync(join(WS, seite), "utf8");
if (!/rel="apple-touch-icon"/i.test(text)) ohneApple.push(seite);
}
ok(ohneApple.length === 0,
ohneApple.length ? `ohne apple-touch-icon (iPhone nimmt sonst einen Screenshot): ${ohneApple.join(", ")}`
: `alle ${seiten.length} Workspace-Seiten nennen ein iPhone-Symbol`);
/* UND DAS MANIFEST (06.09.2026 nachgetragen).
Gefunden, weil ich es selbst zweimal vergessen habe: chat.html und
leistung.html -- die beiden neuesten Seiten -- hatten kein
<link rel="manifest">. Folge auf dem Handy: Wer die Anwendung
installiert hat und ueber eine Benachrichtigung auf einer dieser
Seiten landet, verlaesst damit den App-Rahmen. Die Seite oeffnet
in der Adressleiste des Browsers statt in der App, mit falscher
Leistenfarbe und ohne Zurueckweg in den Rest.
Es faellt niemandem auf, weil am Rechner alles normal aussieht.
Genau darum steht es jetzt hier: Was man beim Anlegen einer neuen
Seite vergessen kann, muss eine Pruefung nachhalten -- nicht das
Gedaechtnis. Und weil `seiten` aus dem Verzeichnis kommt, ist die
naechste neue Seite von selbst dabei.
theme-color gehoert dazu: Ohne sie zeichnet Android die
Systemleiste weiss -- ein greller Balken ueber einer dunklen
Oberflaeche, das Gegenteil von augenschonend. */
const ohneManifest = [];
const ohneFarbe = [];
for (const seite of seiten) {
const text = readFileSync(join(WS, seite), "utf8");
if (!/rel="manifest"/i.test(text)) ohneManifest.push(seite);
if (!/name="theme-color"/i.test(text)) ohneFarbe.push(seite);
}
ok(seiten.length > 10 && ohneManifest.length === 0,
ohneManifest.length
? `ohne <link rel="manifest"> — oeffnet auf dem Handy ausserhalb der App: ${ohneManifest.join(", ")}`
: `alle ${seiten.length} Seiten binden das Manifest ein`);
ok(seiten.length > 10 && ohneFarbe.length === 0,
ohneFarbe.length
? `ohne theme-color — Android zeichnet die Leiste dann weiss: ${ohneFarbe.join(", ")}`
: `alle ${seiten.length} Seiten setzen eine theme-color`);
}
}
/* ---------- 11. Laesst sich jedes Server-Modul ueberhaupt laden? --------
`node --check datei.js` prueft eine .js-Datei als CommonJS. Dieses
Haus faehrt aber ESM ("type": "module" in package.json). Der
Unterschied ist keine Formsache: Ein Syntaxfehler kann dabei
durchrutschen und die Datei trotzdem beim Start abstuerzen lassen.
Genau das ist am 05.09.2026 passiert. In einem SQL-Block -- einem
Template-Literal mit Backticks -- stand in einem KOMMENTAR ein Wort
in Backticks. Das beendete das Literal mitten im Text. `node --check`
meldete "Syntax ok", der Import scheiterte mit "missing ) after
argument list", und der Server waere beim naechsten Start gar nicht
hochgekommen.
Deshalb wird hier jedes Modul WIRKLICH geladen. Das ist die einzige
Pruefung, die dieselbe Frage stellt wie der Serverstart. */
console.log(`\n=== Laedt jedes Server-Modul? ===`);
{
/* GEPRUEFT WIRD MIT NODE SELBST, nicht mit einem Suchmuster.
Der erste Entwurf suchte per regulaerem Ausdruck nach Backticks in
SQL-Bloecken -- und schlug bei drei Bloecken an, die voellig in
Ordnung sind: Enthaelt ein Block ein ${...}, grenzt das Muster ihn
falsch ab. Eine Warnung, die falsch ist, ist schlimmer als keine;
sie kostet beim naechsten Mal zehn Minuten und wird danach
ignoriert.
Node kann die Frage selbst beantworten. Die Datei wird als .mjs
kopiert und mit `node --check` geprueft -- damit gilt dieselbe
Regel wie beim Start (ESM statt CommonJS). Genau darin lag der
Fehler vom 05.09.2026: `node --check datei.js` meldete "ok",
der Import scheiterte trotzdem. */
const { execFileSync } = await import("node:child_process");
const { writeFileSync, unlinkSync, mkdtempSync } = await import("node:fs");
const { tmpdir } = await import("node:os");
const module = readdirSync(SERVER).filter((n) => n.endsWith(".js"));
ok(module.length >= 20, `${module.length} Server-Module gefunden`);
const ordner = mkdtempSync(join(tmpdir(), "modulcheck-"));
const kaputt = [];
for (const datei of module) {
const kopie = join(ordner, datei.replace(/\.js$/, ".mjs"));
writeFileSync(kopie, readFileSync(join(SERVER, datei), "utf8"));
try {
execFileSync(process.execPath, ["--check", kopie], { stdio: "pipe" });
} catch (f) {
const meldung = String(f.stderr || f.message).split(/\r?\n/)
.find((z) => z.includes("Error")) || "Syntaxfehler";
kaputt.push(`${datei}: ${meldung.trim().slice(0, 70)}`);
}
try { unlinkSync(kopie); } catch { /* egal */ }
}
ok(kaputt.length === 0,
kaputt.length
? `als ESM NICHT ladbar: ${kaputt.slice(0, 5).join(" · ")}`
: `alle ${module.length} Module lassen sich als ESM einlesen`);
/* GEGENPROBE mit genau dem Fehler von heute.
Nachgebaut wird der echte Fall: ein Backtick in einem KOMMENTAR,
der innerhalb eines Template-Literals steht. Sieht harmlos aus,
beendet aber die Zeichenkette. */
const boese = join(ordner, "gegenprobe.mjs");
writeFileSync(boese, [
"const d = { exec: () => {} };",
"d.exec(`",
" CREATE TABLE x (a TEXT);",
" /* Der Wert `endpunkt` ist der Schluessel. */",
"`);",
].join("\n"));
let erkannt = false;
try { execFileSync(process.execPath, ["--check", boese], { stdio: "pipe" }); }
catch { erkannt = true; }
ok(erkannt, erkannt
? "Gegenprobe: ein Backtick im SQL-Kommentar wird als Fehler erkannt"
: "Gegenprobe MISSLUNGEN — die Pruefung wuerde den Fehler durchlassen");
/* Und der Beweis, dass sie nicht einfach alles ablehnt. */
const brav = join(ordner, "gegenprobe-ok.mjs");
writeFileSync(brav, "export const x = `alles gut ${1 + 1}`;\n");
let bravOk = true;
try { execFileSync(process.execPath, ["--check", brav], { stdio: "pipe" }); }
catch { bravOk = false; }
ok(bravOk, "Gegenprobe: eine saubere Datei wird durchgelassen");
for (const f of [boese, brav]) { try { unlinkSync(f); } catch { /* egal */ } }
}
/* ---------- Rechnet jede Pruefung den TAG so wie die Anwendung? --------
GEFUNDEN AM 06.09.2026 UM 00:10, und zwar auf die harte Tour: Der
Gesamtlauf meldete 13 Fehlschlaege in pruef-teilnehmer.mjs -- an
einer Datei, die seit Stunden niemand angefasst hatte.
Ortszeit (MESZ): 06.09.2026, 00:10
UTC: 05.09.2026, 22:10
Die Pruefung bildete ihr Datum mit `toISOString().slice(0, 10)` --
das ist UTC. Sie legte ihre Termine also auf gestern und fand heute
nichts. ZWEI STUNDEN AM TAG war sie damit rot (im Winter eine).
Wer nur tagsueber laeuft, sieht das nie. Wer nachts arbeitet, sucht
stundenlang am falschen Ende -- die Meldungen sagen "der Termin ist
weg", nicht "dein Datum ist falsch".
Deshalb wird es hier gesucht, statt darauf zu warten, dass es wieder
jemanden trifft. Gemeint ist nur das TAGESDATUM; ein Zeitstempel
(`erstellt`) darf und soll UTC sein. */
console.log(`\n=== Das deutsche Schlusszeichen ===`);
/* ===================================================================
AM 01.10.2026 NACHGEMESSEN: 199 Stellen, die ein Mensch liest,
schlossen ein deutsches Anfuehrungszeichen mit einem GERADEN " --
auf sechzehn oeffentlichen Seiten, in vierzig Dateien des
Workspace und in zwoelf Servermodulen, die Texte verschicken.
Deutsch oeffnet mit \u201E und schliesst mit \u201C. Auf dem
Bildschirm sieht man den Unterschied sofort; beim Schreiben nicht,
denn das gerade " liegt auf der Tastatur und die anderen nicht.
Ohne Wache sind es in vier Wochen wieder zwanzig.
WAS BEWUSST STEHEN BLEIBT -- und warum die Regel so und nicht
einfacher ist: Ein gerades " ist an vielen Stellen SYNTAX und kein
Schriftzeichen. Es begrenzt eine Zeichenkette, ein HTML-Attribut
oder steht in einem regulaeren Ausdruck. Ein erster Anlauf, der
stumpf ersetzte, musste zurueckgenommen werden.
Die Unterscheidung laeuft an Merkmalen, nicht an einer Liste von
Ausnahmen:
< > = dazwischen -> HTML-Marke oder Attribut
endet auf Leerzeichen -> die Zeichenkette hoert hier auf, der
Satz geht in der naechsten Zeile weiter. Ein deutsches
Schlusszeichen steht NIE hinter einem Leerzeichen.
Rueckstrich mittendrin -> regulaerer Ausdruck
${ ohne } -> mitten in einem Ausdruck
=================================================================== */
{
const AUF = "\u201E"; /* „ */
const ZU = "\u201C"; /* “ */
/* Zusammengesetzt und nicht ausgeschrieben: Sonst zeigte diese
Datei sich beim naechsten Lauf selbst an. */
const suche = new RegExp(AUF + '([^' + ZU + AUF + '"`\n]{1,140})"', "g");
/* ==== ANFUEHRUNGSZEICHEN IN EINER EINSETZUNG SIND KEINE ==========
(01.10.2026, gefunden durch die ZAHL der Ausnahmen, nicht durch
den Blick.)
Der weitere Filter liess die Ausnahmen von 12 auf 13 steigen.
Die neue war server/workspace.js:3304 --
`… „${zeile[titelSpalte] || "ohne Titel"}"`
Das ist keine Ausnahme. Der Ausdruck blieb am `"` von
`"ohne Titel"` haengen; als Inhalt kam `${zeile[titelSpalte] || `
heraus, das endet auf ein Leerzeichen, und damit galt die Regel
„der Satz geht in der naechsten Zeile weiter". Das ECHTE
Schlusszeichen steht hinter dem `}` -- und war gerade. Die
Ausnahme hat den Fund verschluckt.
Dass die Ausnahmen GEZAEHLT werden, hat ihn gefunden. Genau
dafuer steht die Zeile dort.
Behoben an der Wurzel: Anfuehrungszeichen INNERHALB einer
Einsetzung werden vorher durch X ersetzt. Laenge und
Zeilenumbrueche bleiben gleich, alle Zeilennummern stimmen
weiter. Aus
„${zeile[titelSpalte] || "ohne Titel"}"
wird
„${zeile[titelSpalte] || XohneX TitelX}"
und der Ausdruck findet das richtige Zeichen. Das nimmt einer
ganzen Sorte Fehlklassifizierung die Grundlage, statt einen
Einzelfall einzutragen. */
const ohneEinsetzung = (text) => {
let aus = "";
let tief = 0;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (c === "$" && text[i + 1] === "{") { tief++; aus += "${"; i++; continue; }
if (c === "}" && tief > 0) { tief--; aus += "}"; continue; }
aus += (tief > 0 && (c === '"' || c === "'" || c === "`")) ? "X" : c;
}
return aus;
};
const warumNicht = (innen) => {
if (/[<>=]/.test(innen)) return "HTML-Marke oder Attribut";
if (innen.endsWith(" ")) return "Satz geht in der naechsten Zeile weiter";
if (innen.replace(/\\+$/, "").includes("\\")) return "regulaerer Ausdruck";
if ((innen.match(/\$\{/g) || []).length !== (innen.match(/\}/g) || []).length) {
return "offener Ausdruck";
}
return null;
};
/* WAS EIN MENSCH LIEST: die oeffentlichen Seiten, der Workspace und
die Servermodule, die Texte verschicken. Pruef- und Messdateien
schreiben nur in meine Konsole -- dort waere es Kosmetik, und
eine Wache, die auch Kosmetik anmahnt, wird weggeklickt. */
const sammeln = (ordner, tiefe = 0) => {
const raus = [];
for (const e of readdirSync(ordner, { withFileTypes: true })) {
if (e.name === "node_modules" || e.name.startsWith(".")) continue;
const voll = join(ordner, e.name);
if (e.isDirectory()) { if (tiefe < 4) raus.push(...sammeln(voll, tiefe + 1)); continue; }
if (/\.(js|mjs|html|css)$/.test(e.name)) raus.push(voll);
}
return raus;
};
const dateien = [
...sammeln(join(SERVER, "..", "workspace")),
...sammeln(join(SERVER, "..", "assets")),
...readdirSync(join(SERVER, "..")).filter((n) => n.endsWith(".html"))
.map((n) => join(SERVER, "..", n)),
/* ALLE Servermodule, nicht nur `workspace-*` (01.10.2026).
Gemessen war dieses Loch leer -- in den uebrigen Modulen stand
keine falsche Stelle. Leer heisst aber nicht „kann nicht
vorkommen", und der weitere Filter kostet nichts. */
...readdirSync(SERVER).filter((n) => n.endsWith(".js"))
.map((n) => join(SERVER, n)),
];
ok(dateien.length > 100, `${dateien.length} Dateien mit sichtbarem Text angesehen`);
const falsch = [];
const stehenGelassen = [];
for (const pfad of dateien) {
const art = pfad.slice(pfad.lastIndexOf("."));
let text = readFileSync(pfad, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
if (art === ".js" || art === ".mjs") {
text = text.replace(/^\s*\/\/.*$/gm, "");
}
text = ohneEinsetzung(text);
/* Der Pfad AB DER WURZEL des Projekts -- ein abgeschnittener
Pfad ist eine Meldung, der man nicht folgen kann. */
const kurz = pfad.split(sep).join("/").split("/DogiHompage/").pop();
for (const t of text.matchAll(suche)) {
const zeile = text.slice(0, t.index).split("\n").length;
const nein = warumNicht(t[1]);
if (nein) stehenGelassen.push(`${kurz}:${zeile} (${nein})`);
else falsch.push(`${kurz}:${zeile} ${AUF}${t[1].slice(0, 40)}"`);
}
}
ok(falsch.length === 0,
falsch.length
? `schliessen ein deutsches Anfuehrungszeichen mit einem geraden ":\n `
+ falsch.slice(0, 15).join("\n ")
+ (falsch.length > 15 ? `\n ... und ${falsch.length - 15} weitere` : "")
: `kein sichtbarer Text schliesst mit einem geraden " (${dateien.length} Dateien)`);
/* DIE AUSNAHMEN WERDEN GEZAEHLT UND GENANNT. Eine Ausnahme, die
niemand sieht, waechst -- und irgendwann steht der echte Fall
darin und faellt nicht mehr auf. */
/* DIE ZAHL MUSS GENAU STIMMEN, nicht „hoechstens" (01.10.2026).
Sie stand auf `<= 12`. Dann waere nicht aufgefallen, dass sie
durch den weiteren Filter auf 13 gestiegen ist -- und die
dreizehnte war gar keine Ausnahme, sondern ein verschluckter
Fund (siehe `ohneEinsetzung` oben). Nach dessen Behebung sind
es 11.
Waechst sie, ist eine neue Stelle dazugekommen, die die Regel
nicht beurteilen kann. Sinkt sie, gehoert sie hier gesenkt --
sonst deckt sie Platz fuer die naechste. Dieselbe Strenge wie
bei den Grundlinien in pruef-fingermass. */
const AUSNAHMEN = 11;
ok(stehenGelassen.length === AUSNAHMEN,
`${stehenGelassen.length} Stellen bleiben mit Grund stehen`
+ ` (unveraendert: ${AUSNAHMEN}) — ${stehenGelassen.slice(0, 3).join(", ") || "—"}`);
/* ==== UND UEBER DIE ZEILENGRENZE (01.10.2026) ====================
Angestossen von der zweiten Sitzung: Sie hat in den
Praesentationen des Vaults 83 Stellen gefunden, weil sie
ZEILENWEISE mitgezaehlt hat, wie viele `„` offen sind. Die Regel
oben gilt je Zeile und kann das nicht.
Nachgemessen, was mir durchgegangen ist -- drei echte Faelle,
zwei davon sichtbarer Seitentext:
workspace/leistung.html „nicht / zugeordnet"
workspace/leistung.html „Backstage-Tabelle / einfuegen"
workspace/assets/js/ampel.js „noch ' + 'verbessern"
Beim letzten laeuft das Zitat ueber eine
Zeichenketten-Verkettung hinweg.
WIE ES GEHT, OHNE FEHLALARME: Erst alle RICHTIG geschlossenen
Paare entfernen (auch mehrzeilig), dann die einzeilig falschen
(die meldet die Regel oben, hier waeren sie doppelt). Was danach
noch ein `„` hat und binnen drei Zeilen ein gerades `"`, ist ein
Fund.
Ausgenommen bleibt dasselbe wie oben -- und zusaetzlich das
Zeichen ALS WERT: In `content: "„";` und in den Sprachtabellen
(`de: "„"`) ist das `„` der Inhalt und das `"` die Begrenzung. */
const mehrzeilig = [];
const alsWert = new RegExp('["\'`]\\s*' + AUF + '\\s*["\'`]');
for (const pfad of dateien) {
const art = pfad.slice(pfad.lastIndexOf("."));
let text = readFileSync(pfad, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
if (art === ".js" || art === ".mjs") text = text.replace(/^\s*\/\/.*$/gm, "");
text = ohneEinsetzung(text);
const kurz = pfad.split(sep).join("/").split("/DogiHompage/").pop();
/* Richtig geschlossene Paare weg -- auch ueber Zeilen. */
let rest = text.replace(new RegExp(AUF + "[^" + ZU + AUF + "]{0,300}?" + ZU, "g"),
(m) => m.replace(/[^\n]/g, " "));
/* Und die einzeilig falschen -- die meldet die Regel oben. */
rest = rest.replace(new RegExp(suche.source, "g"), (m) => m.replace(/[^\n]/g, " "));
const zeilen = rest.split("\n");
for (const t of rest.matchAll(new RegExp(AUF, "g"))) {
const zeile = rest.slice(0, t.index).split("\n").length;
const fenster = zeilen.slice(zeile - 1, zeile + 3).join("\n");
const paar = fenster.match(new RegExp(AUF + '([^' + ZU + AUF + ']{0,300}?)"'));
if (!paar) continue;
if (alsWert.test(zeilen[zeile - 1])) continue; /* Zeichen als Wert */
if (/[<>=]/.test(paar[1]) || paar[1].includes("\\")) continue;
mehrzeilig.push(`${kurz}:${zeile} ${AUF}${paar[1].replace(/\s+/g, " ").slice(0, 40)}"`);
}
}
ok(mehrzeilig.length === 0,
mehrzeilig.length
? `ein deutsches Anfuehrungszeichen wird erst in einer SPAETEREN Zeile`
+ ` mit einem geraden " geschlossen:\n ` + mehrzeilig.slice(0, 10).join("\n ")
: "kein Anfuehrungszeichen wird ueber die Zeilengrenze hinweg falsch geschlossen");
/* ==== DAS ENGLISCHE SCHLUSSZEICHEN (01.10.2026) ==================
Deutsch oeffnet mit „ und schliesst mit “. Ein ” dahinter ist
das ENGLISCHE Zeichen (U+201D). In data-modis.js stand zweimal
`„Van-Van”`, beide Male in einer deutschen Zeile (`de:` und
`"de-CH":`) -- also nicht einmal eine Uebersetzung, in der es
richtig waere.
Wer mit „ oeffnet, schliesst deutsch. Deshalb braucht diese
Regel keine Ausnahme fuer fremde Sprachen: Dort steht gar kein
„ am Anfang. */
const englisch = [];
const EN_ZU = "\u201D";
for (const pfad of dateien) {
const text = readFileSync(pfad, "utf8")
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/<!--[\s\S]*?-->/g, (m) => m.replace(/[^\n]/g, " "));
const kurz = pfad.split(sep).join("/").split("/DogiHompage/").pop();
for (const t of text.matchAll(
new RegExp(AUF + "([^" + ZU + AUF + EN_ZU + "\n]{1,160})" + EN_ZU, "g"))) {
const zeile = text.slice(0, t.index).split("\n").length;
englisch.push(`${kurz}:${zeile} ${AUF}${t[1].slice(0, 40)}${EN_ZU}`);
}
}
ok(englisch.length === 0,
englisch.length
? `schliessen ein deutsches Anfuehrungszeichen mit dem ENGLISCHEN `
+ `Zeichen (U+201D):\n ` + englisch.slice(0, 10).join("\n ")
: "keines schliesst mit dem englischen Zeichen");
/* ---- GEGENPROBEN zu den zwei neuen Regeln --------------------- */
{
const ueberZeile = (code) => {
let rest = code.replace(new RegExp(AUF + "[^" + ZU + AUF + "]{0,300}?" + ZU, "g"), " ");
rest = rest.replace(new RegExp(suche.source, "g"), " ");
const zl = rest.split("\n");
let n = 0;
for (const t of rest.matchAll(new RegExp(AUF, "g"))) {
const z = rest.slice(0, t.index).split("\n").length;
const f = zl.slice(z - 1, z + 3).join("\n");
const paar = f.match(new RegExp(AUF + '([^' + ZU + AUF + ']{0,300}?)"'));
if (!paar) continue;
if (alsWert.test(zl[z - 1])) continue;
if (/[<>=]/.test(paar[1]) || paar[1].includes("\\")) continue;
n++;
}
return n;
};
ok(ueberZeile(`unter ${AUF}nicht\nzugeordnet" auf`) === 1,
"Gegenprobe: ein Zitat ueber zwei Zeilen faellt auf");
ok(ueberZeile(`unter ${AUF}nicht\nzugeordnet${ZU} auf`) === 0,
" richtig geschlossen NICHT");
ok(ueberZeile(`content: "${AUF}";`) === 0,
" und das Zeichen als Wert auch nicht");
const enTest = (code) => [...code.matchAll(
new RegExp(AUF + "([^" + ZU + AUF + "\u201D\n]{1,160})\u201D", "g"))].length;
ok(enTest(`${AUF}Van-Van\u201D`) === 1,
"Gegenprobe: das englische Schlusszeichen faellt auf");
ok(enTest(`${AUF}Van-Van${ZU}`) === 0, " das deutsche NICHT");
}
/* ---- GEGENPROBEN ------------------------------------------------ */
const findet = (code) => {
const treffer = [...code.matchAll(new RegExp(suche.source, "g"))];
return treffer.filter((t) => !warumNicht(t[1])).length;
};
ok(findet(`Er sagte ${AUF}hallo".`) === 1,
"Gegenprobe: ein gerades Schlusszeichen faellt auf");
ok(findet(`Er sagte ${AUF}hallo${ZU}.`) === 0, " das richtige NICHT");
ok(findet(`${AUF}<a href="x">y</a>`) === 0,
" ein HTML-Attribut ist kein Schlusszeichen");
ok(findet(`"… nichts ${AUF}mal " + "eben …"`) === 0,
" und die Begrenzung einer Zeichenkette, die weitergeht, auch nicht");
ok(findet('/^["\'' + AUF + '»\\s]+|["]/') === 0,
" ein regulaerer Ausdruck ebenfalls nicht");
ok(findet(AUF + "${a.titel}\"") === 1,
" ein eingesetzter Wert dagegen schon");
}
console.log(`\n=== Ruft ein Modul etwas, das es nie eingefuehrt hat? ===`);
/* ===================================================================
AM 01.10.2026 SELBST GEBAUT UND FAST AUSGELIEFERT.
Ein Werkzeug von mir hat in elf Modulen `heuteLokal()` eingesetzt
und die Einfuhr weggelassen. `node --check` sagt dazu nichts, und
„Laedt jedes Server-Modul?" ein Stueck weiter oben auch nicht: Die
Datei ist syntaktisch tadellos. Erst der AUFRUF faellt um:
ReferenceError: heuteLokal is not defined
Gefunden hat es pruef-video -- zufaellig, weil dort genau diese
Zeile geprueft wird. Die anderen zehn Module waeren durchgerutscht,
und bei einer Zeile, die nur nachts laeuft, faellt das erst Wochen
spaeter jemandem auf.
DAS IST EINE DRITTE SORTE FEHLER an dieser Stelle: nicht „die Datei
ist kaputt" und nicht „die Datei tut das Falsche", sondern „die
Datei faellt erst um, wenn jemand genau diese Zeile braucht".
ABGELEITET, NICHT AUFGEZAEHLT: Die Namen des Hauses kommen aus den
`export`-Zeilen von workspace.js und der helfer-Module. Eine
gepflegte Liste waere hier genau die, die beim naechsten Export
nicht mitwaechst -- derselbe Fehler, an dem dieses Haus schon
viermal haengengeblieben ist.
=================================================================== */
{
/** Kommentare und Zeichenketten weg, Zeilen behalten. Sonst zaehlt
* ein Name in einem SQL-Text oder in einer Begruendung als Aufruf. */
const nurCode = (text) => text
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/^\s*\/\/.*$/gm, "")
.replace(/`(?:[^`\\]|\\.)*`/g, (m) => m.replace(/[^\n]/g, " "))
.replace(/"(?:[^"\\\n]|\\.)*"/g, "\"\"")
.replace(/'(?:[^'\\\n]|\\.)*'/g, "''");
/** Was eine Datei nach aussen gibt -- aus ihren eigenen Zeilen. */
const exporte = (text) => {
const raus = new Set();
for (const t of text.matchAll(
/^export\s+(?:async\s+)?(?:function|const|let|class)\s+(\w+)/gm)) raus.add(t[1]);
return raus;
};
const quellen = ["workspace.js", "helfer-zeit.mjs", "helfer-tag.mjs"];
const hausNamen = new Map(); // Name -> woher
for (const q of quellen) {
for (const n of exporte(readFileSync(join(SERVER, q), "utf8"))) hausNamen.set(n, q);
}
ok(hausNamen.size >= 30,
`${hausNamen.size} ausgeleitete Namen aus ${quellen.length} Quellen gelesen`);
/** Welche Namen kennt diese Datei? Eingefuehrte, selbst erklaerte
* und aus einer Zerlegung entnommene -- alles, was nicht zu einem
* ReferenceError fuehren kann. */
const bekannt = (code) => {
const kennt = new Set();
for (const t of code.matchAll(/import\s*\{([^}]*)\}\s*from/g)) {
for (const stueck of t[1].split(",")) {
const n = stueck.trim().split(/\s+as\s+/).pop().trim();
if (n) kennt.add(n);
}
}
for (const t of code.matchAll(/(?:const|let|var)\s*\{([^}]*)\}\s*=/g)) {
for (const stueck of t[1].split(",")) {
const n = stueck.trim().split(":").pop().trim();
if (n) kennt.add(n);
}
}
for (const t of code.matchAll(
/(?:^|\s)(?:const|let|var|function|class)\s+(\w+)/g)) kennt.add(t[1]);
for (const t of code.matchAll(/import\s+(\w+)\s+from/g)) kennt.add(t[1]);
return kennt;
};
const dateien = readdirSync(SERVER)
.filter((n) => (n.endsWith(".js") || n.endsWith(".mjs")) && !quellen.includes(n));
const fehlt = [];
let gesucht = 0;
for (const datei of dateien) {
if (datei === "pruef-struktur.mjs") continue; /* siehe Gegenprobe */
const code = nurCode(readFileSync(join(SERVER, datei), "utf8"));
const kennt = bekannt(code);
for (const [name, woher] of hausNamen) {
/* Nur ein AUFRUF zaehlt. Ein Name in einer Eigenschaft
(`x.heuteLokal`) ist etwas anderes und gehoert nicht hierher. */
const ruft = new RegExp(`(^|[^\\w.])${name}\\s*\\(`, "m").test(code);
if (!ruft) continue;
gesucht++;
if (!kennt.has(name)) fehlt.push(`${datei}: ${name}() (aus ${woher})`);
}
}
ok(gesucht > 50, `${gesucht} Aufrufe von Hausnamen in ${dateien.length} Dateien angesehen`);
ok(fehlt.length === 0,
fehlt.length
? `rufen etwas, das sie nicht eingefuehrt haben — das faellt erst beim`
+ ` Aufruf um, nicht beim Laden:\n ` + fehlt.slice(0, 12).join("\n ")
: "jeder Aufruf eines Hausnamens hat auch seine Einfuhr");
/* ---- GEGENPROBEN --------------------------------------------------
Die Zeile darueber meldet „in Ordnung", solange sie nichts findet.
Heute Nacht haette sie elf Dateien gefunden -- also einmal zeigen,
dass sie das auch kann. */
{
const probeFehlt = (code, name) => {
const c = nurCode(code);
const ruft = new RegExp(`(^|[^\\w.])${name}\\s*\\(`, "m").test(c);
return ruft && !bekannt(c).has(name);
};
ok(probeFehlt('const t = heuteLokal();', "heuteLokal"),
"Gegenprobe: ein Aufruf ohne Einfuhr faellt auf");
ok(!probeFehlt('import { heuteLokal } from "./workspace.js";\nconst t = heuteLokal();',
"heuteLokal"), " mit Einfuhr NICHT");
ok(!probeFehlt('const { heuteLokal } = await import("./workspace.js");\n'
+ 'const t = heuteLokal();', "heuteLokal"), " und ueber eine Zerlegung auch nicht");
ok(!probeFehlt('function heuteLokal() { return 1; }\nconst t = heuteLokal();',
"heuteLokal"), " wer ihn selbst erklaert, ebenfalls nicht");
ok(!probeFehlt('/* heuteLokal() waere hier falsch */\nconst t = 1;', "heuteLokal"),
" und ein Name im Kommentar ist kein Aufruf");
ok(!probeFehlt('const t = uhr.heuteLokal();', "heuteLokal"),
" eine Eigenschaft an einem Objekt auch nicht");
}
}
console.log(`\n=== Wird der TAG in Ortszeit gebildet -- ueberall? ===`);
/* ===================================================================
AM 01.10.2026 UM 01:22 GEWACHSEN, weil diese Wache versagt hatte.
Sie sah seit dem 06.09. nur in die `pruef-*.mjs`. In der ANWENDUNG
standen zur selben Zeit SECHZEHN Stellen, die einen Kalendertag aus
UTC bilden -- zehn davon schrieben ihn in die Datenbank.
Aufgefallen ist es nicht hier, sondern in pruef-kreislauf: Ein
Wunsch mit dem heutigen Datum wurde zu einem Termin, und der trug
`2026-09-30` statt `2026-10-01`. Auf dem Brett „Was ansteht" landete
er damit unter „Vorbei" -- einem Abschnitt, der von sich aus
zugeklappt ist. Der Termin war da und war nicht zu sehen.
Eine Sicherung, die nur die halbe Menge kennt, faellt in der
anderen Haelfte aus -- lautlos, denn sie meldet ja „nichts
gefunden". Deshalb sieht sie jetzt in BEIDES: in die Anwendung und
in die Pruefungen.
=================================================================== */
{
const pruefungen = readdirSync(SERVER)
.filter((n) => (n.startsWith("pruef-") || n.startsWith("mess-")) && n.endsWith(".mjs"));
/* DIE ANWENDUNG -- abgeleitet, nicht aufgezaehlt: alles, was der
Dienst laedt, liegt als `.js` im Serverordner. Eine Liste von
Dateinamen waere die naechste, die beim uebernaechsten Modul
nicht mitwaechst. */
const anwendung = readdirSync(SERVER).filter((n) => n.endsWith(".js"));
const dateien = [...anwendung, ...pruefungen];
ok(anwendung.length >= 30 && pruefungen.length >= 30,
`${anwendung.length} Dateien der Anwendung und ${pruefungen.length} Pruef-/Messdateien durchgesehen`);
/* EINE UHRZEIT IM AUSDRUCK HEISST: DER TAG STEHT SCHON FEST.
`Date.parse(tag + "T12:00:00Z") + n * 86400000` bleibt in jeder
Zeitzone am selben Kalendertag -- das ist der dokumentierte Weg
des Hauses (workspace-kalender, workspace-teamlage,
workspace-serien). Erkannt wird das am Ausdruck selbst; eine
Liste erlaubter Ausnahmen braucht es dadurch nicht, und eine
Liste, die niemand pflegt, waere die naechste Stelle, an der es
auseinanderlaeuft. */
const festeUhrzeit = /T\d{2}:\d{2}:\d{2}Z/;
/** Alle Stellen einer Datei, an denen aus einem ZEITPUNKT ein
* Kalendertag wird. Gibt Zeilennummern zurueck. */
function utcTage(text) {
const treffer = [];
const zeile = (i) => text.slice(0, i).split("\n").length;
/* (1) Direkt: new Date(...).toISOString() und dann auf den Tag
gekuerzt. DREI SCHREIBWEISEN, nicht eine (02.10.2026): Den
UTC-Tag bekommt man mit `.slice(0, 10)`, mit `.split("T")[0]`
und mit `.substring(0, 10)`. Gemessen schreibt heute niemand
die anderen beiden -- aber „niemand schreibt es so" ist kein
Schutz, sondern Glueck. */
for (const t of text.matchAll(
/new Date\(([^;]*?)\)\s*\.\s*toISOString\(\)\s*\.\s*(?:slice\(\s*0\s*,\s*10\s*\)|substring\(\s*0\s*,\s*10\s*\)|split\(\s*["'`]T["'`]\s*\)\s*\[\s*0\s*\])/g)) {
if (festeUhrzeit.test(t[1])) continue;
treffer.push(zeile(t.index));
}
/* (2) Ueber einen NAMEN. Zuerst sammeln, welche Namen einen
UTC-Zeitstempel liefern -- als Funktion ODER als Wert. Genau
diese Schreibweise fehlte bis heute:
const jetzt = () => new Date().toISOString();
... jetzt().slice(0, 10) ...
Und in zwei Schritten kommt sie auch vor:
const jetztIso = jetzt();
... jetztIso.slice(0, 10) ... */
const utcNamen = new Set();
for (const t of text.matchAll(
/(?:const|let)\s+(\w+)\s*=\s*(?:\(\)\s*=>\s*)?new Date\(\)\.toISOString\(\)/g)) {
utcNamen.add(t[1]);
}
for (let runde = 0; runde < 2; runde++) {
for (const t of text.matchAll(/(?:const|let)\s+(\w+)\s*=\s*(\w+)\(\)\s*;/g)) {
if (utcNamen.has(t[2])) utcNamen.add(t[1]);
}
}
for (const t of text.matchAll(
/(\w+)(\(\))?\s*\.\s*(?:slice\(\s*0\s*,\s*10\s*\)|substring\(\s*0\s*,\s*10\s*\)|split\(\s*["'`]T["'`]\s*\)\s*\[\s*0\s*\])/g)) {
if (utcNamen.has(t[1])) treffer.push(zeile(t.index));
}
return treffer;
}
/* ==== DIE ZWEI DATEIEN, DIE DAS MUSTER ENTHALTEN MUESSEN ========
pruef-struktur.mjs: Die Gegenproben weiter unten MUESSEN es als
Text enthalten, sonst koennten sie nicht beweisen, dass die
Erkennung greift.
pruef-tagesgrenze.mjs: Sie stellt den falschen Weg dem richtigen
gegenueber und zeigt, dass der Unterschied auffaellt.
EINE AUSNAHME, DIE NIEMAND NACHPRUEFT, IST DER ANFANG EINER
LISTE, DIE NIEMAND PFLEGT. Deshalb wird nachgesehen, dass jede
ausgenommene Datei das Muster WIRKLICH enthaelt — raeumt es
jemand weg, ist die Ausnahme veraltet und faellt auf. */
const AUSNAHMEN_UTC = new Set(["pruef-struktur.mjs", "pruef-tagesgrenze.mjs"]);
{
const schal = [...AUSNAHMEN_UTC].filter((n) => {
const t = readFileSync(join(SERVER, n), "utf8");
return !/toISOString\(\)\s*\.\s*slice\(\s*0\s*,\s*10\s*\)/.test(t);
});
ok(schal.length === 0,
schal.length
? `diese Ausnahmen brauchen keine mehr zu sein: ${schal.join(", ")}`
: `${AUSNAHMEN_UTC.size} Dateien sind ausgenommen und enthalten das Muster`
+ " auch wirklich");
}
const verdaechtig = [];
for (const datei of dateien) {
/* Die eigene Datei bleibt aussen vor: Die Gegenproben unten
MUESSEN das gesuchte Muster als Text enthalten, sonst koennten
sie nicht beweisen, dass sie es erkennen. Sie wuerde sich sonst
selbst anzeigen -- eine Warnung, die immer kommt. */
if (AUSNAHMEN_UTC.has(datei)) continue;
/* KOMMENTARE RAUS, ZEILEN BEHALTEN (16.09.2026).
Hier stand `replace(…, " ")` -- ein Blockkommentar ueber zwanzig
Zeilen wurde also zu einem Leerzeichen, und ab da zaehlte
`split("\n").length` falsch. Gemeldet wurden Zeilennummern, die
auf voellig anderen Code zeigten. Jetzt bleibt je Zeile eine
Zeile stehen. */
const text = readFileSync(join(SERVER, datei), "utf8")
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, " "))
.replace(/^\s*\/\/.*$/gm, "");
for (const z of utcTage(text)) verdaechtig.push(`${datei}:${z}`);
}
ok(verdaechtig.length === 0,
verdaechtig.length
? `bilden ihr Tagesdatum aus UTC statt Ortszeit — nachts zwischen `
+ `00:00 und 02:00 liegt es einen Tag zurueck:\n `
+ verdaechtig.join("\n ")
: `keine der ${dateien.length} Dateien bildet einen Kalendertag aus UTC`);
/* ---- GEGENPROBEN -------------------------------------------------
Eine Erkennung, die nichts findet, sieht genauso aus wie eine, die
nichts finden KANN. Heute Nacht war sie genau das: Die Schreibweise
mit einer Funktion kannte sie nicht, und die Anwendung sah sie gar
nicht an. Beides steht jetzt hier als Probe. */
const probe = (code) => utcTage(code).length;
ok(probe('const h = new Date().toISOString().slice(0, 10);') === 1,
"Gegenprobe: die direkte Schreibweise faellt auf");
ok(probe('const jetzt = () => new Date().toISOString();\nconst h = jetzt().slice(0, 10);') === 1,
" die ueber eine FUNKTION ebenfalls — genau die, die heute Nacht durchrutschte");
ok(probe('const jetzt = new Date().toISOString();\nconst h = jetzt.slice(0, 10);') === 1,
" und die ueber eine Variable");
ok(probe('const jetzt = () => new Date().toISOString();\n'
+ 'const iso = jetzt();\nconst h = iso.slice(0, 10);') === 1,
" auch ueber zwei Schritte");
ok(probe('const g = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);') === 1,
" und eine Rechnung auf Date.now()");
ok(probe('const h = new Date().toISOString().split("T")[0];') === 1,
" auch die Schreibweise mit split(\"T\")[0]");
ok(probe('const h = new Date().toISOString().substring(0, 10);') === 1,
" und die mit substring(0, 10)");
ok(probe('const jetzt = () => new Date().toISOString();\n'
+ 'const h = jetzt().split("T")[0];') === 1,
" und beides ueber einen Namen");
ok(probe('const jetzt = new Date().toISOString();') === 0,
"Gegenprobe: ein voller Zeitstempel ohne Schnitt schlaegt NICHT an");
ok(probe('const b = new Date(Date.parse(tag + "T12:00:00Z") + n * 86400000)'
+ '.toISOString().slice(0, 10);') === 0,
" und die Rechnung auf UTC-Mittag ebenfalls nicht — die ist richtig so");
ok(probe('const alsText = (d) => d.toISOString().slice(0, 10);') === 0,
" und ein Date, das von aussen kommt, auch nicht");
}
/* =======================================================================
WIRD DAS PROTOKOLL RICHTIG HERUM GERUFEN?
`protokolliere(aktion, { personId, rolle, detail, ip })` -- die
Aktion zuerst, alles Weitere benannt.
Aufgerufen wurde es an 17 Stellen andersherum:
`protokolliere(req.person.id, "aktion", detail, ip)`. JavaScript
beschwert sich darueber NICHT. Das zweite Argument ist ein Text,
und einen Text zu zerlegen ergibt lauter `undefined`. Der Eintrag
entsteht also -- nur steht die Personen-Kennung im Feld `aktion`,
und Person, Detail und IP sind leer.
AUF DEM ECHTEN SERVER NACHGEMESSEN: 39 Zeilen mit Aktionen wie
„16.0" und „4.0". Betroffen war unter anderem jede Aenderung an
Dogi-Media und jede Massnahme im Live-Chat -- also ausgerechnet
das, wofuer es ein Protokoll gibt.
Ein Kommentar haette das nicht verhindert (11.09.2026: „Ein
Kommentar, der vor einem Fehler warnt, verhindert ihn nicht").
Eine Pruefung schon.
======================================================================= */
console.log(`\n=== Wird das Protokoll richtig herum gerufen? ===`);
{
/* Das erste Argument muss etwas sein, das eine Aktion SEIN KANN:
eine Zeichenkette oder ein Ausdruck, der eine liefert (`a ? "x" :
"y"`). Was es nicht sein darf, ist eine Kennung -- und die
erkennt man daran, dass sie auf `.id` endet oder schlicht
`personId` heisst. */
const schlecht = [];
let gesehen = 0;
for (const datei of readdirSync(SERVER).filter((n) => n.endsWith(".js"))) {
const text = readFileSync(join(SERVER, datei), "utf8");
for (const m of text.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)) {
const erstes = m[1].trim();
if (erstes.startsWith("aktion")) continue; // die Erklaerung selbst
gesehen++;
if (/\.id\s*$/.test(erstes) || /^personId$/.test(erstes)
|| /^\d/.test(erstes)) {
const zeile = text.slice(0, m.index).split("\n").length;
schlecht.push(`${datei}:${zeile} -> ${erstes}`);
}
}
}
ok(gesehen > 60, `${gesehen} Protokollaufrufe im Haus gefunden`);
ok(schlecht.length === 0,
schlecht.length
? `FALSCH HERUM: ${schlecht.slice(0, 6).join(" | ")}`
: "alle rufen die Aktion zuerst");
/* GEGENPROBE: Kann die Pruefung „nein" sagen? Ohne sie waere sie
eine, die immer bestaetigt -- und die bestaetigt nichts. */
const probe = 'protokolliere(req.person.id, "test", "x");';
const trifft = [...probe.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)]
.some((m) => /\.id\s*$/.test(m[1].trim()));
ok(trifft, "Gegenprobe: ein falsch herum gebauter Aufruf wird erkannt");
const gut = 'protokolliere("test", { personId: req.person.id });';
const trifftNicht = [...gut.matchAll(/\bprotokolliere\(\s*([^,)]{1,60})/g)]
.some((m) => /\.id\s*$/.test(m[1].trim()));
ok(!trifftNicht, "Gegenprobe: ein richtiger Aufruf schlaegt nicht an");
}
/* ---------- Gegenprobe --------------------------------------------------- */
console.log(`\n=== Gegenprobe (muss anschlagen) ===`);
ok(!routeGibtEs("/workspace/api/gibtesnicht"),
"erfundene Schnittstelle wird als fehlend erkannt");
ok(routeGibtEs("/workspace/api/aufgaben"),
"vorhandene Schnittstelle wird als vorhanden erkannt");
ok(!existsSync(join(WS, "gibtesnicht.html")),
"erfundene Seite wird als fehlend erkannt");
/* DIE NEUE PRAEFIX-REGEL IN BEIDE RICHTUNGEN (02.10.2026).
Sie laesst etwas durch -- also muss nachgewiesen sein, dass sie
nicht ALLES durchlaesst. Sonst waere aus einer Wache eine
Freigabestelle geworden. */
{
const istPraefix = (p) => !routeGibtEs(p)
&& ROUTEN.some((r) => r.art !== "use" && r.pfad.startsWith(p + "/"));
ok(istPraefix("/workspace/api/manager-ziele"),
"eine Praefix-Konstante wird als Praefix erkannt (Routen darunter)");
ok(!istPraefix("/workspace/api/gibtesnicht"),
" eine erfundene Adresse OHNE Routen darunter bleibt ein Fund");
ok(!istPraefix("/workspace/api/aufgaben"),
" und eine echte Route bleibt eine echte Route, kein Praefix");
}
/* =====================================================================
RUFT JEMAND `openssl` WIEDER DIREKT AUF?
WAS HIER SCHIEFGING (29.09.2026 gemessen): Im naechtlichen Lauf
standen DREIZEHN Pruefungen mit derselben Zeile rot --
[uncaughtException] Error: spawnSync openssl ENOENT
Kein Programmfehler. Die Pruefungen brauchen einen https-Vorbau
(der Sitzungskeks ist `secure`), und dafuer erzeugen sie ein
Wegwerf-Zertifikat. Auf diesem Rechner liegt openssl in
`Git\\mingw64\\bin`; im System-PATH steht aber nur `Git\\cmd`. Wer
aus Git Bash startet, erbt die mingw-Pfade und merkt nie etwas --
die Aufgabenplanung startet `node.exe` direkt und bekommt sie
nicht.
Bei mir gruen, nachts rot, und der Grund steht nicht im Code. Die
dreizehn roten Zeilen haben dabei monatelang die echten Befunde
zugedeckt.
`helfer-openssl.mjs` sucht das Programm jetzt auch ausserhalb des
PATH. Diese Wache sorgt dafuer, dass der direkte Aufruf nicht
zurueckkommt -- der naechste, der ihn schreibt, merkt es hier und
nicht erst in einem Nachtlauf, den niemand liest. */
console.log(`\n=== Ruft noch jemand openssl direkt auf? ===`);
{
const direkt = [];
let ueberHelfer = 0;
for (const datei of readdirSync(SERVER).filter((n) => /\.(mjs|js)$/.test(n))) {
if (datei === "helfer-openssl.mjs") continue;
const text = readFileSync(join(SERVER, datei), "utf8");
for (const m of text.matchAll(/exec(?:File)?Sync\(\s*["'`]openssl/g)) {
direkt.push(`${datei}:${text.slice(0, m.index).split("\n").length}`);
}
if (/zertifikatBauen\(/.test(text)) ueberHelfer++;
}
ok(ueberHelfer > 20,
`${ueberHelfer} Dateien bauen ihr Zertifikat ueber den Helfer`);
ok(direkt.length === 0,
direkt.length
? `DIREKT: ${direkt.slice(0, 6).join(" | ")} -- bitte zertifikatBauen() aus `
+ "helfer-openssl.mjs nehmen, sonst faellt es im Nachtlauf um"
: "keine Datei ruft openssl mehr am PATH vorbei");
/* GEGENPROBE IN BEIDE RICHTUNGEN. Eine Wache, die nur „nichts
gefunden" sagen kann, hat nichts bewiesen.
DIE PROBETEXTE WERDEN ZUSAMMENGESETZT und stehen nicht am Stueck
da. Beim ersten Anlauf schlug diese Wache auf ihre EIGENEN
Gegenproben an: Sie liest den Quelltext aller Serverdateien,
und dazu gehoert diese hier. Dasselbe ist mir heute schon
zweimal passiert -- eine Pruefung, die ihre eigene Begruendung
als Fund meldet, ist der haeufigste Selbsttreffer im Haus. */
const findet = (t) => [...t.matchAll(/exec(?:File)?Sync\(\s*["'`]openssl/g)].length;
const O = "openssl";
ok(findet(`execFileSync("${O}", ["version"]);`) === 1,
"Gegenprobe: ein direkter Aufruf wird erkannt");
ok(findet(`execSync(\`${O} version\`);`) === 1,
"auch mit Schraegstrich-Anfuehrung");
ok(findet("zertifikatBauen(k, z, CREW);") === 0,
"und der Weg ueber den Helfer gilt nicht als Fund");
}
/* =====================================================================
GIBT NOCH JEMAND EINE DATEI OHNE TEILANFRAGEN HINAUS? (02.10.2026)
Am 02.10. kam heraus, warum Miss keine Sprachnachrichten hoeren
konnte: Zehn Stellen im Haus gaben Dateien mit
`createReadStream(pfad).pipe(res)` hinaus — ohne `Accept-Ranges`,
ohne `Content-Length`, als `chunked`. Safari verweigert fuer
<audio> und <video> genau das. Sie ist die Einzige mit Safari;
deshalb fiel es sonst niemandem auf.
Alle zehn gehen jetzt ueber `liefereDatei()`. Damit das so bleibt,
wird hier NACHGEZAEHLT statt gehofft: Ein Kommentar, der vor einem
Fehler warnt, verhindert ihn nicht — das hat der Spaltenverlust vom
11.09. bewiesen, wo die Warnung drei Tage direkt daneben stand und
trotzdem nicht gelesen wurde.
PRUEFDATEIEN SIND AUSGENOMMEN und zwar aus einem Grund, nicht aus
Bequemlichkeit: Sie liefern an niemanden aus. Mehrere starten
eigene kleine Server, um genau diesen Unterschied zu MESSEN — dort
ist ein Weg ohne Teilanfragen die Gegenprobe und kein Fehler.
===================================================================== */
console.log(`\n=== Gibt noch jemand eine Datei ohne Teilanfragen hinaus? ===`);
{
const MUSTER = /createReadStream\([^)]*\)\s*\.\s*pipe\(\s*res/g;
const roh = [];
let ueberHelfer = 0;
for (const datei of readdirSync(SERVER).filter((n) => /\.(mjs|js)$/.test(n))) {
if (datei === "helfer-ausliefern.mjs") continue;
if (datei.startsWith("pruef-") || datei.startsWith("mess-")) continue;
const text = readFileSync(join(SERVER, datei), "utf8");
for (const m of text.matchAll(MUSTER)) {
roh.push(`${datei}:${text.slice(0, m.index).split("\n").length}`);
}
if (/liefereDatei\(\s*req/.test(text)) ueberHelfer++;
}
/* DIE ZAHL GEHOERT IN DIE BEDINGUNG, nicht nur in den Meldetext:
Verschwinden die Aufrufe, waere „nichts gefunden" sonst wieder
gruen — und niemand liefert mehr etwas aus. */
ok(ueberHelfer >= 7,
`${ueberHelfer} Server-Module liefern Dateien ueber liefereDatei() aus`);
ok(roh.length === 0,
roh.length
? `OHNE TEILANFRAGEN: ${roh.slice(0, 8).join(" | ")} -- bitte `
+ "liefereDatei(req, res, pfad) aus helfer-ausliefern.mjs nehmen, "
+ "sonst spielt Safari die Datei nicht ab"
: "keine Stelle gibt eine Datei mehr am gemeinsamen Weg vorbei hinaus");
/* GEGENPROBE IN BEIDE RICHTUNGEN. Die Probetexte werden
ZUSAMMENGESETZT: Diese Datei liegt selbst in SERVER, und eine
Pruefung, die ihre eigene Begruendung als Fund meldet, ist der
haeufigste Selbsttreffer im Haus. */
const findet = (t) => [...t.matchAll(MUSTER)].length;
const C = "createReadStream";
ok(findet(`${C}(pfad).pipe(res);`) === 1,
"Gegenprobe: der alte Weg wird erkannt");
ok(findet(`${C}(pfad, { start: a, end: b }) .pipe( res );`) === 1,
"auch mit Abschnitt und Leerzeichen dazwischen");
ok(findet(`${C}(pfad).pipe(ziel);`) === 0,
"und ein Strom, der NICHT in die Antwort geht, gilt nicht als Fund");
ok(findet("liefereDatei(req, res, pfad);") === 0,
"der gemeinsame Weg selbst auch nicht");
}
/* =====================================================================
DER BEREICHSLESER, OHNE SERVER (02.10.2026)
`bereichLesen()` ist die ganze Entscheidung: volle Datei, Abschnitt
oder 416. Sie steht ausdruecklich als eigene, ausgefuehrte Funktion
da, damit sie sich einzeln befragen laesst -- ohne Browser, ohne
Server, ohne Datenbank.
WARUM DAS HIER STEHT UND NICHT NUR IM BROWSER: Beim Messen auf dem
Server reichte ich dem Helfer eine nackte `http.ServerResponse`
statt einer von Express, und er warf „res.status is not a
function". Durch zehn Express-Handler hindurch waere das nie
aufgefallen. Was sich einzeln pruefen laesst, wird einzeln
geprueft.
===================================================================== */
console.log(`\n=== Der Bereichsleser, ohne Server ===`);
{
const { bereichLesen } = await import("./helfer-ausliefern.mjs");
const G = 1000;
const gleich = (a, b) => JSON.stringify(a) === JSON.stringify(b);
ok(bereichLesen(undefined, G) === null, "kein Kopf -> volle Datei");
ok(bereichLesen("", G) === null, "leerer Kopf -> volle Datei");
ok(gleich(bereichLesen("bytes=0-9", G), { von: 0, bis: 9 }), "bytes=0-9");
ok(gleich(bereichLesen("bytes=5-", G), { von: 5, bis: 999 }), "bytes=5- bis zum Ende");
ok(gleich(bereichLesen("bytes=-8", G), { von: 992, bis: 999 }), "bytes=-8 sind die LETZTEN acht");
ok(gleich(bereichLesen("bytes=0-5000", G), { von: 0, bis: 999 }),
"zu viel verlangen endet am Dateiende");
ok(gleich(bereichLesen(" bytes=10-20 ", G), { von: 10, bis: 20 }), "Leerzeichen stoeren nicht");
ok(bereichLesen("bytes=1000-", G) === false, "genau hinter dem Ende -> 416");
ok(bereichLesen("bytes=9-5", G) === false, "rueckwaerts -> 416");
ok(bereichLesen("bytes=-0", G) === false, "die letzten null Bytes -> 416");
ok(bereichLesen("bytes=0-9", 0) === false, "aus einer leeren Datei kommt kein Abschnitt");
/* NICHT VERSTANDEN IST NICHT UNERFUELLBAR: Darauf gehoert die ganze
Datei, nie eine falsche Teilmenge und nie eine Absage. */
ok(bereichLesen("bytes=0-9,20-29", G) === null, "mehrere Bereiche -> volle Datei");
ok(bereichLesen("haeppchen=0-9", G) === null, "fremde Einheit -> volle Datei");
ok(bereichLesen("bytes=abc-def", G) === null, "Unsinn -> volle Datei");
}
console.log(`\n${fehler === 0 ? "ALLES IN ORDNUNG" : `${fehler} FEHLER`}`);
process.exit(fehler ? 1 : 0);