Files
tibi-docs/scripts/coverage-inventory.mjs
T
apairon 1897bb50d9
Deploy tibi-docs / build-and-deploy (push) Failing after 38s
Doku-Vollausbau: 62 Seiten (Projekt/Admin/Anwender/Referenz), Hook-Referenz mit Signaturen, Corporate-Screenshots, Coverage-Inventar
- Einstieg: Schnellstart + Neues Projekt aus Starter-Skill, Was-ist-Tibi mit Backend/Website-Trennung
- Projekt: Sammlungen, Felder, Abfragen, Rechte, Hooks, Dateien, i18n, Suche, SSR, Pagebuilder, Jobs, Audit, Deploy, LLM, MCP
- Hook-Referenz: 66 Symbole mit Signatur + Beispiel + Fehlerfall (5 Kapitel)
- Admin: Ansichten, Editor, Medien, Exporte, Aktionen, Rechte, KI, Setup, Customizing
- Anwender: 11 Anleitungen im professionellen Du-Ton, 30 Corporate-Screenshots
- Referenz: Schemas, Token-Köpfe, Fehlerbilder, Shortcuts, OpenAPI-Platzhalter
- ADR-0001 bis 0003, Coverage-Skript (282 Items) + Map, Plan-Dateien
2026-10-16 22:49:07 +00:00

388 lines
13 KiB
JavaScript
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
/**
* Coverage-Inventar für die Tibi-Doku.
*
* Liest CODE (tibi-server, tibi-types, tibi-admin-nova) und gleicht die
* gefundenen Items mit plans/coverage-map.json ab. Reine Lückenliste:
* Exit-Code ist immer 0, kein Fail.
*
* - stdout: kompakte Übersicht pro Kategorie (total, gemappt, OFFEN, max 40)
* plus je Kategorie eine Depth-Zeile (Stufe 1/2/3/4)
* - plans/coverage-report.json: Zusammenfassung als JSON (inkl. depth)
*
* Körnung: contexts und tools sind Symbole (z. B. "db.find"), alle anderen
* Kategorien behalten ihre bisherige Körnung. Depth-Stufen je Symbol auf
* seiner Map-Seite (src/content/docs/<slug>.md):
* 1 = Symbol fehlt (oder Seite fehlt), 2 = nur Erwähnung,
* 3 = Erwähnung + Fenced-Codeblock mit Symbol ≤30 Zeilen danach,
* 4 = zusätzlich Error-Token im selben H2-Abschnitt.
* Andere Kategorien: reiner Map-Abgleich (gemappt = Stufe 2).
*/
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
const docsRoot = path.resolve(scriptDir, "..");
const cmsRoot = path.dirname(docsRoot);
const SERVER_ROOT = path.join(cmsRoot, "tibi-server");
const TYPES_ROOT = path.join(cmsRoot, "tibi-types");
const ADMIN_ROOT = path.join(cmsRoot, "tibi-admin-nova");
const MAP_PATH = path.join(docsRoot, "plans", "coverage-map.json");
const REPORT_PATH = path.join(docsRoot, "plans", "coverage-report.json");
const MAX_OPEN_STDOUT = 40;
function readText(p) {
return readFileSync(p, "utf8");
}
function warn(msg) {
console.error(`WARN: ${msg}`);
}
/** 1. Routen: apiGroup.GET/POST/PUT/DELETE("pfad") + adm.* + OPTIONS aus setup.go, Duplikate raus. */
function collectRoutes() {
const file = path.join(SERVER_ROOT, "internal", "controller", "setup.go");
if (!existsSync(file)) {
warn(`setup.go nicht gefunden: ${file}`);
return [];
}
const src = readText(file);
const found = new Set();
for (const re of [
/apiGroup\.(GET|POST|PUT|DELETE)\(\s*"([^"]+)"/g,
/\badm\.(GET|POST|PUT|DELETE)\(\s*"([^"]+)"/g,
/\.OPTIONS\(\s*"([^"]+)"/g,
]) {
let m;
while ((m = re.exec(src)) !== null) found.add(m[m.length - 1]);
}
return [...found].sort();
}
/**
* 2. Hook-Symbole in Funktionskörnung: je context_*.go (ohne _test) die
* JS-Aufrufnamen aus RegisterPackageFunctions("paket", ...{"funktion": GoIdent})
* plus Register("name", GoIdent) (Sonderfall request). Item-Format
* "paket.funktion" bzw. "request". Dazu je Symbol: Go-Datei, Go-Ident und
* ob ein `args:`-Kommentar im direkt darüberstehenden Kommentarblock steht.
*/
function collectContextSymbols() {
const dir = path.join(SERVER_ROOT, "internal", "hook");
if (!existsSync(dir)) {
warn(`hook-Verzeichnis nicht gefunden: ${dir}`);
return [];
}
const out = [];
const files = readdirSync(dir)
.filter((f) => f.startsWith("context_") && f.endsWith(".go") && !f.endsWith("_test.go"))
.sort();
for (const f of files) {
const src = readText(path.join(dir, f));
const lines = src.split("\n");
const pkgRe = /RegisterPackageFunctions\(\s*"([^"]+)"\s*,\s*models\.ContextPackageFunctions\{(.*?)\}\)/gs;
let m;
while ((m = pkgRe.exec(src)) !== null) {
const pkg = m[1];
const entryRe = /"([^"]+)"\s*:\s*([A-Za-z_][A-Za-z0-9_]*)/g;
let e;
while ((e = entryRe.exec(m[2])) !== null) {
out.push({ symbol: `${pkg}.${e[1]}`, goFile: f, goIdent: e[2], hasArgs: hasArgsComment(lines, e[2]) });
}
}
// Sonderfall Einzel-Registrierung, z. B. Register("request", FuncRequest)
const singleRe = /\bRegister\(\s*"([^"]+)"\s*,\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/g;
while ((m = singleRe.exec(src)) !== null) {
out.push({ symbol: m[1], goFile: f, goIdent: m[2], hasArgs: hasArgsComment(lines, m[2]) });
}
}
out.sort((a, b) => (a.symbol < b.symbol ? -1 : a.symbol > b.symbol ? 1 : 0));
return out;
}
/** Enthält der zusammenhängende `//`-Block direkt über `func <ident>(` ein `args:`? */
function hasArgsComment(lines, goIdent) {
const idx = lines.findIndex((l) => new RegExp(`^func\\s+${goIdent}\\s*\\(`).test(l));
if (idx < 0) return false;
const block = [];
let j = idx - 1;
while (j >= 0 && /^\s*\/\//.test(lines[j])) {
block.unshift(lines[j]);
j--;
}
return block.some((l) => /(args:|JS signature:)/i.test(l));
}
/** 3. MCP-Tools: Name: "..." aus internal/mcp/tools_*.go. */
function collectTools() {
const dir = path.join(SERVER_ROOT, "internal", "mcp");
if (!existsSync(dir)) {
warn(`mcp-Verzeichnis nicht gefunden: ${dir}`);
return [];
}
const files = readdirSync(dir).filter((f) => f.startsWith("tools_") && f.endsWith(".go"));
const found = new Set();
const re = /Name:\s*"([^"]+)"/g;
for (const f of files) {
const src = readText(path.join(dir, f));
let m;
while ((m = re.exec(src)) !== null) found.add(m[1]);
}
return [...found].sort();
}
/**
* 4. Schemas: Dateinamen aus schemas/config/*.schema.json plus deren
* Top-Level-properties-Keys als Detail (für den Report).
*/
function collectSchemas() {
const dir = path.join(TYPES_ROOT, "schemas", "config");
const detail = {};
if (!existsSync(dir)) {
warn(`schemas-Verzeichnis nicht gefunden: ${dir}`);
return { items: [], detail };
}
const files = readdirSync(dir)
.filter((f) => f.endsWith(".schema.json"))
.sort();
for (const f of files) {
try {
const parsed = JSON.parse(readText(path.join(dir, f)));
const props = parsed && typeof parsed === "object" && parsed.properties && typeof parsed.properties === "object"
? Object.keys(parsed.properties)
: [];
detail[f] = props.sort();
} catch {
warn(`Schema nicht parsbar: ${f}`);
detail[f] = [];
}
}
return { items: files, detail };
}
/** 5. Admin-Komponenten: *.svelte unter frontend/src, relativ, ohne Endung. */
function collectComponents() {
const srcDir = path.join(ADMIN_ROOT, "frontend", "src");
if (!existsSync(srcDir)) {
warn(`frontend/src nicht gefunden: ${srcDir}`);
return [];
}
const out = [];
const walk = (dir) => {
for (const entry of readdirSync(dir)) {
const full = path.join(dir, entry);
if (statSync(full).isDirectory()) {
walk(full);
} else if (entry.endsWith(".svelte")) {
const rel = path.relative(srcDir, full).split(path.sep).join("/");
out.push(rel.slice(0, -".svelte".length));
}
}
};
walk(srcDir);
return out.sort();
}
/** 6. Test-Pläne: Dateinamen aus docs/test-plans/. */
function collectTestplans() {
const dir = path.join(ADMIN_ROOT, "docs", "test-plans");
if (!existsSync(dir)) {
warn(`test-plans nicht gefunden: ${dir}`);
return [];
}
return readdirSync(dir).filter((f) => f.endsWith(".md")).sort();
}
const ERROR_TOKENS = ["Error", "error", "400", "401", "403", "404", "409", "429", "500", "nil", "verweigert", "denied"];
/** Kategorien mit Depth-Mode (Symbol-Tiefe auf der Map-Seite). */
const DEPTH_CATS = ["contexts", "tools"];
const docCache = new Map();
function loadDocLines(slug) {
if (docCache.has(slug)) return docCache.get(slug);
const p = path.join(docsRoot, "src", "content", "docs", `${slug}.md`);
const lines = existsSync(p) ? readText(p).split("\n") : null;
docCache.set(slug, lines);
return lines;
}
/** Fenced-Codeblock-Spannen als [startFence, endFence]-Paare (0-basiert). */
function fencedSpans(lines) {
const spans = [];
let start = -1;
for (let i = 0; i < lines.length; i++) {
if (/^\s*```/.test(lines[i])) {
if (start < 0) start = i;
else {
spans.push([start, i]);
start = -1;
}
}
}
return spans;
}
/**
* Depth-Stufe je Symbol auf seiner Map-Seite:
* 1 = fehlt (kein Map-Eintrag, Seite fehlt oder Symbol nicht als Text),
* 2 = nur Erwähnung, 3 = + Fenced-Codeblock mit Symbol ≤30 Zeilen danach,
* 4 = + Error-Token im selben H2-Abschnitt (bis zur nächsten H2).
*/
function symbolDepth(symbol, slug) {
if (!slug) return 1;
const lines = loadDocLines(slug);
if (!lines) return 1;
// Aliase: "paket.fn" gilt auch als "context.paket.fn" bzw. "context.fn" (Hook-Schreibweise)
const aliases = [symbol, `context.${symbol}`];
const dot = symbol.indexOf(".");
if (dot > 0) aliases.push(`context.${symbol.slice(dot + 1)}`);
const mentions = [];
lines.forEach((l, i) => {
if (aliases.some((a) => l.includes(a))) mentions.push(i);
});
if (mentions.length === 0) return 1;
const spans = fencedSpans(lines);
let anchor = -1;
for (const mi of mentions) {
const hit = spans.some(
([s, e]) => s > mi && s <= mi + 30 && lines.slice(s, e + 1).some((l) => aliases.some((a) => l.includes(a))),
);
if (hit) {
anchor = mi;
break;
}
}
if (anchor < 0) return 2;
// Abschnitt = eigene ###-Sektion (bis zum nächsten ### oder ##), nicht das ganze Paket:
// sonst zählt der Fehler-Token fremder Symbole (Mess-Loophole).
let start = 0;
for (let k = anchor; k >= 0; k--) {
if (/^####?\s/.test(lines[k])) {
start = k;
break;
}
}
let stop = lines.length;
for (let k = anchor + 1; k < lines.length; k++) {
if (/^####?\s/.test(lines[k])) {
stop = k;
break;
}
}
const section = lines.slice(start, stop).join("\n");
// Explizit ausgewiesene Abwesenheit ("kein Fehlerfall") zählt als dokumentiert.
if (/kein Fehlerfall/i.test(section)) return 4;
return ERROR_TOKENS.some((t) => section.includes(t)) ? 4 : 3;
}
function loadMap() {
const empty = { routes: {}, contexts: {}, tools: {}, schemas: {}, components: {}, testplans: {} };
if (!existsSync(MAP_PATH)) {
warn(`keine coverage-map gefunden (${path.relative(docsRoot, MAP_PATH)}), alles OFFEN`);
return empty;
}
try {
const parsed = JSON.parse(readText(MAP_PATH));
for (const key of Object.keys(empty)) {
if (parsed[key] && typeof parsed[key] === "object") empty[key] = parsed[key];
}
return empty;
} catch {
warn("coverage-map.json nicht parsbar, alles OFFEN");
return empty;
}
}
function main() {
const schemas = collectSchemas();
const ctxSymbols = collectContextSymbols();
const ctxBySymbol = new Map(ctxSymbols.map((s) => [s.symbol, s]));
const inventory = {
routes: collectRoutes(),
contexts: ctxSymbols.map((s) => s.symbol),
tools: collectTools(),
schemas: schemas.items,
components: collectComponents(),
testplans: collectTestplans(),
};
const map = loadMap();
const report = { generatedAt: new Date().toISOString(), categories: {}, depth: {} };
let totalAll = 0;
let openAll = 0;
for (const [cat, items] of Object.entries(inventory)) {
const mapping = map[cat] ?? {};
// "__ignore": exakte Items oder Präfixe mit "*" (Infra ohne Doku-Fläche, z. B. Mock-Daten, Tests)
const ignores = Array.isArray(mapping.__ignore) ? mapping.__ignore : [];
const isIgnored = (item) =>
ignores.some((pat) => (pat.endsWith("*") ? item.startsWith(pat.slice(0, -1)) : item === pat));
const open = items.filter((item) => !(item in mapping) && !isIgnored(item));
totalAll += items.length;
openAll += open.length;
report.categories[cat] = {
total: items.length,
mapped: items.length - open.length,
openCount: open.length,
openItems: open,
};
console.log(`${cat}: total=${items.length} gemappt=${items.length - open.length} OFFEN=${open.length}`);
for (const item of open.slice(0, MAX_OPEN_STDOUT)) console.log(` OFFEN: ${item}`);
if (open.length > MAX_OPEN_STDOUT) {
console.log(` ... +${open.length - MAX_OPEN_STDOUT} weitere (siehe plans/coverage-report.json)`);
}
// Depth-Mode: contexts/tools je Symbol auf der Map-Seite, Rest Map-Abgleich.
const depth = { total: items.length, stufe4: 0, stufe3: 0, stufe2: 0, stufe1: 0 };
if (DEPTH_CATS.includes(cat)) {
const details = [];
for (const item of items) {
if (isIgnored(item)) {
depth.stufe2 += 1;
details.push({ symbol: item, page: null, stufe: 2, ignored: true });
continue;
}
const page = mapping[item] ?? null;
const stufe = symbolDepth(item, page);
depth[`stufe${stufe}`] += 1;
const detail = { symbol: item, page, stufe };
if (cat === "contexts") {
const sym = ctxBySymbol.get(item);
detail.goFile = sym?.goFile ?? null;
detail.hasArgs = sym?.hasArgs ?? false;
}
details.push(detail);
}
details.sort((a, b) => a.stufe - b.stufe || (a.symbol < b.symbol ? -1 : 1));
report.categories[cat].symbols = details;
} else {
depth.stufe2 = items.length - open.length;
depth.stufe1 = open.length;
}
report.depth[cat] = depth;
console.log(
`depth ${cat}: total=${depth.total} stufe4=${depth.stufe4} stufe3=${depth.stufe3} stufe2=${depth.stufe2} stufe1=${depth.stufe1}`,
);
if (cat === "contexts") {
const noArgs = ctxSymbols.filter((s) => !s.hasArgs).map((s) => s.symbol);
console.log(` ohne args-Kommentar (${noArgs.length}): ${noArgs.join(", ") || "–"}`);
}
}
console.log(`GESAMT: total=${totalAll} OFFEN=${openAll}`);
report.schemasDetail = schemas.detail;
writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2) + "\n", "utf8");
console.log(`Report: ${path.relative(process.cwd(), REPORT_PATH)}`);
}
try {
main();
} catch (err) {
console.error(`FEHLER: ${err?.message ?? err} (reine Liste, kein Fail)`);
}
process.exit(0);