TenantAtlas/app/Support/Ui/ActionSurface/ActionSurfaceValidator.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

392 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Support\Ui\ActionSurface;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceInspectAffordance;
use App\Support\Ui\ActionSurface\Enums\ActionSurfaceSlot;
final class ActionSurfaceValidator
{
private ActionSurfaceDiscovery $discovery;
private ActionSurfaceProfileDefinition $profileDefinition;
private ActionSurfaceExemptions $exemptions;
public function __construct(
?ActionSurfaceDiscovery $discovery = null,
?ActionSurfaceProfileDefinition $profileDefinition = null,
?ActionSurfaceExemptions $exemptions = null,
) {
$this->discovery = $discovery ?? new ActionSurfaceDiscovery;
$this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition;
$this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline();
}
/**
* @return array<int, ActionSurfaceDiscoveredComponent>
*/
public function discoveredComponents(): array
{
return $this->discovery->discover();
}
public static function withBaselineExemptions(): self
{
return new self(
discovery: new ActionSurfaceDiscovery,
profileDefinition: new ActionSurfaceProfileDefinition,
exemptions: ActionSurfaceExemptions::baseline(),
);
}
public function validate(): ActionSurfaceValidationResult
{
return $this->validateComponents($this->discoveredComponents());
}
/**
* @param array<int, ActionSurfaceDiscoveredComponent> $components
*/
public function validateComponents(array $components): ActionSurfaceValidationResult
{
$issues = [];
foreach ($components as $component) {
if (! class_exists($component->className)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: 'Discovered class does not exist or is not autoloadable.',
hint: 'Verify namespace/path and run composer dump-autoload if needed.',
);
continue;
}
$declaration = $this->resolveDeclarationForComponent($component, $issues);
if ($declaration === null) {
continue;
}
if ($declaration->componentType !== $component->componentType) {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Declaration component type mismatch (%s declared, %s discovered).',
$declaration->componentType->name,
$component->componentType->name,
),
hint: 'Use ActionSurfaceDeclaration::forResource/forPage/forRelationManager consistently.',
);
}
if ($declaration->defaults->moreGroupLabel !== 'More') {
$issues[] = new ActionSurfaceValidationIssue(
className: $component->className,
message: sprintf(
'Invalid more-group label "%s".',
$declaration->defaults->moreGroupLabel,
),
hint: 'Set ActionSurfaceDefaults->moreGroupLabel to "More".',
);
}
$this->validateRequiredSlots($component->className, $declaration, $issues);
$this->validateBehaviorAwareContract($component->className, $declaration, $issues);
$this->validateExemptions($component->className, $declaration, $issues);
$this->validateExportDefaults($component->className, $declaration, $issues);
}
return new ActionSurfaceValidationResult(
issues: $issues,
componentCount: count($components),
);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function resolveDeclarationForComponent(
ActionSurfaceDiscoveredComponent $component,
array &$issues,
): ?ActionSurfaceDeclaration {
$className = $component->className;
if (! method_exists($className, 'actionSurfaceDeclaration')) {
$this->validateClassExemptionOrFail($className, $issues);
return null;
}
try {
$declaration = $className::actionSurfaceDeclaration();
} catch (\Throwable $throwable) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() threw an exception: '.$throwable->getMessage(),
hint: 'Ensure actionSurfaceDeclaration() is static and does not depend on request state.',
);
return null;
}
if (! $declaration instanceof ActionSurfaceDeclaration) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'actionSurfaceDeclaration() must return ActionSurfaceDeclaration.',
hint: 'Return ActionSurfaceDeclaration::forResource/forPage/forRelationManager(...).',
);
return null;
}
return $declaration;
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateClassExemptionOrFail(string $className, array &$issues): void
{
$reason = $this->exemptions->reasonForClass($className);
if ($reason === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Missing action-surface declaration and no component exemption exists.',
hint: 'Add actionSurfaceDeclaration() or register a baseline exemption with a non-empty reason.',
);
return;
}
if (trim($reason) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Component exemption reason must be non-empty.',
hint: 'Provide a concrete, non-empty justification in ActionSurfaceExemptions::baseline().',
);
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateRequiredSlots(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($this->profileDefinition->requiredSlots($declaration->profile) as $slot) {
$requirement = $declaration->slot($slot);
if ($requirement === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Required slot is not declared.',
hint: 'Declare slot as satisfied or exempt with a reason.',
);
continue;
}
if (! $requirement->isExempt()) {
if ($slot === ActionSurfaceSlot::InspectAffordance) {
$this->validateInspectAffordanceSlot($className, $requirement, $issues);
}
continue;
}
$exemption = $declaration->exemption($slot);
if ($exemption === null || ! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: $slot,
message: 'Slot is marked exempt but exemption reason is missing or empty.',
hint: 'Use ->exempt(slot, "reason") with a non-empty reason.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateInspectAffordanceSlot(
string $className,
ActionSurfaceSlotRequirement $requirement,
array &$issues,
): void {
$this->resolveInspectAffordance($className, $requirement, $issues);
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateBehaviorAwareContract(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
if (! $declaration->requiresBehaviorAwareContract()) {
return;
}
if ($declaration->surfaceType === null) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: 'Behavior-aware declarations must define a surface type.',
hint: 'Pass an ActionSurfaceType when creating the declaration.',
);
return;
}
if (! $this->profileDefinition->allowsSurfaceType($declaration->profile, $declaration->surfaceType)) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
message: sprintf(
'Surface type "%s" is incompatible with profile "%s".',
$declaration->surfaceType->value,
$declaration->profile->value,
),
hint: 'Choose a surface type allowed for the profile or change the profile to match the rendered list behavior.',
);
return;
}
$requirement = $declaration->slot(ActionSurfaceSlot::InspectAffordance);
if (! $requirement instanceof ActionSurfaceSlotRequirement || $requirement->isExempt()) {
return;
}
$affordance = $this->resolveInspectAffordance($className, $requirement, $issues);
if (! $affordance instanceof ActionSurfaceInspectAffordance) {
return;
}
if (! $declaration->surfaceType->allowsInspectAffordance($affordance)) {
$allowed = implode(', ', array_map(
static fn (ActionSurfaceInspectAffordance $allowedAffordance): string => $allowedAffordance->value,
$declaration->surfaceType->allowedInspectAffordances(),
));
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: sprintf(
'Inspect affordance "%s" is incompatible with surface type "%s".',
$affordance->value,
$declaration->surfaceType->value,
),
hint: sprintf('Allowed: %s.', $allowed),
);
}
if ($affordance->isPrimaryLinkColumn() && trim((string) $declaration->primaryLinkColumnReason()) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: 'Primary link column inspect affordance requires a non-empty reason.',
hint: 'Call ->withPrimaryLinkColumnReason("why row click is not the right primary inspect model").',
);
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function resolveInspectAffordance(
string $className,
ActionSurfaceSlotRequirement $requirement,
array &$issues,
): ?ActionSurfaceInspectAffordance {
$mode = $requirement->details;
if (! is_string($mode) || trim($mode) === '') {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: 'Inspect affordance must declare how inspection is provided (clickable_row, view_action, or primary_link_column).',
hint: 'Use ->satisfy(ActionSurfaceSlot::InspectAffordance, ActionSurfaceInspectAffordance::ClickableRow->value).',
);
return null;
}
$affordance = ActionSurfaceInspectAffordance::tryFrom($mode);
if ($affordance instanceof ActionSurfaceInspectAffordance) {
return $affordance;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::InspectAffordance,
message: sprintf('Invalid inspect affordance mode "%s".', $mode),
hint: 'Allowed: clickable_row, view_action, primary_link_column.',
);
return null;
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExemptions(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
foreach ($declaration->exemptions() as $slotValue => $exemption) {
if (! $exemption->hasReason()) {
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::from($slotValue),
message: 'Exemption reason must be non-empty.',
hint: 'Provide a concise reason for each exempted slot.',
);
}
}
}
/**
* @param array<int, ActionSurfaceValidationIssue> $issues
*/
private function validateExportDefaults(
string $className,
ActionSurfaceDeclaration $declaration,
array &$issues,
): void {
if (! $this->profileDefinition->requiresExportDefaultBulk($declaration->profile)) {
return;
}
if ($declaration->defaults->exportIsDefaultBulkActionForReadOnly) {
return;
}
$bulkExemption = $declaration->exemption(ActionSurfaceSlot::ListBulkMoreGroup);
if ($bulkExemption instanceof ActionSurfaceExemption && $bulkExemption->hasReason()) {
return;
}
$issues[] = new ActionSurfaceValidationIssue(
className: $className,
slot: ActionSurfaceSlot::ListBulkMoreGroup,
message: 'ReadOnly/RunLog profile disables Export default but no bulk-slot exemption reason was provided.',
hint: 'Keep exportIsDefaultBulkActionForReadOnly=true or exempt ListBulkMoreGroup with a reason.',
);
}
}