Rename folders to match PHP PSR-4 autoloading conventions: - schemas → Schemas - shared → Shared - Wordpress → WordPress (in all locations) Fixes deployment issues on Linux servers where filesystem is case-sensitive. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
306 lines
11 KiB
PHP
306 lines
11 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace ROITheme\Shared\Infrastructure\Validators;
|
||
|
||
/**
|
||
* Validador de Fase 05: Validación General SOLID
|
||
*
|
||
* Escanea TODOS los archivos .php del componente y valida:
|
||
* - Domain Purity (sin WordPress, sin echo, sin HTML)
|
||
* - Application Purity (sin WordPress)
|
||
* - CERO CSS hardcodeado
|
||
* - Dependency Injection
|
||
* - SRP (Single Responsibility - tamaño de archivos)
|
||
* - ISP (Interface Segregation - tamaño de interfaces)
|
||
* - Encapsulación (propiedades private/protected)
|
||
* - NO instanciación directa en Domain/Application
|
||
*/
|
||
final class Phase05Validator implements PhaseValidatorInterface
|
||
{
|
||
private const WORDPRESS_FUNCTIONS = [
|
||
'global \$wpdb',
|
||
'add_action',
|
||
'add_filter',
|
||
'get_option',
|
||
'update_option',
|
||
'wp_enqueue_',
|
||
'register_post_type',
|
||
'add_shortcode',
|
||
'\$_POST',
|
||
'\$_GET',
|
||
'\$_SESSION',
|
||
'\$_COOKIE',
|
||
];
|
||
|
||
private const CSS_PATTERNS = [
|
||
'style\s*=\s*["\']', // style="..."
|
||
'<<<STYLE', // HEREDOC con STYLE
|
||
'<<<CSS', // HEREDOC con CSS
|
||
'onmouseover\s*=', // inline JS events
|
||
'onmouseout\s*=',
|
||
'onclick\s*=',
|
||
'onload\s*=',
|
||
'onchange\s*=',
|
||
];
|
||
|
||
private int $filesScanned = 0;
|
||
|
||
public function validate(string $componentName, string $themePath): ValidationResult
|
||
{
|
||
$result = new ValidationResult();
|
||
|
||
$result->addInfo("Validación SOLID general para: {$componentName}");
|
||
|
||
// Buscar directorio del componente
|
||
$componentPath = $this->findComponentPath($componentName, $themePath);
|
||
|
||
if ($componentPath === null) {
|
||
$result->addError("Componente no encontrado en Admin/ ni Public/");
|
||
return $result;
|
||
}
|
||
|
||
$result->addInfo("Escaneando: {$componentPath}");
|
||
|
||
// Escanear todos los archivos .php
|
||
$this->filesScanned = 0;
|
||
$this->scanDirectory($componentPath, $result);
|
||
|
||
// Estadísticas
|
||
$result->setStat('Archivos escaneados', $this->filesScanned);
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function findComponentPath(string $componentName, string $themePath): ?string
|
||
{
|
||
// Convertir kebab-case a PascalCase (PSR-4 standard)
|
||
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
|
||
|
||
// Intentar en Public/ con PascalCase
|
||
$publicPath = $themePath . '/Public/' . $pascalCaseName;
|
||
if (is_dir($publicPath)) {
|
||
return $publicPath;
|
||
}
|
||
|
||
// Intentar en Admin/ con PascalCase
|
||
$adminPath = $themePath . '/Admin/' . $pascalCaseName;
|
||
if (is_dir($adminPath)) {
|
||
return $adminPath;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function scanDirectory(string $dir, ValidationResult $result): void
|
||
{
|
||
$iterator = new \RecursiveIteratorIterator(
|
||
new \RecursiveDirectoryIterator($dir, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||
\RecursiveIteratorIterator::SELF_FIRST
|
||
);
|
||
|
||
foreach ($iterator as $file) {
|
||
if ($file->isFile() && $file->getExtension() === 'php') {
|
||
$this->validateFile($file->getPathname(), $result);
|
||
}
|
||
}
|
||
}
|
||
|
||
private function validateFile(string $filePath, ValidationResult $result): void
|
||
{
|
||
$this->filesScanned++;
|
||
$content = file_get_contents($filePath);
|
||
$fileName = basename($filePath);
|
||
|
||
// Detectar capa (Domain, Application, Infrastructure)
|
||
$layer = $this->detectLayer($filePath);
|
||
|
||
// REGLA 1: Domain NO puede tener WordPress
|
||
if ($layer === 'domain') {
|
||
$this->checkDomainPurity($content, $fileName, $result);
|
||
}
|
||
|
||
// REGLA 2: Application NO puede tener WordPress
|
||
if ($layer === 'application') {
|
||
$this->checkApplicationPurity($content, $fileName, $result);
|
||
}
|
||
|
||
// REGLA 3: CERO CSS hardcodeado en PHP (general)
|
||
$this->checkNoHardcodedCSS($content, $fileName, $result);
|
||
|
||
// REGLA 4: DIP - Constructores deben recibir interfaces
|
||
if ($layer === 'infrastructure') {
|
||
$this->checkDependencyInjection($content, $fileName, $result);
|
||
}
|
||
|
||
// REGLA 5: SRP - Archivos no deben exceder 300 líneas
|
||
$this->checkFileLength($content, $fileName, $result);
|
||
|
||
// REGLA 6: ISP - Interfaces no deben ser gordas
|
||
if (strpos($filePath, 'Interface.php') !== false) {
|
||
$this->checkInterfaceSize($content, $fileName, $result);
|
||
}
|
||
|
||
// REGLA 7: Propiedades deben ser private/protected
|
||
$this->checkEncapsulation($content, $fileName, $result);
|
||
|
||
// REGLA 8: NO debe haber new ConcreteClass() en Domain/Application
|
||
if ($layer === 'domain' || $layer === 'application') {
|
||
$this->checkNoDirectInstantiation($content, $fileName, $result);
|
||
}
|
||
}
|
||
|
||
private function detectLayer(string $filePath): ?string
|
||
{
|
||
if (stripos($filePath, DIRECTORY_SEPARATOR . 'domain' . DIRECTORY_SEPARATOR) !== false) {
|
||
return 'domain';
|
||
}
|
||
if (stripos($filePath, DIRECTORY_SEPARATOR . 'application' . DIRECTORY_SEPARATOR) !== false) {
|
||
return 'application';
|
||
}
|
||
if (stripos($filePath, DIRECTORY_SEPARATOR . 'infrastructure' . DIRECTORY_SEPARATOR) !== false) {
|
||
return 'infrastructure';
|
||
}
|
||
return null;
|
||
}
|
||
|
||
private function checkDomainPurity(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
foreach (self::WORDPRESS_FUNCTIONS as $wpFunction) {
|
||
if (preg_match('/' . $wpFunction . '/i', $content)) {
|
||
$result->addError("Domain tiene código WordPress '{$wpFunction}': {$file}");
|
||
}
|
||
}
|
||
|
||
// Domain NO debe tener echo/print
|
||
if (preg_match('/\b(echo|print|print_r|var_dump)\s+/', $content)) {
|
||
$result->addError("Domain tiene output directo (echo/print/var_dump): {$file}");
|
||
}
|
||
|
||
// Domain NO debe tener HTML
|
||
if (preg_match('/<(div|span|p|a|ul|li|table|form|input|button|h[1-6])\s/i', $content)) {
|
||
$result->addError("Domain tiene HTML hardcodeado: {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkApplicationPurity(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
foreach (self::WORDPRESS_FUNCTIONS as $wpFunction) {
|
||
if (preg_match('/' . $wpFunction . '/i', $content)) {
|
||
$result->addError("Application tiene código WordPress '{$wpFunction}': {$file}");
|
||
}
|
||
}
|
||
}
|
||
|
||
private function checkNoHardcodedCSS(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
// Skip si es un archivo de assets o FormBuilder (ya validado en Phase04)
|
||
if (strpos($file, 'assets') !== false || strpos($file, 'FormBuilder') !== false) {
|
||
return;
|
||
}
|
||
|
||
$violations = 0;
|
||
foreach (self::CSS_PATTERNS as $pattern) {
|
||
if (preg_match('/' . $pattern . '/i', $content)) {
|
||
$violations++;
|
||
}
|
||
}
|
||
|
||
if ($violations > 0) {
|
||
$result->addError("CSS hardcodeado encontrado ({$violations} patrones): {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkDependencyInjection(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
// Buscar constructores
|
||
if (preg_match('/__construct\s*\([^)]*\)/', $content, $matches)) {
|
||
$constructor = $matches[0];
|
||
|
||
// Verificar si recibe parámetros
|
||
if (strpos($constructor, '$') !== false) {
|
||
// Buscar parámetros que son clases concretas (Service, Repository, Manager)
|
||
if (preg_match('/private\s+([A-Z][a-zA-Z]+)(Service|Repository|Manager)\s+\$/', $constructor)) {
|
||
// Verificar si NO termina en Interface
|
||
if (!preg_match('/interface\s+\$/', $constructor)) {
|
||
$result->addWarning("Constructor recibe clase concreta en lugar de interface: {$file}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
// Verificar si tiene new dentro del código (excepto DTOs, VOs, Entities)
|
||
if (preg_match('/new\s+([A-Z][a-zA-Z]+)(Service|Repository|Manager|Controller|Builder)\s*\(/i', $content)) {
|
||
$result->addError("Instanciación directa de clase de infraestructura (debe usar DI): {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkFileLength(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
$lines = substr_count($content, "\n") + 1;
|
||
|
||
if ($lines > 500) {
|
||
$result->addWarning("Archivo excede 500 líneas ({$lines}) - considerar refactorizar: {$file}");
|
||
} elseif ($lines > 300) {
|
||
$result->addInfo("ℹ️ Archivo tiene {$lines} líneas (recomendado: <300, aceptable: <500): {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkInterfaceSize(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
// Contar métodos públicos en la interface
|
||
$methodCount = preg_match_all('/public\s+function\s+\w+\s*\(/i', $content);
|
||
|
||
if ($methodCount > 10) {
|
||
$result->addError("Interface tiene {$methodCount} métodos (máx 10) - viola ISP: {$file}");
|
||
} elseif ($methodCount > 5) {
|
||
$result->addWarning("Interface tiene {$methodCount} métodos (recomendado: 3-5) - considerar dividir: {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkEncapsulation(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
// Buscar propiedades public (excluir const y function)
|
||
if (preg_match('/\bpublic\s+(?!const|function|static\s+function)\$\w+/i', $content)) {
|
||
$result->addWarning("Propiedades públicas encontradas - usar private/protected: {$file}");
|
||
}
|
||
}
|
||
|
||
private function checkNoDirectInstantiation(string $content, string $file, ValidationResult $result): void
|
||
{
|
||
// Permitir new de: DTOs, Value Objects, Entities, Exceptions
|
||
$allowedPatterns = [
|
||
'DTO', 'Request', 'Response', 'Exception',
|
||
'ComponentName', 'ComponentConfiguration', 'ComponentVisibility',
|
||
'Color', 'Url', 'Email', 'ComponentId', 'MenuItem',
|
||
'self', 'static', 'parent', 'stdClass', 'DateTime'
|
||
];
|
||
|
||
if (preg_match_all('/new\s+([A-Z][a-zA-Z]+)\s*\(/i', $content, $matches)) {
|
||
foreach ($matches[1] as $className) {
|
||
$isAllowed = false;
|
||
foreach ($allowedPatterns as $pattern) {
|
||
if (strpos($className, $pattern) !== false || $className === $pattern) {
|
||
$isAllowed = true;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (!$isAllowed) {
|
||
$result->addWarning("Instanciación directa de '{$className}' en Domain/Application - considerar inyección: {$file}");
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
public function getPhaseNumber(): int|string
|
||
{
|
||
return 5;
|
||
}
|
||
|
||
public function getPhaseDescription(): string
|
||
{
|
||
return 'General SOLID (todos los archivos)';
|
||
}
|
||
}
|