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>
273 lines
11 KiB
PHP
273 lines
11 KiB
PHP
<?php
|
||
declare(strict_types=1);
|
||
|
||
namespace ROITheme\Shared\Infrastructure\Validators;
|
||
|
||
/**
|
||
* Validador de Fase 03: Renderers (DB→HTML/CSS)
|
||
*
|
||
* Valida que:
|
||
* - Renderer existe en ubicación correcta
|
||
* - Namespace y clase correctos
|
||
* - Inyecta CSSGeneratorInterface
|
||
* - CERO CSS hardcodeado (CRÍTICO)
|
||
* - Tiene métodos obligatorios (render, getVisibilityClasses)
|
||
* - Usa escaping correcto
|
||
* - NO usa WordPress de BD
|
||
*/
|
||
final class Phase03Validator implements PhaseValidatorInterface
|
||
{
|
||
/**
|
||
* Componentes especiales que NO requieren CSSGeneratorInterface ni getVisibilityClasses
|
||
*
|
||
* Estos son componentes de inyeccion (no visuales) que:
|
||
* - NO renderizan HTML visual
|
||
* - NO generan CSS dinamico (inyectan CSS del usuario tal cual)
|
||
* - NO necesitan clases de visibilidad responsive
|
||
* - Inyectan codigo en hooks (wp_head, wp_footer)
|
||
*/
|
||
private const INJECTION_COMPONENTS = ['theme-settings'];
|
||
|
||
public function validate(string $componentName, string $themePath): ValidationResult
|
||
{
|
||
$result = new ValidationResult();
|
||
|
||
$result->addInfo("Validando Renderer para: {$componentName}");
|
||
|
||
// Determinar contexto (admin o public) - intentar ambos
|
||
$rendererPath = $this->findRendererPath($componentName, $themePath);
|
||
|
||
if ($rendererPath === null) {
|
||
$result->addError("Renderer no encontrado en Public/ ni Admin/");
|
||
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
|
||
$result->addInfo("Ubicación esperada: Public/{$pascalCaseName}/Infrastructure/Ui/*Renderer.php");
|
||
return $result;
|
||
}
|
||
|
||
$result->addInfo("Archivo encontrado: {$rendererPath}");
|
||
|
||
// Leer contenido del archivo
|
||
$content = file_get_contents($rendererPath);
|
||
|
||
// Validaciones
|
||
$this->validateNamespaceAndClass($content, $componentName, $result);
|
||
|
||
// Validaciones especiales para componentes de inyeccion
|
||
$isInjectionComponent = in_array($componentName, self::INJECTION_COMPONENTS, true);
|
||
|
||
if ($isInjectionComponent) {
|
||
$result->addInfo("✓ Componente de inyección - validaciones CSS/visibility omitidas");
|
||
} else {
|
||
$this->validateCSSGeneratorInjection($content, $result);
|
||
$this->validateNoCSSHardcoded($content, $result); // CRÍTICO
|
||
$this->validateGetVisibilityClassesMethod($content, $result);
|
||
}
|
||
|
||
$this->validateRenderMethod($content, $componentName, $result);
|
||
$this->validateEscaping($content, $result);
|
||
$this->validateNoDirectDatabaseAccess($content, $result);
|
||
|
||
// Estadísticas
|
||
$fileSize = filesize($rendererPath);
|
||
$lineCount = substr_count($content, "\n") + 1;
|
||
|
||
$result->setStat('Archivo', basename($rendererPath));
|
||
$result->setStat('Líneas', $lineCount);
|
||
$result->setStat('Tamaño', $fileSize . ' bytes');
|
||
|
||
if ($lineCount > 500) {
|
||
$result->addWarning("Archivo excede 500 líneas ({$lineCount}) - considerar refactorizar.");
|
||
} elseif ($lineCount > 300) {
|
||
$result->addInfo("ℹ️ Archivo tiene {$lineCount} líneas (recomendado: <300, aceptable: <500)");
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
private function findRendererPath(string $componentName, string $themePath): ?string
|
||
{
|
||
// Convertir kebab-case a PascalCase (para carpetas y archivos)
|
||
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
|
||
|
||
// Intentar en Public/ con PascalCase (PSR-4 standard)
|
||
$publicPath = $themePath . '/Public/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
|
||
if (file_exists($publicPath)) {
|
||
return $publicPath;
|
||
}
|
||
|
||
// Intentar en Admin/ con PascalCase (PSR-4 standard)
|
||
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
|
||
if (file_exists($adminPath)) {
|
||
return $adminPath;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
private function validateNamespaceAndClass(string $content, string $componentName, ValidationResult $result): void
|
||
{
|
||
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
|
||
|
||
// Validar namespace (Ui en PascalCase, primera letra mayúscula)
|
||
if (!preg_match('/namespace\s+ROITheme\\\\(Public|Admin)\\\\' . preg_quote($pascalCaseName, '/') . '\\\\Infrastructure\\\\Ui;/', $content)) {
|
||
$result->addError("Namespace incorrecto. Debe ser: ROITheme\\Public\\{$pascalCaseName}\\Infrastructure\\Ui");
|
||
}
|
||
|
||
// Validar clase final
|
||
if (!preg_match('/final\s+class\s+' . preg_quote($pascalCaseName, '/') . 'Renderer/', $content)) {
|
||
$result->addError("Clase debe ser: final class {$pascalCaseName}Renderer");
|
||
}
|
||
}
|
||
|
||
private function validateCSSGeneratorInjection(string $content, ValidationResult $result): void
|
||
{
|
||
// Verificar que constructor recibe CSSGeneratorInterface
|
||
if (!preg_match('/public\s+function\s+__construct\([^)]*CSSGeneratorInterface\s+\$cssGenerator/s', $content)) {
|
||
$result->addError("Constructor NO inyecta CSSGeneratorInterface (debe recibir interfaz, no clase concreta)");
|
||
}
|
||
|
||
// Verificar propiedad privada
|
||
if (!preg_match('/private\s+CSSGeneratorInterface\s+\$cssGenerator/', $content)) {
|
||
$result->addError("Falta propiedad: private CSSGeneratorInterface \$cssGenerator");
|
||
}
|
||
}
|
||
|
||
private function validateRenderMethod(string $content, string $componentName, ValidationResult $result): void
|
||
{
|
||
// Verificar método render existe (aceptar Component object o array - Component es preferido)
|
||
$hasComponentSignature = preg_match('/public\s+function\s+render\s*\(\s*Component\s+\$component\s*\)\s*:\s*string/', $content);
|
||
$hasArraySignature = preg_match('/public\s+function\s+render\s*\(\s*array\s+\$data\s*\)\s*:\s*string/', $content);
|
||
|
||
if (!$hasComponentSignature && !$hasArraySignature) {
|
||
$result->addError("Falta método: public function render(Component \$component): string o render(array \$data): string");
|
||
} elseif ($hasArraySignature) {
|
||
$result->addWarning("Método usa render(array \$data) - Considerar migrar a render(Component \$component) para type safety");
|
||
}
|
||
|
||
// Componentes de inyeccion no requieren validacion de visibility
|
||
$isInjectionComponent = in_array($componentName, self::INJECTION_COMPONENTS, true);
|
||
|
||
if (!$isInjectionComponent) {
|
||
// Verificar que valida is_enabled
|
||
$hasArrayValidation = preg_match('/\$data\s*\[\s*[\'"]visibility[\'"]\s*\]\s*\[\s*[\'"]is_enabled[\'"]\s*\]/', $content);
|
||
$hasComponentValidation = preg_match('/\$component->getVisibility\(\)->isEnabled\(\)/', $content);
|
||
|
||
if (!$hasArrayValidation && !$hasComponentValidation) {
|
||
$result->addWarning("Método render() debería validar visibilidad (is_enabled)");
|
||
}
|
||
}
|
||
}
|
||
|
||
private function validateNoCSSHardcoded(string $content, ValidationResult $result): void
|
||
{
|
||
$violations = [];
|
||
|
||
// Detectar style="..." (pero permitir algunos casos específicos del design system)
|
||
if (preg_match_all('/style\s*=\s*["\']/', $content, $matches, PREG_OFFSET_CAPTURE)) {
|
||
// Contar ocurrencias
|
||
$count = count($matches[0]);
|
||
|
||
// Si hay más de 2-3 (casos permitidos del design system), es violación
|
||
if ($count > 3) {
|
||
$violations[] = "Encontrado style=\"...\" inline ({$count} ocurrencias) - PROHIBIDO";
|
||
} elseif ($count > 0) {
|
||
$result->addWarning("Encontrado {$count} uso(s) de style=\"...\" - Verificar que sea del design system aprobado");
|
||
}
|
||
}
|
||
|
||
// Detectar heredoc <<<STYLE o <<<CSS
|
||
if (preg_match('/<<<(STYLE|CSS)/', $content)) {
|
||
$violations[] = "Encontrado heredoc <<<STYLE o <<<CSS - PROHIBIDO";
|
||
}
|
||
|
||
// Detectar eventos inline (onclick, onmouseover, etc.)
|
||
$inlineEvents = ['onclick', 'onmouseover', 'onmouseout', 'onload', 'onchange', 'onsubmit'];
|
||
foreach ($inlineEvents as $event) {
|
||
if (preg_match('/' . $event . '\s*=/', $content)) {
|
||
$violations[] = "Encontrado evento inline '{$event}=\"...\"' - PROHIBIDO";
|
||
}
|
||
}
|
||
|
||
// Verificar que usa $this->cssGenerator->generate()
|
||
if (!preg_match('/\$this->cssGenerator->generate\s*\(/', $content)) {
|
||
$violations[] = "NO usa \$this->cssGenerator->generate() - CSS debe generarse vía servicio";
|
||
}
|
||
|
||
if (!empty($violations)) {
|
||
foreach ($violations as $violation) {
|
||
$result->addError("❌ CRÍTICO - CSS HARDCODEADO: {$violation}");
|
||
}
|
||
} else {
|
||
$result->addInfo("✓ CERO CSS hardcodeado detectado");
|
||
}
|
||
}
|
||
|
||
private function validateGetVisibilityClassesMethod(string $content, ValidationResult $result): void
|
||
{
|
||
// Verificar firma del método
|
||
if (!preg_match('/private\s+function\s+getVisibilityClasses\s*\(\s*bool\s+\$desktop\s*,\s*bool\s+\$mobile\s*\)\s*:\s*\?string/', $content)) {
|
||
$result->addError("Falta método: private function getVisibilityClasses(bool \$desktop, bool \$mobile): ?string");
|
||
return;
|
||
}
|
||
|
||
// Verificar implementación de tabla Bootstrap
|
||
$requiredPatterns = [
|
||
'/d-none d-lg-block/' => "Patrón 'd-none d-lg-block' (desktop only)",
|
||
'/d-lg-none/' => "Patrón 'd-lg-none' (mobile only)",
|
||
];
|
||
|
||
foreach ($requiredPatterns as $pattern => $description) {
|
||
if (!preg_match($pattern, $content)) {
|
||
$result->addWarning("getVisibilityClasses() puede no implementar correctamente: {$description}");
|
||
}
|
||
}
|
||
}
|
||
|
||
private function validateEscaping(string $content, ValidationResult $result): void
|
||
{
|
||
$escapingUsed = false;
|
||
|
||
if (preg_match('/esc_html\s*\(/', $content)) {
|
||
$escapingUsed = true;
|
||
}
|
||
|
||
if (preg_match('/esc_attr\s*\(/', $content)) {
|
||
$escapingUsed = true;
|
||
}
|
||
|
||
if (preg_match('/esc_url\s*\(/', $content)) {
|
||
$escapingUsed = true;
|
||
}
|
||
|
||
if (!$escapingUsed) {
|
||
$result->addWarning("No se detectó uso de esc_html(), esc_attr() o esc_url() - Verificar escaping");
|
||
}
|
||
}
|
||
|
||
private function validateNoDirectDatabaseAccess(string $content, ValidationResult $result): void
|
||
{
|
||
// Verificar que NO usa global $wpdb
|
||
if (preg_match('/global\s+\$wpdb/', $content)) {
|
||
$result->addError("Renderer usa 'global \$wpdb' - Acceso a BD debe estar en Repository, NO en Renderer");
|
||
}
|
||
|
||
// Verificar que NO usa funciones directas de BD
|
||
$dbFunctions = ['get_option', 'update_option', 'get_post_meta', 'update_post_meta'];
|
||
foreach ($dbFunctions as $func) {
|
||
if (preg_match('/\b' . $func . '\s*\(/', $content)) {
|
||
$result->addWarning("Renderer usa '{$func}()' - Considerar mover a Repository");
|
||
}
|
||
}
|
||
}
|
||
|
||
public function getPhaseNumber(): int|string
|
||
{
|
||
return 3;
|
||
}
|
||
|
||
public function getPhaseDescription(): string
|
||
{
|
||
return 'Renderers (DB→HTML/CSS)';
|
||
}
|
||
}
|