From bc59ace2403ca04fec00bdbe2f1b8fba5f36d8e5 Mon Sep 17 00:00:00 2001 From: fs Date: Thu, 19 Mar 2026 20:29:57 +0100 Subject: [PATCH] feat: add make:module scaffolder and module:validate command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit make:module generates the full module directory structure with manifest (all 18 fields), ContainerRegistrar stub, AuthorizationPolicy stub, and empty pages/templates/web/tests directories. Prevents duplicate IDs and invalid formats. module:validate (or --all) checks manifest parsing, class existence, interface compliance, namespace rules, permission key format, template file existence, and dependency resolution — catching errors that would otherwise only surface at boot time. Temporarily registers module PSR-4 paths so non-enabled modules can be validated too. Co-Authored-By: Claude Opus 4.6 (1M context) --- lib/Console/Commands/Make/ModuleCommand.php | 265 ++++++++++++++ .../Commands/Module/ValidateCommand.php | 345 ++++++++++++++++++ 2 files changed, 610 insertions(+) create mode 100644 lib/Console/Commands/Make/ModuleCommand.php create mode 100644 lib/Console/Commands/Module/ValidateCommand.php diff --git a/lib/Console/Commands/Make/ModuleCommand.php b/lib/Console/Commands/Make/ModuleCommand.php new file mode 100644 index 0000000..99b197f --- /dev/null +++ b/lib/Console/Commands/Make/ModuleCommand.php @@ -0,0 +1,265 @@ + + * Example: php bin/console make:module notifications + * + * Generates: + * modules//module.php + * modules//lib/Module//ContainerRegistrar.php + * modules//lib/Module//AuthorizationPolicy.php + * modules//pages/ + * modules//templates/ + * modules//web/css/ + * modules//web/js/ + * modules//tests/Module// + */ +final class ModuleCommand extends Command +{ + public function name(): string + { + return 'make:module'; + } + + public function description(): string + { + return 'Scaffold a new module with manifest and stub classes'; + } + + public function usage(): string + { + return <<<'USAGE' + Usage: php bin/console make:module + + Arguments: + module-id Lowercase identifier (e.g. notifications, activity-log) + Must match [a-z][a-z0-9-]* pattern. + + Example: + php bin/console make:module notifications + + Generates the full module directory structure with: + - module.php manifest (all fields, sensible defaults) + - ContainerRegistrar stub + - AuthorizationPolicy stub + - Empty pages/, templates/, web/css/, web/js/, tests/ directories + USAGE; + } + + public function execute(array $args, array $options): int + { + $moduleId = trim($args[0] ?? ''); + + if ($moduleId === '') { + fwrite(STDERR, $this->usage() . "\n"); + return 1; + } + + if (!preg_match('/^[a-z][a-z0-9-]*$/', $moduleId)) { + fwrite(STDERR, "Error: module ID must match [a-z][a-z0-9-]* (got '{$moduleId}')\n"); + return 1; + } + + $modulesDir = $this->projectRoot() . '/modules'; + $moduleDir = $modulesDir . '/' . $moduleId; + + if (is_dir($moduleDir)) { + fwrite(STDERR, "Error: module directory already exists: modules/{$moduleId}/\n"); + return 1; + } + + $pascalName = self::toPascalCase($moduleId); + $snakeName = str_replace('-', '_', $moduleId); + + // Create directories + $dirs = [ + "lib/Module/{$pascalName}", + 'pages', + 'templates', + 'web/css', + 'web/js', + "tests/Module/{$pascalName}", + ]; + + foreach ($dirs as $dir) { + $path = $moduleDir . '/' . $dir; + if (!mkdir($path, 0777, true) && !is_dir($path)) { + fwrite(STDERR, "Error: failed to create directory: {$dir}\n"); + return 1; + } + } + + // Generate files + $files = [ + 'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName), + "lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName), + "lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName), + ]; + + foreach ($files as $relativePath => $content) { + $fullPath = $moduleDir . '/' . $relativePath; + if (file_put_contents($fullPath, $content) === false) { + fwrite(STDERR, "Error: failed to write file: {$relativePath}\n"); + return 1; + } + } + + // Add .gitkeep to empty directories + foreach (['pages', 'templates', 'web/css', 'web/js', "tests/Module/{$pascalName}"] as $dir) { + touch($moduleDir . '/' . $dir . '/.gitkeep'); + } + + fwrite(STDOUT, "Module '{$moduleId}' scaffolded at modules/{$moduleId}/\n\n"); + fwrite(STDOUT, "Created:\n"); + fwrite(STDOUT, " modules/{$moduleId}/module.php\n"); + fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php\n"); + fwrite(STDOUT, " modules/{$moduleId}/lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php\n"); + fwrite(STDOUT, " modules/{$moduleId}/pages/\n"); + fwrite(STDOUT, " modules/{$moduleId}/templates/\n"); + fwrite(STDOUT, " modules/{$moduleId}/web/css/\n"); + fwrite(STDOUT, " modules/{$moduleId}/web/js/\n"); + fwrite(STDOUT, " modules/{$moduleId}/tests/Module/{$pascalName}/\n"); + fwrite(STDOUT, "\nNext steps:\n"); + fwrite(STDOUT, " 1. Add '{$moduleId}' to config/modules.php enabled_modules\n"); + fwrite(STDOUT, " 2. Edit module.php to define routes, permissions, and UI slots\n"); + fwrite(STDOUT, " 3. Run: php bin/console module:sync\n"); + + return 0; + } + + private static function toPascalCase(string $id): string + { + return str_replace(' ', '', ucwords(str_replace('-', ' ', $id))); + } + + private static function manifestTemplate(string $id, string $pascalName, string $snakeName): string + { + $registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class"; + $policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class"; + + return << '{$id}', + 'version' => '0.1.0', + 'enabled_by_default' => false, + 'load_order' => 100, + 'requires' => [], + + 'routes' => [ + // ['path' => '{$id}', 'target' => '{$id}'], + ], + 'public_paths' => [], + 'container_registrars' => [ + {$registrarClass}, + ], + + 'ui_slots' => [], + + 'authorization_policies' => [ + {$policyClass}, + ], + + 'search_resources' => [], + 'asset_groups' => [], + 'scheduler_jobs' => [], + + 'layout_context_providers' => [], + 'session_providers' => [], + + 'permissions' => [ + // [ + // 'key' => '{$snakeName}.view', + // 'description' => 'Can view {$id}', + // 'active' => true, + // 'is_system' => true, + // ], + ], + + 'event_listeners' => [], + 'deactivation_handler' => null, + 'migrations_path' => null, + ]; + + PHP; + } + + private static function registrarTemplate(string $pascalName): string + { + return <<set(MyService::class, static fn (AppContainer \$c): MyService => new MyService( + // \$c->get(SomeDependency::class) + // )); + } + } + + PHP; + } + + private static function policyTemplate(string $pascalName, string $snakeName): string + { + return <<permissionService->userHas(\$actorUserId, self::PERMISSION_KEY)) { + return AuthorizationDecision::deny(403, 'forbidden'); + } + + return AuthorizationDecision::allow(); + } + } + + PHP; + } +} diff --git a/lib/Console/Commands/Module/ValidateCommand.php b/lib/Console/Commands/Module/ValidateCommand.php new file mode 100644 index 0000000..e37402e --- /dev/null +++ b/lib/Console/Commands/Module/ValidateCommand.php @@ -0,0 +1,345 @@ + + 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\\*"; + } + } + } + } + } +}