/** * Ergebnistabellen, die ein Werkzeug in ein Textfeld schreibt. * * Der Ergebnis-Linker sucht eine passende Ergebnisdatei im selben Projekt - * das Modell von Purple Knight, das seine Detailtabellen als eigene Dateien * exportiert. Maester legt sie stattdessen als Markdown-Tabelle direkt in * `ResultDetail`. Fuer den Linker sah dieser Wert wie eine Dateireferenz aus, * die er nirgends fand; die Schaltflaeche blieb abgeschaltet und die Tabelle * stand nur als Pipe-Kette im Hinweistext. * * Diese Ableitung erzeugt aus einer solchen Inline-Tabelle ein vollwertiges * Blatt, das dasselbe Ergebnisfenster oeffnet wie eine verknuepfte Datei. Die * Originalwerte bleiben unveraendert; das Blatt ist eine Projektion, keine * zweite Datenhaltung. */ import type { DatasetSheet } from '../../domain/dataset'; import type { FindingRecord } from '../../domain/finding'; import { makeSheet } from '../../importers/sheet-factory'; import { extractMarkdownTables } from '../../shared/markdown'; import { clean } from '../../shared/text'; /** Felder, in denen Werkzeuge ihre Ergebnisdetails ablegen. */ const RESULT_DETAIL_LABEL = /^(?:result\s*detail|resultdetail|result\s*details|details?|ergebnisdetails?|befund|evidence|nachweis|result|results|ergebnis|ergebnisse)$/i; const MAX_INLINE_JSON_LENGTH = 5 * 1024 * 1024; const MAX_INLINE_JSON_ROWS = 10_000; const MAX_INLINE_JSON_COLUMNS = 256; interface InlineTable { headers: string[]; rows: string[][]; } /** * Legacy PurpleKnight HTML imports retained `IndicatorReportObjects` as a * JSON array whose cells are encoded as `Column: value`. Interpret that inert * value as a table projection; never execute report code and never mutate the * preserved source field. */ function extractLabelValueJsonTable(value: string): InlineTable | undefined { const source = value.trim(); if (!source.startsWith('[[') || source.length > MAX_INLINE_JSON_LENGTH) return undefined; let parsed: unknown; try { parsed = JSON.parse(source); } catch { return undefined; } if (!Array.isArray(parsed) || !parsed.length || parsed.length > MAX_INLINE_JSON_ROWS) return undefined; const rows = parsed.filter((row): row is unknown[] => Array.isArray(row)); if (rows.length !== parsed.length || !rows[0].length || rows[0].length > MAX_INLINE_JSON_COLUMNS) return undefined; const headers = rows[0].map(cell => { if (typeof cell !== 'string') return ''; const separator = cell.indexOf(':'); return separator > 0 && separator <= 120 ? cell.slice(0, separator).trim() : ''; }); if (headers.some(header => !header) || new Set(headers).size !== headers.length) return undefined; const tableRows = rows.map(row => headers.map((header, columnIndex) => { const cell = row[columnIndex]; const text = cell === null || cell === undefined ? '' : String(cell); const prefix = `${header}:`; return text.startsWith(prefix) ? text.slice(prefix.length).trimStart() : text; })); return { headers, rows: tableRows }; } /** * Die Markdown-Auswertung ist mehrere Regex-Durchlaeufe je Feld (siehe * `shared/markdown.ts`) und lohnt sich nicht fuer jeden Aufruf neu: bei einem * grossen Import wird diese Funktion sonst je Datensatz erneut aufgerufen, * sobald er ins virtualisierte Fenster ruckt. Der Cache haelt sie pro * Datensatz konstant, genau wie `linkedSheetsCache` in result-linker.ts. */ const inlineTableCache = new WeakMap(); export function inlineResultTableSheets(record: FindingRecord): DatasetSheet[] { const cached = inlineTableCache.get(record); if (cached) return cached; const sheets: DatasetSheet[] = []; for (const field of record.fields) { if (!field.value || !RESULT_DETAIL_LABEL.test(clean(field.label))) continue; const markdownTables = extractMarkdownTables(field.value); const tables: InlineTable[] = markdownTables.length ? markdownTables : [extractLabelValueJsonTable(field.value)].filter((table): table is InlineTable => Boolean(table)); tables.forEach((table, index) => { const rows = [table.headers, ...table.rows]; const name = index === 0 ? field.label : `${field.label} (${index + 1})`; sheets.push(makeSheet(name, rows, rows.map(row => row.join('\t')).join('\n'), record.id, { sourceFile: record.sourceFile, sourceSheet: name })); }); } inlineTableCache.set(record, sheets); return sheets; } /** * Traegt der Datensatz ueberhaupt eine Inline-Ergebnistabelle? Wird gebraucht, * um den Hinweis "keine Ergebnisdatei gefunden" nur dann zu zeigen, wenn auch * wirklich nichts vorliegt. */ export function hasInlineResultTable(record: FindingRecord): boolean { return inlineResultTableSheets(record).length > 0; }