import type { ImportedDataset } from '../domain/dataset'; import type { FindingRecord } from '../domain/finding'; import { securityDomainBuckets, securityDomainCatalog } from '../evaluation/security-domains'; import { dashboardResultState, dashboardScoreFromStates, type DashboardResultState } from '../features/dashboard/dashboard-result-state'; import { profileForSheet } from '../features/project-import/workspace-profile'; import { toolIdForRecord } from '../importers/status-normalization'; import { gpoRecordSettingKey } from './gpo-record-key'; import { GPO_SOURCE_ID, type TimeslotChangeEntry, type TimeslotComparisonReport } from './timeslot-comparison-report'; export const TIMESLOT_DOMAIN_COMPARISON_VERSION = '1.0.0' as const; export interface TimeslotDomainSide { /** Client Info's domain value: pass rate over passed and deviating checks. */ score?: number; passed: number; deviations: number; } export interface TimeslotDomainRow { id: string; label: string; /** Over the sources compared in both timeslots only, so A and B rest on the same population. */ baseline: TimeslotDomainSide; target: TimeslotDomainSide; /** Set when the domain has assessed checks in one timeslot only, from sources the other lacks. */ onlyIn?: 'baseline' | 'target'; /** Sources behind `onlyIn`. */ onlyInSources: string[]; resolved: number; /** New plus regressed deviations. */ worse: number; } export interface TimeslotDomainComparison { version: typeof TIMESLOT_DOMAIN_COMPARISON_VERSION; /** Sources whose checks enter the domain values: compared in both timeslots. */ sources: string[]; domains: TimeslotDomainRow[]; } interface SideRecords { records: FindingRecord[]; states: Map; sourceOf: Map; domainOf: Map; } /** Client Info's records, states and domain assignment for one timeslot, from its project sheet. */ function sideRecords(dataset: ImportedDataset | null | undefined): SideRecords { const sheet = dataset?.sheets.find(item => item.isProjectSheet) ?? dataset?.sheets[0]; const records = sheet?.analysis.records ?? []; const states = new Map(); const sourceOf = new Map(); const domainOf = new Map(); if (!dataset || !sheet) return { records, states, sourceOf, domainOf }; const profile = profileForSheet(dataset, sheet); for (const record of records) { states.set(record, dashboardResultState(record, profile)); sourceOf.set(record, toolIdForRecord(record, sheet.toolId)); } for (const [domainId, members] of securityDomainBuckets(records, sheet.toolId)) for (const record of members) domainOf.set(record, domainId); return { records, states, sourceOf, domainOf }; } /** The Work record of one timeslot behind a change: scanner records by occurrence, GPO settings by scope and identity. */ function recordFinder(side: SideRecords) { const byOccurrence = new Map(side.records.flatMap(record => record.findingOccurrenceId ? [[record.findingOccurrenceId, record] as const] : [])); const bySetting = new Map(side.records.flatMap(record => { const key = gpoRecordSettingKey(record); return key ? [[key, record] as const] : []; })); return (entry: TimeslotChangeEntry, ids: string[]) => entry.sourceId === GPO_SOURCE_ID ? (entry.identityKey ? bySetting.get(entry.identityKey) : undefined) : ids.map(id => byOccurrence.get(id)).find(Boolean); } /** * Security Domain values of two timeslots for the Compare tab (mockup A). Values use Client Info's records, * result states, classifier and pass rate, restricted to sources compared in both timeslots (score delta only * over the shared population). Resolved and worse counts place each change in the domain of its Work record. */ export function compareTimeslotDomains(report: TimeslotComparisonReport, baseline: ImportedDataset | null | undefined, target: ImportedDataset | null | undefined): TimeslotDomainComparison { const compared = new Set(report.sources.filter(item => item.state === 'compared').map(item => item.sourceId)); const a = sideRecords(baseline); const b = sideRecords(target); const findA = recordFinder(a); const findB = recordFinder(b); const resolved = new Map(); const worse = new Map(); const add = (counts: Map, domain?: string) => { if (domain) counts.set(domain, (counts.get(domain) ?? 0) + 1); }; for (const entry of report.changes) { if (entry.change === 'resolved') add(resolved, a.domainOf.get(findA(entry, entry.recordIds)!) ?? b.domainOf.get(findB(entry, entry.counterpartIds)!)); else if (entry.change === 'new' || entry.change === 'regressed') add(worse, b.domainOf.get(findB(entry, entry.recordIds)!)); } const side = (records: SideRecords, domainId: string, shared: boolean) => { const members = records.records.filter(record => records.domainOf.get(record) === domainId && (!shared || compared.has(records.sourceOf.get(record) ?? ''))); const score = dashboardScoreFromStates(members.map(record => records.states.get(record) ?? 'unknown'), members); return { members, value: { ...(score.score === undefined ? {} : { score: score.score }), passed: score.passed, deviations: score.deviations } }; }; const assessedSources = (records: SideRecords, members: FindingRecord[]) => [...new Set(members .filter(record => ['passed', 'deviation'].includes(records.states.get(record) ?? '')) .flatMap(record => records.sourceOf.get(record) ?? []))].sort(); return { version: TIMESLOT_DOMAIN_COMPARISON_VERSION, sources: [...compared].sort(), domains: securityDomainCatalog.map(({ id, label }) => { const sharedA = side(a, id, true).value; const sharedB = side(b, id, true).value; const allA = side(a, id, false).members; const allB = side(b, id, false).members; const assessed = (value: TimeslotDomainSide) => value.passed + value.deviations > 0; const sourcesA = assessedSources(a, allA); const sourcesB = assessedSources(b, allB); const onlyIn = assessed(sharedA) || assessed(sharedB) ? undefined : sourcesB.length && !sourcesA.length ? 'target' as const : sourcesA.length && !sourcesB.length ? 'baseline' as const : undefined; // A domain held only by one timeslot's sources shows that timeslot's own value; it has no delta. return { id, label, baseline: onlyIn === 'baseline' ? side(a, id, false).value : sharedA, target: onlyIn === 'target' ? side(b, id, false).value : sharedB, ...(onlyIn ? { onlyIn } : {}), onlyInSources: onlyIn === 'target' ? sourcesB : onlyIn === 'baseline' ? sourcesA : [], resolved: resolved.get(id) ?? 0, worse: worse.get(id) ?? 0 }; }) }; }