feat(modules): harden module platform with event constants, validation checks, and CLI improvements

- Add ModuleEvents constants class with all 8 event strings; update dispatch call sites
- ValidateCommand: check AuthorizationPolicy container registration (check 20)
- ValidateCommand: warn on unknown event_listeners keys (check 21)
- make:module --simple flag for minimal manifests
- module:deactivate reverse-dependency warning
- 12 new PHPUnit tests covering all improvements

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 08:23:04 +02:00
parent 143d887a5c
commit b1a907b79c
10 changed files with 604 additions and 24 deletions

View File

@@ -35,17 +35,22 @@ final class ModuleCommand extends Command
public function usage(): string
{
return <<<'USAGE'
Usage: php bin/console make:module <module-id>
Usage: php bin/console make:module <module-id> [--simple]
Arguments:
module-id Lowercase identifier (e.g. notifications, activity-log)
Must match [a-z][a-z0-9-]* pattern.
Options:
--simple Generate a minimal manifest with only essential fields.
Ideal for lightweight UI-only modules.
Example:
php bin/console make:module notifications
php bin/console make:module help-panel --simple
Generates the full module directory structure with:
- module.php manifest (all fields, sensible defaults)
- module.php manifest (all fields or minimal with --simple)
- ContainerRegistrar stub
- AuthorizationPolicy stub
- Empty pages/, templates/, web/css/, web/js/, tests/ directories
@@ -96,8 +101,11 @@ final class ModuleCommand extends Command
}
// Generate files
$simple = $options['simple'] ?? false;
$files = [
'module.php' => self::manifestTemplate($moduleId, $pascalName, $snakeName),
'module.php' => $simple
? self::simpleManifestTemplate($moduleId, $pascalName)
: self::manifestTemplate($moduleId, $pascalName, $snakeName),
"lib/Module/{$pascalName}/{$pascalName}ContainerRegistrar.php" => self::registrarTemplate($pascalName),
"lib/Module/{$pascalName}/{$pascalName}AuthorizationPolicy.php" => self::policyTemplate($pascalName, $snakeName),
];
@@ -199,6 +207,37 @@ final class ModuleCommand extends Command
PHP;
}
private static function simpleManifestTemplate(string $id, string $pascalName): string
{
$registrarClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}ContainerRegistrar::class";
$policyClass = "\\MintyPHP\\Module\\{$pascalName}\\{$pascalName}AuthorizationPolicy::class";
return <<<PHP
<?php
/**
* {$pascalName} module manifest (minimal).
*
* Extend with routes, permissions, ui_slots, etc. as needed.
* See docs/howto-modul-erstellen.md for all available fields.
*/
return [
'id' => '{$id}',
'version' => '0.1.0',
'enabled_by_default' => false,
'load_order' => 100,
'requires' => [],
'container_registrars' => [
{$registrarClass},
],
'authorization_policies' => [
{$policyClass},
],
];
PHP;
}
private static function registrarTemplate(string $pascalName): string
{
return <<<PHP

View File

@@ -8,6 +8,7 @@ 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\ModuleEvents;
use MintyPHP\App\Module\ModuleManifestLoader;
use MintyPHP\Console\Command;
use MintyPHP\Service\Access\AuthorizationPolicyInterface;
@@ -292,9 +293,63 @@ final class ValidateCommand extends Command
}
}
// 20. AuthorizationPolicy classes referenced in ContainerRegistrar source
$registrarSources = $this->loadRegistrarSources($moduleDir, $manifest->containerRegistrars);
foreach ($manifest->authorizationPolicies as $policyClass) {
if (!class_exists($policyClass)) {
continue; // Already reported by check 9
}
$shortName = (new \ReflectionClass($policyClass))->getShortName();
$found = false;
foreach ($registrarSources as $source) {
if (str_contains($source, $shortName)) {
$found = true;
break;
}
}
if (!$found) {
$warnings[] = "AuthorizationPolicy '{$shortName}' is not referenced in any ContainerRegistrar — may not be registered in the container";
}
}
// 21. Event listener keys reference known events
$knownEvents = ModuleEvents::all();
foreach ($manifest->eventListeners as $eventName => $handlers) {
if (!in_array($eventName, $knownEvents, true)) {
$warnings[] = "Event listener subscribes to unknown event '{$eventName}' — not defined in ModuleEvents";
}
}
return [$errors, $warnings];
}
/**
* @param list<class-string> $registrarClasses
* @return list<string> Source code of each registrar file
*/
private function loadRegistrarSources(string $moduleDir, array $registrarClasses): array
{
$sources = [];
foreach ($registrarClasses as $class) {
if (!class_exists($class)) {
continue;
}
try {
$refClass = new \ReflectionClass($class);
$file = $refClass->getFileName();
if ($file !== false && is_file($file)) {
$content = file_get_contents($file);
if ($content !== false) {
$sources[] = $content;
}
}
} catch (\ReflectionException) {
// Skip unresolvable classes
}
}
return $sources;
}
/**
* @param list<string> &$errors
*/