344 lines
9.9 KiB
PHP
344 lines
9.9 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Verification;
|
|
|
|
use App\Models\OperationRun;
|
|
|
|
final class VerificationReportWriter
|
|
{
|
|
/**
|
|
* Baseline reason code taxonomy (v1).
|
|
*
|
|
* @var array<int, string>
|
|
*/
|
|
private const array BASELINE_REASON_CODES = [
|
|
'ok',
|
|
'not_applicable',
|
|
'missing_configuration',
|
|
'permission_denied',
|
|
'authentication_failed',
|
|
'throttled',
|
|
'dependency_unreachable',
|
|
'invalid_state',
|
|
'unknown_error',
|
|
];
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $checks
|
|
* @param array<string, mixed> $identity
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function write(OperationRun $run, array $checks, array $identity = []): array
|
|
{
|
|
$flow = is_string($run->type) && trim($run->type) !== '' ? (string) $run->type : 'unknown';
|
|
|
|
$report = self::build($flow, $checks, $identity);
|
|
$report = VerificationReportSanitizer::sanitizeReport($report);
|
|
|
|
if (! VerificationReportSchema::isValidReport($report)) {
|
|
$report = VerificationReportSanitizer::sanitizeReport(self::buildFallbackReport($flow));
|
|
}
|
|
|
|
$context = is_array($run->context) ? $run->context : [];
|
|
$context['verification_report'] = $report;
|
|
|
|
$run->update(['context' => $context]);
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array<string, mixed>> $checks
|
|
* @param array<string, mixed> $identity
|
|
* @return array<string, mixed>
|
|
*/
|
|
public static function build(string $flow, array $checks, array $identity = []): array
|
|
{
|
|
$flow = trim($flow);
|
|
$flow = $flow !== '' ? $flow : 'unknown';
|
|
|
|
$normalizedChecks = [];
|
|
|
|
foreach ($checks as $check) {
|
|
if (! is_array($check)) {
|
|
continue;
|
|
}
|
|
|
|
$normalizedChecks[] = self::normalizeCheckResult($check);
|
|
}
|
|
|
|
$counts = self::deriveCounts($normalizedChecks);
|
|
|
|
$report = [
|
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
|
'flow' => $flow,
|
|
'generated_at' => now()->toISOString(),
|
|
'summary' => [
|
|
'overall' => self::deriveOverall($normalizedChecks, $counts),
|
|
'counts' => $counts,
|
|
],
|
|
'checks' => $normalizedChecks,
|
|
];
|
|
|
|
if ($identity !== []) {
|
|
$report['identity'] = $identity;
|
|
}
|
|
|
|
return $report;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
private static function buildFallbackReport(string $flow): array
|
|
{
|
|
return [
|
|
'schema_version' => VerificationReportSchema::CURRENT_SCHEMA_VERSION,
|
|
'flow' => $flow !== '' ? $flow : 'unknown',
|
|
'generated_at' => now()->toISOString(),
|
|
'summary' => [
|
|
'overall' => VerificationReportOverall::NeedsAttention->value,
|
|
'counts' => [
|
|
'total' => 0,
|
|
'pass' => 0,
|
|
'fail' => 0,
|
|
'warn' => 0,
|
|
'skip' => 0,
|
|
'running' => 0,
|
|
],
|
|
],
|
|
'checks' => [],
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @param array<string, mixed> $check
|
|
* @return array{
|
|
* key: string,
|
|
* title: string,
|
|
* status: string,
|
|
* severity: string,
|
|
* blocking: bool,
|
|
* reason_code: string,
|
|
* message: string,
|
|
* evidence: array<int, array{kind: string, value: int|string}>,
|
|
* next_steps: array<int, array{label: string, url: string}>
|
|
* }
|
|
*/
|
|
private static function normalizeCheckResult(array $check): array
|
|
{
|
|
$key = self::normalizeNonEmptyString($check['key'] ?? null, fallback: 'unknown_check');
|
|
$title = self::normalizeNonEmptyString($check['title'] ?? null, fallback: 'Check');
|
|
|
|
return [
|
|
'key' => $key,
|
|
'title' => $title,
|
|
'status' => self::normalizeCheckStatus($check['status'] ?? null),
|
|
'severity' => self::normalizeCheckSeverity($check['severity'] ?? null),
|
|
'blocking' => is_bool($check['blocking'] ?? null) ? (bool) $check['blocking'] : false,
|
|
'reason_code' => self::normalizeReasonCode($check['reason_code'] ?? null),
|
|
'message' => self::normalizeNonEmptyString($check['message'] ?? null, fallback: '—'),
|
|
'evidence' => self::normalizeEvidence($check['evidence'] ?? null),
|
|
'next_steps' => self::normalizeNextSteps($check['next_steps'] ?? null),
|
|
];
|
|
}
|
|
|
|
private static function normalizeCheckStatus(mixed $status): string
|
|
{
|
|
if (! is_string($status)) {
|
|
return VerificationCheckStatus::Fail->value;
|
|
}
|
|
|
|
$status = strtolower(trim($status));
|
|
|
|
return in_array($status, VerificationCheckStatus::values(), true)
|
|
? $status
|
|
: VerificationCheckStatus::Fail->value;
|
|
}
|
|
|
|
private static function normalizeCheckSeverity(mixed $severity): string
|
|
{
|
|
if (! is_string($severity)) {
|
|
return VerificationCheckSeverity::Info->value;
|
|
}
|
|
|
|
$severity = strtolower(trim($severity));
|
|
|
|
return in_array($severity, VerificationCheckSeverity::values(), true)
|
|
? $severity
|
|
: VerificationCheckSeverity::Info->value;
|
|
}
|
|
|
|
private static function normalizeReasonCode(mixed $reasonCode): string
|
|
{
|
|
if (! is_string($reasonCode)) {
|
|
return 'unknown_error';
|
|
}
|
|
|
|
$reasonCode = strtolower(trim($reasonCode));
|
|
|
|
if ($reasonCode === '') {
|
|
return 'unknown_error';
|
|
}
|
|
|
|
if (str_starts_with($reasonCode, 'ext.')) {
|
|
return $reasonCode;
|
|
}
|
|
|
|
$reasonCode = match ($reasonCode) {
|
|
'graph_throttled' => 'throttled',
|
|
'graph_timeout', 'provider_outage' => 'dependency_unreachable',
|
|
'provider_auth_failed' => 'authentication_failed',
|
|
'validation_error', 'conflict_detected' => 'invalid_state',
|
|
'unknown' => 'unknown_error',
|
|
default => $reasonCode,
|
|
};
|
|
|
|
return in_array($reasonCode, self::BASELINE_REASON_CODES, true) ? $reasonCode : 'unknown_error';
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{kind: string, value: int|string}>
|
|
*/
|
|
private static function normalizeEvidence(mixed $evidence): array
|
|
{
|
|
if (! is_array($evidence)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($evidence as $pointer) {
|
|
if (! is_array($pointer)) {
|
|
continue;
|
|
}
|
|
|
|
$kind = self::normalizeNonEmptyString($pointer['kind'] ?? null, fallback: null);
|
|
$value = $pointer['value'] ?? null;
|
|
|
|
if ($kind === null) {
|
|
continue;
|
|
}
|
|
|
|
if (! is_int($value) && ! is_string($value)) {
|
|
continue;
|
|
}
|
|
|
|
if (is_string($value) && trim($value) === '') {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'kind' => $kind,
|
|
'value' => is_int($value) ? $value : trim($value),
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array{label: string, url: string}>
|
|
*/
|
|
private static function normalizeNextSteps(mixed $steps): array
|
|
{
|
|
if (! is_array($steps)) {
|
|
return [];
|
|
}
|
|
|
|
$normalized = [];
|
|
|
|
foreach ($steps as $step) {
|
|
if (! is_array($step)) {
|
|
continue;
|
|
}
|
|
|
|
$label = self::normalizeNonEmptyString($step['label'] ?? null, fallback: null);
|
|
$url = self::normalizeNonEmptyString($step['url'] ?? null, fallback: null);
|
|
|
|
if ($label === null || $url === null) {
|
|
continue;
|
|
}
|
|
|
|
$normalized[] = [
|
|
'label' => $label,
|
|
'url' => $url,
|
|
];
|
|
}
|
|
|
|
return $normalized;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
|
* @return array{total: int, pass: int, fail: int, warn: int, skip: int, running: int}
|
|
*/
|
|
private static function deriveCounts(array $checks): array
|
|
{
|
|
$counts = [
|
|
'total' => count($checks),
|
|
'pass' => 0,
|
|
'fail' => 0,
|
|
'warn' => 0,
|
|
'skip' => 0,
|
|
'running' => 0,
|
|
];
|
|
|
|
foreach ($checks as $check) {
|
|
$status = $check['status'] ?? null;
|
|
|
|
if (! is_string($status) || ! array_key_exists($status, $counts)) {
|
|
continue;
|
|
}
|
|
|
|
$counts[$status] += 1;
|
|
}
|
|
|
|
return $counts;
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{status: string, blocking: bool}> $checks
|
|
* @param array{total: int, pass: int, fail: int, warn: int, skip: int, running: int} $counts
|
|
*/
|
|
private static function deriveOverall(array $checks, array $counts): string
|
|
{
|
|
if (($counts['running'] ?? 0) > 0) {
|
|
return VerificationReportOverall::Running->value;
|
|
}
|
|
|
|
if (($counts['total'] ?? 0) === 0) {
|
|
return VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
foreach ($checks as $check) {
|
|
if (($check['status'] ?? null) === VerificationCheckStatus::Fail->value && ($check['blocking'] ?? false) === true) {
|
|
return VerificationReportOverall::Blocked->value;
|
|
}
|
|
}
|
|
|
|
if (($counts['fail'] ?? 0) > 0 || ($counts['warn'] ?? 0) > 0) {
|
|
return VerificationReportOverall::NeedsAttention->value;
|
|
}
|
|
|
|
return VerificationReportOverall::Ready->value;
|
|
}
|
|
|
|
private static function normalizeNonEmptyString(mixed $value, ?string $fallback): ?string
|
|
{
|
|
if (! is_string($value)) {
|
|
return $fallback;
|
|
}
|
|
|
|
$value = trim($value);
|
|
|
|
if ($value === '') {
|
|
return $fallback;
|
|
}
|
|
|
|
return $value;
|
|
}
|
|
}
|