## Summary - add a workspace-scoped baseline compare matrix page under baseline profiles - derive matrix tenant summaries, subject rows, cell states, freshness, and trust from existing snapshots, compare runs, and findings - add confirmation-gated `Compare assigned tenants` actions on the baseline detail and matrix surfaces without introducing a workspace umbrella run - preserve matrix navigation context into tenant compare and finding drilldowns and add centralized matrix badge semantics - include spec, plan, data model, contracts, quickstart, tasks, and focused feature/browser coverage for Spec 190 ## Verification - `cd apps/platform && ./vendor/bin/sail artisan test --compact tests/Unit/Badges/BaselineCompareMatrixBadgesTest.php tests/Feature/Baselines/BaselineCompareMatrixBuilderTest.php tests/Feature/Baselines/BaselineCompareMatrixCompareAllActionTest.php tests/Feature/Baselines/BaselineComparePerformanceGuardTest.php tests/Feature/Filament/BaselineCompareMatrixPageTest.php tests/Feature/Filament/BaselineProfileCompareStartSurfaceTest.php tests/Feature/Rbac/BaselineCompareMatrixAuthorizationTest.php tests/Feature/Guards/ActionSurfaceContractTest.php tests/Feature/Guards/NoAdHocStatusBadgesTest.php tests/Feature/Guards/NoDiagnosticWarningBadgesTest.php` - `cd apps/platform && ./vendor/bin/sail bin pint --dirty --format agent` - completed an integrated-browser smoke flow locally for matrix render, differ filter, finding drilldown round-trip, and `Compare assigned tenants` confirmation/action Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de> Reviewed-on: #221
364 lines
10 KiB
PHP
364 lines
10 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Support\Ui\ActionSurface;
|
|
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceComponentType;
|
|
use App\Support\Ui\ActionSurface\Enums\ActionSurfacePanelScope;
|
|
use ReflectionClass;
|
|
use Filament\Tables\Contracts\HasTable;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use SplFileInfo;
|
|
use Throwable;
|
|
|
|
final class ActionSurfaceDiscovery
|
|
{
|
|
private string $basePath;
|
|
|
|
private string $appPath;
|
|
|
|
private string $routesPath;
|
|
|
|
private string $adminPanelProviderPath;
|
|
|
|
public function __construct(
|
|
?string $basePath = null,
|
|
?string $appPath = null,
|
|
?string $routesPath = null,
|
|
?string $adminPanelProviderPath = null,
|
|
) {
|
|
$this->basePath = $basePath ?? base_path();
|
|
$this->appPath = $appPath ?? app_path();
|
|
$this->routesPath = $routesPath ?? base_path('routes/web.php');
|
|
$this->adminPanelProviderPath = $adminPanelProviderPath ?? app_path('Providers/Filament/AdminPanelProvider.php');
|
|
}
|
|
|
|
/**
|
|
* @return array<int, ActionSurfaceDiscoveredComponent>
|
|
*/
|
|
public function discover(): array
|
|
{
|
|
$adminScopedClasses = $this->discoverAdminScopedClasses();
|
|
|
|
/** @var array<string, ActionSurfaceDiscoveredComponent> $components */
|
|
$components = [];
|
|
|
|
foreach ($this->resourceFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Resource,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
foreach ($this->pageFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Page,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
foreach ($this->systemPageFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
if (! $this->isDeclaredSystemTablePage($className)) {
|
|
continue;
|
|
}
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::Page,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
foreach ($this->relationManagerFiles() as $path) {
|
|
$className = $this->classNameFromPath($path);
|
|
|
|
$components[$className] = new ActionSurfaceDiscoveredComponent(
|
|
className: $className,
|
|
componentType: ActionSurfaceComponentType::RelationManager,
|
|
panelScopes: $this->panelScopesFor($className, $adminScopedClasses),
|
|
);
|
|
}
|
|
|
|
ksort($components);
|
|
|
|
return array_values($components);
|
|
}
|
|
|
|
/**
|
|
* @param array<int, string> $adminScopedClasses
|
|
* @return array<int, ActionSurfacePanelScope>
|
|
*/
|
|
private function panelScopesFor(string $className, array $adminScopedClasses): array
|
|
{
|
|
$scopes = [ActionSurfacePanelScope::Tenant];
|
|
|
|
if (
|
|
in_array($className, $adminScopedClasses, true)
|
|
|| $this->inheritsAdminScopeFromResource($className, $adminScopedClasses)
|
|
) {
|
|
$scopes[] = ActionSurfacePanelScope::Admin;
|
|
}
|
|
|
|
return $scopes;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function resourceFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
|
if (! str_ends_with($path, 'Resource.php')) {
|
|
return false;
|
|
}
|
|
|
|
if (str_contains($path, '/Pages/')) {
|
|
return false;
|
|
}
|
|
|
|
if (str_contains($path, '/RelationManagers/')) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function pageFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Pages', static function (string $path): bool {
|
|
return str_ends_with($path, '.php');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function systemPageFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/System/Pages', static function (string $path): bool {
|
|
return str_ends_with($path, '.php');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function relationManagerFiles(): array
|
|
{
|
|
return $this->collectPhpFiles($this->appPath.'/Filament/Resources', function (string $path): bool {
|
|
if (! str_contains($path, '/RelationManagers/')) {
|
|
return false;
|
|
}
|
|
|
|
return str_ends_with($path, 'RelationManager.php');
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param callable(string): bool $filter
|
|
* @return array<int, string>
|
|
*/
|
|
private function collectPhpFiles(string $directory, callable $filter): array
|
|
{
|
|
if (! is_dir($directory)) {
|
|
return [];
|
|
}
|
|
|
|
$iterator = new RecursiveIteratorIterator(
|
|
new RecursiveDirectoryIterator($directory, RecursiveDirectoryIterator::SKIP_DOTS),
|
|
);
|
|
|
|
$paths = [];
|
|
|
|
/** @var SplFileInfo $file */
|
|
foreach ($iterator as $file) {
|
|
if (! $file->isFile()) {
|
|
continue;
|
|
}
|
|
|
|
$path = str_replace('\\', '/', $file->getPathname());
|
|
|
|
if (! $filter($path)) {
|
|
continue;
|
|
}
|
|
|
|
$paths[] = $path;
|
|
}
|
|
|
|
sort($paths);
|
|
|
|
return $paths;
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function discoverAdminScopedClasses(): array
|
|
{
|
|
$classes = array_merge(
|
|
$this->parseFilamentClassReferences($this->adminPanelProviderPath),
|
|
$this->parseFilamentClassReferences($this->routesPath),
|
|
);
|
|
|
|
$classes = array_values(array_unique(array_filter($classes, static function (string $className): bool {
|
|
return str_starts_with($className, 'App\\Filament\\');
|
|
})));
|
|
|
|
sort($classes);
|
|
|
|
return $classes;
|
|
}
|
|
|
|
private function isDeclaredSystemTablePage(string $className): bool
|
|
{
|
|
if (! class_exists($className)) {
|
|
return false;
|
|
}
|
|
|
|
return is_subclass_of($className, HasTable::class)
|
|
&& method_exists($className, 'actionSurfaceDeclaration');
|
|
}
|
|
|
|
/**
|
|
* Resource-owned Filament pages can live under app/Filament/Pages and be routed
|
|
* from the resource instead of being panel-registered directly. When that happens,
|
|
* inherit admin scope from the owning resource so discovery stays truthful.
|
|
*
|
|
* @param array<int, string> $adminScopedClasses
|
|
*/
|
|
private function inheritsAdminScopeFromResource(string $className, array $adminScopedClasses): bool
|
|
{
|
|
if (! class_exists($className)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$reflection = new ReflectionClass($className);
|
|
|
|
if (! $reflection->hasProperty('resource')) {
|
|
return false;
|
|
}
|
|
|
|
$defaults = $reflection->getDefaultProperties();
|
|
$resourceClass = $defaults['resource'] ?? null;
|
|
|
|
return is_string($resourceClass)
|
|
&& $resourceClass !== ''
|
|
&& in_array($resourceClass, $adminScopedClasses, true);
|
|
} catch (Throwable) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @return array<int, string>
|
|
*/
|
|
private function parseFilamentClassReferences(string $filePath): array
|
|
{
|
|
if (! is_file($filePath)) {
|
|
return [];
|
|
}
|
|
|
|
$contents = file_get_contents($filePath);
|
|
|
|
if (! is_string($contents) || $contents === '') {
|
|
return [];
|
|
}
|
|
|
|
$imports = $this->parseUseStatements($contents);
|
|
|
|
preg_match_all('/\\\\?([A-Z][A-Za-z0-9_\\\\]*)::(?:class|registerRoutes)\b/', $contents, $matches);
|
|
|
|
$classes = [];
|
|
|
|
foreach ($matches[1] as $token) {
|
|
$resolved = $this->resolveClassToken($token, $imports);
|
|
|
|
if ($resolved === null) {
|
|
continue;
|
|
}
|
|
|
|
$classes[] = $resolved;
|
|
}
|
|
|
|
return $classes;
|
|
}
|
|
|
|
/**
|
|
* @return array<string, string>
|
|
*/
|
|
private function parseUseStatements(string $contents): array
|
|
{
|
|
preg_match_all('/^use\s+([^;]+);/m', $contents, $matches);
|
|
|
|
$imports = [];
|
|
|
|
foreach ($matches[1] as $importExpression) {
|
|
$normalized = trim($importExpression);
|
|
|
|
if (! str_contains($normalized, '\\')) {
|
|
continue;
|
|
}
|
|
|
|
$parts = preg_split('/\s+as\s+/i', $normalized);
|
|
$fqcn = ltrim($parts[0], '\\');
|
|
|
|
$alias = $parts[1] ?? null;
|
|
|
|
if (! is_string($alias) || trim($alias) === '') {
|
|
$segments = explode('\\', $fqcn);
|
|
$alias = end($segments);
|
|
}
|
|
|
|
if (! is_string($alias) || trim($alias) === '') {
|
|
continue;
|
|
}
|
|
|
|
$imports[trim($alias)] = $fqcn;
|
|
}
|
|
|
|
return $imports;
|
|
}
|
|
|
|
/**
|
|
* @param array<string, string> $imports
|
|
*/
|
|
private function resolveClassToken(string $token, array $imports): ?string
|
|
{
|
|
$token = ltrim(trim($token), '\\');
|
|
|
|
if ($token === '') {
|
|
return null;
|
|
}
|
|
|
|
if (str_contains($token, '\\')) {
|
|
return $token;
|
|
}
|
|
|
|
return $imports[$token] ?? null;
|
|
}
|
|
|
|
private function classNameFromPath(string $path): string
|
|
{
|
|
$normalizedPath = str_replace('\\', '/', $path);
|
|
$normalizedAppPath = str_replace('\\', '/', $this->appPath);
|
|
$relative = ltrim(substr($normalizedPath, strlen($normalizedAppPath)), '/');
|
|
|
|
return 'App\\'.str_replace('/', '\\', substr($relative, 0, -4));
|
|
}
|
|
}
|