64 lines
3.2 KiB
TypeScript
64 lines
3.2 KiB
TypeScript
import type { AssessmentContextResponse } from '../../api/assessor-api-contract';
|
|
import type { FindingRecord } from '../../domain/finding';
|
|
import type { DynamicFacet } from '../search-filter/dynamic-facets';
|
|
import { publishContextReport } from '../../assessment-context/context-report-publication';
|
|
|
|
/**
|
|
* Work facet of the context layer (Phase 13E, decision (c)). It exists only while the layer is on and
|
|
* its context is loaded; switched off, the facet is absent and Work filters exactly as without it.
|
|
*/
|
|
export const CONTEXT_CLUSTER_FACET_ID = 'context:cluster';
|
|
const SOURCE_SCAN_FACET_ID = 'provenance:source-scan';
|
|
|
|
export interface ContextClusterIndex {
|
|
publication: ReturnType<typeof publishContextReport>;
|
|
facet: DynamicFacet;
|
|
/** The loaded clusters with their state, for the Pathfinder details (Phase 13E). */
|
|
clusters: AssessmentContextResponse['clusters'];
|
|
}
|
|
|
|
const field = (record: FindingRecord, label: string) =>
|
|
record.fields.find(item => item.label.toLocaleLowerCase('en-US') === label.toLocaleLowerCase('en-US'))?.value?.trim() ?? '';
|
|
|
|
const indexes = new WeakMap<AssessmentContextResponse, ContextClusterIndex>();
|
|
|
|
/** One shared index per loaded context object, so every Work consumer uses identical cache keys. */
|
|
export function contextClusterIndex(context: AssessmentContextResponse): ContextClusterIndex {
|
|
const existing = indexes.get(context);
|
|
if (existing) return existing;
|
|
const byOccurrence = new Map(context.findings.map(item => [item.occurrenceId, item.clusterIds]));
|
|
const bySetting = new Map(context.settings.map(item => [`${item.scope}\u0000${item.identityKey}`, item.clusterIds]));
|
|
// A record belongs to a cluster when it provides evidence for one of its conditions.
|
|
const valuesFor = (record: FindingRecord): string[] => {
|
|
const scanner = record.findingOccurrenceId ? byOccurrence.get(record.findingOccurrenceId) : undefined;
|
|
if (scanner) return scanner;
|
|
const identity = field(record, 'Technical Identity');
|
|
return identity ? bySetting.get(`${field(record, 'Scope')}\u0000${identity}`) ?? [] : [];
|
|
};
|
|
const index: ContextClusterIndex = {
|
|
publication: publishContextReport(context),
|
|
clusters: context.clusters,
|
|
facet: {
|
|
id: CONTEXT_CLUSTER_FACET_ID,
|
|
label: 'Context Cluster',
|
|
source: 'derived',
|
|
kind: 'category',
|
|
derivedKind: 'context-cluster',
|
|
preserveEmptyValues: true,
|
|
description: `Derived context layer (catalog ${context.catalog.version}): findings and GPO settings that provide evidence for a cluster. A second reading; it does not change results.`,
|
|
values: context.clusters.map(cluster => ({ value: cluster.clusterId, label: cluster.label.en, count: 0 })),
|
|
valuesFor
|
|
}
|
|
};
|
|
indexes.set(context, index);
|
|
return index;
|
|
}
|
|
|
|
/** Adds the context facet directly before the permanent final `Source: Scan` control. */
|
|
export function withContextClusterFacet(facets: DynamicFacet[], index: ContextClusterIndex | undefined): DynamicFacet[] {
|
|
if (!index) return facets;
|
|
const sourceScan = facets.findIndex(facet => facet.id === SOURCE_SCAN_FACET_ID);
|
|
return sourceScan < 0 ? [...facets, index.facet]
|
|
: [...facets.slice(0, sourceScan), index.facet, ...facets.slice(sourceScan)];
|
|
}
|