Doku-Vollausbau: 62 Seiten (Projekt/Admin/Anwender/Referenz), Hook-Referenz mit Signaturen, Corporate-Screenshots, Coverage-Inventar
Deploy tibi-docs / build-and-deploy (push) Failing after 38s

- 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
This commit is contained in:
apairon committed 2026-10-16 22:49:07 +00:00
1 parent acb1d32355
commit 1897bb50d9
101 files changed
+8980 -68

No files matched your search

+387
View File
@@ -0,0 +1,387 @@
#!/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);
+2 -2
View File
@@ -3,7 +3,7 @@
* sync-server-docs.mjs — Single Source bleibt in den Quell-Repos,
* diese Doku rendert nur. Kopiert:
* - ../tibi-server/docs/*.md -> src/content/docs/server/ (ohne uebersicht.md zu überschreiben)
* - ../tibi-admin-nova/docs/*.md -> src/content/docs/nova/ (+ test-plans/)
* - ../tibi-admin-nova/docs/*.md -> src/content/docs/admin/ (+ test-plans/)
* Fügt fehlendes Starlight-Frontmatter (title) aus Dateinamen/H1 hinzu.
*/
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
@@ -14,7 +14,7 @@ const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const serverSrc = join(root, '..', 'tibi-server', 'docs');
const novaSrc = join(root, '..', 'tibi-admin-nova', 'docs');
const serverDst = join(root, 'src', 'content', 'docs', 'server');
const novaDst = join(root, 'src', 'content', 'docs', 'nova');
const novaDst = join(root, 'src', 'content', 'docs', 'admin');
const titleFromName = (f) =>
f.replace(/\.md$/, '').replace(/^\d+-/, '').split('-')