TenantAtlas/app/Support/Baselines/BaselineCompareEvidenceGapDetails.php
2026-03-24 20:04:41 +01:00

558 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
use App\Models\OperationRun;
use Illuminate\Support\Str;
final class BaselineCompareEvidenceGapDetails
{
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromOperationRun(?OperationRun $run): array
{
if (! $run instanceof OperationRun || ! is_array($run->context)) {
return self::empty();
}
return self::fromContext($run->context);
}
/**
* @param array<string, mixed> $context
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromContext(array $context): array
{
$baselineCompare = $context['baseline_compare'] ?? null;
if (! is_array($baselineCompare)) {
return self::empty();
}
return self::fromBaselineCompare($baselineCompare);
}
/**
* @param array<string, mixed> $baselineCompare
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
public static function fromBaselineCompare(array $baselineCompare): array
{
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
$subjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
foreach ($subjects as $reason => $keys) {
if (! array_key_exists($reason, $byReason)) {
$byReason[$reason] = count($keys);
}
}
$count = self::normalizeTotalCount($evidenceGaps['count'] ?? null, $byReason, $subjects);
$detailState = self::detailState($count, $subjects);
$buckets = [];
foreach (self::orderedReasons($byReason, $subjects) as $reason) {
$rows = self::rowsForReason($reason, $subjects[$reason] ?? []);
$reasonCount = $byReason[$reason] ?? count($rows);
if ($reasonCount <= 0 && $rows === []) {
continue;
}
$recordedCount = count($rows);
$searchText = trim(implode(' ', array_filter([
Str::lower($reason),
Str::lower(self::reasonLabel($reason)),
...array_map(
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
$rows,
),
])));
$buckets[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $reasonCount,
'recorded_count' => $recordedCount,
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
'detail_state' => $recordedCount > 0 ? 'details_recorded' : 'details_not_recorded',
'search_text' => $searchText,
'rows' => $rows,
];
}
$recordedSubjectsTotal = array_sum(array_map(
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
$buckets,
));
return [
'summary' => [
'count' => $count,
'by_reason' => $byReason,
'detail_state' => $detailState,
'recorded_subjects_total' => $recordedSubjectsTotal,
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
],
'buckets' => $buckets,
];
}
/**
* @param array<string, mixed> $baselineCompare
* @return array<string, mixed>
*/
public static function diagnosticsPayload(array $baselineCompare): array
{
return array_filter([
'reason_code' => self::stringOrNull($baselineCompare['reason_code'] ?? null),
'subjects_total' => self::intOrNull($baselineCompare['subjects_total'] ?? null),
'resume_token' => self::stringOrNull($baselineCompare['resume_token'] ?? null),
'fidelity' => self::stringOrNull($baselineCompare['fidelity'] ?? null),
'coverage' => is_array($baselineCompare['coverage'] ?? null) ? $baselineCompare['coverage'] : null,
'evidence_capture' => is_array($baselineCompare['evidence_capture'] ?? null) ? $baselineCompare['evidence_capture'] : null,
'evidence_gaps' => is_array($baselineCompare['evidence_gaps'] ?? null) ? $baselineCompare['evidence_gaps'] : null,
], static fn (mixed $value): bool => $value !== null && $value !== []);
}
public static function reasonLabel(string $reason): string
{
$reason = trim($reason);
return match ($reason) {
'ambiguous_match' => 'Ambiguous inventory match',
'policy_not_found' => 'Policy not found',
'missing_current' => 'Missing current evidence',
'invalid_subject' => 'Invalid subject',
'duplicate_subject' => 'Duplicate subject',
'capture_failed' => 'Evidence capture failed',
'budget_exhausted' => 'Capture budget exhausted',
'throttled' => 'Graph throttled',
'missing_role_definition_baseline_version_reference' => 'Missing baseline role definition evidence',
'missing_role_definition_current_version_reference' => 'Missing current role definition evidence',
'missing_role_definition_compare_surface' => 'Missing role definition compare surface',
'rollout_disabled' => 'Rollout disabled',
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
};
}
/**
* @param array<string, int> $byReason
* @return list<array{reason_code: string, reason_label: string, count: int}>
*/
public static function topReasons(array $byReason, int $limit = 5): array
{
$normalized = self::normalizeCounts($byReason);
arsort($normalized);
return array_map(
static fn (string $reason, int $count): array => [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'count' => $count,
],
array_slice(array_keys($normalized), 0, $limit),
array_slice(array_values($normalized), 0, $limit),
);
}
/**
* @param list<array<string, mixed>> $buckets
* @return list<array{
* __id: string,
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
*/
public static function tableRows(array $buckets): array
{
$rows = [];
foreach ($buckets as $bucket) {
if (! is_array($bucket)) {
continue;
}
$bucketRows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
foreach ($bucketRows as $row) {
if (! is_array($row)) {
continue;
}
$reasonCode = self::stringOrNull($row['reason_code'] ?? null);
$reasonLabel = self::stringOrNull($row['reason_label'] ?? null);
$policyType = self::stringOrNull($row['policy_type'] ?? null);
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
if ($reasonCode === null || $reasonLabel === null || $policyType === null || $subjectKey === null) {
continue;
}
$rows[] = [
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey])),
'reason_code' => $reasonCode,
'reason_label' => $reasonLabel,
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reasonCode,
$reasonLabel,
$policyType,
$subjectKey,
])),
];
}
}
return $rows;
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function reasonFilterOptions(array $rows): array
{
return collect($rows)
->filter(fn (array $row): bool => filled($row['reason_code'] ?? null) && filled($row['reason_label'] ?? null))
->mapWithKeys(fn (array $row): array => [
(string) $row['reason_code'] => (string) $row['reason_label'],
])
->sortBy(fn (string $label): string => Str::lower($label))
->all();
}
/**
* @param list<array<string, mixed>> $rows
* @return array<string, string>
*/
public static function policyTypeFilterOptions(array $rows): array
{
return collect($rows)
->pluck('policy_type')
->filter(fn (mixed $value): bool => is_string($value) && trim($value) !== '')
->mapWithKeys(fn (string $value): array => [$value => $value])
->sortKeysUsing('strnatcasecmp')
->all();
}
/**
* @return array{
* summary: array{
* count: int,
* by_reason: array<string, int>,
* detail_state: string,
* recorded_subjects_total: int,
* missing_detail_count: int
* },
* buckets: list<array{
* reason_code: string,
* reason_label: string,
* count: int,
* recorded_count: int,
* missing_detail_count: int,
* detail_state: string,
* search_text: string,
* rows: list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
* }>
* }
*/
private static function empty(): array
{
return [
'summary' => [
'count' => 0,
'by_reason' => [],
'detail_state' => 'no_gaps',
'recorded_subjects_total' => 0,
'missing_detail_count' => 0,
],
'buckets' => [],
];
}
/**
* @return array<string, int>
*/
private static function normalizeCounts(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $count) {
if (! is_string($reason) || trim($reason) === '' || ! is_numeric($count)) {
continue;
}
$intCount = (int) $count;
if ($intCount <= 0) {
continue;
}
$normalized[trim($reason)] = $intCount;
}
arsort($normalized);
return $normalized;
}
/**
* @return array<string, list<string>>
*/
private static function normalizeSubjects(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $reason => $keys) {
if (! is_string($reason) || trim($reason) === '' || ! is_array($keys)) {
continue;
}
$items = array_values(array_unique(array_filter(array_map(
static fn (mixed $item): ?string => is_string($item) && trim($item) !== '' ? trim($item) : null,
$keys,
))));
if ($items === []) {
continue;
}
$normalized[trim($reason)] = $items;
}
ksort($normalized);
return $normalized;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
* @return list<string>
*/
private static function orderedReasons(array $byReason, array $subjects): array
{
$reasons = array_keys($byReason);
foreach (array_keys($subjects) as $reason) {
if (! in_array($reason, $reasons, true)) {
$reasons[] = $reason;
}
}
return $reasons;
}
/**
* @param array<string, int> $byReason
* @param array<string, list<string>> $subjects
*/
private static function normalizeTotalCount(mixed $count, array $byReason, array $subjects): int
{
if (is_numeric($count)) {
$intCount = (int) $count;
if ($intCount >= 0) {
return $intCount;
}
}
$byReasonCount = array_sum($byReason);
if ($byReasonCount > 0) {
return $byReasonCount;
}
return array_sum(array_map(
static fn (array $keys): int => count($keys),
$subjects,
));
}
/**
* @param array<string, list<string>> $subjects
*/
private static function detailState(int $count, array $subjects): string
{
if ($count <= 0) {
return 'no_gaps';
}
return $subjects !== [] ? 'details_recorded' : 'details_not_recorded';
}
/**
* @param list<string> $subjects
* @return list<array{
* reason_code: string,
* reason_label: string,
* policy_type: string,
* subject_key: string,
* search_text: string
* }>
*/
private static function rowsForReason(string $reason, array $subjects): array
{
$rows = [];
foreach ($subjects as $subject) {
[$policyType, $subjectKey] = self::splitSubject($subject);
if ($policyType === null || $subjectKey === null) {
continue;
}
$rows[] = [
'reason_code' => $reason,
'reason_label' => self::reasonLabel($reason),
'policy_type' => $policyType,
'subject_key' => $subjectKey,
'search_text' => Str::lower(implode(' ', [
$reason,
self::reasonLabel($reason),
$policyType,
$subjectKey,
])),
];
}
return $rows;
}
/**
* @return array{0: ?string, 1: ?string}
*/
private static function splitSubject(string $subject): array
{
$parts = explode('|', $subject, 2);
if (count($parts) !== 2) {
return [null, null];
}
$policyType = trim($parts[0]);
$subjectKey = trim($parts[1]);
if ($policyType === '' || $subjectKey === '') {
return [null, null];
}
return [$policyType, $subjectKey];
}
private static function stringOrNull(mixed $value): ?string
{
if (! is_string($value)) {
return null;
}
$value = trim($value);
return $value !== '' ? $value : null;
}
private static function intOrNull(mixed $value): ?int
{
return is_numeric($value) ? (int) $value : null;
}
}