Migración completa a Clean Architecture con componentes funcionales
- Reorganización de estructura: Admin/, Public/, Shared/, Schemas/ - 12 componentes migrados: TopNotificationBar, Navbar, CtaLetsTalk, Hero, FeaturedImage, TableOfContents, CtaBoxSidebar, SocialShare, CtaPost, RelatedPost, ContactForm, Footer - Panel de administración con tabs Bootstrap 5 funcionales - Schemas JSON para configuración de componentes - Renderers dinámicos con CSSGeneratorService (cero CSS hardcodeado) - FormBuilders para UI admin con Design System consistente - Fix: Bootstrap JS cargado en header para tabs funcionales - Fix: buildTextInput maneja valores mixed (bool/string) - Eliminación de estructura legacy (src/, admin/, assets/css/componente-*) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
299
shared/Infrastructure/Validators/CSSConflictValidator.php
Normal file
299
shared/Infrastructure/Validators/CSSConflictValidator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
333
shared/Infrastructure/Validators/FolderStructureValidator.php
Normal file
333
shared/Infrastructure/Validators/FolderStructureValidator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
239
shared/Infrastructure/Validators/Phase01Validator.php
Normal file
239
shared/Infrastructure/Validators/Phase01Validator.php
Normal file
@@ -0,0 +1,239 @@
|
||||
<?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'
|
||||
];
|
||||
|
||||
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
|
||||
$this->validateVisibilityFields($schema, $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, ValidationResult $result): void
|
||||
{
|
||||
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';
|
||||
}
|
||||
}
|
||||
215
shared/Infrastructure/Validators/Phase02Validator.php
Normal file
215
shared/Infrastructure/Validators/Phase02Validator.php
Normal 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';
|
||||
}
|
||||
}
|
||||
247
shared/Infrastructure/Validators/Phase03Validator.php
Normal file
247
shared/Infrastructure/Validators/Phase03Validator.php
Normal file
@@ -0,0 +1,247 @@
|
||||
<?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
|
||||
{
|
||||
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);
|
||||
$this->validateCSSGeneratorInjection($content, $result);
|
||||
$this->validateRenderMethod($content, $result);
|
||||
$this->validateNoCSSHardcoded($content, $result); // CRÍTICO
|
||||
$this->validateGetVisibilityClassesMethod($content, $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, 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");
|
||||
}
|
||||
|
||||
// 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)';
|
||||
}
|
||||
}
|
||||
365
shared/Infrastructure/Validators/Phase04Validator.php
Normal file
365
shared/Infrastructure/Validators/Phase04Validator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
305
shared/Infrastructure/Validators/Phase05Validator.php
Normal file
305
shared/Infrastructure/Validators/Phase05Validator.php
Normal 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)';
|
||||
}
|
||||
}
|
||||
36
shared/Infrastructure/Validators/PhaseValidatorInterface.php
Normal file
36
shared/Infrastructure/Validators/PhaseValidatorInterface.php
Normal 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;
|
||||
}
|
||||
225
shared/Infrastructure/Validators/TemplateCallsValidator.php
Normal file
225
shared/Infrastructure/Validators/TemplateCallsValidator.php
Normal 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;
|
||||
}
|
||||
}
|
||||
157
shared/Infrastructure/Validators/ValidationResult.php
Normal file
157
shared/Infrastructure/Validators/ValidationResult.php
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user