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>
366 lines
15 KiB
PHP
366 lines
15 KiB
PHP
<?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)';
|
||
}
|
||
}
|