Assessor_2.0/src/importers/maester-html-source-extract...

199 lines
11 KiB
TypeScript

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<string, JsonValue>; path: string }>;
envelope?: Record<string, JsonValue>;
recognized: boolean;
diagnostics: ImportDiagnostic[];
}
const payloadCache = new WeakMap<DecodedSourceDocument, MaesterPayload>();
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(/<script\b([^>]*)>([\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<string, JsonValue>, 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<ReturnType<typeof maesterEnvironmentContext>['reportSections']> = [];
const environmentObservations: NonNullable<ReturnType<typeof maesterEnvironmentContext>['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 };
}
};