import type { JsonValue } from '../domain/canonical-model'; import type { DecodedSourceDocument, ImportDiagnostic } from '../domain/import-intermediate'; import type { FindingCandidate, SourceExtractor, SourceFieldCandidate } from '../domain/source-extraction'; import { cellText, plainTextValue } from '../shared/text'; import { isJsonObject } from './embedded-report-json'; import { assignedReportObjects, readStaticReportObject, StaticReportLiteralError } from './static-report-literal'; import { maesterEnvironmentContext, maesterCountDiagnostics } from './maester-environment-context'; import { ImportError } from '../shared/errors'; const MAX_TESTS = 250_000; interface MaesterPayload { roots: Array<{ value: Record; path: string }>; envelope?: Record; recognized: boolean; diagnostics: ImportDiagnostic[]; } const payloadCache = new WeakMap(); const REPORT_START = /^\{\s*["']?(?:Result|Tests|Tenants|TenantId|TenantName|TotalCount|FailedCount|ExecutedAt|MaesterConfig)["']?\s*:/; const REPORT_SHAPE = /["']?(?:Tests|Tenants|FailedCount|MaesterConfig)["']?\s*:/; function* reportObjects(script: string) { try { yield* assignedReportObjects(script); } catch (error) { if (!(error instanceof StaticReportLiteralError)) throw error; throw new ImportError('Die eingebettete Scriptstruktur überschreitet die sichere Lesegrenze.', [{ severity: 'error', code: 'maester-script-limit', stage: 'extraction', message: 'Die eingebettete Scriptstruktur ist zu tief verschachtelt.' }]); } } function maesterPayload(document: DecodedSourceDocument): MaesterPayload { if (document.format !== 'HTML') return { roots: [], recognized: false, diagnostics: [] }; const cached = payloadCache.get(document); if (cached) return cached; const result: MaesterPayload = { roots: [], recognized: false, diagnostics: [] }; let attempts = 0; for (const script of document.rawText.matchAll(/]*)>([\s\S]*?)<\/script\s*>/gi)) { const text = script[2]; const starts = /\btype\s*=\s*["']application\/json["']/i.test(script[1]) ? [{ start: text.search(/\S/), binding: 'json-data' }] : reportObjects(text); let consumedUntil = -1; for (const { start, binding } of starts) { const reportBinding = /^(?:testResults|maesterData|maesterReportData)$/i.test(binding); const recognizable = reportBinding || REPORT_SHAPE.test(text.slice(start, start + 512)); if (start < consumedUntil || (!reportBinding && !REPORT_START.test(text.slice(start, start + 512)))) continue; if (++attempts > 32) { result.recognized = true; result.diagnostics.push({ severity: 'error', code: 'maester-payload-limit', stage: 'extraction', message: 'Zu viele mögliche Maester-Datenobjekte im Bericht.' }); break; } try { const embedded = readStaticReportObject(text, start); consumedUntil = embedded.end; const value = embedded.value; if (Array.isArray(value.Tenants)) { result.recognized = true; if (result.roots.length) throw new StaticReportLiteralError('Ambiguous report roots.'); result.envelope = Object.fromEntries(Object.entries(value).filter(([key]) => key !== 'Tenants')); for (const [index, tenant] of value.Tenants.entries()) { if (!isJsonObject(tenant) || !Array.isArray(tenant.Tests)) throw new StaticReportLiteralError('Invalid tenant tests.'); result.roots.push({ value: tenant, path: `$.Tenants[${index}]` }); } } else if (Array.isArray(value.Tests) && ('Result' in value || 'EndOfJson' in value)) { result.recognized = true; if (result.roots.length) throw new StaticReportLiteralError('Ambiguous report roots.'); result.roots.push({ value, path: '$' }); } else if (recognizable) throw new StaticReportLiteralError('Invalid report shape.'); } catch (error) { if (!(error instanceof StaticReportLiteralError)) throw error; if (recognizable) { result.recognized = true; result.diagnostics.push({ severity: 'error', code: 'maester-payload-invalid', stage: 'extraction', message: 'Maester-Daten erkannt, aber nicht sicher vollständig lesbar. Nur statische Datenobjekte ohne Ausdrücke sind zulässig; beschädigte, mehrdeutige oder zu große Berichte werden nicht importiert.' }); } } } } if (!result.recognized && /maester/i.test(document.rawText) && /\b(?:testResults|maesterData)\s*=/.test(document.rawText)) { result.recognized = true; result.diagnostics.push({ severity: 'error', code: 'maester-payload-invalid', stage: 'extraction', message: 'Ein Maester-Bericht wurde erkannt, seine Ergebnisdaten liegen aber nicht als unterstütztes statisches Datenobjekt vor.' }); } payloadCache.set(document, result); return result; } function field( fileName: string, path: string, key: string, label: string, value: JsonValue, decodedValue = cellText(value) ): SourceFieldCandidate { return { key, label, originalValue: value, decodedValue, source: { fileName, objectPath: `${path}.${key}` } }; } function optionalField( fields: SourceFieldCandidate[], fileName: string, path: string, key: string, label: string, value: JsonValue | undefined, decodedValue?: string ): void { if (value === undefined) return; fields.push(field(fileName, path, key, label, value, decodedValue)); } function testCandidate(fileName: string, test: Record, path: string, index: number): FindingCandidate { const detail = isJsonObject(test.ResultDetail) ? test.ResultDetail : {}; const fields: SourceFieldCandidate[] = []; optionalField(fields, fileName, path, 'Id', 'ID', test.Id); optionalField(fields, fileName, path, 'Title', 'Title', test.Title); if (test.Title === undefined || test.Title === null || test.Title === '') { optionalField(fields, fileName, path, 'Name', 'Title', test.Name); } optionalField(fields, fileName, path, 'Result', 'Result', test.Result); optionalField(fields, fileName, path, 'Severity', 'Severity', test.Severity); if ((test.Severity === undefined || test.Severity === null || test.Severity === '') && detail.Severity !== undefined) { optionalField(fields, fileName, `${path}.ResultDetail`, 'Severity', 'Severity', detail.Severity); } optionalField(fields, fileName, path, 'Tag', 'Tag', test.Tag); optionalField(fields, fileName, path, 'Block', 'Block', test.Block); optionalField(fields, fileName, path, 'Duration', 'Duration', test.Duration); optionalField(fields, fileName, `${path}.ResultDetail`, 'TestDescription', 'Description', detail.TestDescription, plainTextValue(detail.TestDescription)); optionalField(fields, fileName, `${path}.ResultDetail`, 'TestResult', 'ResultDetail', detail.TestResult, plainTextValue(detail.TestResult)); optionalField(fields, fileName, `${path}.ResultDetail`, 'TestSkipped', 'TestSkipped', detail.TestSkipped); optionalField(fields, fileName, `${path}.ResultDetail`, 'SkippedReason', 'SkippedReason', detail.SkippedReason); optionalField(fields, fileName, path, 'ErrorRecord', 'ErrorMessage', test.ErrorRecord); optionalField(fields, fileName, path, 'Name', 'Name', test.Name); optionalField(fields, fileName, path, 'HelpUrl', 'HelpUrl', test.HelpUrl); optionalField(fields, fileName, path, 'ScriptBlockFile', 'TestScriptFile', test.ScriptBlockFile); optionalField(fields, fileName, path, 'ScriptBlock', 'TestScript', test.ScriptBlock); optionalField(fields, fileName, `${path}.ResultDetail`, 'Service', 'Service', detail.Service); // Keep future/unmapped fields and their exact object paths, not just known labels. const known = new Set(['Id', 'Title', 'Result', 'Severity', 'Tag', 'Block', 'Duration', 'ErrorRecord', 'Name', 'HelpUrl', 'ScriptBlockFile', 'ScriptBlock', 'ResultDetail']); for (const [key, value] of Object.entries(test)) { if (!known.has(key)) fields.push(field(fileName, path, key, key, value)); } const knownDetails = new Set(['TestDescription', 'TestResult', 'TestSkipped', 'SkippedReason', 'Service']); for (const [key, value] of Object.entries(detail)) { if (!knownDetails.has(key)) fields.push(field(fileName, `${path}.ResultDetail`, key, `ResultDetail.${key}`, value)); } if (test.ResultDetail !== undefined && !isJsonObject(test.ResultDetail)) { fields.push(field(fileName, path, 'ResultDetail', 'ResultDetail', test.ResultDetail)); } return { version: 1, id: `maester-test-${index}`, source: { fileName, objectPath: path }, fields }; } export const maesterHtmlSourceExtractor: SourceExtractor = { id: 'maester-html-source-extractor', version: '1.2.0', detect(document) { const payload = maesterPayload(document); const tests = payload.roots.reduce((count, root) => count + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0); return payload.recognized && (tests > 0 || payload.diagnostics.length > 0) ? { score: 1, toolId: 'maester', evidence: [`Strukturiertes Maester-Ergebnisobjekt mit ${tests} Testeinträgen erkannt.`] } : { score: 0, evidence: [] }; }, extract(document) { const payload = maesterPayload(document); if (payload.diagnostics.some(item => item.severity === 'error')) { throw new ImportError('Maester-HTML konnte nicht sicher vollständig gelesen werden.', payload.diagnostics); } const total = payload.roots.reduce((sum, root) => sum + (Array.isArray(root.value.Tests) ? root.value.Tests.length : 0), 0); if (total > MAX_TESTS) throw new ImportError('Maester-Testlimit überschritten.', [{ severity: 'error', code: 'maester-test-limit', stage: 'extraction', message: `Mehr als ${MAX_TESTS} Tests. Der Bericht wird nicht still gekürzt.` }]); const candidates: FindingCandidate[] = []; const diagnostics = [...payload.diagnostics]; const reportSections: NonNullable['reportSections']> = []; const environmentObservations: NonNullable['environmentObservations']> = []; if (payload.envelope) { const context = maesterEnvironmentContext(document.file.name, payload.envelope, '$', 0); reportSections.push(...context.reportSections); environmentObservations.push(...context.environmentObservations); } let sourceIndex = 0; for (const root of payload.roots) { const tests = Array.isArray(root.value.Tests) ? root.value.Tests : []; diagnostics.push(...maesterCountDiagnostics(root.value, root.path)); const context = maesterEnvironmentContext(document.file.name, root.value, root.path, reportSections.length); reportSections.push(...context.reportSections); environmentObservations.push(...context.environmentObservations); for (let index = 0; index < tests.length && candidates.length < MAX_TESTS; index += 1) { const test = tests[index]; if (!isJsonObject(test) || (!test.Id && !test.Title && !test.Name)) { throw new ImportError('Maester enthält einen ungültigen Testeintrag.', [{ severity: 'error', code: 'maester-test-invalid', stage: 'extraction', message: 'Ein Testeintrag besitzt keine lesbare Identität.', details: { objectPath: `${root.path}.Tests[${index}]` } }]); } const path = `${root.path}.Tests[${index}]`; candidates.push(testCandidate(document.file.name, test, path, sourceIndex++)); } } return { version: 1, candidates, diagnostics, reportSections, environmentObservations }; } };