feat: add make:module scaffolder and module:validate command
make:module <id> 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 <id> (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) <noreply@anthropic.com>
This commit is contained in:
265
lib/Console/Commands/Make/ModuleCommand.php
Normal file
265
lib/Console/Commands/Make/ModuleCommand.php
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Make;
|
||||||
|
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scaffolds a new module with manifest, registrar, policy, and directory structure.
|
||||||
|
*
|
||||||
|
* Usage: php bin/console make:module <id>
|
||||||
|
* Example: php bin/console make:module notifications
|
||||||
|
*
|
||||||
|
* Generates:
|
||||||
|
* modules/<id>/module.php
|
||||||
|
* modules/<id>/lib/Module/<Name>/<Name>ContainerRegistrar.php
|
||||||
|
* modules/<id>/lib/Module/<Name>/<Name>AuthorizationPolicy.php
|
||||||
|
* modules/<id>/pages/
|
||||||
|
* modules/<id>/templates/
|
||||||
|
* modules/<id>/web/css/
|
||||||
|
* modules/<id>/web/js/
|
||||||
|
* modules/<id>/tests/Module/<Name>/
|
||||||
|
*/
|
||||||
|
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 <module-id>
|
||||||
|
|
||||||
|
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 <<<PHP
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {$pascalName} module manifest.
|
||||||
|
*/
|
||||||
|
return [
|
||||||
|
'id' => '{$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 <<<PHP
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\\Module\\{$pascalName};
|
||||||
|
|
||||||
|
use MintyPHP\\App\\AppContainer;
|
||||||
|
use MintyPHP\\App\\Container\\ContainerRegistrar;
|
||||||
|
|
||||||
|
final class {$pascalName}ContainerRegistrar implements ContainerRegistrar
|
||||||
|
{
|
||||||
|
public function register(AppContainer \$container): void
|
||||||
|
{
|
||||||
|
// Register module services here.
|
||||||
|
// Example:
|
||||||
|
// \$container->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 <<<PHP
|
||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\\Module\\{$pascalName};
|
||||||
|
|
||||||
|
use MintyPHP\\Service\\Access\\AuthorizationDecision;
|
||||||
|
use MintyPHP\\Service\\Access\\AuthorizationPolicyInterface;
|
||||||
|
use MintyPHP\\Service\\Access\\PermissionService;
|
||||||
|
|
||||||
|
final class {$pascalName}AuthorizationPolicy implements AuthorizationPolicyInterface
|
||||||
|
{
|
||||||
|
public const ABILITY_VIEW = '{$snakeName}.view';
|
||||||
|
public const PERMISSION_KEY = '{$snakeName}.view';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly PermissionService \$permissionService
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public function supports(string \$ability): bool
|
||||||
|
{
|
||||||
|
return \$ability === self::ABILITY_VIEW;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authorize(string \$ability, array \$context = []): AuthorizationDecision
|
||||||
|
{
|
||||||
|
\$actorUserId = (int) (\$context['actor_user_id'] ?? 0);
|
||||||
|
if (\$actorUserId <= 0) {
|
||||||
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!\$this->permissionService->userHas(\$actorUserId, self::PERMISSION_KEY)) {
|
||||||
|
return AuthorizationDecision::deny(403, 'forbidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
return AuthorizationDecision::allow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
PHP;
|
||||||
|
}
|
||||||
|
}
|
||||||
345
lib/Console/Commands/Module/ValidateCommand.php
Normal file
345
lib/Console/Commands/Module/ValidateCommand.php
Normal file
@@ -0,0 +1,345 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace MintyPHP\Console\Commands\Module;
|
||||||
|
|
||||||
|
use MintyPHP\App\Container\ContainerRegistrar;
|
||||||
|
use MintyPHP\App\Module\Contracts\EventListener;
|
||||||
|
use MintyPHP\App\Module\Contracts\LayoutContextProvider;
|
||||||
|
use MintyPHP\App\Module\Contracts\ModuleDeactivationHandler;
|
||||||
|
use MintyPHP\App\Module\Contracts\SearchResourceProvider;
|
||||||
|
use MintyPHP\App\Module\Contracts\SessionProvider;
|
||||||
|
use MintyPHP\App\Module\ModuleManifest;
|
||||||
|
use MintyPHP\Console\Command;
|
||||||
|
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates a module's manifest, class contracts, and structure.
|
||||||
|
*
|
||||||
|
* Catches errors that would otherwise only surface at boot time.
|
||||||
|
*/
|
||||||
|
final class ValidateCommand extends Command
|
||||||
|
{
|
||||||
|
public function name(): string
|
||||||
|
{
|
||||||
|
return 'module:validate';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function description(): string
|
||||||
|
{
|
||||||
|
return 'Validate a module\'s manifest, classes, and structure';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function usage(): string
|
||||||
|
{
|
||||||
|
return <<<'USAGE'
|
||||||
|
Usage: php bin/console module:validate <module-id>
|
||||||
|
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<string>
|
||||||
|
*/
|
||||||
|
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<string>, list<string>} [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<string> &$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\\*";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user