Ein zugewiesener Lauf fiel bisher nur auf, wenn jemand die Uebersicht oeffnete; ein ueberfaelliger ueberhaupt nicht. Vier Anlaesse gehen jetzt hinaus — zugewiesen, ueberfaellig, abgeschlossen und Verzeichnisabgleich fehlgeschlagen —, je Person abschaltbar. Niemand bekommt Post ueber die eigene Tat: das ist der haeufigste Grund, Benachrichtigungen ganz abzuschalten, und deshalb faellt der Handelnde immer aus der Empfaengerliste. Eingereiht wird im Anfrage-Pfad, gesendet wird es nicht. Ein haengender Mailserver darf kein Abhaken blockieren, also schreibt der Dienst eine Zeile in `Zustellung` und ein eigener Zeitplan schickt sie. Betreff und Rumpf stehen fertig darin: eine Meldung sagt, was *damals* war. In keiner Nachricht steht ein Schrittinhalt, und damit auch kein erzeugtes Passwort. Darueber wacht src/lib/meldungen.ts — die Funktionen dort nehmen nur Kopfdaten, der Typ laesst nichts anderes zu. Geprueft wird es zusaetzlich ueber alle Rumpftexte, die wirklich verschickt wurden, so wie #012 es fuer die Druckansicht tat. Zwei Dinge haetten ohne Datenbank nicht gehalten. Die Einmaligkeit der Ueberfaelligkeitsmeldung liegt in einem partiellen eindeutigen Index: der Zeitplan sieht jede Minute nach, und ohne ihn bekaeme der Zustaendige fuer einen Lauf, der ueber das Wochenende steht, 2880 Mails — jede fuer sich korrekt, kein Test rot. Und die Sperre der Warteschlange liegt in derselben Tabelle wie die des Abgleichs, denn hinter einem Lastverteiler laufen zwei Instanzen. Eine stille Warteschlange ist schlimmer als keine: Verwaltung → Zustellungen zeigt mit Grund, was hinausging und was nicht. Geprueft gegen einen echten SMTP-Faenger (mailpit) statt gegen eine abgefangene Funktion — und `mailpit` gehoert damit ins Tor vor dem Commit, sonst ueberspringt die Suite 3 Tests und sieht gruen aus. Closes #54 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
208 lines
7.4 KiB
JavaScript
Executable File
208 lines
7.4 KiB
JavaScript
Executable File
#!/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) {
|
||
/*
|
||
* 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.`,
|
||
);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---- 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.");
|