TenantAtlas/app/Support/Inventory/InventoryCoverage.php
ahmido 7620144ab6 Spec 116: Baseline drift engine v1 (meta fidelity + coverage guard) (#141)
Implements Spec 116 baseline drift engine v1 (meta fidelity) with coverage guard, stable finding identity, and Filament UI surfaces.

Highlights
- Baseline capture/compare jobs and supporting services (meta contract hashing via InventoryMetaContract + DriftHasher)
- Coverage proof parsing + compare partial outcome behavior
- Filament pages/resources/widgets for baseline compare + drift landing improvements
- Pest tests for capture/compare/coverage guard and UI start surfaces
- Research report: docs/research/golden-master-baseline-drift-deep-analysis.md

Validation
- `vendor/bin/sail bin pint --dirty`
- `vendor/bin/sail artisan test --compact --filter="Baseline"`

Notes
- No destructive user actions added; compare/capture remain queued jobs.
- Provider registration unchanged (Laravel 11+/12 uses bootstrap/providers.php for panel providers; not touched here).

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #141
2026-03-02 22:02:58 +00:00

173 lines
4.7 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Inventory;
final readonly class InventoryCoverage
{
public const string StatusSucceeded = 'succeeded';
public const string StatusFailed = 'failed';
public const string StatusSkipped = 'skipped';
/**
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $policyTypes
* @param array<string, array{status: string, item_count?: int, error_code?: string|null}> $foundationTypes
*/
public function __construct(
public array $policyTypes,
public array $foundationTypes,
) {}
public static function fromContext(mixed $context): ?self
{
if (! is_array($context)) {
return null;
}
$inventory = $context['inventory'] ?? null;
if (! is_array($inventory)) {
return null;
}
$coverage = $inventory['coverage'] ?? null;
if (! is_array($coverage)) {
return null;
}
$policyTypes = self::normalizeCoverageMap($coverage['policy_types'] ?? null);
$foundationTypes = self::normalizeCoverageMap($coverage['foundation_types'] ?? null);
if ($policyTypes === [] && $foundationTypes === []) {
return null;
}
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function coveredTypes(): array
{
$covered = [];
foreach (array_merge($this->policyTypes, $this->foundationTypes) as $type => $meta) {
if (($meta['status'] ?? null) === self::StatusSucceeded) {
$covered[] = $type;
}
}
sort($covered, SORT_STRING);
return array_values(array_unique($covered));
}
/**
* Build the canonical `inventory.coverage.*` payload for OperationRun.context.
*
* @param array<string, string> $statusByType
* @param list<string> $foundationTypes
* @return array{policy_types: array<string, array{status: string}>, foundation_types: array<string, array{status: string}>}
*/
public static function buildPayload(array $statusByType, array $foundationTypes): array
{
$foundationTypes = array_values(array_unique(array_filter($foundationTypes, fn (mixed $type): bool => is_string($type) && $type !== '')));
$foundationLookup = array_fill_keys($foundationTypes, true);
$policy = [];
$foundations = [];
foreach ($statusByType as $type => $status) {
if (! is_string($type) || $type === '') {
continue;
}
$normalizedStatus = self::normalizeStatus($status);
if ($normalizedStatus === null) {
continue;
}
$row = ['status' => $normalizedStatus];
if (array_key_exists($type, $foundationLookup)) {
$foundations[$type] = $row;
continue;
}
$policy[$type] = $row;
}
ksort($policy);
ksort($foundations);
return [
'policy_types' => $policy,
'foundation_types' => $foundations,
];
}
private static function normalizeStatus(mixed $status): ?string
{
if (! is_string($status)) {
return null;
}
return match ($status) {
self::StatusSucceeded, self::StatusFailed, self::StatusSkipped => $status,
default => null,
};
}
/**
* @return array<string, array{status: string, item_count?: int, error_code?: string|null}>
*/
private static function normalizeCoverageMap(mixed $value): array
{
if (! is_array($value)) {
return [];
}
$normalized = [];
foreach ($value as $type => $meta) {
if (! is_string($type) || $type === '') {
continue;
}
if (! is_array($meta)) {
continue;
}
$status = self::normalizeStatus($meta['status'] ?? null);
if ($status === null) {
continue;
}
$row = ['status' => $status];
if (array_key_exists('item_count', $meta) && is_int($meta['item_count'])) {
$row['item_count'] = $meta['item_count'];
}
if (array_key_exists('error_code', $meta) && (is_string($meta['error_code']) || $meta['error_code'] === null)) {
$row['error_code'] = $meta['error_code'];
}
$normalized[$type] = $row;
}
ksort($normalized);
return $normalized;
}
}