Zwei Saetze in CLAUDE.md beschrieben den Code nicht genau. "Nie auf rohe Hexwerte" hat sechs begruendete Ausnahmen, nicht null: Papier, ein QR-Decoder und das Web-App-Manifest entscheiden nicht nach unserem Farbschema. Sie stehen jetzt als Tabelle mit Begruendung je Zeile. Die Regel wurde ergaenzt, nicht der Code geaendert -- eine Konstante ueber drei unverwandte Dinge haette die Begruendung versteckt. Das Tor vor jedem Commit nennt den LDAP-Container. Ohne ihn ueberspringt die Suite 22 Tests -- nachgezaehlt, nicht geschaetzt: 81 mit Container, 59 ohne. Sie sagt es auf stderr, der Lauf sieht trotzdem gruen aus. Im README fehlte zusaetzlich format:pruefen, das CLAUDE.md seit #010 verlangt. Damit das haelt, auch wenn niemand hinsieht: pruefe:doku vergleicht die beiden Schranken-Bloecke und verlangt von beiden den Container. Gegenprobe gefahren. Der Review hatte vier Hexwerte genannt und src/lib/qr.ts uebersehen, und das Issue behauptete die Regel stehe auch in docs/architektur.md -- tut sie nicht. Beides im Issue-Text richtiggestellt, statt einen Haken ueber falschen Text zu setzen. Closes #40
196 lines
7.0 KiB
JavaScript
Executable File
196 lines
7.0 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) {
|
||
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.");
|