TenantAtlas/app/Support/Baselines/BaselineScope.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

222 lines
6.5 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Baselines;
/**
* Value object for baseline scope resolution.
*
* A scope defines which policy types are included in a baseline profile.
*
* Spec 116 semantics:
* - Empty policy_types means "all supported policy types" (excluding foundations).
* - Empty foundation_types means "none".
*/
final class BaselineScope
{
/**
* @param array<string> $policyTypes
* @param array<string> $foundationTypes
*/
public function __construct(
public readonly array $policyTypes = [],
public readonly array $foundationTypes = [],
) {}
/**
* Create from the scope_jsonb column value.
*
* @param array<string, mixed>|null $scopeJsonb
*/
public static function fromJsonb(?array $scopeJsonb): self
{
if ($scopeJsonb === null) {
return new self;
}
$policyTypes = $scopeJsonb['policy_types'] ?? [];
$foundationTypes = $scopeJsonb['foundation_types'] ?? [];
return new self(
policyTypes: is_array($policyTypes) ? array_values(array_filter($policyTypes, 'is_string')) : [],
foundationTypes: is_array($foundationTypes) ? array_values(array_filter($foundationTypes, 'is_string')) : [],
);
}
/**
* Normalize the effective scope by intersecting profile scope with an optional override.
*
* Override can only narrow the profile scope (subset enforcement).
* Empty override means "no override".
*/
public static function effective(self $profileScope, ?self $overrideScope): self
{
$profileScope = $profileScope->expandDefaults();
if ($overrideScope === null || $overrideScope->isEmpty()) {
return $profileScope;
}
$overridePolicyTypes = self::normalizePolicyTypes($overrideScope->policyTypes);
$overrideFoundationTypes = self::normalizeFoundationTypes($overrideScope->foundationTypes);
$effectivePolicyTypes = $overridePolicyTypes !== []
? array_values(array_intersect($profileScope->policyTypes, $overridePolicyTypes))
: $profileScope->policyTypes;
$effectiveFoundationTypes = $overrideFoundationTypes !== []
? array_values(array_intersect($profileScope->foundationTypes, $overrideFoundationTypes))
: $profileScope->foundationTypes;
return new self(
policyTypes: self::uniqueSorted($effectivePolicyTypes),
foundationTypes: self::uniqueSorted($effectiveFoundationTypes),
);
}
/**
* An empty scope means "no override" (for override_scope semantics).
*/
public function isEmpty(): bool
{
return $this->policyTypes === [] && $this->foundationTypes === [];
}
/**
* Apply Spec 116 defaults and filter to supported types.
*/
public function expandDefaults(): self
{
$policyTypes = $this->policyTypes === []
? self::supportedPolicyTypes()
: self::normalizePolicyTypes($this->policyTypes);
$foundationTypes = self::normalizeFoundationTypes($this->foundationTypes);
return new self(
policyTypes: $policyTypes,
foundationTypes: $foundationTypes,
);
}
/**
* @return list<string>
*/
public function allTypes(): array
{
$expanded = $this->expandDefaults();
return self::uniqueSorted(array_merge(
$expanded->policyTypes,
$expanded->foundationTypes,
));
}
/**
* @return array<string, mixed>
*/
public function toJsonb(): array
{
return [
'policy_types' => $this->policyTypes,
'foundation_types' => $this->foundationTypes,
];
}
/**
* Effective scope payload for OperationRun.context.
*
* @return array{policy_types: list<string>, foundation_types: list<string>, all_types: list<string>, foundations_included: bool}
*/
public function toEffectiveScopeContext(): array
{
$expanded = $this->expandDefaults();
$allTypes = self::uniqueSorted(array_merge($expanded->policyTypes, $expanded->foundationTypes));
return [
'policy_types' => $expanded->policyTypes,
'foundation_types' => $expanded->foundationTypes,
'all_types' => $allTypes,
'foundations_included' => $expanded->foundationTypes !== [],
];
}
/**
* @return list<string>
*/
private static function supportedPolicyTypes(): array
{
$supported = config('tenantpilot.supported_policy_types', []);
if (! is_array($supported)) {
return [];
}
$types = collect($supported)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @return list<string>
*/
private static function supportedFoundationTypes(): array
{
$foundations = config('tenantpilot.foundation_types', []);
if (! is_array($foundations)) {
return [];
}
$types = collect($foundations)
->filter(fn (mixed $row): bool => is_array($row) && filled($row['type'] ?? null))
->map(fn (array $row): string => (string) $row['type'])
->filter(fn (string $type): bool => $type !== '')
->values()
->all();
return self::uniqueSorted($types);
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizePolicyTypes(array $types): array
{
$supported = self::supportedPolicyTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<string> $types
* @return list<string>
*/
private static function normalizeFoundationTypes(array $types): array
{
$supported = self::supportedFoundationTypes();
return self::uniqueSorted(array_values(array_intersect($types, $supported)));
}
/**
* @param array<int, string> $types
* @return list<string>
*/
private static function uniqueSorted(array $types): array
{
$types = array_values(array_unique(array_filter($types, fn (mixed $type): bool => is_string($type) && $type !== '')));
sort($types, SORT_STRING);
return $types;
}
}