TenantAtlas/apps/platform/app/Support/Baselines/BaselineCompareMatrixBuilder.php
ahmido 74210bac2e feat: add baseline compare operator modes (#224)
## Summary
- add adaptive baseline compare presentation modes with `auto`, `dense`, and `compact` route handling on the existing matrix page
- compress support surfaces with staged filters, grouped legends, last-updated and passive refresh cues, compact single-tenant results, and dense multi-tenant scan rendering
- extend the matrix builder plus Pest and browser smoke coverage for visible-set-only compact and dense workflows

## Filament / Laravel notes
- Livewire v4 compliance preserved; no legacy Livewire v3 patterns introduced
- provider registration is unchanged; no `bootstrap/providers.php` changes were needed for this feature
- no globally searchable resources were changed by this branch
- no destructive actions were added; the existing compare action remains simulation-only and non-destructive
- asset strategy is unchanged; no new Filament assets were introduced

## Validation
- `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Browser/Spec190BaselineCompareMatrixSmokeTest.php`
- `80` tests passed with `673` assertions
- integrated browser smoke run on `http://localhost/admin/baseline-profiles/20/compare-matrix`

## Scope
- Spec 191 implementation
- spec contract updates in `spec.md`, `tasks.md`, and the logical OpenAPI contract

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #224
2026-04-11 15:48:22 +00:00

1006 lines
38 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\BaselineProfile;
use App\Models\BaselineSnapshot;
use App\Models\BaselineSnapshotItem;
use App\Models\BaselineTenantAssignment;
use App\Models\Finding;
use App\Models\OperationRun;
use App\Models\Tenant;
use App\Models\User;
use App\Services\Auth\CapabilityResolver;
use App\Services\Baselines\BaselineSnapshotTruthResolver;
use App\Support\Auth\Capabilities;
use App\Support\Badges\BadgeCatalog;
use App\Support\Badges\BadgeDomain;
use App\Support\Inventory\InventoryPolicyTypeMeta;
use App\Support\OperationRunOutcome;
use App\Support\OperationRunStatus;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
final class BaselineCompareMatrixBuilder
{
public function __construct(
private readonly BaselineSnapshotTruthResolver $snapshotTruthResolver,
private readonly CapabilityResolver $capabilityResolver,
private readonly BaselineCompareExplanationRegistry $explanationRegistry,
) {}
/**
* @param array<string, mixed> $filters
* @return array<string, mixed>
*/
public function build(BaselineProfile $profile, User $user, array $filters = []): array
{
$normalizedFilters = $this->normalizeFilters($filters);
$assignments = BaselineTenantAssignment::query()
->inWorkspace((int) $profile->workspace_id)
->forBaselineProfile($profile)
->with('tenant')
->get();
$visibleTenants = $this->visibleTenants($assignments, $user);
$referenceResolution = $this->snapshotTruthResolver->resolveCompareSnapshot($profile);
$referenceSnapshot = $this->resolvedSnapshot($referenceResolution);
$referenceReasonCode = is_string($referenceResolution['reason_code'] ?? null)
? trim((string) $referenceResolution['reason_code'])
: null;
$reference = [
'workspaceId' => (int) $profile->workspace_id,
'baselineProfileId' => (int) $profile->getKey(),
'baselineProfileName' => (string) $profile->name,
'baselineStatus' => $profile->status instanceof BaselineProfileStatus
? $profile->status->value
: (string) $profile->status,
'referenceSnapshotId' => $referenceSnapshot?->getKey(),
'referenceSnapshotCapturedAt' => $referenceSnapshot?->captured_at?->toIso8601String(),
'referenceState' => $referenceSnapshot instanceof BaselineSnapshot ? 'ready' : 'no_snapshot',
'referenceReasonCode' => $referenceReasonCode,
'assignedTenantCount' => $assignments->count(),
'visibleTenantCount' => $visibleTenants->count(),
];
$snapshotItems = $referenceSnapshot instanceof BaselineSnapshot
? BaselineSnapshotItem::query()
->where('baseline_snapshot_id', (int) $referenceSnapshot->getKey())
->orderBy('policy_type')
->orderBy('subject_key')
->orderBy('id')
->get()
: collect();
$policyTypeOptions = $snapshotItems
->pluck('policy_type')
->filter(static fn (mixed $type): bool => is_string($type) && trim($type) !== '')
->unique()
->sort()
->mapWithKeys(static fn (string $type): array => [
$type => InventoryPolicyTypeMeta::label($type) ?? $type,
])
->all();
$bundle = [
'reference' => $reference,
'filters' => [
'policyTypes' => $normalizedFilters['policyTypes'],
'states' => $normalizedFilters['states'],
'severities' => $normalizedFilters['severities'],
'tenantSort' => $normalizedFilters['tenantSort'],
'subjectSort' => $normalizedFilters['subjectSort'],
'focusedSubjectKey' => $normalizedFilters['focusedSubjectKey'],
],
'policyTypeOptions' => $policyTypeOptions,
'stateOptions' => BadgeCatalog::options(BadgeDomain::BaselineCompareMatrixState, [
'match',
'differ',
'missing',
'ambiguous',
'not_compared',
'stale_result',
]),
'severityOptions' => BadgeCatalog::options(BadgeDomain::FindingSeverity, [
Finding::SEVERITY_LOW,
Finding::SEVERITY_MEDIUM,
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
]),
'tenantSortOptions' => [
'tenant_name' => 'Tenant name',
'deviation_count' => 'Deviation count',
'freshness_urgency' => 'Freshness urgency',
],
'subjectSortOptions' => [
'deviation_breadth' => 'Deviation breadth',
'policy_type' => 'Policy type',
'display_name' => 'Display name',
],
'stateLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixState, [
'match',
'differ',
'missing',
'ambiguous',
'not_compared',
'stale_result',
]),
'freshnessLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixFreshness, [
'fresh',
'stale',
'never_compared',
'unknown',
]),
'trustLegend' => $this->legendSpecs(BadgeDomain::BaselineCompareMatrixTrust, [
'trustworthy',
'limited_confidence',
'diagnostic_only',
'unusable',
]),
'supportSurfaceState' => [
'legendMode' => 'grouped',
'showActiveFilterSummary' => true,
'showLastUpdated' => true,
'showAutoRefreshHint' => false,
'showBlockingRefreshState' => false,
],
'lastUpdatedAt' => now()->toIso8601String(),
'tenantSummaries' => [],
'subjectSummaries' => [],
'rows' => [],
'denseRows' => [],
'compactResults' => [],
'emptyState' => $this->emptyState(
reference: $reference,
snapshotItemsCount: $snapshotItems->count(),
visibleTenantsCount: $visibleTenants->count(),
),
'hasActiveRuns' => false,
];
if (! $referenceSnapshot instanceof BaselineSnapshot) {
return $bundle;
}
if ($visibleTenants->isEmpty()) {
return $bundle;
}
if ($snapshotItems->isEmpty()) {
return $bundle;
}
$tenantIds = $visibleTenants
->map(static fn (Tenant $tenant): int => (int) $tenant->getKey())
->values()
->all();
$latestRuns = OperationRun::latestBaselineCompareRunsForProfile(
profile: $profile,
tenantIds: $tenantIds,
workspaceId: (int) $profile->workspace_id,
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
$completedRuns = OperationRun::latestBaselineCompareRunsForProfile(
profile: $profile,
tenantIds: $tenantIds,
workspaceId: (int) $profile->workspace_id,
completedOnly: true,
)->keyBy(static fn (OperationRun $run): int => (int) $run->tenant_id);
$findingMap = $this->findingMap($profile, $tenantIds, $completedRuns);
$rows = [];
foreach ($snapshotItems as $item) {
if (! $item instanceof BaselineSnapshotItem) {
continue;
}
$subjectKey = is_string($item->subject_key) ? trim($item->subject_key) : '';
if ($subjectKey === '') {
continue;
}
$subject = [
'subjectKey' => $subjectKey,
'policyType' => (string) $item->policy_type,
'displayName' => $this->subjectDisplayName($item),
'baselineExternalId' => is_string($item->subject_external_id) ? $item->subject_external_id : null,
];
$cells = [];
foreach ($visibleTenants as $tenant) {
$tenantId = (int) $tenant->getKey();
$latestRun = $latestRuns->get($tenantId);
$completedRun = $completedRuns->get($tenantId);
$cells[] = $this->cellFor(
item: $item,
tenant: $tenant,
referenceSnapshot: $referenceSnapshot,
latestRun: $latestRun instanceof OperationRun ? $latestRun : null,
completedRun: $completedRun instanceof OperationRun ? $completedRun : null,
finding: $findingMap[$this->cellKey($tenantId, $subjectKey)] ?? null,
);
}
if (! $this->rowMatchesFilters($subject, $cells, $normalizedFilters)) {
continue;
}
$rows[] = [
'subject' => $this->subjectSummary($subject, $cells),
'cells' => $cells,
];
}
$rows = $this->sortRows($rows, $normalizedFilters['subjectSort']);
$tenantSummaries = $this->sortTenantSummaries(
tenantSummaries: $this->tenantSummaries($visibleTenants, $latestRuns, $completedRuns, $rows, $referenceSnapshot),
sort: $normalizedFilters['tenantSort'],
);
foreach ($rows as &$row) {
$row['cells'] = $this->sortCellsForTenants($row['cells'], $tenantSummaries);
}
unset($row);
$bundle['tenantSummaries'] = $tenantSummaries;
$bundle['subjectSummaries'] = array_map(
static fn (array $row): array => $row['subject'],
$rows,
);
$bundle['rows'] = $rows;
$bundle['denseRows'] = $rows;
$bundle['compactResults'] = $this->compactResults($rows, $tenantSummaries);
$bundle['emptyState'] = $this->emptyState(
reference: $reference,
snapshotItemsCount: $snapshotItems->count(),
visibleTenantsCount: $visibleTenants->count(),
renderedRowsCount: count($rows),
);
$bundle['hasActiveRuns'] = collect($tenantSummaries)
->contains(static fn (array $summary): bool => in_array((string) ($summary['compareRunStatus'] ?? ''), [
OperationRunStatus::Queued->value,
OperationRunStatus::Running->value,
], true));
$bundle['supportSurfaceState']['showAutoRefreshHint'] = $bundle['hasActiveRuns'];
return $bundle;
}
/**
* @param array<string, mixed> $filters
* @return array{
* policyTypes: list<string>,
* states: list<string>,
* severities: list<string>,
* tenantSort: string,
* subjectSort: string,
* focusedSubjectKey: ?string
* }
*/
private function normalizeFilters(array $filters): array
{
$policyTypes = $this->normalizeStringList($filters['policy_type'] ?? $filters['policyTypes'] ?? []);
$states = array_values(array_intersect(
$this->normalizeStringList($filters['state'] ?? $filters['states'] ?? []),
['match', 'differ', 'missing', 'ambiguous', 'not_compared', 'stale_result'],
));
$severities = array_values(array_intersect(
$this->normalizeStringList($filters['severity'] ?? $filters['severities'] ?? []),
[Finding::SEVERITY_LOW, Finding::SEVERITY_MEDIUM, Finding::SEVERITY_HIGH, Finding::SEVERITY_CRITICAL],
));
$tenantSort = in_array((string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name'), [
'tenant_name',
'deviation_count',
'freshness_urgency',
], true)
? (string) ($filters['tenant_sort'] ?? $filters['tenantSort'] ?? 'tenant_name')
: 'tenant_name';
$subjectSort = in_array((string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth'), [
'deviation_breadth',
'policy_type',
'display_name',
], true)
? (string) ($filters['subject_sort'] ?? $filters['subjectSort'] ?? 'deviation_breadth')
: 'deviation_breadth';
$focusedSubjectKey = $filters['subject_key'] ?? $filters['focusedSubjectKey'] ?? null;
$focusedSubjectKey = is_string($focusedSubjectKey) && trim($focusedSubjectKey) !== ''
? trim($focusedSubjectKey)
: null;
return [
'policyTypes' => $policyTypes,
'states' => $states,
'severities' => $severities,
'tenantSort' => $tenantSort,
'subjectSort' => $subjectSort,
'focusedSubjectKey' => $focusedSubjectKey,
];
}
/**
* @return list<string>
*/
private function normalizeStringList(mixed $value): array
{
$values = is_array($value) ? $value : [$value];
return array_values(array_unique(array_filter(array_map(static function (mixed $item): ?string {
if (! is_string($item)) {
return null;
}
$normalized = trim($item);
return $normalized !== '' ? $normalized : null;
}, $values))));
}
/**
* @param Collection<int, BaselineTenantAssignment> $assignments
* @return Collection<int, Tenant>
*/
private function visibleTenants(Collection $assignments, User $user): Collection
{
return $assignments
->map(static fn (BaselineTenantAssignment $assignment): ?Tenant => $assignment->tenant)
->filter(fn (?Tenant $tenant): bool => $tenant instanceof Tenant
&& $this->capabilityResolver->isMember($user, $tenant)
&& $this->capabilityResolver->can($user, $tenant, Capabilities::TENANT_VIEW))
->sortBy(static fn (Tenant $tenant): string => Str::lower((string) $tenant->name))
->values();
}
/**
* @param array<string, mixed> $resolution
*/
private function resolvedSnapshot(array $resolution): ?BaselineSnapshot
{
$snapshot = $resolution['effective_snapshot'] ?? $resolution['snapshot'] ?? null;
return $snapshot instanceof BaselineSnapshot ? $snapshot : null;
}
/**
* @param array<int, int> $tenantIds
* @param Collection<int, OperationRun> $completedRuns
* @return array<string, Finding>
*/
private function findingMap(BaselineProfile $profile, array $tenantIds, Collection $completedRuns): array
{
$findings = Finding::query()
->baselineCompareForProfile($profile)
->whereIn('tenant_id', $tenantIds)
->orderByDesc('last_seen_at')
->orderByDesc('id')
->get();
$map = [];
foreach ($findings as $finding) {
if (! $finding instanceof Finding) {
continue;
}
$tenantId = (int) $finding->tenant_id;
$subjectKey = $this->subjectKeyForFinding($finding);
if ($subjectKey === null) {
continue;
}
$completedRun = $completedRuns->get($tenantId);
if (
$completedRun instanceof OperationRun
&& (int) ($finding->current_operation_run_id ?? 0) !== (int) $completedRun->getKey()
) {
continue;
}
$cellKey = $this->cellKey($tenantId, $subjectKey);
if (! array_key_exists($cellKey, $map)) {
$map[$cellKey] = $finding;
}
}
return $map;
}
private function subjectKeyForFinding(Finding $finding): ?string
{
$subjectKey = data_get($finding->evidence_jsonb, 'subject_key');
if (is_string($subjectKey) && trim($subjectKey) !== '') {
return trim($subjectKey);
}
return null;
}
private function cellKey(int $tenantId, string $subjectKey): string
{
return $tenantId.'|'.trim(mb_strtolower($subjectKey));
}
private function subjectDisplayName(BaselineSnapshotItem $item): ?string
{
$displayName = data_get($item->meta_jsonb, 'display_name');
if (is_string($displayName) && trim($displayName) !== '') {
return trim($displayName);
}
return is_string($item->subject_key) && trim($item->subject_key) !== ''
? Str::headline($item->subject_key)
: null;
}
private function cellFor(
BaselineSnapshotItem $item,
Tenant $tenant,
BaselineSnapshot $referenceSnapshot,
?OperationRun $latestRun,
?OperationRun $completedRun,
?Finding $finding,
): array {
$subjectKey = (string) $item->subject_key;
$policyType = (string) $item->policy_type;
$completedAt = $completedRun?->finished_at;
$policyTypeCovered = $this->policyTypeCovered($completedRun, $policyType);
$subjectReasons = $completedRun instanceof OperationRun
? (BaselineCompareEvidenceGapDetails::subjectReasonsFromOperationRun($completedRun)[BaselineCompareEvidenceGapDetails::subjectCompositeKey($policyType, $subjectKey)] ?? [])
: [];
$reasonCode = $subjectReasons[0] ?? $this->runReasonCode($completedRun);
$changeType = is_string(data_get($finding?->evidence_jsonb, 'change_type')) ? (string) data_get($finding?->evidence_jsonb, 'change_type') : null;
$staleResult = $this->isStaleResult($completedRun, $referenceSnapshot);
$tenantTrustLevel = $this->tenantTrustLevel($completedRun);
$state = match (true) {
! $completedRun instanceof OperationRun => 'not_compared',
(string) $completedRun->outcome === OperationRunOutcome::Failed->value => 'not_compared',
! $policyTypeCovered => 'not_compared',
$staleResult => 'stale_result',
$subjectReasons !== [] => 'ambiguous',
$changeType === 'missing_policy' => 'missing',
$finding instanceof Finding => 'differ',
default => 'match',
};
$trustLevel = match ($state) {
'not_compared' => 'unusable',
'stale_result' => 'limited_confidence',
'ambiguous' => 'diagnostic_only',
default => $tenantTrustLevel,
};
return [
'tenantId' => (int) $tenant->getKey(),
'subjectKey' => $subjectKey,
'state' => $state,
'severity' => $finding instanceof Finding ? (string) $finding->severity : null,
'trustLevel' => $trustLevel,
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
'attentionLevel' => $this->attentionLevel($state, $finding instanceof Finding ? (string) $finding->severity : null),
'reasonSummary' => $this->reasonSummary($state, $reasonCode, $policyTypeCovered),
'reasonCode' => $reasonCode,
'compareRunId' => $completedRun?->getKey(),
'findingId' => $finding?->getKey(),
'findingWorkflowState' => $finding instanceof Finding ? (string) $finding->status : null,
'lastComparedAt' => $completedAt?->toIso8601String(),
'policyTypeCovered' => $policyTypeCovered,
'latestRunId' => $latestRun?->getKey(),
'latestRunStatus' => $latestRun?->status,
];
}
private function policyTypeCovered(?OperationRun $run, string $policyType): bool
{
if (! $run instanceof OperationRun) {
return false;
}
$coverage = data_get($run->context, 'baseline_compare.coverage');
if (! is_array($coverage)) {
return true;
}
$coveredTypes = is_array($coverage['covered_types'] ?? null)
? array_values(array_filter($coverage['covered_types'], 'is_string'))
: [];
$uncoveredTypes = is_array($coverage['uncovered_types'] ?? null)
? array_values(array_filter($coverage['uncovered_types'], 'is_string'))
: [];
if (in_array($policyType, $uncoveredTypes, true)) {
return false;
}
if ($coveredTypes === []) {
return true;
}
return in_array($policyType, $coveredTypes, true);
}
private function runReasonCode(?OperationRun $run): ?string
{
$reasonCode = data_get($run?->context, 'baseline_compare.reason_code');
return is_string($reasonCode) && trim($reasonCode) !== ''
? trim($reasonCode)
: null;
}
private function attentionLevel(string $state, ?string $severity): string
{
if (in_array($state, ['differ', 'missing', 'ambiguous'], true) || in_array($severity, [
Finding::SEVERITY_HIGH,
Finding::SEVERITY_CRITICAL,
], true)) {
return 'needs_attention';
}
if (in_array($state, ['stale_result', 'not_compared'], true)) {
return 'refresh_recommended';
}
if ($state === 'match') {
return 'aligned';
}
return 'review';
}
private function reasonSummary(string $state, ?string $reasonCode, bool $policyTypeCovered): ?string
{
return match ($state) {
'differ' => 'A baseline compare finding exists for this subject.',
'missing' => 'The reference subject is missing from the tenant result.',
'ambiguous' => $reasonCode !== null
? Str::headline(str_replace(['.', '_'], ' ', $reasonCode))
: 'Identity or evidence stayed ambiguous.',
'stale_result' => 'Refresh recommended before acting on this result.',
'not_compared' => $policyTypeCovered
? 'No completed compare result is available yet.'
: 'Policy type coverage was not proven in the latest compare run.',
default => null,
};
}
private function isStaleResult(?OperationRun $run, BaselineSnapshot $referenceSnapshot): bool
{
if (! $run instanceof OperationRun || ! $run->finished_at) {
return false;
}
$runSnapshotId = data_get($run->context, 'baseline_snapshot_id');
if (is_numeric($runSnapshotId) && (int) $runSnapshotId !== (int) $referenceSnapshot->getKey()) {
return true;
}
if ($referenceSnapshot->captured_at && $run->finished_at->lt($referenceSnapshot->captured_at)) {
return true;
}
return BaselineCompareSummaryAssessor::isStaleComparedAt($run->finished_at);
}
private function tenantTrustLevel(?OperationRun $run): string
{
return BadgeCatalog::normalizeState(
$this->explanationRegistry->trustLevelForRun($run),
) ?? 'unusable';
}
/**
* @param array<string, mixed> $subject
* @param list<array<string, mixed>> $cells
* @param array<string, mixed> $filters
*/
private function rowMatchesFilters(array $subject, array $cells, array $filters): bool
{
if ($filters['policyTypes'] !== [] && ! in_array((string) $subject['policyType'], $filters['policyTypes'], true)) {
return false;
}
if ($filters['focusedSubjectKey'] !== null && (string) $subject['subjectKey'] !== $filters['focusedSubjectKey']) {
return false;
}
foreach ($cells as $cell) {
if ($filters['states'] !== [] && ! in_array((string) ($cell['state'] ?? ''), $filters['states'], true)) {
continue;
}
if ($filters['severities'] !== [] && ! in_array((string) ($cell['severity'] ?? ''), $filters['severities'], true)) {
continue;
}
return true;
}
return $filters['states'] === [] && $filters['severities'] === [];
}
/**
* @param list<array<string, mixed>> $cells
* @return array<string, mixed>
*/
private function subjectSummary(array $subject, array $cells): array
{
return [
'subjectKey' => $subject['subjectKey'],
'policyType' => $subject['policyType'],
'displayName' => $subject['displayName'],
'baselineExternalId' => $subject['baselineExternalId'],
'deviationBreadth' => $this->countStates($cells, ['differ', 'missing']),
'missingBreadth' => $this->countStates($cells, ['missing']),
'ambiguousBreadth' => $this->countStates($cells, ['ambiguous']),
'notComparedBreadth' => $this->countStates($cells, ['not_compared']),
'maxSeverity' => $this->maxSeverity($cells),
'trustLevel' => $this->worstTrustLevel($cells),
'attentionLevel' => $this->worstAttentionLevel($cells),
];
}
/**
* @param Collection<int, Tenant> $visibleTenants
* @param Collection<int, OperationRun> $latestRuns
* @param Collection<int, OperationRun> $completedRuns
* @param list<array<string, mixed>> $rows
* @return list<array<string, mixed>>
*/
private function tenantSummaries(
Collection $visibleTenants,
Collection $latestRuns,
Collection $completedRuns,
array $rows,
BaselineSnapshot $referenceSnapshot,
): array {
$summaries = [];
foreach ($visibleTenants as $tenant) {
$tenantId = (int) $tenant->getKey();
$latestRun = $latestRuns->get($tenantId);
$completedRun = $completedRuns->get($tenantId);
$cells = array_map(
static fn (array $row): array => collect($row['cells'])->firstWhere('tenantId', $tenantId) ?? [],
$rows,
);
$summaries[] = [
'tenantId' => $tenantId,
'tenantName' => (string) $tenant->name,
'compareRunId' => $latestRun?->getKey(),
'compareRunStatus' => $latestRun?->status,
'compareRunOutcome' => $latestRun?->outcome,
'freshnessState' => $this->freshnessState($completedRun, $referenceSnapshot),
'lastComparedAt' => $completedRun?->finished_at?->toIso8601String(),
'matchedCount' => $this->countStates($cells, ['match']),
'differingCount' => $this->countStates($cells, ['differ']),
'missingCount' => $this->countStates($cells, ['missing']),
'ambiguousCount' => $this->countStates($cells, ['ambiguous']),
'notComparedCount' => $this->countStates($cells, ['not_compared']),
'maxSeverity' => $this->maxSeverity($cells),
'trustLevel' => $this->worstTrustLevel($cells),
];
}
return $summaries;
}
private function freshnessState(?OperationRun $completedRun, BaselineSnapshot $referenceSnapshot): string
{
if (! $completedRun instanceof OperationRun) {
return 'never_compared';
}
if ((string) $completedRun->outcome === OperationRunOutcome::Failed->value) {
return 'unknown';
}
if ($this->isStaleResult($completedRun, $referenceSnapshot)) {
return 'stale';
}
return 'fresh';
}
/**
* @param list<array<string, mixed>> $cells
* @param array<int, string> $states
*/
private function countStates(array $cells, array $states): int
{
return count(array_filter(
$cells,
static fn (array $cell): bool => in_array((string) ($cell['state'] ?? ''), $states, true),
));
}
/**
* @param list<array<string, mixed>> $cells
*/
private function maxSeverity(array $cells): ?string
{
$ranked = collect($cells)
->map(static fn (array $cell): ?string => is_string($cell['severity'] ?? null) ? (string) $cell['severity'] : null)
->filter()
->sortByDesc(fn (string $severity): int => $this->severityRank($severity))
->values();
return $ranked->first();
}
private function severityRank(string $severity): int
{
return match ($severity) {
Finding::SEVERITY_CRITICAL => 4,
Finding::SEVERITY_HIGH => 3,
Finding::SEVERITY_MEDIUM => 2,
Finding::SEVERITY_LOW => 1,
default => 0,
};
}
/**
* @param list<array<string, mixed>> $cells
*/
private function worstTrustLevel(array $cells): string
{
return collect($cells)
->map(static fn (array $cell): string => (string) ($cell['trustLevel'] ?? 'unusable'))
->sortByDesc(fn (string $trust): int => $this->trustRank($trust))
->first() ?? 'unusable';
}
private function trustRank(string $trustLevel): int
{
return match ($trustLevel) {
'unusable' => 4,
'diagnostic_only' => 3,
'limited_confidence' => 2,
'trustworthy' => 1,
default => 0,
};
}
/**
* @param list<array<string, mixed>> $cells
*/
private function worstAttentionLevel(array $cells): string
{
return collect($cells)
->map(static fn (array $cell): string => (string) ($cell['attentionLevel'] ?? 'review'))
->sortByDesc(fn (string $level): int => $this->attentionRank($level))
->first() ?? 'review';
}
private function attentionRank(string $attentionLevel): int
{
return match ($attentionLevel) {
'needs_attention' => 4,
'refresh_recommended' => 3,
'review' => 2,
'aligned' => 1,
default => 0,
};
}
/**
* @param list<array<string, mixed>> $rows
* @return list<array<string, mixed>>
*/
private function sortRows(array $rows, string $sort): array
{
usort($rows, function (array $left, array $right) use ($sort): int {
$leftSubject = $left['subject'] ?? [];
$rightSubject = $right['subject'] ?? [];
return match ($sort) {
'policy_type' => [(string) ($leftSubject['policyType'] ?? ''), Str::lower((string) ($leftSubject['displayName'] ?? ''))]
<=> [(string) ($rightSubject['policyType'] ?? ''), Str::lower((string) ($rightSubject['displayName'] ?? ''))],
'display_name' => [Str::lower((string) ($leftSubject['displayName'] ?? '')), (string) ($leftSubject['subjectKey'] ?? '')]
<=> [Str::lower((string) ($rightSubject['displayName'] ?? '')), (string) ($rightSubject['subjectKey'] ?? '')],
default => [
-1 * (int) ($leftSubject['deviationBreadth'] ?? 0),
-1 * (int) ($leftSubject['ambiguousBreadth'] ?? 0),
Str::lower((string) ($leftSubject['displayName'] ?? '')),
] <=> [
-1 * (int) ($rightSubject['deviationBreadth'] ?? 0),
-1 * (int) ($rightSubject['ambiguousBreadth'] ?? 0),
Str::lower((string) ($rightSubject['displayName'] ?? '')),
],
};
});
return array_values($rows);
}
/**
* @param list<array<string, mixed>> $tenantSummaries
* @return list<array<string, mixed>>
*/
private function sortTenantSummaries(array $tenantSummaries, string $sort): array
{
usort($tenantSummaries, function (array $left, array $right) use ($sort): int {
return match ($sort) {
'deviation_count' => [
-1 * ((int) ($left['differingCount'] ?? 0) + (int) ($left['missingCount'] ?? 0) + (int) ($left['ambiguousCount'] ?? 0)),
Str::lower((string) ($left['tenantName'] ?? '')),
] <=> [
-1 * ((int) ($right['differingCount'] ?? 0) + (int) ($right['missingCount'] ?? 0) + (int) ($right['ambiguousCount'] ?? 0)),
Str::lower((string) ($right['tenantName'] ?? '')),
],
'freshness_urgency' => [
-1 * $this->freshnessRank((string) ($left['freshnessState'] ?? 'unknown')),
Str::lower((string) ($left['tenantName'] ?? '')),
] <=> [
-1 * $this->freshnessRank((string) ($right['freshnessState'] ?? 'unknown')),
Str::lower((string) ($right['tenantName'] ?? '')),
],
default => Str::lower((string) ($left['tenantName'] ?? '')) <=> Str::lower((string) ($right['tenantName'] ?? '')),
};
});
return array_values($tenantSummaries);
}
private function freshnessRank(string $freshnessState): int
{
return match ($freshnessState) {
'stale' => 4,
'unknown' => 3,
'never_compared' => 2,
'fresh' => 1,
default => 0,
};
}
/**
* @param list<array<string, mixed>> $cells
* @param list<array<string, mixed>> $tenantSummaries
* @return list<array<string, mixed>>
*/
private function sortCellsForTenants(array $cells, array $tenantSummaries): array
{
$order = collect($tenantSummaries)
->values()
->mapWithKeys(static fn (array $summary, int $index): array => [
(int) ($summary['tenantId'] ?? 0) => $index,
]);
usort($cells, static fn (array $left, array $right): int => ($order[(int) ($left['tenantId'] ?? 0)] ?? 9999) <=> ($order[(int) ($right['tenantId'] ?? 0)] ?? 9999));
return array_values($cells);
}
/**
* @param list<array<string, mixed>> $rows
* @param list<array<string, mixed>> $tenantSummaries
* @return list<array<string, mixed>>
*/
private function compactResults(array $rows, array $tenantSummaries): array
{
if (count($tenantSummaries) !== 1) {
return [];
}
$tenantSummary = $tenantSummaries[0];
$tenantId = (int) ($tenantSummary['tenantId'] ?? 0);
if ($tenantId <= 0) {
return [];
}
return array_values(array_map(function (array $row) use ($tenantId, $tenantSummary): array {
$subject = is_array($row['subject'] ?? null) ? $row['subject'] : [];
$cell = collect($row['cells'] ?? [])->firstWhere('tenantId', $tenantId) ?? [];
return [
'tenantId' => $tenantId,
'tenantName' => (string) ($tenantSummary['tenantName'] ?? 'Tenant'),
'subjectKey' => (string) ($subject['subjectKey'] ?? ''),
'displayName' => $subject['displayName'] ?? $subject['subjectKey'] ?? 'Subject',
'policyType' => (string) ($subject['policyType'] ?? ''),
'baselineExternalId' => $subject['baselineExternalId'] ?? null,
'deviationBreadth' => (int) ($subject['deviationBreadth'] ?? 0),
'missingBreadth' => (int) ($subject['missingBreadth'] ?? 0),
'ambiguousBreadth' => (int) ($subject['ambiguousBreadth'] ?? 0),
'state' => (string) ($cell['state'] ?? 'not_compared'),
'freshnessState' => (string) ($cell['freshnessState'] ?? 'unknown'),
'trustLevel' => (string) ($cell['trustLevel'] ?? 'unusable'),
'attentionLevel' => (string) ($cell['attentionLevel'] ?? 'review'),
'severity' => $cell['severity'] ?? null,
'reasonSummary' => $cell['reasonSummary'] ?? null,
'reasonCode' => $cell['reasonCode'] ?? null,
'compareRunId' => $cell['compareRunId'] ?? null,
'findingId' => $cell['findingId'] ?? null,
'lastComparedAt' => $cell['lastComparedAt'] ?? null,
'policyTypeCovered' => $cell['policyTypeCovered'] ?? true,
];
}, $rows));
}
/**
* @param array<string, mixed> $reference
* @return array{title: string, body: string}|null
*/
private function emptyState(
array $reference,
int $snapshotItemsCount,
int $visibleTenantsCount,
int $renderedRowsCount = 0,
): ?array {
if (($reference['referenceState'] ?? null) !== 'ready') {
return [
'title' => 'No usable reference snapshot',
'body' => 'Capture a complete baseline snapshot before using the compare matrix.',
];
}
if ((int) ($reference['assignedTenantCount'] ?? 0) === 0) {
return [
'title' => 'No assigned tenants',
'body' => 'Assign tenants to this baseline profile to build the visible compare set.',
];
}
if ($visibleTenantsCount === 0) {
return [
'title' => 'No visible assigned tenants',
'body' => 'This baseline has assigned tenants, but none are visible in your current tenant scope.',
];
}
if ($snapshotItemsCount === 0) {
return [
'title' => 'No baseline subjects',
'body' => 'The active reference snapshot completed without any baseline subjects to compare.',
];
}
if ($renderedRowsCount === 0) {
return [
'title' => 'No rows match the current filters',
'body' => 'Adjust the policy type, state, or severity filters to broaden the matrix view.',
];
}
return null;
}
/**
* @param array<int, string> $values
* @return list<array<string, mixed>>
*/
private function legendSpecs(BadgeDomain $domain, array $values): array
{
return array_map(
static function (string $value) use ($domain): array {
$spec = BadgeCatalog::spec($domain, $value);
return [
'value' => $value,
'label' => $spec->label,
'color' => $spec->color,
'icon' => $spec->icon,
'iconColor' => $spec->iconColor,
];
},
$values,
);
}
}