TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceDiscovery.php
ahmido 37c6d0622c feat: implement spec 169 action surface contract v1.1 (#200)
## Summary
- implement the Action Surface Contract v1.1 runtime changes for Spec 169
- add the new explicit ActionSurfaceType contract, validator/discovery updates, and enrolled surface declarations
- update Filament action-surface documentation, focused guard tests, and spec artifacts for the completed feature

## Included
- clickable-row vs explicit-inspect enforcement across monitoring, reporting, CRUD, and system reference surfaces
- helper-first, workflow-next, destructive-last overflow ordering checks
- system panel list discovery in the primary action-surface validator
- Spec 169 artifacts: spec, plan, tasks, research, data model, quickstart, and logical contract

## Verification
- focused Pest verification pack completed for:
  - tests/Feature/Guards/ActionSurfaceValidatorTest.php
  - tests/Feature/Guards/ActionSurfaceContractTest.php
  - tests/Feature/Rbac/TenantActionSurfaceConsistencyTest.php
- integrated browser smoke test completed for admin-side reference surfaces:
  - /admin/operations
  - /admin/audit-log
  - /admin/finding-exceptions/queue
  - /admin/reviews
  - /admin/tenants

## Notes
- system panel browser smoke coverage could not be exercised in the same session because /system routes require platform authentication in the integrated browser
- Livewire target remains v4-compliant and no provider registration or asset strategy changes are introduced by this PR

Co-authored-by: Ahmed Darrazi <ahmed.darrazi@live.de>
Reviewed-on: #200
2026-03-30 09:21:39 +00:00

328 lines
9.0 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 Filament\Tables\Contracts\HasTable;
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->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)) {
$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');
}
/**
* @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));
}
}