Files
Laufzettel/scripts/pruefe-doku.mjs
T
florian.wilde 88d7db8c3d
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
fix(abnahme): Healthcheck schweigt, zwei Konventionen praezisiert (#41)
2026-10-04 14:12:28 +00:00

196 lines
7.0 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(/(?<!!)\[[^\]]*\]\(([^)]+)\)/g)) {
const ziel = treffer[1];
if (/^(https?:|mailto:|#)/.test(ziel)) continue;
const ohneAnker = ziel.split("#")[0];
if (!ohneAnker) continue;
if (!existsSync(resolve(dirname(datei), ohneAnker))) {
melde(`${datei.replace(`${wurzel}/`, "")} verweist auf ${ohneAnker}, das es nicht gibt.`);
}
}
}
}
// ---- 6. Die Schranken in CLAUDE.md und README.md -------------------------
//
// Zwei Dateien beschreiben dasselbe Tor, und sie sind auseinandergelaufen: im
// README fehlte `format:pruefen`, und beide nannten den LDAP-Container nicht, ohne
// den die Suite 22 Tests still ueberspringt. Beides fiel erst bei der Abnahme zu
// #019 auf -- ein gruener Lauf sieht genauso aus. Geprueft wird deshalb der
// Befehlsblock selbst, nicht seine Umgebung (#040).
{
/** Der erste eingezaeunte Block nach einer Ueberschrift. */
const blockNach = (text, ueberschrift) => {
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.");