## 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
392 lines
14 KiB
PHP
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.',
|
|
);
|
|
}
|
|
}
|