fix(structure): Correct case-sensitivity for Linux compatibility

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>
This commit is contained in:
FrankZamora
2025-11-26 22:53:34 -06:00
parent a2548ab5c2
commit 90863cd8f5
92 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,299 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Conflictos CSS
*
* Detecta archivos CSS en Assets/ que podrían sobrescribir
* estilos generados dinámicamente por Renderers.
*
* Valida que:
* - NO existan archivos CSS hardcodeados para componentes con Renderer dinámico
* - NO haya reglas !important que sobrescriban estilos dinámicos
* - NO se encolen CSS externos que conflictúen con Renderers
*
* @since 1.0.0
*/
final class CSSConflictValidator implements PhaseValidatorInterface
{
/**
* Mapeo de componentes con Renderer dinámico a sus posibles archivos CSS conflictivos
*/
private const DYNAMIC_RENDERER_COMPONENTS = [
'top-notification-bar' => [
'css_files' => ['componente-top-bar.css', 'top-notification-bar.css'],
'css_classes' => ['.top-notification-bar', '.top-bar'],
],
'navbar' => [
'css_files' => ['componente-navbar.css', 'navbar.css'],
'css_classes' => ['.navbar', '.nav-link', '.navbar-brand', '.dropdown-menu'],
],
'cta-lets-talk' => [
'css_files' => ['componente-boton-lets-talk.css', 'cta-lets-talk.css'],
'css_classes' => ['.btn-lets-talk', '.cta-lets-talk'],
],
];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando conflictos CSS para: {$componentName}");
// Solo validar componentes con Renderer dinámico conocidos
if (!isset(self::DYNAMIC_RENDERER_COMPONENTS[$componentName])) {
$result->addInfo("Componente no tiene Renderer dinámico registrado - Omitiendo validación CSS");
return $result;
}
$componentConfig = self::DYNAMIC_RENDERER_COMPONENTS[$componentName];
// 1. Verificar que existe un Renderer dinámico
$hasRenderer = $this->hasRendererFile($componentName, $themePath);
if (!$hasRenderer) {
$result->addInfo("No se encontró Renderer dinámico - Validación CSS no aplica");
return $result;
}
$result->addInfo("✓ Renderer dinámico detectado");
// 2. Buscar archivos CSS conflictivos en Assets/css/
$cssData = $this->validateAssetsCSSFiles($componentName, $componentConfig, $themePath, $result);
// 3. Validar enqueue-scripts.php y determinar errores vs warnings
$this->validateEnqueueScripts($componentName, $componentConfig, $themePath, $result, $cssData);
return $result;
}
/**
* Verifica si existe un Renderer para el componente
*/
private function hasRendererFile(string $componentName, string $themePath): bool
{
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
$publicPath = $themePath . '/Public/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($publicPath)) {
return true;
}
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'Renderer.php';
if (file_exists($adminPath)) {
return true;
}
return false;
}
/**
* Valida archivos CSS en Assets/css/
*
* @return array{files: string[], important: array<string, int>} Archivos encontrados y violaciones
*/
private function validateAssetsCSSFiles(
string $componentName,
array $componentConfig,
string $themePath,
ValidationResult $result
): array {
$assetsPath = $themePath . '/Assets/css';
$cssFilesFound = [];
$importantViolations = [];
if (!is_dir($assetsPath)) {
$result->addInfo("No existe carpeta Assets/css/");
return ['files' => [], 'important' => []];
}
foreach ($componentConfig['css_files'] as $cssFileName) {
$cssFilePath = $assetsPath . '/' . $cssFileName;
if (file_exists($cssFilePath)) {
$cssFilesFound[] = $cssFileName;
$content = file_get_contents($cssFilePath);
// Buscar reglas !important
$importantCount = $this->countImportantRules($content);
if ($importantCount > 0) {
$importantViolations[$cssFileName] = $importantCount;
}
// Buscar clases CSS del componente
$conflictingClasses = $this->findConflictingClasses($content, $componentConfig['css_classes']);
if (!empty($conflictingClasses)) {
$result->addWarning(
"Archivo '{$cssFileName}' define clases del componente: " .
implode(', ', $conflictingClasses)
);
}
}
}
// Reportar archivos encontrados
if (!empty($cssFilesFound)) {
$result->addWarning(
"Archivos CSS hardcodeados encontrados en Assets/css/: " .
implode(', ', $cssFilesFound)
);
$result->addInfo(
"⚠️ Estos archivos podrían sobrescribir estilos generados dinámicamente por el Renderer"
);
} else {
$result->addInfo("✓ Sin archivos CSS hardcodeados conflictivos en Assets/css/");
}
// Stats
$result->setStat('Archivos CSS conflictivos', count($cssFilesFound));
$result->setStat('Reglas !important', array_sum($importantViolations));
return ['files' => $cssFilesFound, 'important' => $importantViolations];
}
/**
* Cuenta reglas !important en contenido CSS
*/
private function countImportantRules(string $content): int
{
preg_match_all('/!important/i', $content, $matches);
return count($matches[0]);
}
/**
* Encuentra clases CSS conflictivas en el contenido
*/
private function findConflictingClasses(string $content, array $cssClasses): array
{
$found = [];
foreach ($cssClasses as $className) {
// Escapar el punto para regex
$pattern = '/' . preg_quote($className, '/') . '\s*[{,]/';
if (preg_match($pattern, $content)) {
$found[] = $className;
}
}
return $found;
}
/**
* Valida Inc/enqueue-scripts.php para detectar CSS encolados
*
* @param array{files: string[], important: array<string, int>} $cssData Datos de archivos CSS encontrados
*/
private function validateEnqueueScripts(
string $componentName,
array $componentConfig,
string $themePath,
ValidationResult $result,
array $cssData
): void {
$enqueueScriptsPath = $themePath . '/Inc/enqueue-scripts.php';
if (!file_exists($enqueueScriptsPath)) {
$result->addWarning("No se encontró Inc/enqueue-scripts.php");
return;
}
$content = file_get_contents($enqueueScriptsPath);
$enqueuedFiles = [];
$commentedFiles = [];
foreach ($componentConfig['css_files'] as $cssFileName) {
// Buscar si el archivo está siendo encolado (multiline - wp_enqueue_style puede tener saltos de línea)
$pattern = '/wp_enqueue_style\s*\([^;]*' . preg_quote($cssFileName, '/') . '[^;]*\);/s';
if (preg_match($pattern, $content, $match)) {
// Verificar si está comentado
$matchPos = strpos($content, $match[0]);
$lineStart = strrpos(substr($content, 0, $matchPos), "\n") + 1;
$lineContent = substr($content, $lineStart, $matchPos - $lineStart);
// Verificar si la línea está en un bloque comentado /* */
$beforeMatch = substr($content, 0, $matchPos);
$lastCommentOpen = strrpos($beforeMatch, '/*');
$lastCommentClose = strrpos($beforeMatch, '*/');
$isCommented = false;
if ($lastCommentOpen !== false) {
if ($lastCommentClose === false || $lastCommentOpen > $lastCommentClose) {
$isCommented = true;
}
}
// También verificar comentario de línea //
if (strpos($lineContent, '//') !== false) {
$isCommented = true;
}
if ($isCommented) {
$commentedFiles[] = $cssFileName;
} else {
$enqueuedFiles[] = $cssFileName;
}
}
}
// Reportar resultados de enqueue
if (!empty($enqueuedFiles)) {
$result->addError(
"❌ CRÍTICO: CSS conflictivo ACTIVO en enqueue-scripts.php: " .
implode(', ', $enqueuedFiles)
);
$result->addInfo(
"SOLUCIÓN: Comentar wp_enqueue_style() para estos archivos ya que el Renderer genera CSS dinámico"
);
}
if (!empty($commentedFiles)) {
$result->addInfo(
"✓ CSS correctamente deshabilitado en enqueue-scripts.php: " .
implode(', ', $commentedFiles)
);
}
if (empty($enqueuedFiles) && empty($commentedFiles)) {
$result->addInfo("✓ Sin conflictos de enqueue detectados");
}
// Reportar !important violations basado en estado de enqueue
$importantViolations = $cssData['important'] ?? [];
if (!empty($importantViolations)) {
foreach ($importantViolations as $file => $count) {
if (in_array($file, $enqueuedFiles, true)) {
// CSS activo con !important → ERROR CRÍTICO
$result->addError(
"❌ CRÍTICO: '{$file}' ACTIVO con {$count} regla(s) !important que SOBRESCRIBEN estilos dinámicos"
);
} elseif (in_array($file, $commentedFiles, true)) {
// CSS deshabilitado con !important → WARNING (considerar eliminar archivo)
$result->addWarning(
"'{$file}' deshabilitado pero tiene {$count} regla(s) !important - Considerar eliminar archivo"
);
} else {
// Archivo existe pero no está en enqueue-scripts.php → WARNING
$result->addWarning(
"'{$file}' no está en enqueue-scripts.php pero tiene {$count} regla(s) !important"
);
}
}
}
}
public function getPhaseNumber(): int|string
{
return 'css';
}
public function getPhaseDescription(): string
{
return 'CSS Conflicts (Assets vs Dynamic Renderers)';
}
}

View File

@@ -0,0 +1,333 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Estructura de Carpetas Clean Architecture (PSR-4 PascalCase)
*
* Valida que:
* - Solo existen contextos permitidos (Admin/, Public/, Shared/)
* - Nombres de módulos en PascalCase (PSR-4 standard)
* - Shared Kernel: Infrastructure/ obligatoria, Domain/Application/ a nivel contexto
* - Carpetas dentro de capas son solo las arquitectónicamente permitidas
* - NO hay carpetas inventadas (Helpers/, Utils/, Lib/, etc.)
* - Niveles de profundidad correctos
*/
final class FolderStructureValidator implements PhaseValidatorInterface
{
private const ALLOWED_CONTEXTS = ['Admin', 'Public', 'Shared'];
private const REQUIRED_LAYERS = ['Domain', 'Application', 'Infrastructure'];
private const ALLOWED_IN_DOMAIN = ['Contracts', 'ValueObjects', 'Exceptions'];
private const ALLOWED_IN_APPLICATION = ['UseCases', 'Dtos', 'Contracts', 'Services'];
private const ALLOWED_IN_INFRASTRUCTURE = ['Persistence', 'Api', 'Ui', 'Services', 'WordPress', 'Traits', 'Di', 'Scripts', 'Validators'];
private const FORBIDDEN_NAMES = ['Helpers', 'Utils', 'Utilities', 'Lib', 'Libs', 'Core', 'Common', 'Base', 'Models'];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando estructura de carpetas para: {$componentName}");
// Buscar el módulo en Admin/ o Public/
$modulePath = $this->findModulePath($componentName, $themePath);
if ($modulePath === null) {
$result->addError("Módulo no encontrado en Admin/ ni Public/");
return $result;
}
$context = basename(dirname($modulePath));
$result->addInfo("Módulo encontrado en: {$context}/{$componentName}/");
// Validar nombre del módulo (debe coincidir con PascalCase esperado)
$this->validateModuleName($componentName, $modulePath, $result);
// Validar capas obligatorias
$this->validateRequiredLayers($modulePath, $result);
// Validar contenido de cada capa
$this->validateLayerContents($modulePath, $result);
// Validar profundidad de carpetas
$this->validateDepth($modulePath, $result);
// Buscar carpetas prohibidas
$this->findForbiddenFolders($modulePath, $result);
return $result;
}
private function findModulePath(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 validateModuleName(string $componentName, string $modulePath, ValidationResult $result): void
{
// Convertir a PascalCase esperado
$pascalCaseExpected = str_replace('-', '', ucwords($componentName, '-'));
$actualFolderName = basename($modulePath);
// El nombre de la carpeta debe ser PascalCase (PSR-4 standard)
if ($actualFolderName !== $pascalCaseExpected) {
$result->addError("Nombre de carpeta '{$actualFolderName}' no coincide con PascalCase esperado '{$pascalCaseExpected}'");
} else {
$result->addInfo("✓ Nombre de carpeta en PascalCase: {$actualFolderName}");
}
}
private function validateRequiredLayers(string $modulePath, ValidationResult $result): void
{
// SHARED KERNEL PATTERN:
// Los módulos individuales solo requieren Infrastructure/
// Domain/ y Application/ están a nivel de contexto (Admin/, Public/)
// Infrastructure/ es OBLIGATORIA
$infrastructurePath = $modulePath . '/Infrastructure';
if (!is_dir($infrastructurePath)) {
$result->addError("Falta capa obligatoria: Infrastructure/");
} else {
$result->addInfo("✓ Capa Infrastructure/ existe");
}
// Domain/ y Application/ son OPCIONALES (Shared Kernel a nivel contexto)
foreach (['Domain', 'Application'] as $optionalLayer) {
$layerPath = $modulePath . '/' . $optionalLayer;
if (is_dir($layerPath)) {
$result->addInfo("✓ Capa {$optionalLayer}/ existe (específica del módulo)");
}
}
$result->addInfo(" Arquitectura Shared Kernel: Domain/ y Application/ a nivel contexto (Admin/, Public/)");
}
private function validateLayerContents(string $modulePath, ValidationResult $result): void
{
// Validar Domain/
$this->validateLayerContent(
$modulePath . '/Domain',
'Domain',
self::ALLOWED_IN_DOMAIN,
$result
);
// Validar Application/
$this->validateLayerContent(
$modulePath . '/Application',
'Application',
self::ALLOWED_IN_APPLICATION,
$result
);
// Validar Infrastructure/
$this->validateLayerContent(
$modulePath . '/Infrastructure',
'Infrastructure',
self::ALLOWED_IN_INFRASTRUCTURE,
$result
);
}
private function validateLayerContent(string $layerPath, string $layerName, array $allowedFolders, ValidationResult $result): void
{
if (!is_dir($layerPath)) {
return; // Ya reportado en validateRequiredLayers
}
$items = scandir($layerPath);
$folders = array_filter($items, function($item) use ($layerPath) {
return $item !== '.' && $item !== '..' && is_dir($layerPath . '/' . $item);
});
foreach ($folders as $folder) {
// Validar que la carpeta está en la lista permitida (PascalCase)
if (!in_array($folder, $allowedFolders, true)) {
$result->addError("Carpeta NO permitida en {$layerName}/: '{$folder}' (permitidas: " . implode(', ', $allowedFolders) . ")");
}
}
// Validaciones especiales para infrastructure/ui/
if ($layerName === 'infrastructure' && in_array('ui', $folders, true)) {
$this->validateUIStructure($layerPath . '/ui', $result);
}
// Validaciones especiales para infrastructure/api/
if ($layerName === 'infrastructure' && in_array('api', $folders, true)) {
$this->validateAPIStructure($layerPath . '/api', $result);
}
}
private function validateUIStructure(string $uiPath, ValidationResult $result): void
{
if (!is_dir($uiPath)) {
return;
}
$items = scandir($uiPath);
$folders = array_filter($items, function($item) use ($uiPath) {
return $item !== '.' && $item !== '..' && is_dir($uiPath . '/' . $item);
});
$allowedInUI = ['assets', 'templates', 'views'];
foreach ($folders as $folder) {
if (!in_array($folder, $allowedInUI, true)) {
$result->addWarning("Carpeta en ui/: '{$folder}' (esperadas: " . implode(', ', $allowedInUI) . ")");
}
}
// Si existe assets/, validar que tiene css/ y/o js/
if (in_array('assets', $folders, true)) {
$assetsPath = $uiPath . '/assets';
$assetsItems = scandir($assetsPath);
$assetsFolders = array_filter($assetsItems, function($item) use ($assetsPath) {
return $item !== '.' && $item !== '..' && is_dir($assetsPath . '/' . $item);
});
$allowedInAssets = ['css', 'js', 'images', 'fonts'];
foreach ($assetsFolders as $folder) {
if (!in_array($folder, $allowedInAssets, true)) {
$result->addWarning("Carpeta en ui/assets/: '{$folder}' (esperadas: " . implode(', ', $allowedInAssets) . ")");
}
}
}
}
private function validateAPIStructure(string $apiPath, ValidationResult $result): void
{
if (!is_dir($apiPath)) {
return;
}
$items = scandir($apiPath);
$folders = array_filter($items, function($item) use ($apiPath) {
return $item !== '.' && $item !== '..' && is_dir($apiPath . '/' . $item);
});
$allowedInAPI = ['wordpress'];
foreach ($folders as $folder) {
if (!in_array($folder, $allowedInAPI, true)) {
$result->addWarning("Carpeta en api/: '{$folder}' (esperada: wordpress)");
}
}
}
private function validateDepth(string $modulePath, ValidationResult $result): void
{
// Máximo 5 niveles desde módulo: module/layer/category/subcategory/file.php
$maxDepth = 5;
$deepPaths = [];
$this->scanDirectoryDepth($modulePath, $modulePath, 0, $maxDepth, $deepPaths);
if (!empty($deepPaths)) {
foreach ($deepPaths as $path => $depth) {
$result->addWarning("Ruta muy profunda ({$depth} niveles): {$path}");
}
}
}
private function scanDirectoryDepth(string $basePath, string $currentPath, int $currentDepth, int $maxDepth, array &$deepPaths): void
{
if ($currentDepth > $maxDepth) {
$relativePath = str_replace($basePath . '/', '', $currentPath);
$deepPaths[$relativePath] = $currentDepth;
return;
}
if (!is_dir($currentPath)) {
return;
}
$items = scandir($currentPath);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $currentPath . '/' . $item;
if (is_dir($itemPath)) {
$this->scanDirectoryDepth($basePath, $itemPath, $currentDepth + 1, $maxDepth, $deepPaths);
}
}
}
private function findForbiddenFolders(string $modulePath, ValidationResult $result): void
{
$foundForbidden = [];
$this->scanForForbiddenNames($modulePath, $modulePath, $foundForbidden);
if (!empty($foundForbidden)) {
foreach ($foundForbidden as $path) {
$relativePath = str_replace($modulePath . '/', '', $path);
$folderName = basename($path);
$result->addError("❌ Carpeta PROHIBIDA encontrada: {$relativePath}/ ('{$folderName}' NO es arquitectónicamente válido)");
}
} else {
$result->addInfo("✓ Sin carpetas prohibidas (helpers, utils, etc.)");
}
}
private function scanForForbiddenNames(string $basePath, string $currentPath, array &$foundForbidden): void
{
if (!is_dir($currentPath)) {
return;
}
$items = scandir($currentPath);
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$itemPath = $currentPath . '/' . $item;
if (is_dir($itemPath)) {
// Verificar si el nombre de la carpeta está prohibido
if (in_array(strtolower($item), self::FORBIDDEN_NAMES, true)) {
$foundForbidden[] = $itemPath;
}
// Recursivo
$this->scanForForbiddenNames($basePath, $itemPath, $foundForbidden);
}
}
}
public function getPhaseNumber(): int|string
{
return 'structure';
}
public function getPhaseDescription(): string
{
return 'Folder Structure (Clean Architecture)';
}
}

View File

@@ -0,0 +1,255 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 01: Schema JSON
*
* Valida que el archivo JSON del componente cumple con:
* - Estructura correcta
* - Campos obligatorios presentes
* - Tipos de datos válidos
* - Grupos y campos requeridos
*/
final class Phase01Validator implements PhaseValidatorInterface
{
private const ALLOWED_TYPES = ['boolean', 'text', 'textarea', 'url', 'select', 'color'];
private const ALLOWED_PRIORITIES = [10, 20, 30, 40, 50, 60, 70, 80, 90];
private const STANDARD_GROUPS = [
'visibility', 'content', 'typography', 'colors', 'spacing',
'visual_effects', 'behavior', 'layout', 'links', 'icons', 'media', 'forms'
];
/**
* Componentes especiales que NO requieren grupo visibility
*
* Estos son componentes de inyeccion (no visuales) que:
* - NO renderizan HTML visual
* - Inyectan codigo en hooks (wp_head, wp_footer)
* - Siempre estan activos (controlados por campos vacios/llenos)
*/
private const INJECTION_COMPONENTS = ['theme-settings'];
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
// Construir ruta al schema
$schemaPath = $themePath . '/Schemas/' . $componentName . '.json';
$result->addInfo("Validando Schema JSON: schemas/{$componentName}.json");
// 1. Verificar que el archivo existe
if (!file_exists($schemaPath)) {
$result->addError("Schema JSON no encontrado: {$schemaPath}");
return $result;
}
// 2. Leer y parsear JSON
$jsonContent = file_get_contents($schemaPath);
$schema = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$result->addError("JSON inválido: " . json_last_error_msg());
return $result;
}
// 3. Validar estructura top-level
$this->validateTopLevelStructure($schema, $componentName, $result);
// 4. Validar grupos
if (isset($schema['groups'])) {
$this->validateGroups($schema['groups'], $result);
}
// 5. Validar campos obligatorios de visibilidad (excepto componentes de inyeccion)
$this->validateVisibilityFields($schema, $componentName, $result);
// Estadísticas
$totalFields = $this->countTotalFields($schema);
$totalGroups = isset($schema['groups']) ? count($schema['groups']) : 0;
$result->setStat('Archivo', "schemas/{$componentName}.json");
$result->setStat('Grupos totales', $totalGroups);
$result->setStat('Campos totales', $totalFields);
$result->setStat('Tamaño JSON', strlen($jsonContent) . ' bytes');
return $result;
}
private function validateTopLevelStructure(array $schema, string $componentName, ValidationResult $result): void
{
// Campos obligatorios
$requiredFields = ['component_name', 'version', 'description', 'groups'];
foreach ($requiredFields as $field) {
if (!isset($schema[$field])) {
$result->addError("Campo obligatorio faltante: '{$field}'");
}
}
// Validar component_name coincide con archivo
if (isset($schema['component_name']) && $schema['component_name'] !== $componentName) {
$result->addError(
"component_name '{$schema['component_name']}' no coincide con nombre de archivo '{$componentName}'"
);
}
// Validar versión semver
if (isset($schema['version'])) {
if (!preg_match('/^\d+\.\d+\.\d+$/', $schema['version'])) {
$result->addError("Versión '{$schema['version']}' no es semver válido (debe ser X.Y.Z)");
}
}
}
private function validateGroups(array $groups, ValidationResult $result): void
{
if (empty($groups)) {
$result->addError("Schema debe tener al menos un grupo");
return;
}
foreach ($groups as $groupName => $group) {
// Validar nombre de grupo es snake_case
if (!preg_match('/^[a-z_]+$/', $groupName)) {
$result->addError("Nombre de grupo '{$groupName}' debe estar en snake_case (solo minúsculas y _)");
}
// Advertencia si grupo no es estándar
if (!in_array($groupName, self::STANDARD_GROUPS, true)) {
$result->addWarning("Grupo '{$groupName}' no es estándar (considerar usar: " . implode(', ', self::STANDARD_GROUPS) . ")");
}
// Validar estructura del grupo
if (!isset($group['label'])) {
$result->addError("Grupo '{$groupName}' no tiene 'label'");
}
if (!isset($group['priority'])) {
$result->addError("Grupo '{$groupName}' no tiene 'priority'");
} elseif (!in_array($group['priority'], self::ALLOWED_PRIORITIES, true)) {
$result->addError(
"Grupo '{$groupName}' tiene priority inválido ({$group['priority']}). " .
"Debe ser uno de: " . implode(', ', self::ALLOWED_PRIORITIES)
);
}
if (!isset($group['fields'])) {
$result->addError("Grupo '{$groupName}' no tiene 'fields'");
} elseif (!is_array($group['fields']) || empty($group['fields'])) {
$result->addError("Grupo '{$groupName}' debe tener al menos un campo");
} else {
$this->validateFields($groupName, $group['fields'], $result);
}
}
}
private function validateFields(string $groupName, array $fields, ValidationResult $result): void
{
foreach ($fields as $fieldName => $field) {
$fullFieldName = "{$groupName}.{$fieldName}";
// Validar nombre de campo es snake_case
if (!preg_match('/^[a-z_]+$/', $fieldName)) {
$result->addError("Campo '{$fullFieldName}' debe estar en snake_case (solo minúsculas y _)");
}
// Campos obligatorios
if (!isset($field['type'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'type'");
} elseif (!in_array($field['type'], self::ALLOWED_TYPES, true)) {
$result->addError(
"Campo '{$fullFieldName}' tiene type inválido '{$field['type']}'. " .
"Debe ser uno de: " . implode(', ', self::ALLOWED_TYPES)
);
}
if (!isset($field['label'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'label'");
}
if (!array_key_exists('default', $field)) {
$result->addError("Campo '{$fullFieldName}' no tiene 'default'");
}
if (!isset($field['editable'])) {
$result->addError("Campo '{$fullFieldName}' no tiene 'editable'");
} elseif (!is_bool($field['editable'])) {
$result->addError("Campo '{$fullFieldName}' tiene 'editable' que no es boolean");
}
// Si type es select, debe tener options
if (isset($field['type']) && $field['type'] === 'select') {
if (!isset($field['options']) || !is_array($field['options']) || empty($field['options'])) {
$result->addError("Campo '{$fullFieldName}' es type 'select' pero no tiene array 'options' válido");
}
}
// Si tiene required, debe ser boolean
if (isset($field['required']) && !is_bool($field['required'])) {
$result->addError("Campo '{$fullFieldName}' tiene 'required' que no es boolean");
}
}
}
private function validateVisibilityFields(array $schema, string $componentName, ValidationResult $result): void
{
// Componentes de inyeccion no requieren grupo visibility
if (in_array($componentName, self::INJECTION_COMPONENTS, true)) {
$result->addInfo("✓ Componente de inyección '{$componentName}' - grupo visibility no requerido");
return;
}
if (!isset($schema['groups']['visibility'])) {
$result->addError("Grupo 'visibility' es obligatorio y no está presente");
return;
}
$visibilityFields = $schema['groups']['visibility']['fields'] ?? [];
// Campos obligatorios de visibilidad
$requiredVisibilityFields = [
'is_enabled' => 'boolean',
'show_on_desktop' => 'boolean',
'show_on_mobile' => 'boolean',
];
foreach ($requiredVisibilityFields as $fieldName => $expectedType) {
if (!isset($visibilityFields[$fieldName])) {
$result->addError("Campo obligatorio de visibilidad faltante: 'visibility.{$fieldName}'");
} elseif (isset($visibilityFields[$fieldName]['type']) && $visibilityFields[$fieldName]['type'] !== $expectedType) {
$result->addError(
"Campo 'visibility.{$fieldName}' debe ser type '{$expectedType}' " .
"(encontrado: '{$visibilityFields[$fieldName]['type']}')"
);
}
}
}
private function countTotalFields(array $schema): int
{
$count = 0;
if (isset($schema['groups'])) {
foreach ($schema['groups'] as $group) {
if (isset($group['fields'])) {
$count += count($group['fields']);
}
}
}
return $count;
}
public function getPhaseNumber(): int|string
{
return 1;
}
public function getPhaseDescription(): string
{
return 'Schema JSON';
}
}

View File

@@ -0,0 +1,215 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 02: Sincronización JSON→BD
*
* Valida que:
* - Schema JSON existe y es válido
* - Tabla de BD existe
* - Todos los campos del JSON están sincronizados en BD
* - No hay campos huérfanos en BD
* - is_editable coincide entre JSON y BD
* - No hay duplicados
*/
final class Phase02Validator implements PhaseValidatorInterface
{
private const TABLE_NAME = 'wp_roi_theme_component_settings';
public function validate(string $componentName, string $themePath): ValidationResult
{
global $wpdb;
$result = new ValidationResult();
$result->addInfo("Validando sincronización JSON→BD para: {$componentName}");
// 1. Verificar que schema JSON existe
$schemaPath = $themePath . '/Schemas/' . $componentName . '.json';
if (!file_exists($schemaPath)) {
$result->addError("Schema JSON no encontrado: {$schemaPath}");
$result->addInfo("Ejecutar primero: wp roi-theme sync-component {$componentName}");
return $result;
}
// 2. Parsear JSON
$jsonContent = file_get_contents($schemaPath);
$schema = json_decode($jsonContent, true);
if (json_last_error() !== JSON_ERROR_NONE) {
$result->addError("JSON inválido: " . json_last_error_msg());
return $result;
}
// 3. Verificar que tabla existe
$tableName = $wpdb->prefix . 'roi_theme_component_settings';
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$tableExists = $wpdb->get_var($wpdb->prepare(
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = DATABASE() AND table_name = %s",
$tableName
));
if ($tableExists == 0) {
$result->addError("Tabla '{$tableName}' no existe en la base de datos");
$result->addInfo("La tabla debería crearse automáticamente en functions.php");
return $result;
}
// 4. Obtener todos los campos del JSON
$jsonFields = $this->extractFieldsFromSchema($schema);
$totalJsonFields = count($jsonFields);
// 5. Obtener todos los registros de BD para este componente
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$dbRecords = $wpdb->get_results($wpdb->prepare(
"SELECT component_name, group_name, attribute_name, is_editable FROM {$tableName} WHERE component_name = %s",
$componentName
), ARRAY_A);
$totalDbRecords = count($dbRecords);
// 6. Validar sincronización
$this->validateSync($componentName, $jsonFields, $dbRecords, $result);
// 7. Validar no hay duplicados
$this->validateNoDuplicates($componentName, $tableName, $wpdb, $result);
// Estadísticas
$result->setStat('Schema JSON', "schemas/{$componentName}.json");
$result->setStat('Campos en JSON', $totalJsonFields);
$result->setStat('Registros en BD', $totalDbRecords);
$result->setStat('Tabla BD', $tableName);
return $result;
}
/**
* Extrae todos los campos del schema JSON
*
* @param array $schema
* @return array Array de arrays con ['group' => '', 'attribute' => '', 'editable' => bool]
*/
private function extractFieldsFromSchema(array $schema): array
{
$fields = [];
if (!isset($schema['groups'])) {
return $fields;
}
foreach ($schema['groups'] as $groupName => $group) {
if (!isset($group['fields'])) {
continue;
}
foreach ($group['fields'] as $attributeName => $field) {
$fields[] = [
'group' => $groupName,
'attribute' => $attributeName,
'editable' => $field['editable'] ?? false,
];
}
}
return $fields;
}
private function validateSync(string $componentName, array $jsonFields, array $dbRecords, ValidationResult $result): void
{
// Crear índice de registros de BD para búsqueda rápida
$dbIndex = [];
foreach ($dbRecords as $record) {
$key = $record['group_name'] . '.' . $record['attribute_name'];
$dbIndex[$key] = $record;
}
// Validar que cada campo del JSON está en BD
$missingInDb = [];
$editableMismatch = [];
foreach ($jsonFields as $field) {
$key = $field['group'] . '.' . $field['attribute'];
if (!isset($dbIndex[$key])) {
$missingInDb[] = $key;
} else {
// Validar is_editable coincide
$dbEditable = (bool) $dbIndex[$key]['is_editable'];
$jsonEditable = $field['editable'];
if ($dbEditable !== $jsonEditable) {
$editableMismatch[] = "{$key} (JSON: " . ($jsonEditable ? 'true' : 'false') .
", BD: " . ($dbEditable ? 'true' : 'false') . ")";
}
// Remover de índice para detectar huérfanos
unset($dbIndex[$key]);
}
}
// Campos faltantes en BD
if (!empty($missingInDb)) {
foreach ($missingInDb as $field) {
$result->addError("Campo '{$field}' existe en JSON pero NO en BD");
}
$result->addInfo("Ejecutar: wp roi-theme sync-component {$componentName}");
}
// Campos huérfanos en BD (no están en JSON)
if (!empty($dbIndex)) {
foreach ($dbIndex as $key => $record) {
$result->addWarning("Campo '{$key}' existe en BD pero NO en JSON (campo huérfano)");
}
}
// is_editable no coincide
if (!empty($editableMismatch)) {
foreach ($editableMismatch as $mismatch) {
$result->addError("Campo {$mismatch} tiene is_editable diferente entre JSON y BD");
}
$result->addInfo("Ejecutar: wp roi-theme sync-component {$componentName}");
}
// Si todo está sincronizado
if (empty($missingInDb) && empty($editableMismatch) && empty($dbIndex)) {
$result->addInfo("✓ Todos los campos están sincronizados correctamente");
}
}
private function validateNoDuplicates(string $componentName, string $tableName, $wpdb, ValidationResult $result): void
{
// Buscar duplicados
// phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching
$duplicates = $wpdb->get_results($wpdb->prepare(
"SELECT component_name, group_name, attribute_name, COUNT(*) as count
FROM {$tableName}
WHERE component_name = %s
GROUP BY component_name, group_name, attribute_name
HAVING count > 1",
$componentName
), ARRAY_A);
if (!empty($duplicates)) {
foreach ($duplicates as $dup) {
$result->addError(
"Duplicado en BD: {$dup['group_name']}.{$dup['attribute_name']} " .
"({$dup['count']} registros)"
);
}
$result->addInfo("El constraint UNIQUE debería prevenir duplicados. Revisar integridad de BD.");
}
}
public function getPhaseNumber(): int|string
{
return 2;
}
public function getPhaseDescription(): string
{
return 'JSON→DB Sync';
}
}

View File

@@ -0,0 +1,272 @@
<?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)';
}
}

View File

@@ -0,0 +1,365 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de Fase 04: FormBuilders (UI Admin)
*
* Valida que:
* - FormBuilder existe en ubicación correcta (admin/)
* - Namespace y clase correctos
* - Inyecta AdminDashboardRenderer
* - Tiene método buildForm()
* - Es modular (métodos privados build*)
* - Usa AdminDashboardRenderer correctamente
* - Usa Bootstrap 5
* - Escaping correcto
* - NO accede directamente a BD
*/
final class Phase04Validator implements PhaseValidatorInterface
{
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando FormBuilder para: {$componentName}");
// Buscar FormBuilder en Admin/
$formBuilderPath = $this->findFormBuilderPath($componentName, $themePath);
if ($formBuilderPath === null) {
$result->addError("FormBuilder no encontrado en Admin/");
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
$result->addInfo("Ubicación esperada: Admin/{$pascalCaseName}/Infrastructure/Ui/*FormBuilder.php");
return $result;
}
$result->addInfo("Archivo encontrado: {$formBuilderPath}");
// Leer contenido
$content = file_get_contents($formBuilderPath);
// Validaciones del FormBuilder
$this->validateNamespaceAndClass($content, $componentName, $result);
$this->validateRendererInjection($content, $result);
$this->validateBuildFormMethod($content, $result);
$this->validateModularity($content, $result);
$this->validateRendererUsage($content, $result);
$this->validateBootstrapCompliance($content, $result);
$this->validateEscaping($content, $result);
$this->validateNoDirectDatabaseAccess($content, $result);
// Validaciones de integración con Admin Panel (CRÍTICAS)
$this->validateComponentRegistration($componentName, $themePath, $result);
$this->validateAjaxFieldMapping($componentName, $themePath, $content, $result);
$this->validateResetButton($componentName, $content, $result);
// Estadísticas
$fileSize = filesize($formBuilderPath);
$lineCount = substr_count($content, "\n") + 1;
$buildMethodsCount = $this->countBuildMethods($content);
$result->setStat('Archivo', basename($formBuilderPath));
$result->setStat('Líneas', $lineCount);
$result->setStat('Métodos build*', $buildMethodsCount);
$result->setStat('Tamaño', $fileSize . ' bytes');
if ($lineCount > 500) {
$result->addWarning("Archivo excede 500 líneas ({$lineCount}) - considerar dividir en múltiples FormBuilders.");
} elseif ($lineCount > 300) {
$result->addInfo(" Archivo tiene {$lineCount} líneas (recomendado: <300, aceptable: <500)");
}
if ($buildMethodsCount < 2) {
$result->addWarning("Solo {$buildMethodsCount} método(s) build*. FormBuilders modulares deben tener al menos 2-3 métodos privados.");
}
return $result;
}
private function findFormBuilderPath(string $componentName, string $themePath): ?string
{
// Convertir kebab-case a PascalCase (para carpetas y archivos)
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
// Buscar en Admin/ con PascalCase (PSR-4 standard)
$adminPath = $themePath . '/Admin/' . $pascalCaseName . '/Infrastructure/Ui/' . $pascalCaseName . 'FormBuilder.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\\\\Admin\\\\' . preg_quote($pascalCaseName, '/') . '\\\\Infrastructure\\\\Ui;/', $content)) {
$result->addError("Namespace incorrecto. Debe ser: ROITheme\\Admin\\{$pascalCaseName}\\Infrastructure\\Ui");
}
// Validar clase final
if (!preg_match('/final\s+class\s+' . preg_quote($pascalCaseName, '/') . 'FormBuilder/', $content)) {
$result->addError("Clase debe ser: final class {$pascalCaseName}FormBuilder");
}
}
private function validateRendererInjection(string $content, ValidationResult $result): void
{
// Verificar que constructor recibe AdminDashboardRenderer
if (!preg_match('/public\s+function\s+__construct\([^)]*AdminDashboardRenderer\s+\$renderer/s', $content)) {
$result->addError("Constructor NO inyecta AdminDashboardRenderer");
}
// Verificar propiedad privada
if (!preg_match('/private\s+AdminDashboardRenderer\s+\$renderer/', $content)) {
$result->addError("Falta propiedad: private AdminDashboardRenderer \$renderer");
}
}
private function validateBuildFormMethod(string $content, ValidationResult $result): void
{
// Verificar método buildForm existe
if (!preg_match('/public\s+function\s+buildForm\s*\(\s*string\s+\$componentId\s*\)\s*:\s*string/', $content)) {
$result->addError("Falta método: public function buildForm(string \$componentId): string");
}
}
private function validateModularity(string $content, ValidationResult $result): void
{
// Contar métodos privados build*
$buildMethodsCount = $this->countBuildMethods($content);
if ($buildMethodsCount === 0) {
$result->addError("FormBuilder NO tiene métodos privados build* - Debe ser modular");
} elseif ($buildMethodsCount < 2) {
$result->addWarning("Solo {$buildMethodsCount} método build* - Considerar más modularidad");
} else {
$result->addInfo("✓ Modularidad: {$buildMethodsCount} métodos build* detectados");
}
}
private function countBuildMethods(string $content): int
{
preg_match_all('/private\s+function\s+build[A-Z][a-zA-Z]*\s*\(/', $content, $matches);
return count($matches[0]);
}
private function validateRendererUsage(string $content, ValidationResult $result): void
{
// Verificar que usa $this->renderer->getFieldValue()
if (!preg_match('/\$this->renderer->getFieldValue\s*\(/', $content)) {
$result->addWarning("No se detectó uso de \$this->renderer->getFieldValue() - Verificar que obtiene datos del renderer");
}
}
private function validateBootstrapCompliance(string $content, ValidationResult $result): void
{
$bootstrapClasses = [
'form-control' => false,
'form-select' => false,
'form-check' => false,
'card' => false,
'row' => false,
];
foreach ($bootstrapClasses as $class => $found) {
// Buscar clase dentro de atributos class="..." o class='...'
// Acepta: class="row", class="row g-3", class="form-check form-switch", etc.
if (preg_match('/class=["\'][^"\']*\b' . preg_quote($class, '/') . '\b[^"\']*["\']/', $content)) {
$bootstrapClasses[$class] = true;
}
}
$usedClasses = array_keys(array_filter($bootstrapClasses));
if (count($usedClasses) === 0) {
$result->addWarning("No se detectaron clases Bootstrap 5 - Verificar que usa Bootstrap");
} else {
$result->addInfo("✓ Bootstrap 5: Usa " . implode(', ', $usedClasses));
}
}
private function validateEscaping(string $content, ValidationResult $result): void
{
$escapingUsed = false;
if (preg_match('/esc_attr\s*\(/', $content)) {
$escapingUsed = true;
}
if (preg_match('/esc_html\s*\(/', $content)) {
$escapingUsed = true;
}
if (!$escapingUsed) {
$result->addWarning("No se detectó uso de esc_attr() o esc_html() - Verificar escaping de valores");
}
}
private function validateNoDirectDatabaseAccess(string $content, ValidationResult $result): void
{
// Verificar que NO usa global $wpdb
if (preg_match('/global\s+\$wpdb/', $content)) {
$result->addError("FormBuilder usa 'global \$wpdb' - Acceso a BD debe estar en Repository o AdminDashboardRenderer");
}
// 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->addError("FormBuilder usa '{$func}()' - Debe usar AdminDashboardRenderer, NO acceder BD directamente");
}
}
}
/**
* Valida que el componente está registrado en AdminDashboardRenderer::getComponents()
*/
private function validateComponentRegistration(string $componentName, string $themePath, ValidationResult $result): void
{
$dashboardRendererPath = $themePath . '/Admin/Infrastructure/Ui/AdminDashboardRenderer.php';
if (!file_exists($dashboardRendererPath)) {
$result->addWarning("No se encontró AdminDashboardRenderer.php - No se puede validar registro del componente");
return;
}
$dashboardContent = file_get_contents($dashboardRendererPath);
// Buscar si el componente está registrado en getComponents()
// Busca patrones como: 'cta-lets-talk' => [ o "cta-lets-talk" => [
$pattern = '/[\'"]' . preg_quote($componentName, '/') . '[\'"]\s*=>\s*\[/';
if (!preg_match($pattern, $dashboardContent)) {
$result->addError("Componente NO registrado en AdminDashboardRenderer::getComponents()");
$result->addInfo("Agregar en Admin/Infrastructure/Ui/AdminDashboardRenderer.php método getComponents():");
$result->addInfo("'{$componentName}' => ['id' => '{$componentName}', 'label' => '...', 'icon' => 'bi-...'],");
} else {
$result->addInfo("✓ Componente registrado en AdminDashboardRenderer::getComponents()");
}
}
/**
* Valida que existe mapeo AJAX para los campos del FormBuilder
*/
private function validateAjaxFieldMapping(string $componentName, string $themePath, string $formBuilderContent, ValidationResult $result): void
{
$ajaxHandlerPath = $themePath . '/Admin/Infrastructure/Api/Wordpress/AdminAjaxHandler.php';
if (!file_exists($ajaxHandlerPath)) {
$result->addWarning("No se encontró AdminAjaxHandler.php - No se puede validar mapeo AJAX");
return;
}
$ajaxContent = file_get_contents($ajaxHandlerPath);
// Extraer todos los IDs de campos del FormBuilder (id="...")
preg_match_all('/id=["\']([a-zA-Z0-9_]+)["\']/', $formBuilderContent, $fieldMatches);
$formFieldIds = array_unique($fieldMatches[1]);
// Filtrar solo los IDs que corresponden a inputs (no contenedores)
// Los IDs de inputs generalmente tienen un patrón como componentNameFieldName
$fieldPrefix = $this->getFieldPrefix($componentName);
$inputFieldIds = array_filter($formFieldIds, function($id) use ($fieldPrefix) {
// Solo IDs que empiezan con el prefijo del componente
return stripos($id, $fieldPrefix) === 0;
});
if (empty($inputFieldIds)) {
$result->addWarning("No se detectaron campos con prefijo '{$fieldPrefix}' en FormBuilder");
return;
}
// Verificar que cada campo tiene mapeo en getFieldMapping()
$unmappedFields = [];
foreach ($inputFieldIds as $fieldId) {
// Buscar el ID en el contenido del AjaxHandler
if (!preg_match('/[\'"]' . preg_quote($fieldId, '/') . '[\'"]\s*=>/', $ajaxContent)) {
$unmappedFields[] = $fieldId;
}
}
$totalFields = count($inputFieldIds);
$mappedFields = $totalFields - count($unmappedFields);
if (count($unmappedFields) > 0) {
$result->addError("Mapeo AJAX incompleto: {$mappedFields}/{$totalFields} campos mapeados");
$result->addInfo("Campos sin mapeo en AdminAjaxHandler::getFieldMapping():");
foreach (array_slice($unmappedFields, 0, 5) as $field) {
$result->addInfo(" - {$field}");
}
if (count($unmappedFields) > 5) {
$result->addInfo(" ... y " . (count($unmappedFields) - 5) . " más");
}
} else {
$result->addInfo("✓ Mapeo AJAX completo: {$totalFields}/{$totalFields} campos mapeados");
}
}
/**
* Valida que el botón "Restaurar valores por defecto" sigue el patrón correcto
*
* El patrón correcto es:
* - class="btn-reset-defaults"
* - data-component="nombre-componente"
*
* NO usar id="reset*Defaults" (patrón antiguo hardcodeado)
*/
private function validateResetButton(string $componentName, string $content, ValidationResult $result): void
{
// Verificar patrón correcto: class="btn-reset-defaults" data-component="..."
$correctPattern = '/class=["\'][^"\']*btn-reset-defaults[^"\']*["\'][^>]*data-component=["\']' . preg_quote($componentName, '/') . '["\']/';
$correctPatternAlt = '/data-component=["\']' . preg_quote($componentName, '/') . '["\'][^>]*class=["\'][^"\']*btn-reset-defaults[^"\']*["\']/';
$hasCorrectPattern = preg_match($correctPattern, $content) || preg_match($correctPatternAlt, $content);
// Verificar patrón incorrecto: id="reset*Defaults" (hardcodeado)
$incorrectPattern = '/id=["\']reset[A-Za-z]+Defaults["\']/';
$hasIncorrectPattern = preg_match($incorrectPattern, $content);
if ($hasIncorrectPattern && !$hasCorrectPattern) {
$result->addError("Botón reset usa patrón hardcodeado (id=\"reset*Defaults\")");
$result->addInfo("Cambiar a: class=\"btn-reset-defaults\" data-component=\"{$componentName}\"");
} elseif (!$hasCorrectPattern) {
$result->addWarning("No se detectó botón 'Restaurar valores por defecto' con patrón dinámico");
$result->addInfo("Agregar: <button class=\"btn-reset-defaults\" data-component=\"{$componentName}\">...</button>");
} else {
$result->addInfo("✓ Botón reset con patrón dinámico correcto (data-component=\"{$componentName}\")");
}
}
/**
* Obtiene el prefijo de campos para un componente
* Permite prefijos personalizados más cortos para componentes con nombres largos
*/
private function getFieldPrefix(string $componentName): string
{
// Mapeo de prefijos personalizados (más cortos/legibles)
$customPrefixes = [
'top-notification-bar' => 'topBar',
];
if (isset($customPrefixes[$componentName])) {
return $customPrefixes[$componentName];
}
// Por defecto: convertir kebab-case a camelCase
$pascalCaseName = str_replace('-', '', ucwords($componentName, '-'));
return lcfirst($pascalCaseName);
}
public function getPhaseNumber(): int|string
{
return 4;
}
public function getPhaseDescription(): string
{
return 'FormBuilders (UI Admin)';
}
}

View File

@@ -0,0 +1,305 @@
<?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)';
}
}

View File

@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Contrato para validadores de fases
*
* Cada fase del flujo de trabajo tiene un validador específico
* que verifica que la implementación cumple con los estándares
*/
interface PhaseValidatorInterface
{
/**
* Valida un componente para la fase específica
*
* @param string $componentName Nombre del componente en kebab-case (ej: 'top-notification-bar')
* @param string $themePath Ruta absoluta al tema de WordPress
* @return ValidationResult Resultado de la validación con errores, warnings e info
*/
public function validate(string $componentName, string $themePath): ValidationResult;
/**
* Retorna el número de fase que valida
*
* @return int|string 1, 2, 3, 4, 5 o 'structure'
*/
public function getPhaseNumber(): int|string;
/**
* Retorna descripción corta de qué valida esta fase
*
* @return string Descripción breve (ej: 'Schema JSON', 'JSON→DB Sync')
*/
public function getPhaseDescription(): string;
}

View File

@@ -0,0 +1,225 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Validador de llamadas a roi_render_component() en templates
*
* Verifica que:
* - Los nombres de componentes usen kebab-case (guiones, no underscores)
* - Los componentes llamados estén registrados en functions-addon.php
* - No haya llamadas con nombres inválidos
*/
final class TemplateCallsValidator implements PhaseValidatorInterface
{
public function getPhaseNumber(): int|string
{
return 'templates';
}
public function getPhaseDescription(): string
{
return 'Template Calls (roi_render_component)';
}
public function validate(string $componentName, string $themePath): ValidationResult
{
$result = new ValidationResult();
$result->addInfo("Validando llamadas a roi_render_component() para: {$componentName}");
// 1. Obtener componentes registrados en functions-addon.php
$registeredComponents = $this->getRegisteredComponents($themePath);
$result->addInfo("Componentes registrados: " . count($registeredComponents));
// 2. Buscar todas las llamadas en templates
$templateFiles = $this->getTemplateFiles($themePath);
$allCalls = [];
$invalidCalls = [];
$unregisteredCalls = [];
foreach ($templateFiles as $file) {
$calls = $this->findRenderCalls($file);
foreach ($calls as $call) {
$allCalls[] = [
'file' => basename($file),
'line' => $call['line'],
'component' => $call['component']
];
// Verificar si usa underscore en lugar de guión
if (strpos($call['component'], '_') !== false) {
$invalidCalls[] = $call + ['file' => basename($file)];
}
// Verificar si está registrado
if (!in_array($call['component'], $registeredComponents)) {
// Solo marcar como no registrado si no tiene underscore
// (los de underscore ya se marcan como inválidos)
if (strpos($call['component'], '_') === false) {
$unregisteredCalls[] = $call + ['file' => basename($file)];
}
}
}
}
// 3. Verificar específicamente el componente que se está validando
$componentFound = false;
$componentCallsCorrect = true;
foreach ($allCalls as $call) {
// Buscar variantes del componente (con guión o underscore)
$kebabName = $componentName;
$snakeName = str_replace('-', '_', $componentName);
if ($call['component'] === $kebabName) {
$componentFound = true;
$result->addInfo("✓ Componente '{$componentName}' llamado correctamente en {$call['file']}:{$call['line']}");
} elseif ($call['component'] === $snakeName) {
$componentFound = true;
$componentCallsCorrect = false;
$result->addError(
"Llamada incorrecta en {$call['file']}:{$call['line']}: " .
"usa '{$snakeName}' (underscore) en lugar de '{$kebabName}' (kebab-case)"
);
}
}
// 4. Reportar errores generales de formato
foreach ($invalidCalls as $call) {
// Solo reportar si no es el componente actual (ya se reportó arriba)
$snakeName = str_replace('-', '_', $componentName);
if ($call['component'] !== $snakeName) {
$suggestedName = str_replace('_', '-', $call['component']);
$result->addWarning(
"Llamada con underscore en {$call['file']}:{$call['line']}: " .
"'{$call['component']}' debería ser '{$suggestedName}'"
);
}
}
// 5. Reportar componentes no registrados
foreach ($unregisteredCalls as $call) {
$result->addWarning(
"Componente no registrado en {$call['file']}:{$call['line']}: '{$call['component']}'"
);
}
// 6. Advertir si el componente no se llama en ningún template
if (!$componentFound) {
$result->addWarning(
"El componente '{$componentName}' no se llama en ningún template. " .
"Verifica que esté incluido en single.php, header.php, footer.php u otro template."
);
}
// Estadísticas
$result->setStat('Templates escaneados', count($templateFiles));
$result->setStat('Llamadas totales encontradas', count($allCalls));
$result->setStat('Componentes registrados', count($registeredComponents));
if (!empty($invalidCalls)) {
$result->setStat('Llamadas con underscore (error)', count($invalidCalls));
}
return $result;
}
/**
* Obtiene la lista de componentes registrados en functions-addon.php
*/
private function getRegisteredComponents(string $themePath): array
{
$functionsFile = $themePath . '/functions-addon.php';
$components = [];
if (!file_exists($functionsFile)) {
return $components;
}
$content = file_get_contents($functionsFile);
// Buscar patrones: case 'nombre-componente':
if (preg_match_all("/case\s+'([a-z0-9-]+)':/", $content, $matches)) {
$components = $matches[1];
}
return array_unique($components);
}
/**
* Obtiene los archivos de template PHP del tema
*/
private function getTemplateFiles(string $themePath): array
{
$templates = [];
// Archivos principales de template
$mainTemplates = [
'header.php',
'footer.php',
'single.php',
'page.php',
'index.php',
'archive.php',
'search.php',
'sidebar.php',
'404.php',
'front-page.php',
'home.php',
];
foreach ($mainTemplates as $template) {
$path = $themePath . '/' . $template;
if (file_exists($path)) {
$templates[] = $path;
}
}
// Buscar en template-parts/
$templatePartsDir = $themePath . '/template-parts';
if (is_dir($templatePartsDir)) {
$iterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($templatePartsDir)
);
foreach ($iterator as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$templates[] = $file->getPathname();
}
}
}
// Buscar templates personalizados (page-*.php, single-*.php)
$customTemplates = glob($themePath . '/{page-*.php,single-*.php}', GLOB_BRACE);
if ($customTemplates) {
$templates = array_merge($templates, $customTemplates);
}
return $templates;
}
/**
* Busca llamadas a roi_render_component() en un archivo
*/
private function findRenderCalls(string $filePath): array
{
$calls = [];
$content = file_get_contents($filePath);
$lines = explode("\n", $content);
foreach ($lines as $lineNum => $line) {
// Buscar: roi_render_component('nombre') o roi_render_component("nombre")
if (preg_match_all("/roi_render_component\s*\(\s*['\"]([^'\"]+)['\"]\s*\)/", $line, $matches, PREG_SET_ORDER)) {
foreach ($matches as $match) {
$calls[] = [
'line' => $lineNum + 1,
'component' => $match[1]
];
}
}
}
return $calls;
}
}

View File

@@ -0,0 +1,157 @@
<?php
declare(strict_types=1);
namespace ROITheme\Shared\Infrastructure\Validators;
/**
* Resultado de una validación
*
* Almacena errores, advertencias, información y estadísticas
*/
final class ValidationResult
{
/**
* @param bool $success Estado inicial de éxito
* @param array<string> $errors Lista de errores críticos
* @param array<string> $warnings Lista de advertencias
* @param array<string> $info Lista de mensajes informativos
* @param array<string, mixed> $stats Estadísticas de la validación
*/
public function __construct(
private bool $success = true,
private array $errors = [],
private array $warnings = [],
private array $info = [],
private array $stats = []
) {}
/**
* Verifica si la validación fue exitosa
*
* @return bool True si no hay errores, false si hay al menos un error
*/
public function isSuccess(): bool
{
return $this->success && empty($this->errors);
}
/**
* Obtiene todos los errores críticos
*
* @return array<string>
*/
public function getErrors(): array
{
return $this->errors;
}
/**
* Obtiene todas las advertencias
*
* @return array<string>
*/
public function getWarnings(): array
{
return $this->warnings;
}
/**
* Obtiene todos los mensajes informativos
*
* @return array<string>
*/
public function getInfo(): array
{
return $this->info;
}
/**
* Obtiene todas las estadísticas
*
* @return array<string, mixed>
*/
public function getStats(): array
{
return $this->stats;
}
/**
* Agrega un error crítico
*
* Al agregar un error, la validación se marca como fallida
*
* @param string $message Mensaje del error
* @return void
*/
public function addError(string $message): void
{
$this->errors[] = $message;
$this->success = false;
}
/**
* Agrega una advertencia
*
* Las advertencias NO marcan la validación como fallida
*
* @param string $message Mensaje de advertencia
* @return void
*/
public function addWarning(string $message): void
{
$this->warnings[] = $message;
}
/**
* Agrega un mensaje informativo
*
* @param string $message Mensaje informativo
* @return void
*/
public function addInfo(string $message): void
{
$this->info[] = $message;
}
/**
* Establece una estadística
*
* @param string $key Clave de la estadística
* @param mixed $value Valor de la estadística
* @return void
*/
public function setStat(string $key, mixed $value): void
{
$this->stats[$key] = $value;
}
/**
* Cuenta total de errores
*
* @return int
*/
public function getErrorCount(): int
{
return count($this->errors);
}
/**
* Cuenta total de advertencias
*
* @return int
*/
public function getWarningCount(): int
{
return count($this->warnings);
}
/**
* Marca la validación como fallida manualmente
*
* @return void
*/
public function markAsFailed(): void
{
$this->success = false;
}
}