/* ===================================================================== STEHT IN EINER AUSGELIEFERTEN DATEI EIN ZERSTOERTES ZEICHEN? WARUM ES DIESE PRUEFUNG GIBT: Weil genau das zweimal live stand, und zwar am selben Tag (23.09.2026), in zwei Dateien. 1. `content: "<0x15>C9<0x00>A0"` in chat.css. Gemeint war U+25C9 (dasselbe Zeichen, das chat.js fuer das Rudel benutzt) und ein geschuetztes Leerzeichen. Im Quelltext standen stattdessen ZWEI ROHE STEUERZEICHEN. Auf dem Bildschirm las man deshalb woertlich "C9 A0" vor jedem hervorgehobenen @rudel -- die Steuerzeichen selbst sind unsichtbar. 2. `content: "¹3"` in chat.css UND entwicklung.css. Gemeint war ein Haken. Auf der gewaehlten Kachel stand "¹3". DAS BESONDERE AN DIESER FEHLERART: Niemand sieht sie beim Lesen. Punkt 1 war sogar committet, ausgeliefert und lag live -- und als ich in der Datei danach suchte, meldete grep "Binary file matches" und zeigte die Zeile gar nicht erst an. Ein Mensch, der den Unterschied durchsieht, liest `content: "C9 A0"` und denkt sich nichts dabei. Gefunden hat es am Ende Filipes Bildschirmfoto, nicht der Quelltext. Das ist zu spaet und zu zufaellig. Also eine Pruefung. --------------------------------------------------------------------- WAS GEPRUEFT WIRD, UND WARUM DIE GRENZE DA LIEGT AUSGELIEFERTE DATEIEN (workspace/, assets/, *.html) -- dort ist beides ein Fehler: ein Steuerzeichen UND ein Ersatzzeichen (U+FFFD). Was dort steht, sieht ein Mensch. SERVERDATEIEN -- dort nur Steuerzeichen. Ein U+FFFD kann in einer Pruefung absichtlich stehen: pruef-ics.mjs sucht genau danach in Kalenderdateien. Eine Ausnahmeliste mit Dateinamen waere die naheliegende Loesung und die schlechtere -- sie altert. Die Grenze "wird es ausgeliefert?" altert nicht. TABULATOR, ZEILENVORSCHUB UND WAGENRUECKLAUF sind erlaubt. Alles andere unter 0x20 ist in Quelltext nie Absicht. ===================================================================== */ import { readdirSync, readFileSync, statSync, writeFileSync, unlinkSync } from "node:fs"; import { join, dirname, extname, relative } from "node:path"; import { fileURLToPath } from "node:url"; import { tmpdir } from "node:os"; const HIER = dirname(fileURLToPath(import.meta.url)); const WURZEL = join(HIER, ".."); let fehler = 0; let geprueft = 0; const ok = (b, t) => { geprueft++; console.log((b ? " ok " : " FEHL ") + t); if (!b) fehler++; }; /* NOTBREMSE. Auch eine Pruefung ohne Netzzugriff bekommt eine -- ein Lauf, der haengt, ist schlimmer als einer, der scheitert (06.09.2026, damals drei Stunden). */ const notbremse = setTimeout(() => { console.log(" FEHL Notbremse: die Pruefung haengt."); process.exit(1); }, 60_000); const ENDUNGEN = new Set([".js", ".mjs", ".html", ".css", ".json", ".svg", ".txt", ".webmanifest"]); /* Ordner, die uns nicht gehoeren oder erzeugt sind. */ const AUSSEN = new Set([".git", "node_modules", "sicherungen", ".tmp"]); /** Alle Dateien unterhalb eines Ordners, die uns angehen. */ function dateien(ordner) { const raus = []; const gehe = (o) => { let eintraege; try { eintraege = readdirSync(o, { withFileTypes: true }); } catch { return; } for (const e of eintraege) { if (e.name.startsWith(".tmp-")) continue; const p = join(o, e.name); if (e.isDirectory()) { if (!AUSSEN.has(e.name)) gehe(p); continue; } if (ENDUNGEN.has(extname(e.name).toLowerCase())) raus.push(p); } }; gehe(ordner); return raus; } /* --------------------------------------------------------------------- Die beiden Sucher. Getrennt, damit die Gegenprobe jeden einzeln nachweisen kann. --------------------------------------------------------------------- */ /** Rohe Steuerzeichen -- alles unter 0x20 ausser \t \n \r. */ function steuerzeichenIn(roh) { const treffer = []; for (let i = 0; i < roh.length; i++) { const b = roh[i]; if (b >= 0x20 || b === 0x09 || b === 0x0a || b === 0x0d) continue; /* Die Zeile mitgeben -- eine Bytenummer allein sucht man lange. */ const zeile = roh.subarray(0, i).toString("utf8").split("\n").length; treffer.push({ zeile, byte: b }); } return treffer; } /** Ersatzzeichen U+FFFD -- in UTF-8 die drei Bytes ef bf bd. */ function ersatzzeichenIn(roh) { const treffer = []; let i = -1; while ((i = roh.indexOf(Buffer.from([0xef, 0xbf, 0xbd]), i + 1)) !== -1) { treffer.push({ zeile: roh.subarray(0, i).toString("utf8").split("\n").length }); } return treffer; } /* ===================================================================== 1. GEGENPROBE ZUERST Eine Pruefung, die immer bestaetigt, bestaetigt nichts. Bevor sie ueber echte Dateien redet, weist sie nach, dass sie ueberhaupt anschlagen KANN -- an einer Datei, die sie sich selbst baut. Ohne das waere ein gruener Lauf hier auch dann gruen, wenn beide Sucher ins Leere greifen (der Fehler vom 01.09.2026, zweimal an einem Tag). ===================================================================== */ console.log("=== 0. Kann die Pruefung ueberhaupt Alarm schlagen? ==="); { const probe = join(tmpdir(), "pruef-zeichen-probe.css"); try { /* Genau die beiden Faelle, die live standen. */ writeFileSync(probe, Buffer.concat([ Buffer.from('a { content: "'), Buffer.from([0x15]), Buffer.from("C9"), Buffer.from([0x00]), Buffer.from('A0"; }\n'), Buffer.from('b::after { content: "'), Buffer.from([0xef, 0xbf, 0xbd]), Buffer.from('"; }\n'), ])); const roh = readFileSync(probe); const st = steuerzeichenIn(roh); const er = ersatzzeichenIn(roh); ok(st.length === 2, `sie findet rohe Steuerzeichen (${st.length}, erwartet 2)`); ok(er.length === 1, `sie findet Ersatzzeichen (${er.length}, erwartet 1)`); /* Und die andere Richtung: saubere Datei, kein Alarm. Eine Pruefung, die das richtige Verhalten bestraft, wird abgeschafft (03.09.2026). */ writeFileSync(probe, 'a { content: "\\25C9\\A0"; }\n\tb { color: red; }\r\n'); const sauber = readFileSync(probe); ok(steuerzeichenIn(sauber).length === 0 && ersatzzeichenIn(sauber).length === 0, "und sie schweigt bei einer sauberen Datei -- auch mit Tabulator und CRLF"); } finally { try { unlinkSync(probe); } catch { /* beim Aufraeumen ist Scheitern egal */ } } } /* ===================================================================== 2. DIE AUSGELIEFERTEN DATEIEN ===================================================================== */ console.log(""); console.log("=== 1. Was der Browser bekommt ==="); const geliefert = [ ...dateien(join(WURZEL, "workspace")), ...dateien(join(WURZEL, "assets")), ...dateien(WURZEL).filter((p) => dirname(p) === WURZEL && extname(p) === ".html"), ]; { const schlimm = []; for (const p of geliefert) { const roh = readFileSync(p); for (const t of steuerzeichenIn(roh)) { schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); } for (const t of ersatzzeichenIn(roh)) { schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Ersatzzeichen)`); } } /* DIE ANZAHL STEHT IN DER BEDINGUNG, nicht nur im Text. Waere der Ordner leer oder der Pfad falsch, meldete "keine Fundstellen" sonst Erfolg -- gruen, ohne eine einzige Datei angesehen zu haben. */ ok(geliefert.length > 50 && schlimm.length === 0, `${geliefert.length} ausgelieferte Dateien, ${schlimm.length} mit zerstoerten Zeichen`); for (const s of schlimm.slice(0, 12)) console.log(" " + s); if (schlimm.length > 12) console.log(` ... und ${schlimm.length - 12} weitere`); } /* ===================================================================== 3. DIE SERVERDATEIEN -- nur Steuerzeichen ===================================================================== */ console.log(""); console.log("=== 2. Der Server ==="); { const schlimm = []; const serverDateien = [...dateien(join(WURZEL, "server")), ...dateien(join(WURZEL, "tools"))]; for (const p of serverDateien) { const roh = readFileSync(p); for (const t of steuerzeichenIn(roh)) { schlimm.push(`${relative(WURZEL, p)}:${t.zeile} (Byte 0x${t.byte.toString(16).padStart(2, "0")})`); } } ok(serverDateien.length > 50 && schlimm.length === 0, `${serverDateien.length} Serverdateien, ${schlimm.length} mit Steuerzeichen`); for (const s of schlimm.slice(0, 12)) console.log(" " + s); } /* ===================================================================== 4. DIE STELLE, AN DER ES ZWEIMAL PASSIERT IST `content:` in CSS ist der eine Ort, an dem ein Zeichen SICHTBAR wird, ohne dass es im Text steht. Beide Fehler sassen dort. --------------------------------------------------------------------- DIE REGEL WAR IM ERSTEN ANLAUF ZU WEIT -- und das ist hier notiert, weil es der wichtigere Teil ist. Zuerst stand hier "jedes Zeichen ueber 0x7f ist verdaechtig, schreib es als Escape". Das meldete auf Anhieb ACHT Stellen: ✓ in vier Dateien, ↯, ↻, „ und ⚠. Alle acht sind richtig, absichtlich und seit Monaten in Ordnung. Eine Pruefung, die beim ersten Lauf acht korrekte Stellen anmeckert, wird beim neunten Mal weggeklickt -- und dann faengt sie auch den echten Fall nicht mehr (03.09.2026). DIE ENGERE REGEL KOMMT AUS DEM BEFUND SELBST. Das kaputte Zeichen war U+00B9 (hochgestellte Eins) -- ein Rest aus dem Latin-1-Block. Genau dort, zwischen U+0080 und U+00BF, liegen die typischen Truemmer einer verungluekten Kodierung: ¡ ¢ £ ¤ ¥ ¦ § ¨ © ª « ¬ ® ° ± ² ³ ´ µ ¶ · ¸ ¹ º » ¼ ½ ¾. Nachgemessen ueber alle 203 content-Angaben im Haus: KEINE einzige benutzt ein Zeichen aus diesem Block. Alle echten liegen darueber (✓ = U+2713, ⚠ = U+26A0, „ = U+201E). Die Regel ist damit heute gruen, haette den Fehler aber gefunden -- und sie ist nicht geraten, sondern an den vorhandenen Daten geprueft. ===================================================================== */ console.log(""); console.log("=== 3. Zeichen, die CSS auf den Bildschirm schreibt ==="); /** Truemmerverdacht: Latin-1-Block, wo echte Typografie nie hingehoert. */ const istTruemmer = (c) => c >= 0x80 && c <= 0xbf; { const verdaechtig = []; let stellen = 0; for (const p of geliefert.filter((x) => extname(x) === ".css")) { const text = readFileSync(p, "utf8"); for (const m of text.matchAll(/content:\s*"([^"]*)"/g)) { stellen++; for (const z of m[1]) { const c = z.codePointAt(0); if (istTruemmer(c)) { verdaechtig.push(`${relative(WURZEL, p)}: content "${m[1]}" enthaelt U+${c.toString(16).toUpperCase().padStart(4, "0")}`); } } } } ok(stellen > 50 && verdaechtig.length === 0, `${stellen} content-Angaben in CSS, ${verdaechtig.length} mit Kodierungstruemmern`); for (const s of verdaechtig.slice(0, 12)) console.log(" " + s); /* GEGENPROBE: Wuerde sie den echten Fall sehen? Nicht theoretisch -- an genau der Zeichenfolge, die in chat.css stand. */ const kaputt = [...'content: "¹3"'.matchAll(/content:\s*"([^"]*)"/g)] .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); const heil = [...'content: "✓"'.matchAll(/content:\s*"([^"]*)"/g)] .flatMap((m) => [...m[1]]).some((z) => istTruemmer(z.codePointAt(0))); ok(kaputt && !heil, 'sie erkennt das echte "¹3" und laesst den richtigen Haken in Ruhe'); } clearTimeout(notbremse); console.log(""); console.log(fehler === 0 ? `Alles in Ordnung — ${geprueft} Prüfungen` : `${fehler} FEHLER — ${geprueft} Prüfungen`); process.exit(fehler === 0 ? 0 : 1);