199 lines
11 KiB
TypeScript
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 };
|
|
}
|
|
};
|