diff --git a/phpunit.xml b/phpunit.xml
index 17cc638..1e9232e 100644
--- a/phpunit.xml
+++ b/phpunit.xml
@@ -19,8 +19,11 @@
tests/Architecture/DatabaseUpdatesContractTest.php
tests/Architecture/FrontendTelemetryContractTest.php
tests/Architecture/I18nKeyCompletenessContractTest.php
+ tests/Architecture/ModuleBoundaryContractTest.php
+ tests/Architecture/ModuleManifestContractTest.php
tests/Architecture/ModuleRegistryContractTest.php
- tests/Architecture/ModuleStructureContractTest.php
+ tests/Architecture/ModuleProviderContractTest.php
+ tests/Architecture/ModuleUiContractTest.php
tests/Architecture/RepositoryInterfaceContractTest.php
tests/Architecture/StatusTaxonomyContractTest.php
@@ -58,7 +61,10 @@
tests/Architecture/ModuleRegistryContractTest.php
- tests/Architecture/ModuleStructureContractTest.php
+ tests/Architecture/ModuleBoundaryContractTest.php
+ tests/Architecture/ModuleManifestContractTest.php
+ tests/Architecture/ModuleProviderContractTest.php
+ tests/Architecture/ModuleUiContractTest.php
tests/Architecture/NoAddressBookHardcodingTest.php
tests/Architecture/NoBookmarksHardcodingTest.php
diff --git a/tests/Architecture/AbstractModuleStructureContractTestCase.php b/tests/Architecture/AbstractModuleStructureContractTestCase.php
new file mode 100644
index 0000000..7fefc87
--- /dev/null
+++ b/tests/Architecture/AbstractModuleStructureContractTestCase.php
@@ -0,0 +1,53 @@
+}> */
+ protected static array $modules = [];
+
+ public static function setUpBeforeClass(): void
+ {
+ $modulesDir = realpath(__DIR__ . '/../../modules');
+ if ($modulesDir === false || !is_dir($modulesDir)) {
+ return;
+ }
+
+ $entries = scandir($modulesDir);
+ if ($entries === false) {
+ return;
+ }
+
+ self::$modules = [];
+
+ foreach ($entries as $entry) {
+ if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
+ continue;
+ }
+
+ $manifestFile = $modulesDir . '/' . $entry . '/module.php';
+ if (!is_file($manifestFile)) {
+ continue;
+ }
+
+ $raw = require $manifestFile;
+ if (!is_array($raw)) {
+ continue;
+ }
+
+ $manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
+ self::$modules[] = [
+ 'id' => $entry,
+ 'path' => $modulesDir . '/' . $entry,
+ 'manifest' => $manifest,
+ 'raw' => $raw,
+ ];
+ }
+ }
+}
diff --git a/tests/Architecture/ModuleBoundaryContractTest.php b/tests/Architecture/ModuleBoundaryContractTest.php
new file mode 100644
index 0000000..5a67180
--- /dev/null
+++ b/tests/Architecture/ModuleBoundaryContractTest.php
@@ -0,0 +1,83 @@
+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) {
+ self::assertFalse(
+ str_starts_with($namespace . '\\', $corePrefix),
+ "Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
+ . "Module classes must use MintyPHP\\Module\\* namespace."
+ );
+ }
+ }
+ }
+ }
+ }
+
+ public function testModulePagesDoNotImportCoreAbilityConstants(): void
+ {
+ foreach (self::$modules as $module) {
+ $pagesDir = $module['path'] . '/pages';
+ if (!is_dir($pagesDir)) {
+ continue;
+ }
+
+ $iterator = new \RecursiveIteratorIterator(
+ new \RecursiveDirectoryIterator($pagesDir)
+ );
+
+ foreach ($iterator as $file) {
+ if (!$file->isFile() || $file->getExtension() !== 'php') {
+ continue;
+ }
+
+ $content = file_get_contents($file->getPathname());
+ if ($content === false) {
+ continue;
+ }
+
+ self::assertStringNotContainsString(
+ 'OperationsAuthorizationPolicy::ABILITY_',
+ $content,
+ "Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
+ . "Use the module's own AuthorizationPolicy instead."
+ );
+ }
+ }
+ }
+}
diff --git a/tests/Architecture/ModuleManifestContractTest.php b/tests/Architecture/ModuleManifestContractTest.php
new file mode 100644
index 0000000..2c8bfc2
--- /dev/null
+++ b/tests/Architecture/ModuleManifestContractTest.php
@@ -0,0 +1,142 @@
+id,
+ "Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
+ );
+ }
+ }
+
+ public function testManifestHasVersion(): void
+ {
+ foreach (self::$modules as $module) {
+ self::assertMatchesRegularExpression(
+ '/^\d+\.\d+\.\d+$/',
+ $module['manifest']->version,
+ "Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
+ );
+ }
+ }
+
+ public function testManifestHasAllRequiredKeys(): void
+ {
+ $requiredKeys = [
+ 'id', 'version', 'enabled_by_default', 'load_order',
+ 'routes', 'public_paths', 'container_registrars',
+ 'ui_slots', 'search_resources', 'asset_groups',
+ 'scheduler_jobs', 'layout_context_providers',
+ 'session_providers', 'permissions',
+ ];
+
+ foreach (self::$modules as $module) {
+ foreach ($requiredKeys as $key) {
+ self::assertArrayHasKey(
+ $key,
+ $module['raw'],
+ "Module '{$module['id']}' manifest is missing required key '{$key}'"
+ );
+ }
+ }
+ }
+
+ 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);
+ }
+
+ public function testModuleHasLibDirectory(): void
+ {
+ foreach (self::$modules as $module) {
+ self::assertDirectoryExists(
+ $module['path'] . '/lib',
+ "Module '{$module['id']}' must have a lib/ directory for PHP classes"
+ );
+ }
+ }
+
+ public function testRoutesHavePathAndTarget(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->routes as $idx => $route) {
+ self::assertArrayHasKey(
+ 'path',
+ $route,
+ "Module '{$module['id']}' route[{$idx}] is missing 'path'"
+ );
+ self::assertArrayHasKey(
+ 'target',
+ $route,
+ "Module '{$module['id']}' route[{$idx}] is missing 'target'"
+ );
+ self::assertNotEmpty(
+ trim((string) $route['path']),
+ "Module '{$module['id']}' route[{$idx}] has empty 'path'"
+ );
+ self::assertNotEmpty(
+ trim((string) $route['target']),
+ "Module '{$module['id']}' route[{$idx}] has empty 'target'"
+ );
+ }
+ }
+ }
+
+ public function testModulesWithRoutesHavePagesDirectory(): void
+ {
+ foreach (self::$modules as $module) {
+ if (empty($module['manifest']->routes)) {
+ continue;
+ }
+ self::assertDirectoryExists(
+ $module['path'] . '/pages',
+ "Module '{$module['id']}' declares routes but has no pages/ directory"
+ );
+ }
+ }
+}
diff --git a/tests/Architecture/ModuleProviderContractTest.php b/tests/Architecture/ModuleProviderContractTest.php
new file mode 100644
index 0000000..89f0384
--- /dev/null
+++ b/tests/Architecture/ModuleProviderContractTest.php
@@ -0,0 +1,141 @@
+containerRegistrars as $registrar) {
+ self::assertTrue(
+ class_exists($registrar),
+ "Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
+ );
+ }
+ }
+ }
+
+ public function testSearchProviderClassesImplementContract(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->searchResources as $providerClass) {
+ self::assertTrue(
+ class_exists($providerClass),
+ "Module '{$module['id']}' search provider '{$providerClass}' class not found"
+ );
+ $provider = new $providerClass();
+ self::assertInstanceOf(
+ SearchResourceProvider::class,
+ $provider,
+ "Module '{$module['id']}' search provider must implement SearchResourceProvider"
+ );
+ }
+ }
+ }
+
+ public function testLayoutContextProviderClassesImplementContract(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->layoutContextProviders as $providerClass) {
+ self::assertTrue(
+ class_exists($providerClass),
+ "Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
+ );
+ $provider = new $providerClass();
+ self::assertInstanceOf(
+ LayoutContextProvider::class,
+ $provider,
+ "Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
+ );
+ }
+ }
+ }
+
+ public function testSessionProviderClassesImplementContract(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->sessionProviders as $providerClass) {
+ self::assertTrue(
+ class_exists($providerClass),
+ "Module '{$module['id']}' session provider '{$providerClass}' class not found"
+ );
+ $provider = new $providerClass();
+ self::assertInstanceOf(
+ SessionProvider::class,
+ $provider,
+ "Module '{$module['id']}' session provider must implement SessionProvider"
+ );
+ }
+ }
+ }
+
+ public function testAuthorizationPolicyClassesImplementContract(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->authorizationPolicies as $policyClass) {
+ self::assertTrue(
+ class_exists($policyClass),
+ "Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
+ );
+ self::assertTrue(
+ is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
+ || in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
+ "Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
+ );
+ }
+ }
+ }
+
+ 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);
+ }
+
+ 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);
+ }
+}
diff --git a/tests/Architecture/ModuleStructureContractTest.php b/tests/Architecture/ModuleStructureContractTest.php
deleted file mode 100644
index cf74a32..0000000
--- a/tests/Architecture/ModuleStructureContractTest.php
+++ /dev/null
@@ -1,708 +0,0 @@
-}> */
- private static array $modules = [];
-
- public static function setUpBeforeClass(): void
- {
- $modulesDir = realpath(__DIR__ . '/../../modules');
- if ($modulesDir === false || !is_dir($modulesDir)) {
- return;
- }
-
- $entries = scandir($modulesDir);
- if ($entries === false) {
- return;
- }
-
- foreach ($entries as $entry) {
- if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
- continue;
- }
-
- $manifestFile = $modulesDir . '/' . $entry . '/module.php';
- if (!is_file($manifestFile)) {
- continue;
- }
-
- $raw = require $manifestFile;
- if (!is_array($raw)) {
- continue;
- }
-
- $manifest = ModuleManifest::fromArray($raw, $modulesDir . '/' . $entry);
- self::$modules[] = [
- 'id' => $entry,
- 'path' => $modulesDir . '/' . $entry,
- 'manifest' => $manifest,
- 'raw' => $raw,
- ];
- }
- }
-
- // ─── Manifest structure ──────────────────────────────────────────
-
- public function testAtLeastOneModuleExists(): void
- {
- self::assertNotEmpty(self::$modules, 'Expected at least one module under modules/');
- }
-
- public function testEveryModuleHasManifest(): void
- {
- $modulesDir = $this->projectRootPath() . '/modules';
- if (!is_dir($modulesDir)) {
- self::markTestSkipped('No modules/ directory');
- }
-
- $entries = scandir($modulesDir) ?: [];
- foreach ($entries as $entry) {
- if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
- continue;
- }
- self::assertFileExists(
- $modulesDir . '/' . $entry . '/module.php',
- "Module directory '{$entry}' is missing module.php manifest"
- );
- }
- }
-
- public function testManifestIdMatchesDirectoryName(): void
- {
- foreach (self::$modules as $module) {
- self::assertSame(
- $module['id'],
- $module['manifest']->id,
- "Module directory '{$module['id']}' has mismatched manifest id '{$module['manifest']->id}'"
- );
- }
- }
-
- public function testManifestHasVersion(): void
- {
- foreach (self::$modules as $module) {
- self::assertMatchesRegularExpression(
- '/^\d+\.\d+\.\d+$/',
- $module['manifest']->version,
- "Module '{$module['id']}' must have a semver version (got '{$module['manifest']->version}')"
- );
- }
- }
-
- public function testManifestHasAllRequiredKeys(): void
- {
- $requiredKeys = [
- 'id', 'version', 'enabled_by_default', 'load_order',
- 'routes', 'public_paths', 'container_registrars',
- 'ui_slots', 'search_resources', 'asset_groups',
- 'scheduler_jobs', 'layout_context_providers',
- 'session_providers', 'permissions',
- ];
-
- foreach (self::$modules as $module) {
- foreach ($requiredKeys as $key) {
- self::assertArrayHasKey(
- $key,
- $module['raw'],
- "Module '{$module['id']}' manifest is missing required key '{$key}'"
- );
- }
- }
- }
-
- 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
- {
- foreach (self::$modules as $module) {
- self::assertDirectoryExists(
- $module['path'] . '/lib',
- "Module '{$module['id']}' must have a lib/ directory for PHP classes"
- );
- }
- }
-
- // ─── Container registrars ────────────────────────────────────────
-
- public function testContainerRegistrarClassesExist(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->containerRegistrars as $registrar) {
- self::assertTrue(
- class_exists($registrar),
- "Module '{$module['id']}' declares container_registrar '{$registrar}' but class does not exist"
- );
- }
- }
- }
-
- // ─── Provider contracts ──────────────────────────────────────────
-
- public function testSearchProviderClassesImplementContract(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->searchResources as $providerClass) {
- self::assertTrue(
- class_exists($providerClass),
- "Module '{$module['id']}' search provider '{$providerClass}' class not found"
- );
- $provider = new $providerClass();
- self::assertInstanceOf(
- SearchResourceProvider::class,
- $provider,
- "Module '{$module['id']}' search provider must implement SearchResourceProvider"
- );
- }
- }
- }
-
- public function testLayoutContextProviderClassesImplementContract(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->layoutContextProviders as $providerClass) {
- self::assertTrue(
- class_exists($providerClass),
- "Module '{$module['id']}' layout context provider '{$providerClass}' class not found"
- );
- $provider = new $providerClass();
- self::assertInstanceOf(
- LayoutContextProvider::class,
- $provider,
- "Module '{$module['id']}' layout context provider must implement LayoutContextProvider"
- );
- }
- }
- }
-
- public function testSessionProviderClassesImplementContract(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->sessionProviders as $providerClass) {
- self::assertTrue(
- class_exists($providerClass),
- "Module '{$module['id']}' session provider '{$providerClass}' class not found"
- );
- $provider = new $providerClass();
- self::assertInstanceOf(
- SessionProvider::class,
- $provider,
- "Module '{$module['id']}' session provider must implement SessionProvider"
- );
- }
- }
- }
-
- // ─── Authorization policies ──────────────────────────────────────
-
- public function testAuthorizationPolicyClassesImplementContract(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->authorizationPolicies as $policyClass) {
- self::assertTrue(
- class_exists($policyClass),
- "Module '{$module['id']}' authorization policy '{$policyClass}' class not found"
- );
- self::assertTrue(
- is_subclass_of($policyClass, AuthorizationPolicyInterface::class)
- || in_array(AuthorizationPolicyInterface::class, class_implements($policyClass) ?: [], true),
- "Module '{$module['id']}' authorization policy must implement AuthorizationPolicyInterface"
- );
- }
- }
- }
-
- // ─── UI slot abilities ──────────────────────────────────────────
-
- /**
- * Every 'permission' value in a module's UI slots must reference an ability
- * declared as a constant on one of the module's authorization policies.
- */
- public function testUiSlotAbilitiesMatchPolicySupports(): void
- {
- foreach (self::$modules as $module) {
- // Collect all abilities supported by this module's policies
- $supportedAbilities = [];
- foreach ($module['manifest']->authorizationPolicies as $policyClass) {
- if (!class_exists($policyClass)) {
- continue;
- }
- $reflection = new \ReflectionClass($policyClass);
- foreach ($reflection->getConstants() as $constValue) {
- if (is_string($constValue)) {
- $supportedAbilities[] = $constValue;
- }
- }
- }
-
- if ($supportedAbilities === []) {
- continue;
- }
-
- // Check every permission value from UI slots
- foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
- $contributionList = is_array($contributions) ? $contributions : [$contributions];
- foreach ($contributionList as $idx => $contribution) {
- if (!is_array($contribution)) {
- continue;
- }
- $ability = trim((string) ($contribution['permission'] ?? ''));
- if ($ability === '') {
- continue;
- }
- self::assertContains(
- $ability,
- $supportedAbilities,
- "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' "
- . "is not declared as a constant on any module authorization policy"
- );
- }
- }
- }
- }
-
- // ─── Permissions contract ───────────────────────────────────────
-
- public function testPermissionsUseObjectContract(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->permissions as $index => $permission) {
- self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
- self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
- self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
- self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
- self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
- self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
- }
- }
- }
-
- // ─── Scheduler jobs contract ────────────────────────────────────
-
- public function testSchedulerJobsUseFullContractSchema(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->schedulerJobs as $index => $job) {
- $required = [
- 'job_key',
- 'handler',
- 'label',
- 'description',
- 'default_enabled',
- 'default_timezone',
- 'default_schedule_type',
- 'default_schedule_interval',
- 'default_schedule_time',
- 'default_schedule_weekdays_csv',
- 'default_catchup_once',
- 'allowed_schedule_types',
- ];
-
- foreach ($required as $requiredKey) {
- self::assertArrayHasKey(
- $requiredKey,
- $job,
- "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
- );
- }
-
- self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
- self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
- self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
- self::assertContains(
- (string) $job['default_schedule_type'],
- ['hourly', 'daily', 'weekly'],
- "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
- );
-
- $allowedTypes = array_values($job['allowed_schedule_types']);
- self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
- foreach ($allowedTypes as $allowedType) {
- self::assertContains(
- (string) $allowedType,
- ['hourly', 'daily', 'weekly'],
- "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
- );
- }
- }
- }
-
- self::addToAssertionCount(1);
- }
-
- // ─── UI slots ────────────────────────────────────────────────────
-
- public function testUiSlotContributionsHaveRequiredKeys(): void
- {
- $slotKeyRequirements = [
- 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
- 'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
- 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
- 'topbar.right_item' => ['key', 'template'],
- 'layout.body_end_template' => ['key', 'template'],
- 'layout.head_style' => ['key', 'path'],
- 'runtime.component' => ['key', 'script'],
- ];
-
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
- if (!isset($slotKeyRequirements[$slotName])) {
- continue;
- }
-
- $required = $slotKeyRequirements[$slotName];
- $contributionList = is_array($contributions) ? $contributions : [$contributions];
-
- foreach ($contributionList as $idx => $contribution) {
- if (!is_array($contribution)) {
- continue;
- }
- foreach ($required as $requiredKey) {
- self::assertArrayHasKey(
- $requiredKey,
- $contribution,
- "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
- );
- }
- }
- }
- }
- }
-
- public function testPanelTemplatesExist(): void
- {
- foreach (self::$modules as $module) {
- $templateSlots = [
- 'aside.tab_panel' => 'panel_template',
- 'topbar.right_item' => 'template',
- 'layout.body_end_template' => 'template',
- ];
-
- foreach ($templateSlots as $slotName => $templateKey) {
- $contributions = $module['manifest']->uiSlots[$slotName] ?? [];
- if (!is_array($contributions)) {
- continue;
- }
- foreach ($contributions as $contribution) {
- if (!is_array($contribution)) {
- continue;
- }
- $template = $contribution[$templateKey] ?? null;
- if ($template === null) {
- continue;
- }
- // Resolve relative paths against module basePath
- $templatePath = (string) $template;
- if (!str_starts_with($templatePath, '/')) {
- $templatePath = $module['manifest']->basePath . '/' . $templatePath;
- }
- self::assertFileExists(
- $templatePath,
- "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}"
- );
- }
- }
- }
- }
-
- public function testRuntimeComponentPhasesAreValid(): void
- {
- foreach (self::$modules as $module) {
- $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
- if (!is_array($runtimeComponents)) {
- continue;
- }
-
- foreach ($runtimeComponents as $index => $contribution) {
- if (!is_array($contribution)) {
- continue;
- }
-
- $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
- self::assertContains(
- $phase,
- ['early', 'default', 'late'],
- "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
- );
- }
- }
- }
-
- // ─── Routes ──────────────────────────────────────────────────────
-
- public function testRoutesHavePathAndTarget(): void
- {
- foreach (self::$modules as $module) {
- foreach ($module['manifest']->routes as $idx => $route) {
- self::assertArrayHasKey(
- 'path',
- $route,
- "Module '{$module['id']}' route[{$idx}] is missing 'path'"
- );
- self::assertArrayHasKey(
- 'target',
- $route,
- "Module '{$module['id']}' route[{$idx}] is missing 'target'"
- );
- self::assertNotEmpty(
- trim((string) $route['path']),
- "Module '{$module['id']}' route[{$idx}] has empty 'path'"
- );
- self::assertNotEmpty(
- trim((string) $route['target']),
- "Module '{$module['id']}' route[{$idx}] has empty 'target'"
- );
- }
- }
- }
-
- // ─── Pages directory ─────────────────────────────────────────────
-
- public function testModulesWithRoutesHavePagesDirectory(): void
- {
- foreach (self::$modules as $module) {
- if (empty($module['manifest']->routes)) {
- continue;
- }
- self::assertDirectoryExists(
- $module['path'] . '/pages',
- "Module '{$module['id']}' declares routes but has no pages/ directory"
- );
- }
- }
-
- // ─── Namespace ownership ───────────────────────────────────────
-
- /**
- * All PHP classes under modules//lib/ must use a MintyPHP\Module\\
- * namespace — never a Core namespace like MintyPHP\Service\ or MintyPHP\Repository\.
- *
- * This prevents module code from masquerading as core code and ensures
- * `grep MintyPHP\Module\` reliably finds all module-owned classes.
- */
- public function testModuleClassesUseModuleNamespace(): void
- {
- $coreNamespacePrefixes = [
- 'MintyPHP\\Service\\',
- 'MintyPHP\\Repository\\',
- 'MintyPHP\\Support\\',
- 'MintyPHP\\Http\\',
- 'MintyPHP\\App\\Container\\',
- ];
-
- foreach (self::$modules as $module) {
- $libDir = $module['path'] . '/lib';
- if (!is_dir($libDir)) {
- continue;
- }
-
- $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) {
- self::assertFalse(
- str_starts_with($namespace . '\\', $corePrefix),
- "Module '{$module['id']}' class in {$file->getFilename()} uses core namespace '{$namespace}'. "
- . "Module classes must use MintyPHP\\Module\\* namespace."
- );
- }
- }
- }
- }
- }
-
- // ─── 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..' prefix for all session keys.
- *
- * Scans session provider classes for $_SESSION[ and ->set( patterns and verifies
- * that the key argument starts with 'module..'.
- */
- 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
- {
- foreach (self::$modules as $module) {
- $pagesDir = $module['path'] . '/pages';
- if (!is_dir($pagesDir)) {
- continue;
- }
-
- $iterator = new \RecursiveIteratorIterator(
- new \RecursiveDirectoryIterator($pagesDir)
- );
-
- foreach ($iterator as $file) {
- if (!$file->isFile() || $file->getExtension() !== 'php') {
- continue;
- }
-
- $content = file_get_contents($file->getPathname());
- if ($content === false) {
- continue;
- }
-
- // Module pages should use their own policy constants, not Core's OperationsAuthorizationPolicy
- self::assertStringNotContainsString(
- 'OperationsAuthorizationPolicy::ABILITY_',
- $content,
- "Module '{$module['id']}' page '{$file->getFilename()}' imports Core OperationsAuthorizationPolicy ability. "
- . "Use the module's own AuthorizationPolicy instead."
- );
- }
- }
- }
-}
diff --git a/tests/Architecture/ModuleUiContractTest.php b/tests/Architecture/ModuleUiContractTest.php
new file mode 100644
index 0000000..7420e2a
--- /dev/null
+++ b/tests/Architecture/ModuleUiContractTest.php
@@ -0,0 +1,255 @@
+authorizationPolicies as $policyClass) {
+ if (!class_exists($policyClass)) {
+ continue;
+ }
+ $reflection = new \ReflectionClass($policyClass);
+ foreach ($reflection->getConstants() as $constValue) {
+ if (is_string($constValue)) {
+ $supportedAbilities[] = $constValue;
+ }
+ }
+ }
+
+ if ($supportedAbilities === []) {
+ continue;
+ }
+
+ foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
+ $contributionList = is_array($contributions) ? $contributions : [$contributions];
+ foreach ($contributionList as $idx => $contribution) {
+ if (!is_array($contribution)) {
+ continue;
+ }
+ $ability = trim((string) ($contribution['permission'] ?? ''));
+ if ($ability === '') {
+ continue;
+ }
+ self::assertContains(
+ $ability,
+ $supportedAbilities,
+ "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] permission '{$ability}' "
+ . "is not declared as a constant on any module authorization policy"
+ );
+ }
+ }
+ }
+ }
+
+ public function testPermissionsUseObjectContract(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->permissions as $index => $permission) {
+ self::assertArrayHasKey('key', $permission, "Module '{$module['id']}' permissions[{$index}] missing key");
+ self::assertArrayHasKey('description', $permission, "Module '{$module['id']}' permissions[{$index}] missing description");
+ self::assertNotEmpty(trim((string) $permission['key']), "Module '{$module['id']}' permissions[{$index}] key must be non-empty");
+ self::assertNotEmpty(trim((string) $permission['description']), "Module '{$module['id']}' permissions[{$index}] description must be non-empty");
+ self::assertContains((int) $permission['active'], [0, 1], "Module '{$module['id']}' permissions[{$index}] active must be 0|1");
+ self::assertContains((int) $permission['is_system'], [0, 1], "Module '{$module['id']}' permissions[{$index}] is_system must be 0|1");
+ }
+ }
+ }
+
+ public function testSchedulerJobsUseFullContractSchema(): void
+ {
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->schedulerJobs as $index => $job) {
+ $required = [
+ 'job_key',
+ 'handler',
+ 'label',
+ 'description',
+ 'default_enabled',
+ 'default_timezone',
+ 'default_schedule_type',
+ 'default_schedule_interval',
+ 'default_schedule_time',
+ 'default_schedule_weekdays_csv',
+ 'default_catchup_once',
+ 'allowed_schedule_types',
+ ];
+
+ foreach ($required as $requiredKey) {
+ self::assertArrayHasKey(
+ $requiredKey,
+ $job,
+ "Module '{$module['id']}' scheduler_jobs[{$index}] missing '{$requiredKey}'"
+ );
+ }
+
+ self::assertNotEmpty(trim((string) $job['job_key']), "Module '{$module['id']}' scheduler_jobs[{$index}] job_key must be non-empty");
+ self::assertNotEmpty(trim((string) $job['handler']), "Module '{$module['id']}' scheduler_jobs[{$index}] handler must be non-empty");
+ self::assertNotEmpty(trim((string) $job['label']), "Module '{$module['id']}' scheduler_jobs[{$index}] label must be non-empty");
+ self::assertContains(
+ (string) $job['default_schedule_type'],
+ ['hourly', 'daily', 'weekly'],
+ "Module '{$module['id']}' scheduler_jobs[{$index}] default_schedule_type invalid"
+ );
+
+ $allowedTypes = array_values($job['allowed_schedule_types']);
+ self::assertNotEmpty($allowedTypes, "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types must be non-empty");
+ foreach ($allowedTypes as $allowedType) {
+ self::assertContains(
+ (string) $allowedType,
+ ['hourly', 'daily', 'weekly'],
+ "Module '{$module['id']}' scheduler_jobs[{$index}] allowed_schedule_types entry invalid"
+ );
+ }
+ }
+ }
+
+ self::addToAssertionCount(1);
+ }
+
+ public function testUiSlotContributionsHaveRequiredKeys(): void
+ {
+ $slotKeyRequirements = [
+ 'aside.tab_panel' => ['key', 'label', 'icon', 'permission', 'panel_template'],
+ 'search.resource_item' => ['key', 'label', 'base_url', 'permission'],
+ 'user.edit.aside_action' => ['key', 'type', 'label', 'permission'],
+ 'topbar.right_item' => ['key', 'template'],
+ 'layout.body_end_template' => ['key', 'template'],
+ 'layout.head_style' => ['key', 'path'],
+ 'runtime.component' => ['key', 'script'],
+ ];
+
+ foreach (self::$modules as $module) {
+ foreach ($module['manifest']->uiSlots as $slotName => $contributions) {
+ if (!isset($slotKeyRequirements[$slotName])) {
+ continue;
+ }
+
+ $required = $slotKeyRequirements[$slotName];
+ $contributionList = is_array($contributions) ? $contributions : [$contributions];
+
+ foreach ($contributionList as $idx => $contribution) {
+ if (!is_array($contribution)) {
+ continue;
+ }
+ foreach ($required as $requiredKey) {
+ self::assertArrayHasKey(
+ $requiredKey,
+ $contribution,
+ "Module '{$module['id']}' ui_slots['{$slotName}'][{$idx}] is missing required key '{$requiredKey}'"
+ );
+ }
+ }
+ }
+ }
+ }
+
+ public function testPanelTemplatesExist(): void
+ {
+ foreach (self::$modules as $module) {
+ $templateSlots = [
+ 'aside.tab_panel' => 'panel_template',
+ 'topbar.right_item' => 'template',
+ 'layout.body_end_template' => 'template',
+ ];
+
+ foreach ($templateSlots as $slotName => $templateKey) {
+ $contributions = $module['manifest']->uiSlots[$slotName] ?? [];
+ if (!is_array($contributions)) {
+ continue;
+ }
+ foreach ($contributions as $contribution) {
+ if (!is_array($contribution)) {
+ continue;
+ }
+ $template = $contribution[$templateKey] ?? null;
+ if ($template === null) {
+ continue;
+ }
+ $templatePath = (string) $template;
+ if (!str_starts_with($templatePath, '/')) {
+ $templatePath = $module['manifest']->basePath . '/' . $templatePath;
+ }
+ self::assertFileExists(
+ $templatePath,
+ "Module '{$module['id']}' {$slotName} {$templateKey} does not exist: {$templatePath}"
+ );
+ }
+ }
+ }
+ }
+
+ public function testRuntimeComponentPhasesAreValid(): void
+ {
+ foreach (self::$modules as $module) {
+ $runtimeComponents = $module['manifest']->uiSlots['runtime.component'] ?? [];
+ if (!is_array($runtimeComponents)) {
+ continue;
+ }
+
+ foreach ($runtimeComponents as $index => $contribution) {
+ if (!is_array($contribution)) {
+ continue;
+ }
+
+ $phase = strtolower(trim((string) ($contribution['phase'] ?? 'late')));
+ self::assertContains(
+ $phase,
+ ['early', 'default', 'late'],
+ "Module '{$module['id']}' runtime.component[{$index}] has invalid phase '{$phase}'"
+ );
+ }
+ }
+ }
+
+ 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'] . '.';
+
+ 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"
+ );
+ }
+ }
+
+ 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);
+ }
+}
diff --git a/tests/README.md b/tests/README.md
index 501cd5d..43c427a 100644
--- a/tests/README.md
+++ b/tests/README.md
@@ -32,8 +32,8 @@ ohne dass jede Aenderung immer die komplette Suite im Kopf behalten muss.
## Naechste Refactor-Kandidaten
-- `tests/Architecture/ModuleStructureContractTest.php`
- `tests/Architecture/FrontendComponentRuntimeContractTest.php`
- `tests/Architecture/FilterDrawerContractTest.php`
- `tests/Architecture/PublicPageContractTest.php`
- `tests/Architecture/CodexSkillsContractTest.php`
+- `tests/Architecture/ModuleRegistryContractTest.php`