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