#!/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) { if (!/docker compose -f docker-compose\.test\.yml up .*ldap/.test(block)) { melde( `${datei}: der Schranken-Block nennt den LDAP-Container nicht. ` + `Ohne ihn ueberspringt die Suite den Verzeichnis-Abgleich, und der Lauf sieht gruen aus.`, ); } } } } // ---- 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.");