discovery = $discovery ?? new ActionSurfaceDiscovery; $this->profileDefinition = $profileDefinition ?? new ActionSurfaceProfileDefinition; $this->exemptions = $exemptions ?? ActionSurfaceExemptions::baseline(); } /** * @return array */ 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 $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->validateExemptions($component->className, $declaration, $issues); $this->validateExportDefaults($component->className, $declaration, $issues); } return new ActionSurfaceValidationResult( issues: $issues, componentCount: count($components), ); } /** * @param array $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 $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 $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 $issues */ private function validateInspectAffordanceSlot( string $className, ActionSurfaceSlotRequirement $requirement, array &$issues, ): void { $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; } if (ActionSurfaceInspectAffordance::tryFrom($mode) instanceof ActionSurfaceInspectAffordance) { return; } $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.', ); } /** * @param array $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 $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.', ); } }