#!/usr/bin/env node /** * Prueft, dass Dokumentation und Code nicht auseinanderlaufen. Laeuft in CI und * vor jedem Commit. Geprueft wird: * * 1. Die Version aus package.json hat einen Abschnitt im Changelog. * 2. Jede Issue-Datei steht in der Uebersicht – und umgekehrt. * 3. Geschaetzte und tatsaechliche Zeiten stimmen zwischen Datei und Uebersicht. * 4. Alle in der Anleitung verlinkten Bilder sind vorhanden. * 5. Kein Markdown verweist auf eine Datei, die es nicht gibt. * 6. Die Schranken-Befehle in CLAUDE.md und README.md stimmen ueberein. */ import { readFileSync, existsSync, readdirSync } from "node:fs"; import { join, dirname, resolve } from "node:path"; const wurzel = resolve(import.meta.dirname, ".."); const befunde = []; function melde(text) { befunde.push(text); } function lies(pfad) { return readFileSync(join(wurzel, pfad), "utf8"); } function feld(text, name) { const treffer = text.match(new RegExp(`\\*\\*${name}:\\*\\*\\s*(.+?)(?=\\s*\\*\\*|$)`, "m")); return treffer?.[1]?.trim() ?? ""; } // ---- 1. Version im Changelog ---------------------------------------------- { const { version } = JSON.parse(lies("package.json")); const changelog = lies("CHANGELOG.md"); const veroeffentlicht = changelog.includes(`## [${version}]`); const unveroeffentlicht = /^## \[Unveröffentlicht\]/m.test(changelog); if (!veroeffentlicht && !unveroeffentlicht) { melde( `Version ${version} hat keinen Abschnitt im Changelog und es gibt keinen Abschnitt ` + `[Unveröffentlicht].`, ); } } // ---- 2. und 3. Issue-Dateien gegen die Uebersicht -------------------------- { const verz = "docs/issues"; const dateien = readdirSync(join(wurzel, verz)) .filter((n) => /^\d{3}-.+\.md$/.test(n)) .sort(); const uebersicht = lies(join(verz, "README.md")); for (const name of dateien) { if (!uebersicht.includes(`(${name})`)) { melde(`${verz}/${name} fehlt in ${verz}/README.md.`); continue; } const text = lies(join(verz, name)); const kopf = text.match(/^#\s*#(\d+)\s+(.+)$/m); if (!kopf) { melde(`${verz}/${name} hat keine Kopfzeile der Form "# #007 Titel".`); continue; } const [, nummer, titel] = kopf; if (Number(nummer) !== Number(name.slice(0, 3))) { melde(`${verz}/${name} nennt #${nummer}, der Dateiname sagt ${name.slice(0, 3)}.`); } const zeile = uebersicht.split("\n").find((z) => z.includes(`(${name})`)); for (const [beschriftung, wert] of [ ["Status", feld(text, "Status")], ["Geschätzt", feld(text, "Geschätzt")], ["Tatsächlich", feld(text, "Tatsächlich")], ]) { if (wert && !zeile.includes(`| ${wert} |`)) { melde(`${verz}/${name}: "${beschriftung}: ${wert}" weicht von der Übersicht ab.`); } } if (!zeile.includes(titel)) { melde(`${verz}/${name}: Titel "${titel}" weicht von der Übersicht ab.`); } } for (const treffer of uebersicht.matchAll(/\((\d{3}-[^)]+\.md)\)/g)) { if (!dateien.includes(treffer[1])) { melde(`${verz}/README.md verweist auf ${treffer[1]}, die Datei fehlt.`); } } } // ---- 4. Verlinkte Bilder in der Anleitung ---------------------------------- { const verz = join(wurzel, "docs/anleitung"); if (existsSync(verz)) { const sammeln = (ordner) => readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => { const pfad = join(ordner, eintrag.name); if (eintrag.isDirectory()) return sammeln(pfad); return eintrag.name.endsWith(".md") ? [pfad] : []; }); for (const datei of sammeln(verz)) { const text = readFileSync(datei, "utf8"); for (const treffer of text.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { const ziel = treffer[1]; if (/^https?:/.test(ziel)) continue; if (!existsSync(resolve(dirname(datei), ziel))) { melde(`${datei.replace(`${wurzel}/`, "")} verlinkt das fehlende Bild ${ziel}.`); } } } } } // ---- 5. Verweise zwischen Markdown-Dateien -------------------------------- { const sammeln = (ordner) => readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => { if (["node_modules", ".git", ".next"].includes(eintrag.name)) return []; const pfad = join(ordner, eintrag.name); if (eintrag.isDirectory()) return sammeln(pfad); return eintrag.name.endsWith(".md") ? [pfad] : []; }); for (const datei of sammeln(wurzel)) { const text = readFileSync(datei, "utf8"); for (const treffer of text.matchAll(/(? { const ab = text.indexOf(ueberschrift); if (ab < 0) return null; const treffer = text.slice(ab).match(/```(?:bash|sh)?\n([\s\S]*?)```/); return treffer?.[1] ?? null; }; const skripte = (block) => [...block.matchAll(/pnpm ([a-z:]+)/g)].map((m) => m[1]).sort(); const quellen = [ ["CLAUDE.md", "## Vor jedem Commit"], ["README.md", "### Qualitätsschranken"], ]; const bloecke = quellen.map(([datei, ueberschrift]) => { const block = blockNach(lies(datei), ueberschrift); if (block === null) melde(`${datei}: kein Befehlsblock unter "${ueberschrift}" gefunden.`); return [datei, block]; }); if (bloecke.every(([, block]) => block !== null)) { const [[dateiA, blockA], [dateiB, blockB]] = bloecke; const a = skripte(blockA); const b = skripte(blockB); if (a.join(" ") !== b.join(" ")) { melde( `Die Schranken laufen auseinander: ${dateiA} nennt [${a.join(", ")}], ` + `${dateiB} nennt [${b.join(", ")}].`, ); } for (const [datei, block] of bloecke) { /* * Beide Container namentlich. Ohne sie ueberspringt die Suite Tests und * sieht gruen aus — genau die Art Luecke, die dieses Skript finden soll. */ for (const [dienst, was] of [ ["ldap", "den Verzeichnis-Abgleich"], ["mailpit", "den echten Mailversand"], ]) { const gefunden = new RegExp(`docker compose -f docker-compose\\.test\\.yml up [^\n]*${dienst}`).test( block, ); if (!gefunden) { melde( `${datei}: der Schranken-Block nennt den Container "${dienst}" nicht. ` + `Ohne ihn ueberspringt die Suite ${was}, und der Lauf sieht gruen aus.`, ); } } } } } // ---- 7. Jeder (#NNN)-Verweis zeigt auf eine Issue-Datei -------------------- { /* * Verweise wie `(#032)` stehen in Kommentaren, im Changelog und in CLAUDE.md * und sind die einzige Bruecke zwischen einer Codestelle und ihrer * Begruendung. Zeigt einer ins Leere, ist die Begruendung verloren — und * schlimmer: zeigt er auf die **falsche** Datei, fuehrt er jemanden in die * Irre, der ihm vertraut. * * Genau das war passiert: vier Codestellen und sechs Changelog-Zeilen nannten * dreistellig die **Tracker**-Nummern 39 und 40 — gemeint waren die * Issue-Dateien 025 und 026. Solange es keine Dateien mit diesen Nummern gab, * fiel es niemandem auf; mit Phase 3 waere es eine falsche Spur geworden. * Verbindlich ist die **Dateinummer**; die Tracker-Nummer steht in der * Issue-Datei unter „Im Tracker“. * * Diese Datei nennt deshalb selbst keine Nummer in der dreistelligen Form — * sonst meldete die Pruefung ihren eigenen Kommentar. */ const verz = "docs/issues"; const nummern = new Set( readdirSync(join(wurzel, verz)) .filter((n) => /^\d{3}-.+\.md$/.test(n)) .map((n) => n.slice(0, 3)), ); const sammeln = (ordner) => readdirSync(ordner, { withFileTypes: true }).flatMap((eintrag) => { if (["node_modules", ".git", ".next", "generated", "test-results"].includes(eintrag.name)) { return []; } const pfad = join(ordner, eintrag.name); if (eintrag.isDirectory()) return sammeln(pfad); return /\.(ts|tsx|mjs|md|sql)$/.test(eintrag.name) ? [pfad] : []; }); for (const datei of sammeln(wurzel)) { const kurz = datei.replace(`${wurzel}/`, ""); // Der Devlog zitiert Tracker-Nummern aus Pull Requests; dort gilt die Regel nicht. if (kurz === "docs/devlog.md") continue; const text = readFileSync(datei, "utf8"); const gemeldet = new Set(); for (const treffer of text.matchAll(/#(\d{3})\b/g)) { const nummer = treffer[1]; if (nummern.has(nummer) || gemeldet.has(nummer)) continue; gemeldet.add(nummer); melde( `${kurz} verweist auf #${nummer}, aber ${verz}/${nummer}-*.md gibt es nicht. ` + `Gemeint ist vielleicht eine Tracker-Nummer — verbindlich ist die Dateinummer.`, ); } } } // ---- Ergebnis -------------------------------------------------------------- if (befunde.length > 0) { console.error(`\nDokumentation und Code laufen auseinander (${befunde.length}):\n`); for (const b of befunde) console.error(` - ${b}`); console.error(""); process.exit(1); } console.log("Dokumentation stimmt.");