php bin/console module:validate --all Validates: - Manifest parses without error - All declared classes exist and implement correct interfaces - PHP classes use MintyPHP\Module\* namespace - Permission keys are well-formed - Required directories exist - Route paths don't contain suspicious patterns USAGE; } public function execute(array $args, array $options): int { $this->bootstrapApp('cli', 'bin/console module:validate'); $modulesDir = $this->projectRoot() . '/modules'; $validateAll = $options['all'] ?? false; $moduleId = trim($args[0] ?? ''); if (!$validateAll && $moduleId === '') { fwrite(STDERR, $this->usage() . "\n"); return 1; } $moduleIds = $validateAll ? $this->discoverModuleIds($modulesDir) : [$moduleId]; if ($moduleIds === []) { fwrite(STDERR, "No modules found in modules/\n"); return 1; } $totalErrors = 0; $totalWarnings = 0; foreach ($moduleIds as $id) { [$errors, $warnings] = $this->validateModule($modulesDir, $id); $totalErrors += count($errors); $totalWarnings += count($warnings); if ($errors === [] && $warnings === []) { fwrite(STDOUT, "[OK] {$id}\n"); continue; } foreach ($errors as $error) { fwrite(STDOUT, "[FAIL] {$id}: {$error}\n"); } foreach ($warnings as $warning) { fwrite(STDOUT, "[WARN] {$id}: {$warning}\n"); } } fwrite(STDOUT, sprintf( "\nSummary: %d module(s), %d error(s), %d warning(s)\n", count($moduleIds), $totalErrors, $totalWarnings )); return $totalErrors > 0 ? 1 : 0; } /** * @return list */ private function discoverModuleIds(string $modulesDir): array { if (!is_dir($modulesDir)) { return []; } $ids = []; $entries = scandir($modulesDir) ?: []; foreach ($entries as $entry) { if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { continue; } if (is_file($modulesDir . '/' . $entry . '/module.php')) { $ids[] = $entry; } } sort($ids); return $ids; } /** * @return array{list, list} [errors, warnings] */ private function validateModule(string $modulesDir, string $moduleId): array { $errors = []; $warnings = []; $moduleDir = $modulesDir . '/' . $moduleId; $manifestFile = $moduleDir . '/module.php'; // 1. Directory exists if (!is_dir($moduleDir)) { return [["Module directory not found: modules/{$moduleId}/"], []]; } // 2. Manifest exists if (!is_file($manifestFile)) { return [["Manifest not found: modules/{$moduleId}/module.php"], []]; } // 3. Manifest parses try { $raw = require $manifestFile; if (!is_array($raw)) { return [['Manifest must return an array'], []]; } } catch (\Throwable $e) { return [['Manifest parse error: ' . $e->getMessage()], []]; } // 4. ModuleManifest validates try { $manifest = ModuleManifest::fromArray($raw, $moduleDir); } catch (\Throwable $e) { return [['Manifest validation error: ' . $e->getMessage()], []]; } // Register module lib/ with autoloader so we can resolve classes // even if the module is not in the enabled list $libDir = $moduleDir . '/lib'; if (is_dir($libDir)) { $composerLoader = null; foreach (spl_autoload_functions() as $autoloader) { if (is_array($autoloader) && $autoloader[0] instanceof \Composer\Autoload\ClassLoader) { $composerLoader = $autoloader[0]; break; } } $composerLoader?->addPsr4('MintyPHP\\', [$libDir]); } // 5. ID matches directory if ($manifest->id !== $moduleId) { $errors[] = "Manifest id '{$manifest->id}' does not match directory '{$moduleId}'"; } // 6. Version is semver if (!preg_match('/^\d+\.\d+\.\d+$/', $manifest->version)) { $warnings[] = "Version '{$manifest->version}' is not semver (expected x.y.z)"; } // 7. lib/ directory exists if (!is_dir($moduleDir . '/lib')) { $warnings[] = 'No lib/ directory (module has no PHP classes)'; } // 8. Container registrar classes exist and implement interface foreach ($manifest->containerRegistrars as $class) { if (!class_exists($class)) { $errors[] = "Container registrar class not found: {$class}"; } elseif (!is_subclass_of($class, ContainerRegistrar::class)) { $errors[] = "Container registrar does not implement ContainerRegistrar: {$class}"; } } // 9. Authorization policy classes foreach ($manifest->authorizationPolicies as $class) { if (!class_exists($class)) { $errors[] = "Authorization policy class not found: {$class}"; } elseif (!in_array(AuthorizationPolicyInterface::class, class_implements($class) ?: [], true)) { $errors[] = "Authorization policy does not implement AuthorizationPolicyInterface: {$class}"; } } // 10. Session provider classes foreach ($manifest->sessionProviders as $class) { if (!class_exists($class)) { $errors[] = "Session provider class not found: {$class}"; } elseif (!in_array(SessionProvider::class, class_implements($class) ?: [], true)) { $errors[] = "Session provider does not implement SessionProvider: {$class}"; } } // 11. Layout context provider classes foreach ($manifest->layoutContextProviders as $class) { if (!class_exists($class)) { $errors[] = "Layout context provider class not found: {$class}"; } elseif (!in_array(LayoutContextProvider::class, class_implements($class) ?: [], true)) { $errors[] = "Layout context provider does not implement LayoutContextProvider: {$class}"; } } // 12. Search resource provider classes foreach ($manifest->searchResources as $class) { if (!class_exists($class)) { $errors[] = "Search resource provider class not found: {$class}"; } elseif (!in_array(SearchResourceProvider::class, class_implements($class) ?: [], true)) { $errors[] = "Search resource provider does not implement SearchResourceProvider: {$class}"; } } // 13. Event listener classes foreach ($manifest->eventListeners as $eventName => $handlers) { foreach ($handlers as $i => $handler) { $class = $handler['class']; if (!class_exists($class)) { $errors[] = "Event listener class not found: {$class} (event: {$eventName})"; } elseif (!in_array(EventListener::class, class_implements($class) ?: [], true)) { $errors[] = "Event listener does not implement EventListener: {$class} (event: {$eventName})"; } } } // 14. Deactivation handler class if ($manifest->deactivationHandler !== null) { $class = $manifest->deactivationHandler; if (!class_exists($class)) { $errors[] = "Deactivation handler class not found: {$class}"; } elseif (!in_array(ModuleDeactivationHandler::class, class_implements($class) ?: [], true)) { $errors[] = "Deactivation handler does not implement ModuleDeactivationHandler: {$class}"; } } // 15. Permission keys are well-formed foreach ($manifest->permissions as $i => $perm) { $key = $perm['key']; if (!preg_match('/^[a-z][a-z0-9._-]+$/', $key)) { $errors[] = "Permission key has invalid format: '{$key}'"; } } // 16. Namespace compliance — PHP files in lib/ must use MintyPHP\Module\* if (is_dir($moduleDir . '/lib')) { $this->checkNamespaceCompliance($moduleDir . '/lib', $moduleId, $errors); } // 17. Routes have pages directory if ($manifest->routes !== [] && !is_dir($moduleDir . '/pages')) { $errors[] = 'Module declares routes but has no pages/ directory'; } // 18. UI slot templates exist foreach ($manifest->uiSlots as $slotName => $contributions) { $contributionList = is_array($contributions) ? $contributions : [$contributions]; foreach ($contributionList as $idx => $contribution) { if (!is_array($contribution)) { continue; } foreach (['panel_template', 'template'] as $templateKey) { $template = $contribution[$templateKey] ?? null; if ($template === null) { continue; } $templatePath = (string) $template; if (!str_starts_with($templatePath, '/')) { $templatePath = $moduleDir . '/' . $templatePath; } if (!is_file($templatePath)) { $errors[] = "UI slot '{$slotName}' {$templateKey} not found: {$template}"; } } } } // 19. Required modules exist on disk foreach ($manifest->requires as $requiredId) { if (!is_dir($modulesDir . '/' . $requiredId)) { $errors[] = "Required module '{$requiredId}' not found on disk"; } } return [$errors, $warnings]; } /** * @param list &$errors */ private function checkNamespaceCompliance(string $libDir, string $moduleId, array &$errors): void { $coreNamespacePrefixes = [ 'MintyPHP\\Service\\', 'MintyPHP\\Repository\\', 'MintyPHP\\Support\\', 'MintyPHP\\Http\\', 'MintyPHP\\App\\Container\\', ]; $iterator = new \RecursiveIteratorIterator( new \RecursiveDirectoryIterator($libDir, \FilesystemIterator::SKIP_DOTS), \RecursiveIteratorIterator::LEAVES_ONLY ); foreach ($iterator as $file) { if ($file->getExtension() !== 'php') { continue; } $content = file_get_contents($file->getPathname()); if ($content === false) { continue; } if (preg_match('/^namespace\s+(.+?);/m', $content, $match)) { $namespace = $match[1]; foreach ($coreNamespacePrefixes as $corePrefix) { if (str_starts_with($namespace . '\\', $corePrefix)) { $errors[] = "{$file->getFilename()} uses core namespace '{$namespace}' — must use MintyPHP\\Module\\*"; } } } } } }