#!/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/.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: Controller-Routen aus setup.go plus System-CRUD/Meta-Routen aus dbapi/register.go. */ 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]); } // RegisterModel erzeugt für diese Modelle GET/POST auf der Collection-Route // sowie GET/PUT/DELETE auf der :id-Route. AddMetaRoute erzeugt _meta und // eine /_meta/-Route je registriertem Modell. Diese Routen liegen // in dbapi/register.go und waren im bisherigen setup.go-only-Inventar weg. const models = ['user', 'project', 'org', 'team', 'token']; for (const model of models) { found.add(`/${model}`); found.add(`/${model}/:id`); } found.add('/_meta'); for (const model of models) found.add(`/_meta/${model}`); 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 (` 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);