Assessor_2.0/src/reporting/report-projection.ts

726 lines
27 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import type { CanonicalOutcome, CanonicalSeverity } from '../domain/canonical-model';
import type { ReportContextPublication } from './report-context';
import type { RemediationEvidenceState } from '../domain/timeslot-comparison';
import type { ToolPriorityBasisDefinition } from '../domain/tool-profile-definition';
import type { UnifiedResultProjection } from '../domain/unified-result';
import type { PolicySourceEvidence, RecordField } from '../domain/finding';
import { correlateReportResults, type ReportDomainConvergence, type ReportRemediationBundle, type ReportSingleSourcePolicyAction } from './report-correlation';
import { severityRank } from '../domain/severity-order';
import { buildReportStatements, type ReportStatement } from './report-narrative';
/**
* Shared projection behind every assessment output (docs/REPORT_STRATEGY.md):
* the Management Overview, the Technical Report, the Arbeitsgrundlage and the
* Delta Report. The renderers choose depth, never content, so a number shown in
* two artifacts is provably the same number.
*
* The projection is language-neutral. It carries codes, counts and identities;
* every German wording lives in a renderer. It is also platform-neutral: no
* clock, no storage, no network and no presentation types.
*/
export const REPORT_PROJECTION_VERSION = '1.3.0';
/* -------------------------------------------------------------------------- */
/* Priority basis */
/* -------------------------------------------------------------------------- */
/**
* Severity is not a property every scanner delivers: CIS-CAT and the GPO
* baseline comparison have none at all, and PingCastle cannot reach `critical`
* by construction. A source therefore declares how its findings may be ordered,
* or declares that it has no usable basis. Never infer one from an arbitrary
* field named severity, level, score or rank.
*/
export type ReportPriorityBasis =
| { kind: 'severity' }
| {
kind: 'numeric';
/** Exact mapped property behind `UnifiedResultProjection.score`. */
property: string;
scale: string;
direction: 'higher-is-worse' | 'lower-is-worse';
}
| { kind: 'equal-weight'; reason: string }
| { kind: 'undeclared' };
export type ReportPriorityBasisIssue =
| 'basis-not-declared'
| 'declared-severity-without-coverage'
| 'declared-numeric-without-values';
/**
* Reads a profile's declaration. An `unavailable` declaration is a decision, not
* a gap: it becomes an explicit `equal-weight` basis carrying the profile's own
* reason, so a report can say why a source is unranked instead of implying the
* declaration is missing.
*/
export function reportPriorityBasisFrom(definition: ToolPriorityBasisDefinition): ReportPriorityBasis {
const { basis } = definition;
if (basis.kind === 'severity') return { kind: 'severity' };
if (basis.kind === 'numeric') {
return {
kind: 'numeric',
property: basis.properties[0],
scale: basis.scale,
direction: basis.direction
};
}
return { kind: 'equal-weight', reason: basis.reason };
}
/** Declarations of every registered profile, keyed by tool id. */
export function reportPriorityBases(
definitions: ReadonlyArray<{ id: string; priorityBasis: ToolPriorityBasisDefinition }>
): Record<string, ReportPriorityBasis> {
return Object.fromEntries(definitions.map(definition =>
[definition.id, reportPriorityBasisFrom(definition.priorityBasis)]));
}
export interface ReportSourcePriority {
toolId: string;
declared: ReportPriorityBasis;
/** What the projection actually applied after validating the declaration. */
applied: ReportPriorityBasis;
/** Share of this source's deviations carrying a usable severity, 0..1. */
severityCoverage: number;
issues: ReportPriorityBasisIssue[];
}
/* -------------------------------------------------------------------------- */
/* Input */
/* -------------------------------------------------------------------------- */
/** One row of the shared Client Info security-domain catalog. */
export interface ReportDomain {
id: string;
label: string;
}
/** A single environment fact. An absent value stays absent, never zero. */
export interface ReportFact {
key: string;
value?: string;
}
export interface ReportScoreSource {
toolId: string;
/** Versioned ASSESSOR contribution on the 0-100 scale, absent when unavailable. */
score?: number;
method: 'native' | 'inverted-native' | 'pass-rate' | 'unavailable';
weight: number;
nativeValue?: number;
nativeProperty?: string;
passed: number;
deviations: number;
assessed: number;
observedAt?: string;
issues: string[];
}
/**
* One rolled-up GPO policy setting. Supplied by the caller from the evaluation
* layer so that the projection never re-derives a verdict of its own.
*/
export interface ReportPolicyEntry {
id: string;
/** Authoritative rolled-up verdict; diagnostics stay separate. */
verdict: 'Fail' | 'Pass' | 'Not configured' | 'No baseline rule' | 'Not comparable' | 'Review required';
conflict: boolean;
duplicate: boolean;
title: string;
scope: string;
identity: string;
backupInstanceIds: string[];
}
export interface ReportDiagnostic {
code: string;
reason?: string;
/** Where the diagnostic was raised; keeps one observation from being restated. */
origin: 'import' | 'analysis' | 'aggregation' | 'evaluation' | 'comparison' | 'projection';
count: number;
}
export interface ReportProvenanceEntry {
kind: string;
id: string;
version?: string;
observedAt?: string;
}
/** Client-owned planning fields. Never proof, always self-reported. */
export interface ReportReviewInput {
resultProjectionId: string;
include: boolean;
status: string;
priority: string;
owner?: string;
due?: string;
prerequisites?: string;
access?: string;
nextStep?: string;
markers: string[];
riskAccepted: boolean;
}
/**
* A stored timeslot comparison. The reference run is chosen in ASSESSOR; the
* projection reads that choice and never derives a pairing of its own.
*/
export interface ReportDeltaInput {
comparisonId: string;
contractVersion: string;
referenceRunId: string;
referenceObservedAt?: string;
currentRunId: string;
currentObservedAt?: string;
entries: Array<{
resultProjectionId: string;
toolId?: string;
state: RemediationEvidenceState;
reasons: string[];
}>;
}
export interface ReportProjectionInput {
generatedAt: string;
project: { id: string; name: string };
assessmentId?: string;
results: UnifiedResultProjection[];
domains: ReadonlyArray<ReportDomain>;
/** Shared catalog assignment, injected so the domain layer stays independent. */
domainOf: (result: UnifiedResultProjection) => string;
priorityBases?: Readonly<Record<string, ReportPriorityBasis>>;
scoreSources?: ReportScoreSource[];
workspaceCounts?: { deviations: number; assessed: number };
workspaceDeviationKeys?: string[];
overallScore?: number;
environment?: ReportFact[];
policyEntries?: ReportPolicyEntry[];
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
policyScopeLimited?: boolean;
reviews?: ReportReviewInput[];
diagnostics?: ReportDiagnostic[];
provenance?: ReportProvenanceEntry[];
delta?: ReportDeltaInput;
/** Cap per domain block in the management cut. Zero means no cap. */
priorityLimitPerDomain?: number;
}
/* -------------------------------------------------------------------------- */
/* Output */
/* -------------------------------------------------------------------------- */
export interface ReportFindingRef {
resultProjectionId: string;
sourceRuleId?: string;
toolId?: string;
domainId: string;
title: string;
affectedAsset?: string;
severity: CanonicalSeverity;
outcome: CanonicalOutcome;
/** The source's own rank value, meaningless across sources. */
rankValue?: number;
rankBasis: ReportPriorityBasis;
ranked: boolean;
}
export interface ReportDomainPriority {
domainId: string;
domainLabel: string;
deviations: number;
/** Grouped per source; ordering never crosses sources. */
sources: Array<{
toolId: string;
basis: ReportPriorityBasis;
ranked: boolean;
shown: ReportFindingRef[];
total: number;
}>;
}
export interface ReportDomainSummary {
domainId: string;
domainLabel: string;
passed: number;
deviations: number;
assessed: number;
/** Pass rate 0..1, absent when nothing comparable was assessed. */
passRate?: number;
/** Evidence chain: the result IDs behind `passed` and `deviations`. */
evidence: { passed: string[]; deviations: string[] };
}
export interface ReportGpoBlock {
/** GPO deviations carry no severity and are never ranked against each other. */
basis: Extract<ReportPriorityBasis, { kind: 'equal-weight' }>;
counts: Record<ReportPolicyEntry['verdict'], number>;
conflicts: number;
duplicates: number;
affectedSettings: number;
affectedBackups: number;
scopeLimited: boolean;
}
export interface ReportCoverage {
total: number;
assessed: number;
deviations: number;
passed: number;
notRun: number;
notApplicable: number;
executionErrors: number;
unknown: number;
/** Sources present in the results, with how much of each could be assessed. */
perSource: Array<{ toolId: string; total: number; assessed: number; notRun: number }>;
/** Evidence chain: result IDs per group; the unassessed groups partition every non-assessed result. */
evidence: {
passed: string[];
deviations: string[];
notRun: string[];
notApplicable: string[];
executionErrors: string[];
otherUnassessed: string[];
};
}
export interface ReportInventory {
bySeverity: Record<CanonicalSeverity, number>;
byOutcome: Record<CanonicalOutcome, number>;
byDomain: Record<string, number>;
bySource: Record<string, number>;
}
export interface ReportRemediationSummary {
/** Self-reported planning state. Never a resolved finding. */
selfReported: true;
items: number;
byStatus: Record<string, number>;
withoutOwner: number;
withoutDue: number;
riskAccepted: number;
}
export interface ReportWorkItem {
resultProjectionId: string;
domainId: string;
toolId?: string;
title: string;
affectedAsset?: string;
severity: CanonicalSeverity;
/** Planning fields, marked at the point of display as unverified. */
status: string;
priority: string;
owner?: string;
due?: string;
prerequisites?: string;
access?: string;
nextStep?: string;
markers: string[];
riskAccepted: boolean;
}
export interface ReportDelta {
comparisonId: string;
contractVersion: string;
referenceRunId: string;
referenceObservedAt?: string;
currentRunId: string;
currentObservedAt?: string;
counts: Record<RemediationEvidenceState, number>;
/** Per source, so a format that cannot prove absence is visible as such. */
perSource: Array<{ toolId: string; counts: Record<RemediationEvidenceState, number> }>;
entries: Array<{
resultProjectionId: string;
toolId?: string;
state: RemediationEvidenceState;
reasons: string[];
title?: string;
domainId?: string;
}>;
}
export interface ReportProjection {
/** New runs never create a legacy management file without Context; historical snapshots retain their renderer. */
managementContextRequired?: boolean;
/** Frozen published Context result, never computed by this engine. */
context?: ReportContextPublication;
version: typeof REPORT_PROJECTION_VERSION;
generatedAt: string;
project: { id: string; name: string };
assessmentId?: string;
environment: ReportFact[];
overallScore?: number;
scoreSources: ReportScoreSource[];
workspaceCounts?: { deviations: number; assessed: number };
workspaceDeviationKeys?: string[];
domains: ReportDomainSummary[];
priorities: ReportDomainPriority[];
gpo?: ReportGpoBlock;
coverage: ReportCoverage;
inventory: ReportInventory;
/** Present only when reviews exist; an empty section is omitted, not rendered. */
remediation?: ReportRemediationSummary;
/**
* Every deviation, unordered. Report B groups it, artifact C uses it for the
* complete second sheet. Ordering lives in `priorities`, never here.
*/
findings: ReportFindingRef[];
/** Internal technical evidence, retained only in the Technical Report cut. */
technicalResults?: UnifiedResultProjection[];
technicalResultDomains?: Record<string, string>;
policyEntries: ReportPolicyEntry[];
technicalPolicySources?: Array<{ settingId: string; sources: PolicySourceEvidence[] }>;
technicalFindingFields?: Array<{ resultProjectionId: string; fields: RecordField[] }>;
technicalReviews?: ReportReviewInput[];
workItems: ReportWorkItem[];
sourcePriorities: ReportSourcePriority[];
convergence: ReportDomainConvergence[];
bundles: ReportRemediationBundle[];
singleSourcePolicyActions: ReportSingleSourcePolicyAction[];
/** Deterministic key statements with their evidence; wording lives in the renderers. */
statements: ReportStatement[];
diagnostics: ReportDiagnostic[];
provenance: ReportProvenanceEntry[];
delta?: ReportDelta;
}
/* -------------------------------------------------------------------------- */
/* Build */
/* -------------------------------------------------------------------------- */
const SEVERITIES: CanonicalSeverity[] = ['critical', 'high', 'medium', 'low', 'info', 'unknown'];
const OUTCOMES: CanonicalOutcome[] = ['fail', 'pass', 'warning', 'informational',
'execution_error', 'not_run', 'not_applicable', 'not_evaluated', 'unknown'];
const REMEDIATION_STATES: RemediationEvidenceState[] = ['confirmed-open', 'verified-remediated',
'unverified-absent', 'not-applicable', 'not-comparable'];
const GPO_EQUAL_WEIGHT = {
kind: 'equal-weight',
reason: 'A baseline deviation carries no native criticality.'
} as const;
function emptyCounts<K extends string>(keys: readonly K[]): Record<K, number> {
return Object.fromEntries(keys.map(key => [key, 0])) as Record<K, number>;
}
function toolOf(result: UnifiedResultProjection): string {
return result.sourceTool ?? 'unknown';
}
/**
* Validates a declared basis against the data it would order. A source that
* declares severity but delivers none must not silently produce an ordering
* that only describes its report format.
*/
function resolveSourcePriority(
toolId: string, declared: ReportPriorityBasis, deviations: UnifiedResultProjection[]
): ReportSourcePriority {
const withSeverity = deviations.filter(item => item.severityNormalized !== 'unknown').length;
const severityCoverage = deviations.length ? withSeverity / deviations.length : 0;
const issues: ReportPriorityBasisIssue[] = [];
let applied = declared;
if (declared.kind === 'undeclared') {
issues.push('basis-not-declared');
} else if (declared.kind === 'severity' && withSeverity === 0) {
issues.push('declared-severity-without-coverage');
applied = { kind: 'undeclared' };
} else if (declared.kind === 'numeric'
&& !deviations.some(item => typeof item.score === 'number' && Number.isFinite(item.score))) {
issues.push('declared-numeric-without-values');
applied = { kind: 'undeclared' };
}
return { toolId, declared, applied, severityCoverage, issues };
}
function coverageEvidence(results: UnifiedResultProjection[]): ReportCoverage['evidence'] {
const ids = (predicate: (result: UnifiedResultProjection) => boolean) =>
results.filter(predicate).map(result => result.resultProjectionId);
const assessed = (result: UnifiedResultProjection) => result.outcomeNormalized === 'pass' || result.isDeviation;
const unassessed = (outcome: CanonicalOutcome) => (result: UnifiedResultProjection) =>
!assessed(result) && result.outcomeNormalized === outcome;
return {
passed: ids(result => result.outcomeNormalized === 'pass'),
deviations: ids(result => result.isDeviation),
notRun: ids(unassessed('not_run')),
notApplicable: ids(unassessed('not_applicable')),
executionErrors: ids(unassessed('execution_error')),
otherUnassessed: ids(result => !assessed(result)
&& !['not_run', 'not_applicable', 'execution_error'].includes(result.outcomeNormalized))
};
}
function rankValueFor(result: UnifiedResultProjection, basis: ReportPriorityBasis): number | undefined {
if (basis.kind === 'severity') return severityRank(result.severityNormalized);
if (basis.kind === 'numeric') {
return typeof result.score === 'number' && Number.isFinite(result.score) ? result.score : undefined;
}
return undefined;
}
/**
* Ordering is defined inside one source only; there is no cross-source rank.
* `severityRank` counts upwards from `critical`, so severity sorts ascending
* while a higher-is-worse numeric basis sorts descending.
*/
function orderWithinSource(items: ReportFindingRef[], basis: ReportPriorityBasis): ReportFindingRef[] {
if (basis.kind !== 'severity' && basis.kind !== 'numeric') return items;
const worseFirst = basis.kind === 'numeric' && basis.direction === 'higher-is-worse';
return [...items].sort((left, right) => {
const a = left.rankValue;
const b = right.rankValue;
if (a === undefined && b === undefined) return 0;
if (a === undefined) return 1;
if (b === undefined) return -1;
return worseFirst ? b - a : a - b;
});
}
export function buildReportProjection(input: ReportProjectionInput): ReportProjection {
const { results, domainOf, domains } = input;
const bases = input.priorityBases ?? {};
const deviations = results.filter(result => result.isDeviation);
const deviationsByTool = new Map<string, UnifiedResultProjection[]>();
for (const result of deviations) {
const tool = toolOf(result);
const bucket = deviationsByTool.get(tool);
if (bucket) bucket.push(result); else deviationsByTool.set(tool, [result]);
}
const sourcePriorities = [...deviationsByTool].map(([toolId, items]) =>
resolveSourcePriority(toolId, bases[toolId] ?? { kind: 'undeclared' }, items));
const appliedByTool = new Map(sourcePriorities.map(entry => [entry.toolId, entry.applied]));
const refOf = (result: UnifiedResultProjection): ReportFindingRef => {
const tool = toolOf(result);
const basis = appliedByTool.get(tool) ?? { kind: 'undeclared' as const };
return {
resultProjectionId: result.resultProjectionId,
sourceRuleId: result.sourceRuleId,
toolId: result.sourceTool,
domainId: domainOf(result),
title: result.title,
affectedAsset: result.affectedAsset,
severity: result.severityNormalized,
outcome: result.outcomeNormalized,
rankValue: rankValueFor(result, basis),
rankBasis: basis,
ranked: basis.kind === 'severity' || basis.kind === 'numeric'
};
};
const deviationRefs = deviations.map(refOf);
// Priority Findings: grouped by domain, ordered per source, never across sources.
const limit = input.priorityLimitPerDomain ?? 0;
const priorities: ReportDomainPriority[] = domains.map(domain => {
const inDomain = deviationRefs.filter(ref => ref.domainId === domain.id);
const byTool = new Map<string, ReportFindingRef[]>();
for (const ref of inDomain) {
const tool = ref.toolId ?? 'unknown';
const bucket = byTool.get(tool);
if (bucket) bucket.push(ref); else byTool.set(tool, [ref]);
}
return {
domainId: domain.id,
domainLabel: domain.label,
deviations: inDomain.length,
sources: [...byTool].map(([toolId, items]) => {
const basis = appliedByTool.get(toolId) ?? { kind: 'undeclared' as const };
const ordered = orderWithinSource(items, basis);
return {
toolId, basis,
ranked: basis.kind === 'severity' || basis.kind === 'numeric',
shown: limit > 0 ? ordered.slice(0, limit) : ordered,
total: items.length
};
})
};
}).filter(entry => entry.deviations > 0);
const domainSummaries: ReportDomainSummary[] = domains.map(domain => {
const inDomain = results.filter(result => domainOf(result) === domain.id);
const passedIds = inDomain.filter(result => result.outcomeNormalized === 'pass').map(result => result.resultProjectionId);
const deviationIds = inDomain.filter(result => result.isDeviation).map(result => result.resultProjectionId);
const passed = passedIds.length;
const failed = deviationIds.length;
const assessed = passed + failed;
return {
domainId: domain.id,
domainLabel: domain.label,
passed,
deviations: failed,
assessed,
passRate: assessed ? passed / assessed : undefined,
evidence: { passed: passedIds, deviations: deviationIds }
};
});
const coverage: ReportCoverage = {
total: results.length,
assessed: results.filter(result => result.outcomeNormalized === 'pass' || result.isDeviation).length,
deviations: deviations.length,
passed: results.filter(result => result.outcomeNormalized === 'pass').length,
notRun: results.filter(result => result.outcomeNormalized === 'not_run').length,
notApplicable: results.filter(result => result.outcomeNormalized === 'not_applicable').length,
executionErrors: results.filter(result => result.outcomeNormalized === 'execution_error').length,
unknown: results.filter(result => result.outcomeNormalized === 'unknown').length,
perSource: [...new Set(results.map(toolOf))].sort().map(toolId => {
const items = results.filter(result => toolOf(result) === toolId);
return {
toolId,
total: items.length,
assessed: items.filter(item => item.outcomeNormalized === 'pass' || item.isDeviation).length,
notRun: items.filter(item => item.outcomeNormalized === 'not_run').length
};
}),
evidence: coverageEvidence(results)
};
const inventory: ReportInventory = {
bySeverity: emptyCounts(SEVERITIES),
byOutcome: emptyCounts(OUTCOMES),
byDomain: {},
bySource: {}
};
for (const result of results) {
inventory.bySeverity[result.severityNormalized] += 1;
inventory.byOutcome[result.outcomeNormalized] += 1;
const domainId = domainOf(result);
inventory.byDomain[domainId] = (inventory.byDomain[domainId] ?? 0) + 1;
const tool = toolOf(result);
inventory.bySource[tool] = (inventory.bySource[tool] ?? 0) + 1;
}
const policyEntries = input.policyEntries ?? [];
const gpo: ReportGpoBlock | undefined = policyEntries.length ? {
basis: GPO_EQUAL_WEIGHT,
counts: policyEntries.reduce((counts, entry) => {
counts[entry.verdict] += 1;
return counts;
}, emptyCounts<ReportPolicyEntry['verdict']>(
['Fail', 'Pass', 'Not configured', 'No baseline rule', 'Not comparable', 'Review required'])),
conflicts: policyEntries.filter(entry => entry.conflict).length,
duplicates: policyEntries.filter(entry => entry.duplicate).length,
affectedSettings: new Set(policyEntries.filter(entry => entry.verdict === 'Fail')
.map(entry => `${entry.scope}${entry.identity}`)).size,
affectedBackups: new Set(policyEntries.filter(entry => entry.verdict === 'Fail')
.flatMap(entry => entry.backupInstanceIds)).size,
scopeLimited: input.policyScopeLimited ?? false
} : undefined;
const reviews = input.reviews ?? [];
const reviewById = new Map(reviews.map(review => [review.resultProjectionId, review]));
const remediation: ReportRemediationSummary | undefined = reviews.length ? {
selfReported: true,
items: reviews.filter(review => review.include).length,
byStatus: reviews.filter(review => review.include).reduce<Record<string, number>>((counts, review) => {
counts[review.status] = (counts[review.status] ?? 0) + 1;
return counts;
}, {}),
withoutOwner: reviews.filter(review => review.include && !review.owner).length,
withoutDue: reviews.filter(review => review.include && !review.due).length,
riskAccepted: reviews.filter(review => review.riskAccepted).length
} : undefined;
// Artifact C sheet 1: only what the consultant selected through `include`.
const workItems: ReportWorkItem[] = results.flatMap(result => {
const review = reviewById.get(result.resultProjectionId);
if (!review?.include) return [];
return [{
resultProjectionId: result.resultProjectionId,
domainId: domainOf(result),
toolId: result.sourceTool,
title: result.title,
affectedAsset: result.affectedAsset,
severity: result.severityNormalized,
status: review.status,
priority: review.priority,
owner: review.owner,
due: review.due,
prerequisites: review.prerequisites,
access: review.access,
nextStep: review.nextStep,
markers: review.markers,
riskAccepted: review.riskAccepted
}];
});
const delta: ReportDelta | undefined = input.delta ? {
comparisonId: input.delta.comparisonId,
contractVersion: input.delta.contractVersion,
referenceRunId: input.delta.referenceRunId,
referenceObservedAt: input.delta.referenceObservedAt,
currentRunId: input.delta.currentRunId,
currentObservedAt: input.delta.currentObservedAt,
counts: input.delta.entries.reduce((counts, entry) => {
counts[entry.state] += 1;
return counts;
}, emptyCounts(REMEDIATION_STATES)),
perSource: [...new Set(input.delta.entries.map(entry => entry.toolId ?? 'unknown'))].sort()
.map(toolId => ({
toolId,
counts: input.delta!.entries.filter(entry => (entry.toolId ?? 'unknown') === toolId)
.reduce((counts, entry) => {
counts[entry.state] += 1;
return counts;
}, emptyCounts(REMEDIATION_STATES))
})),
entries: input.delta.entries.map(entry => {
const result = results.find(item => item.resultProjectionId === entry.resultProjectionId);
return {
resultProjectionId: entry.resultProjectionId,
toolId: entry.toolId,
state: entry.state,
reasons: entry.reasons,
title: result?.title,
domainId: result ? domainOf(result) : undefined
};
})
} : undefined;
const statements = buildReportStatements(domainSummaries, coverage);
const diagnostics = [...(input.diagnostics ?? [])];
const correlation = correlateReportResults(results, domains, domainOf, policyEntries);
for (const entry of sourcePriorities) {
for (const issue of entry.issues) {
diagnostics.push({ code: `report-priority-${issue}:${entry.toolId}`, origin: 'projection', count: 1 });
}
}
return {
version: REPORT_PROJECTION_VERSION,
generatedAt: input.generatedAt,
project: input.project,
assessmentId: input.assessmentId,
environment: input.environment ?? [],
overallScore: input.overallScore,
scoreSources: input.scoreSources ?? [],
workspaceCounts: input.workspaceCounts ? { ...input.workspaceCounts } : undefined,
workspaceDeviationKeys: input.workspaceDeviationKeys ? [...input.workspaceDeviationKeys] : undefined,
domains: domainSummaries,
priorities,
gpo,
coverage,
inventory,
remediation,
findings: deviationRefs,
technicalResults: results,
technicalResultDomains: Object.fromEntries(results.map(result => [result.resultProjectionId, domainOf(result)])),
policyEntries,
technicalPolicySources: input.technicalPolicySources,
technicalFindingFields: input.technicalFindingFields,
technicalReviews: reviews,
workItems,
sourcePriorities,
...correlation,
statements,
diagnostics,
provenance: input.provenance ?? [],
delta
};
}