TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
Ahmed Darrazi 72faa38472 feat: require inspect affordance for lists
- Replace view-only row buttons with clickable rows (recordUrl)\n- Update action-surface contract slot to InspectAffordance + validator support\n- Add golden guard tests + contract doc\n- Update SpecKit constitution/templates to include inspection affordance rule
2026-02-08 21:29:20 +01:00

293 lines
7.9 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 RecursiveDirectoryIterator;
use RecursiveIteratorIterator;
use SplFileInfo;
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->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)) {
$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 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;
}
/**
* @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));
}
}