Some checks failed
PR Fast Feedback / fast-feedback (pull_request) Failing after 4m7s
Implemented deterministic Baseline Result Semantics (Spec 383), introducing CompareSubjectResult and CompareEvidenceResult. Replaced generic arrays with strict Data Transfer Objects for Baseline engine output.
767 lines
30 KiB
PHP
767 lines
30 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Baselines;
|
|
|
|
use App\Models\OperationRun;
|
|
use App\Support\Governance\PlatformSubjectDescriptorNormalizer;
|
|
use Illuminate\Support\Str;
|
|
|
|
final class BaselineCompareEvidenceGapDetails
|
|
{
|
|
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
|
|
*/
|
|
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
|
|
*/
|
|
public static function fromBaselineCompare(array $baselineCompare): array
|
|
{
|
|
$evidenceGaps = $baselineCompare['evidence_gaps'] ?? null;
|
|
$evidenceGaps = is_array($evidenceGaps) ? $evidenceGaps : [];
|
|
|
|
$byReason = self::normalizeCounts($evidenceGaps['by_reason'] ?? null);
|
|
$normalizedSubjects = self::normalizeSubjects($evidenceGaps['subjects'] ?? null);
|
|
|
|
foreach ($normalizedSubjects['subjects'] as $reasonCode => $subjects) {
|
|
if (! array_key_exists($reasonCode, $byReason)) {
|
|
$byReason[$reasonCode] = count($subjects);
|
|
}
|
|
}
|
|
|
|
$count = self::normalizeTotalCount(
|
|
$evidenceGaps['count'] ?? null,
|
|
$byReason,
|
|
$normalizedSubjects['subjects'],
|
|
);
|
|
$detailState = self::detailState($count, $normalizedSubjects);
|
|
$buckets = [];
|
|
|
|
foreach (self::orderedReasons($byReason, $normalizedSubjects['subjects']) as $reasonCode) {
|
|
$rows = $detailState === 'structured_details_recorded'
|
|
? array_map(
|
|
static fn (array $subject): array => self::projectSubjectRow($subject),
|
|
$normalizedSubjects['subjects'][$reasonCode] ?? [],
|
|
)
|
|
: [];
|
|
$reasonCount = $byReason[$reasonCode] ?? count($rows);
|
|
|
|
if ($reasonCount <= 0 && $rows === []) {
|
|
continue;
|
|
}
|
|
|
|
$recordedCount = count($rows);
|
|
$structuralCount = count(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => (bool) ($row['structural'] ?? false),
|
|
));
|
|
$transientCount = count(array_filter(
|
|
$rows,
|
|
static fn (array $row): bool => (bool) ($row['retryable'] ?? false),
|
|
));
|
|
$operationalCount = max(0, $recordedCount - $structuralCount - $transientCount);
|
|
|
|
$searchText = trim(implode(' ', array_filter([
|
|
Str::lower($reasonCode),
|
|
Str::lower(self::reasonLabel($reasonCode)),
|
|
...array_map(
|
|
static fn (array $row): string => (string) ($row['search_text'] ?? ''),
|
|
$rows,
|
|
),
|
|
])));
|
|
|
|
$buckets[] = [
|
|
'reason_code' => $reasonCode,
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'count' => $reasonCount,
|
|
'recorded_count' => $recordedCount,
|
|
'missing_detail_count' => max(0, $reasonCount - $recordedCount),
|
|
'structural_count' => $structuralCount,
|
|
'operational_count' => $operationalCount,
|
|
'transient_count' => $transientCount,
|
|
'detail_state' => self::bucketDetailState($detailState, $recordedCount),
|
|
'search_text' => $searchText,
|
|
'rows' => $rows,
|
|
];
|
|
}
|
|
|
|
$recordedSubjectsTotal = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['recorded_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$structuralCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['structural_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$operationalCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['operational_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$transientCount = array_sum(array_map(
|
|
static fn (array $bucket): int => (int) ($bucket['transient_count'] ?? 0),
|
|
$buckets,
|
|
));
|
|
$legacyMode = $detailState === 'legacy_broad_reason';
|
|
|
|
return [
|
|
'summary' => [
|
|
'count' => $count,
|
|
'by_reason' => $byReason,
|
|
'detail_state' => $detailState,
|
|
'recorded_subjects_total' => $recordedSubjectsTotal,
|
|
'missing_detail_count' => max(0, $count - $recordedSubjectsTotal),
|
|
'structural_count' => $structuralCount,
|
|
'operational_count' => $operationalCount,
|
|
'transient_count' => $transientCount,
|
|
'legacy_mode' => $legacyMode,
|
|
'requires_regeneration' => $legacyMode,
|
|
],
|
|
'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),
|
|
'strategy' => is_array($baselineCompare['strategy'] ?? null) ? $baselineCompare['strategy'] : 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,
|
|
'result_semantics' => is_array($baselineCompare['result_semantics'] ?? null) ? $baselineCompare['result_semantics'] : null,
|
|
], static fn (mixed $value): bool => $value !== null && $value !== []);
|
|
}
|
|
|
|
public static function reasonLabel(string $reason): string
|
|
{
|
|
$reason = trim($reason);
|
|
|
|
return match ($reason) {
|
|
'verified_no_drift' => 'No drift verified',
|
|
'verified_drift_detected' => 'Drift detected',
|
|
'resolved_active_binding' => 'Resolved by active binding',
|
|
'resolved_canonical_identity' => 'Resolved by canonical identity',
|
|
'resolved_provider_identity' => 'Resolved by provider identity',
|
|
'identity_required' => 'Identity required',
|
|
'unresolved_duplicate_candidates' => 'Duplicate provider candidates',
|
|
'unresolved_low_trust_match' => 'Low-trust identity match',
|
|
'unresolved_ambiguous_identity' => 'Ambiguous identity',
|
|
'missing_local_evidence' => 'Missing local evidence',
|
|
'missing_provider_resource' => 'Missing provider resource',
|
|
'unsupported_resource_class' => 'Unsupported resource class',
|
|
'foundation_inventory_only' => 'Foundation inventory only',
|
|
'foundation_identity_only' => 'Foundation identity only',
|
|
'foundation_canonical_only' => 'Foundation canonical only',
|
|
'accepted_limitation' => 'Accepted limitation',
|
|
'excluded_non_governed' => 'Excluded from governance',
|
|
'compare_not_supported' => 'Compare not supported',
|
|
'compare_failed' => 'Compare failed',
|
|
'compare_coverage_incomplete' => 'Compare coverage incomplete',
|
|
'compare_evidence_incomplete' => 'Compare evidence incomplete',
|
|
'invalid_subject' => 'Invalid subject',
|
|
'duplicate_subject' => 'Duplicate subject',
|
|
'capture_failed' => 'Evidence capture failed',
|
|
'retryable_capture_failure' => 'Retryable evidence capture failure',
|
|
'budget_exhausted' => 'Capture budget exhausted',
|
|
'throttled' => 'Graph throttled',
|
|
'invalid_support_config' => 'Invalid support configuration',
|
|
'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',
|
|
'policy_not_found' => 'Legacy policy not found',
|
|
default => Str::of($reason)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
|
};
|
|
}
|
|
|
|
public static function subjectClassLabel(string $subjectClass): string
|
|
{
|
|
return match (trim($subjectClass)) {
|
|
SubjectClass::PolicyBacked->value => 'Policy-backed',
|
|
SubjectClass::InventoryBacked->value => 'Inventory-backed',
|
|
SubjectClass::FoundationBacked->value => 'Foundation-backed',
|
|
default => 'Derived',
|
|
};
|
|
}
|
|
|
|
public static function resolutionOutcomeLabel(string $resolutionOutcome): string
|
|
{
|
|
return match (trim($resolutionOutcome)) {
|
|
ResolutionOutcome::ResolvedPolicy->value => 'Resolved policy',
|
|
ResolutionOutcome::ResolvedInventory->value => 'Resolved inventory',
|
|
ResolutionOutcome::MissingLocalEvidence->value => 'Missing local evidence',
|
|
ResolutionOutcome::MissingProviderResource->value => 'Missing provider resource',
|
|
ResolutionOutcome::FoundationInventoryOnly->value => 'Foundation inventory only',
|
|
ResolutionOutcome::FoundationIdentityOnly->value => 'Foundation identity only',
|
|
ResolutionOutcome::FoundationCanonicalOnly->value => 'Foundation canonical only',
|
|
ResolutionOutcome::UnsupportedResourceClass->value => 'Unsupported resource class',
|
|
ResolutionOutcome::UnresolvedDuplicateCandidates->value => 'Duplicate provider candidates',
|
|
ResolutionOutcome::UnresolvedAmbiguousIdentity->value => 'Ambiguous identity',
|
|
ResolutionOutcome::InvalidSubject->value => 'Invalid subject',
|
|
ResolutionOutcome::DuplicateSubject->value => 'Duplicate subject',
|
|
ResolutionOutcome::InvalidSupportConfig->value => 'Invalid support configuration',
|
|
ResolutionOutcome::Throttled->value => 'Graph throttled',
|
|
ResolutionOutcome::CaptureFailed->value => 'Capture failed',
|
|
ResolutionOutcome::RetryableCaptureFailure->value => 'Retryable capture failure',
|
|
ResolutionOutcome::BudgetExhausted->value => 'Budget exhausted',
|
|
default => Str::of($resolutionOutcome)->replace('_', ' ')->trim()->ucfirst()->toString(),
|
|
};
|
|
}
|
|
|
|
public static function operatorActionCategoryLabel(string $operatorActionCategory): string
|
|
{
|
|
return match (trim($operatorActionCategory)) {
|
|
OperatorActionCategory::Retry->value => 'Retry',
|
|
OperatorActionCategory::RunInventorySync->value => 'Run inventory sync',
|
|
OperatorActionCategory::RunPolicySyncOrBackup->value => 'Run policy sync or backup',
|
|
OperatorActionCategory::ReviewPermissions->value => 'Review permissions',
|
|
OperatorActionCategory::InspectSubjectMapping->value => 'Inspect subject mapping',
|
|
OperatorActionCategory::ProductFollowUp->value => 'Product follow-up',
|
|
default => 'No action',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @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),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, list<string>>
|
|
*/
|
|
public static function subjectReasonsFromOperationRun(?OperationRun $run): array
|
|
{
|
|
$details = self::fromOperationRun($run);
|
|
$buckets = is_array($details['buckets'] ?? null) ? $details['buckets'] : [];
|
|
$reasonMap = [];
|
|
|
|
foreach ($buckets as $bucket) {
|
|
if (! is_array($bucket)) {
|
|
continue;
|
|
}
|
|
|
|
$reasonCode = self::stringOrNull($bucket['reason_code'] ?? null);
|
|
|
|
if ($reasonCode === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows = is_array($bucket['rows'] ?? null) ? $bucket['rows'] : [];
|
|
|
|
foreach ($rows as $row) {
|
|
if (! is_array($row)) {
|
|
continue;
|
|
}
|
|
|
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
|
|
if ($policyType === null || $subjectKey === null) {
|
|
continue;
|
|
}
|
|
|
|
$compositeKey = self::subjectCompositeKey($policyType, $subjectKey);
|
|
$reasonMap[$compositeKey] ??= [];
|
|
$reasonMap[$compositeKey][] = $reasonCode;
|
|
}
|
|
}
|
|
|
|
return array_map(
|
|
static fn (array $reasons): array => array_values(array_unique(array_filter($reasons, 'is_string'))),
|
|
$reasonMap,
|
|
);
|
|
}
|
|
|
|
public static function subjectCompositeKey(string $policyType, string $subjectKey): string
|
|
{
|
|
return trim(mb_strtolower($policyType)).'|'.trim(mb_strtolower($subjectKey));
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $buckets
|
|
* @return list<array<string, mixed>>
|
|
*/
|
|
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);
|
|
$policyType = self::stringOrNull($row['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($row['subject_key'] ?? null);
|
|
$subjectClass = self::stringOrNull($row['subject_class'] ?? null);
|
|
$resolutionOutcome = self::stringOrNull($row['resolution_outcome'] ?? null);
|
|
$operatorActionCategory = self::stringOrNull($row['operator_action_category'] ?? null);
|
|
|
|
if ($reasonCode === null || $policyType === null || $subjectKey === null || $subjectClass === null || $resolutionOutcome === null || $operatorActionCategory === null) {
|
|
continue;
|
|
}
|
|
|
|
$rows[] = [
|
|
'__id' => md5(implode('|', [$reasonCode, $policyType, $subjectKey, $resolutionOutcome])),
|
|
'reason_code' => $reasonCode,
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'policy_type' => $policyType,
|
|
'governed_subject_label' => (string) ($row['governed_subject_label'] ?? self::governedSubjectLabel($policyType)),
|
|
'governed_subject' => is_array($row['governed_subject'] ?? null) ? $row['governed_subject'] : self::subjectDescriptor($policyType),
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => $subjectClass,
|
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
|
'resolution_path' => self::stringOrNull($row['resolution_path'] ?? null),
|
|
'resolution_outcome' => $resolutionOutcome,
|
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
|
'operator_action_category' => $operatorActionCategory,
|
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
|
'structural' => (bool) ($row['structural'] ?? false),
|
|
'retryable' => (bool) ($row['retryable'] ?? false),
|
|
'search_text' => self::stringOrNull($row['search_text'] ?? null) ?? '',
|
|
];
|
|
}
|
|
}
|
|
|
|
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)
|
|
->filter(fn (array $row): bool => filled($row['policy_type'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['policy_type'] => (string) ($row['governed_subject_label'] ?? $row['policy_type']),
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function subjectClassFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['subject_class'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['subject_class'] => (string) ($row['subject_class_label'] ?? self::subjectClassLabel((string) $row['subject_class'])),
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
/**
|
|
* @param list<array<string, mixed>> $rows
|
|
* @return array<string, string>
|
|
*/
|
|
public static function actionCategoryFilterOptions(array $rows): array
|
|
{
|
|
return collect($rows)
|
|
->filter(fn (array $row): bool => filled($row['operator_action_category'] ?? null))
|
|
->mapWithKeys(fn (array $row): array => [
|
|
(string) $row['operator_action_category'] => (string) ($row['operator_action_category_label'] ?? self::operatorActionCategoryLabel((string) $row['operator_action_category'])),
|
|
])
|
|
->sortBy(fn (string $label): string => Str::lower($label))
|
|
->all();
|
|
}
|
|
|
|
private static function empty(): array
|
|
{
|
|
return [
|
|
'summary' => [
|
|
'count' => 0,
|
|
'by_reason' => [],
|
|
'detail_state' => 'no_gaps',
|
|
'recorded_subjects_total' => 0,
|
|
'missing_detail_count' => 0,
|
|
'structural_count' => 0,
|
|
'operational_count' => 0,
|
|
'transient_count' => 0,
|
|
'legacy_mode' => false,
|
|
'requires_regeneration' => false,
|
|
],
|
|
'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{
|
|
* subjects: array<string, list<array<string, mixed>>>,
|
|
* legacy_mode: bool
|
|
* }
|
|
*/
|
|
private static function normalizeSubjects(mixed $value): array
|
|
{
|
|
if ($value === null) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => false,
|
|
];
|
|
}
|
|
|
|
if (! is_array($value)) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
if (! array_is_list($value)) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
$subjects = [];
|
|
|
|
foreach ($value as $item) {
|
|
$normalized = self::normalizeStructuredSubject($item);
|
|
|
|
if ($normalized === null) {
|
|
return [
|
|
'subjects' => [],
|
|
'legacy_mode' => true,
|
|
];
|
|
}
|
|
|
|
$subjects[$normalized['reason_code']][] = $normalized;
|
|
}
|
|
|
|
foreach ($subjects as &$bucket) {
|
|
usort($bucket, static function (array $left, array $right): int {
|
|
return [$left['policy_type'], $left['subject_key'], $left['resolution_outcome']]
|
|
<=> [$right['policy_type'], $right['subject_key'], $right['resolution_outcome']];
|
|
});
|
|
}
|
|
unset($bucket);
|
|
|
|
ksort($subjects);
|
|
|
|
return [
|
|
'subjects' => $subjects,
|
|
'legacy_mode' => false,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>|null
|
|
*/
|
|
private static function normalizeStructuredSubject(mixed $value): ?array
|
|
{
|
|
if (! is_array($value)) {
|
|
return null;
|
|
}
|
|
|
|
$policyType = self::stringOrNull($value['policy_type'] ?? null);
|
|
$subjectKey = self::stringOrNull($value['subject_key'] ?? null);
|
|
$subjectClass = self::stringOrNull($value['subject_class'] ?? null);
|
|
$resolutionPath = self::stringOrNull($value['resolution_path'] ?? null);
|
|
$resolutionOutcome = self::stringOrNull($value['resolution_outcome'] ?? null);
|
|
$reasonCode = self::stringOrNull($value['reason_code'] ?? null);
|
|
$operatorActionCategory = self::stringOrNull($value['operator_action_category'] ?? null);
|
|
|
|
if ($policyType === null
|
|
|| $subjectKey === null
|
|
|| $subjectClass === null
|
|
|| $resolutionPath === null
|
|
|| $resolutionOutcome === null
|
|
|| $reasonCode === null
|
|
|| $operatorActionCategory === null) {
|
|
return null;
|
|
}
|
|
|
|
if (! SubjectClass::tryFrom($subjectClass) instanceof SubjectClass
|
|
|| ! ResolutionPath::tryFrom($resolutionPath) instanceof ResolutionPath
|
|
|| ! ResolutionOutcome::tryFrom($resolutionOutcome) instanceof ResolutionOutcome
|
|
|| ! OperatorActionCategory::tryFrom($operatorActionCategory) instanceof OperatorActionCategory) {
|
|
return null;
|
|
}
|
|
|
|
$sourceModelExpected = self::stringOrNull($value['source_model_expected'] ?? null);
|
|
$sourceModelExpected = in_array($sourceModelExpected, ['policy', 'inventory', 'derived'], true) ? $sourceModelExpected : null;
|
|
|
|
$sourceModelFound = self::stringOrNull($value['source_model_found'] ?? null);
|
|
$sourceModelFound = in_array($sourceModelFound, ['policy', 'inventory', 'derived'], true) ? $sourceModelFound : null;
|
|
|
|
return [
|
|
'policy_type' => $policyType,
|
|
'subject_external_id' => self::stringOrNull($value['subject_external_id'] ?? null),
|
|
'subject_key' => $subjectKey,
|
|
'subject_class' => $subjectClass,
|
|
'resolution_path' => $resolutionPath,
|
|
'resolution_outcome' => $resolutionOutcome,
|
|
'reason_code' => $reasonCode,
|
|
'operator_action_category' => $operatorActionCategory,
|
|
'structural' => self::boolOrFalse($value['structural'] ?? null),
|
|
'retryable' => self::boolOrFalse($value['retryable'] ?? null),
|
|
'source_model_expected' => $sourceModelExpected,
|
|
'source_model_found' => $sourceModelFound,
|
|
'legacy_reason_code' => self::stringOrNull($value['legacy_reason_code'] ?? null),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, int> $byReason
|
|
* @param array<string, list<array<string, mixed>>> $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<array<string, mixed>>> $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 $rows): int => count($rows),
|
|
$subjects,
|
|
));
|
|
}
|
|
|
|
/**
|
|
* @param array{subjects: array<string, list<array<string, mixed>>>, legacy_mode: bool} $subjects
|
|
*/
|
|
private static function detailState(int $count, array $subjects): string
|
|
{
|
|
if ($count <= 0) {
|
|
return 'no_gaps';
|
|
}
|
|
|
|
if ($subjects['legacy_mode']) {
|
|
return 'legacy_broad_reason';
|
|
}
|
|
|
|
return $subjects['subjects'] !== [] ? 'structured_details_recorded' : 'details_not_recorded';
|
|
}
|
|
|
|
private static function bucketDetailState(string $detailState, int $recordedCount): string
|
|
{
|
|
if ($detailState === 'legacy_broad_reason') {
|
|
return 'legacy_broad_reason';
|
|
}
|
|
|
|
if ($recordedCount > 0) {
|
|
return 'structured_details_recorded';
|
|
}
|
|
|
|
return 'details_not_recorded';
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $subject
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function projectSubjectRow(array $subject): array
|
|
{
|
|
$reasonCode = (string) $subject['reason_code'];
|
|
$subjectClass = (string) $subject['subject_class'];
|
|
$resolutionOutcome = (string) $subject['resolution_outcome'];
|
|
$operatorActionCategory = (string) $subject['operator_action_category'];
|
|
$policyType = (string) ($subject['policy_type'] ?? '');
|
|
|
|
return array_merge($subject, [
|
|
'reason_label' => self::reasonLabel($reasonCode),
|
|
'governed_subject' => self::subjectDescriptor($policyType),
|
|
'governed_subject_label' => self::governedSubjectLabel($policyType),
|
|
'subject_class_label' => self::subjectClassLabel($subjectClass),
|
|
'resolution_outcome_label' => self::resolutionOutcomeLabel($resolutionOutcome),
|
|
'operator_action_category_label' => self::operatorActionCategoryLabel($operatorActionCategory),
|
|
'search_text' => Str::lower(trim(implode(' ', array_filter([
|
|
$reasonCode,
|
|
self::reasonLabel($reasonCode),
|
|
$policyType,
|
|
self::governedSubjectLabel($policyType),
|
|
(string) ($subject['subject_key'] ?? ''),
|
|
$subjectClass,
|
|
self::subjectClassLabel($subjectClass),
|
|
(string) ($subject['resolution_path'] ?? ''),
|
|
$resolutionOutcome,
|
|
self::resolutionOutcomeLabel($resolutionOutcome),
|
|
$operatorActionCategory,
|
|
self::operatorActionCategoryLabel($operatorActionCategory),
|
|
(string) ($subject['subject_external_id'] ?? ''),
|
|
])))),
|
|
]);
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function subjectDescriptor(string $policyType): array
|
|
{
|
|
static $cache = [];
|
|
|
|
if (array_key_exists($policyType, $cache)) {
|
|
return $cache[$policyType];
|
|
}
|
|
|
|
$result = app(PlatformSubjectDescriptorNormalizer::class)->fromArray([
|
|
'policy_type' => $policyType,
|
|
], 'baseline_compare');
|
|
|
|
return $cache[$policyType] = $result->descriptor->toArray();
|
|
}
|
|
|
|
private static function governedSubjectLabel(string $policyType): string
|
|
{
|
|
return (string) (data_get(self::subjectDescriptor($policyType), 'display_label') ?: $policyType);
|
|
}
|
|
|
|
private static function stringOrNull(mixed $value): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return null;
|
|
}
|
|
|
|
$trimmed = trim($value);
|
|
|
|
return $trimmed !== '' ? $trimmed : null;
|
|
}
|
|
|
|
private static function intOrNull(mixed $value): ?int
|
|
{
|
|
return is_numeric($value) ? (int) $value : null;
|
|
}
|
|
|
|
private static function boolOrFalse(mixed $value): bool
|
|
{
|
|
if (is_bool($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (is_int($value) || is_float($value) || is_string($value)) {
|
|
return filter_var($value, FILTER_VALIDATE_BOOL);
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|