import type { ImportDiagnostic, SourceLocator } from '../domain/import-intermediate'; import type { FindingCandidate, ScannerReportSectionCandidate } from '../domain/source-extraction'; import { ImportError } from '../shared/errors'; import { markupText } from './html-extraction'; export function acceptPingCastleRuleId(ruleId: string, seen: Set, source: SourceLocator, diagnostics: ImportDiagnostic[]): boolean { if (!ruleId.trim()) throw new ImportError('PingCastle-Regel ohne Rule ID kann nicht vollständig importiert werden.', [{ severity: 'error', code: 'pingcastle-rule-id-missing', stage: 'extraction', source, message: 'Ein nativer Regelblock enthält keine lesbare Rule ID.' }]); const identity = ruleId.trim().toLocaleUpperCase('en-US'); if (seen.has(identity)) { diagnostics.push({ severity: 'warning', code: 'pingcastle-duplicate-rule-id', stage: 'extraction', source, message: 'Eine Rule ID ist innerhalb der Quelle wiederholt. Die erste Darstellung bleibt das Finding; weitere Darstellungen werden nicht als zusätzliche Treffer gezählt.', details: { ruleId, retained: 'first-presentation' } }); return false; } seen.add(identity); return true; } /** Matched counts describe findings; the adjacent catalog size is not a result count. */ export function pingCastleHtmlRuleCoverage(fileName: string, markup: string, candidates: FindingCandidate[]): { diagnostics: ImportDiagnostic[]; section?: ScannerReportSectionCandidate; } { const diagnostics: ImportDiagnostic[] = []; const rows: Array> = []; const key = (value: string) => value.replace(/[^a-z]/gi, '').toLocaleLowerCase('en-US'); const actualByCategory = new Map(); for (const candidate of candidates) { const category = key(candidate.fields.find(field => field.label === 'Category')?.decodedValue ?? ''); actualByCategory.set(category, (actualByCategory.get(category) ?? 0) + 1); } const pattern = /]*)>([\s\S]*?)<\/h2\s*>/gi; let match: RegExpExecArray | null; let positiveDeclared = false; while ((match = pattern.exec(markup))) { if (!/\bclass\s*=\s*["'][^"']*\bindicator-header\b/i.test(match[1])) continue; const heading = markupText(match[2]); if (!/rules?\s+matched/i.test(heading)) continue; const source = { fileName, objectPath: `html/indicator-headings/${rows.length}` }; const parsed = /^(.*?)\s+rule\s+details\s*\[\s*(\S+)\s+rules?\s+matched\s+on\s+a\s+total\s+of\s+(\S+)\s*\]/i.exec(heading); const category = parsed?.[1] ?? heading; const declared = parsed && /^\d+$/.test(parsed[2]) ? Number(parsed[2]) : NaN; const catalog = parsed && /^\d+$/.test(parsed[3]) ? Number(parsed[3]) : NaN; const actual = actualByCategory.get(key(category)) ?? 0; rows.push([category, parsed?.[2] ?? '', parsed?.[3] ?? '', actual, heading]); positiveDeclared ||= Number.isFinite(declared) && declared > 0; if (!Number.isSafeInteger(declared) || !Number.isSafeInteger(catalog) || declared > catalog) { diagnostics.push({ severity: 'warning', code: 'pingcastle-rule-count-invalid', stage: 'extraction', source, message: 'Die deklarierte PingCastle-Regelbilanz ist ungültig; Originalangaben bleiben erhalten.' }); } if (Number.isSafeInteger(declared) && declared !== actual) { diagnostics.push({ severity: 'warning', code: 'pingcastle-rule-count-mismatch', stage: 'extraction', source, message: 'Die deklarierte Trefferzahl stimmt nicht mit den extrahierten eindeutigen Regeln überein.', details: { category, declared, actual } }); } } if (!candidates.length && (positiveDeclared || diagnostics.length)) throw new ImportError('PingCastle kündigt Regeln an, enthält aber keine auslesbaren Ergebnisse.', [ ...diagnostics, { severity: 'error', code: 'pingcastle-rule-results-missing', stage: 'extraction', message: 'Eine erfolgreiche Nullbefund-Verarbeitung ist bei dieser Regelbilanz nicht zulässig.', source: { fileName } } ]); return { diagnostics, ...(rows.length ? { section: { version: 1 as const, sectionKey: 'pingcastle-rule-coverage', label: 'PingCastle rule coverage', ordinal: 0, state: 'observed' as const, tables: [{ name: 'Declared and extracted rules', headers: ['Category', 'Declared matched', 'Declared catalog size', 'Extracted unique rules', 'Original heading'], rows }], source: { fileName, objectPath: 'html/indicator-headings' }, attributes: { derivedColumn: 'Extracted unique rules' } } } : {}) }; }