feat: module architecture improvements — session keys, dependency graph, event dispatcher, deactivation hooks

Six targeted improvements to the modular monolith platform:

1. Fix AddressBook session key prefix to follow module.<id>.* convention
2. Move ADDRESS_BOOK_VIEW permission constant from core PermissionService into module
3. Add declarative JSON schema for module manifests (.agents/contracts/)
4. Add `requires` field with missing-dependency and circular-dependency detection
5. Add lightweight fire-and-forget event dispatcher (user.created/deleted/login/logout)
6. Add module deactivation hook interface and CLI script (bin/module-deactivate.php)

Includes 15 new architecture/unit tests covering all new functionality.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-19 18:20:13 +01:00
parent 866d43e15a
commit ef72b34c40
31 changed files with 1041 additions and 75 deletions

View File

@@ -2,7 +2,9 @@
namespace MintyPHP\Tests\Architecture;
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;
@@ -128,6 +130,26 @@ final class ModuleStructureContractTest extends TestCase
}
}
public function testRequiresFieldContainsOnlyValidModuleIds(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->requires as $index => $requiredId) {
self::assertIsString($requiredId);
self::assertNotEmpty(
trim($requiredId),
"Module '{$module['id']}' requires[{$index}] must be a non-empty string"
);
self::assertMatchesRegularExpression(
'/^[a-z][a-z0-9_-]*$/',
$requiredId,
"Module '{$module['id']}' requires[{$index}] has invalid module id format: '{$requiredId}'"
);
}
}
self::addToAssertionCount(1);
}
// ─── File structure ──────────────────────────────────────────────
public function testModuleHasLibDirectory(): void
@@ -542,6 +564,113 @@ final class ModuleStructureContractTest extends TestCase
}
}
// ─── Event listener contract ────────────────────────────────────────
public function testEventListenerClassesImplementContract(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->eventListeners as $eventName => $handlers) {
foreach ($handlers as $index => $handler) {
$class = $handler['class'];
self::assertTrue(
class_exists($class),
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' not found"
);
self::assertTrue(
is_subclass_of($class, EventListener::class)
|| in_array(EventListener::class, class_implements($class) ?: [], true),
"Module '{$module['id']}' event_listeners['{$eventName}'][{$index}] class '{$class}' "
. "must implement EventListener interface"
);
}
}
}
self::addToAssertionCount(1);
}
// ─── Deactivation handler contract ──────────────────────────────
public function testDeactivationHandlerClassImplementsContract(): void
{
foreach (self::$modules as $module) {
$handler = $module['manifest']->deactivationHandler;
if ($handler === null) {
continue;
}
self::assertTrue(
class_exists($handler),
"Module '{$module['id']}' deactivation_handler class '{$handler}' not found"
);
self::assertTrue(
is_subclass_of($handler, ModuleDeactivationHandler::class)
|| in_array(ModuleDeactivationHandler::class, class_implements($handler) ?: [], true),
"Module '{$module['id']}' deactivation_handler class '{$handler}' "
. "must implement ModuleDeactivationHandler interface"
);
}
self::addToAssertionCount(1);
}
// ─── Session key convention ────────────────────────────────────────
/**
* Module session providers must use the 'module.<id>.' prefix for all session keys.
*
* Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
* that the key argument starts with 'module.<module_id>.'.
*/
public function testSessionProvidersUseModulePrefixedKeys(): void
{
foreach (self::$modules as $module) {
foreach ($module['manifest']->sessionProviders as $providerClass) {
if (!class_exists($providerClass)) {
continue;
}
$reflection = new \ReflectionClass($providerClass);
$file = $reflection->getFileName();
if ($file === false) {
continue;
}
$content = file_get_contents($file);
if ($content === false) {
continue;
}
$expectedPrefix = 'module.' . $module['id'] . '.';
// Check $_SESSION['...'] writes and unset($_SESSION['...'])
if (preg_match_all('/\$_SESSION\[\'([^\']+)\'\]/', $content, $matches)) {
foreach ($matches[1] as $key) {
self::assertStringStartsWith(
$expectedPrefix,
$key,
"Module '{$module['id']}' session provider '{$providerClass}' uses session key "
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
);
}
}
// Check ->set('...', ...) calls on session store
if (preg_match_all('/->set\(\s*\'([^\']+)\'/', $content, $matches)) {
foreach ($matches[1] as $key) {
self::assertStringStartsWith(
$expectedPrefix,
$key,
"Module '{$module['id']}' session provider '{$providerClass}' uses session store key "
. "'{$key}' which does not follow the '{$expectedPrefix}*' convention"
);
}
}
}
}
self::addToAssertionCount(1);
}
// ─── No hardcoded Core ability imports in module pages ───────────
public function testModulePagesDoNotImportCoreAbilityConstants(): void