, * detail_state: string, * recorded_subjects_total: int, * missing_detail_count: int * }, * buckets: list * }> * } */ 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 $context * @return array{ * summary: array{ * count: int, * by_reason: array, * detail_state: string, * recorded_subjects_total: int, * missing_detail_count: int * }, * buckets: list * }> * } */ 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 $baselineCompare * @return array{ * summary: array{ * count: int, * by_reason: array, * detail_state: string, * recorded_subjects_total: int, * missing_detail_count: int * }, * buckets: list * }> * } */ 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 $baselineCompare * @return array */ 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 $byReason * @return list */ 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> $buckets * @return list */ 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> $rows * @return array */ 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> $rows * @return array */ 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, * detail_state: string, * recorded_subjects_total: int, * missing_detail_count: int * }, * buckets: list * }> * } */ 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 */ 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> */ 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 $byReason * @param array> $subjects * @return list */ 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 $byReason * @param array> $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> $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 $subjects * @return list */ 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; } }