diff --git a/src/Core/Module/ModuleLoader.php b/src/Core/Module/ModuleLoader.php index 18d6fb69..b5b72a27 100644 --- a/src/Core/Module/ModuleLoader.php +++ b/src/Core/Module/ModuleLoader.php @@ -114,10 +114,6 @@ class ModuleLoader { $modulePath = $this->getModulePath($name); } - // Register autoloader before loading so multi-file modules (including - // encrypted ones) can reference their own classes via require/include. - $this->registerModuleAutoloader($modulePath); - // Resolve the class from the module's OWN manifest name, not the passed // $name. Platform installs use the marketplace slug (e.g. "watch-d2bho") // as the directory, but the module's class follows its canonical manifest @@ -125,6 +121,13 @@ class ModuleLoader { // discoverModules(), which already keys class resolution on manifest name. $className = $this->resolveClassName($this->manifestName($modulePath, $name)); + // Register a PSR-4 autoloader for this module's namespace before loading, + // so multi-file modules (including encrypted ones) can reference their own + // classes. The base namespace is the module FQCN minus its short class name + // (e.g. XcVm\Module\Watch), mapped onto $modulePath. + $baseNamespace = ($nsPos = strrpos($className, '\\')) !== false ? substr($className, 0, $nsPos) : ''; + $this->registerModuleAutoloader($modulePath, $baseNamespace); + // NB: class_exists() with autoload=false. The module's main class file is // at the deterministic path modulePath/{ShortName}.php and is require'd // below — we must NOT let class_exists() trigger the global autoloader, @@ -674,39 +677,42 @@ class ModuleLoader { } /** - * Registers a PSR-style autoloader for a module directory (once per path). + * Registers a PSR-4 autoloader for a single module's namespace (once per path). * - * Handles two layouts: - * - modules/{name}/MyClass.php (flat) - * - modules/{name}/SubDir/MyClass.php (one level deep) + * The module's base namespace ({$baseNamespace}, e.g. XcVm\Module\Watch) is + * mapped onto its directory ({$modulePath}). The namespace remainder becomes + * the sub-path, exactly per PSR-4: * - * Encrypted files are loaded with require_once and transparently decrypted - * by the XC_VM zend_compile_file hook. + * XcVm\Module\Watch\WatchModule → {modulePath}/WatchModule.php + * XcVm\Module\Watch\Service\WatchService → {modulePath}/Service/WatchService.php + * + * Classes outside the module's namespace (global legacy classes, other + * modules, core) fall through to the next autoloader untouched — no short-name + * glob, so two modules can declare same-named sub-classes without colliding. + * Marketplace slug directories are unaffected: {modulePath} is the real path. + * + * Encrypted files are require'd as-is and transparently decrypted by the + * XC_VM zend_compile_file hook. */ - private function registerModuleAutoloader(string $modulePath): void { - if (isset(self::$autoloadedPaths[$modulePath])) { + private function registerModuleAutoloader(string $modulePath, string $baseNamespace): void { + if ($baseNamespace === '' || isset(self::$autoloadedPaths[$modulePath])) { return; } self::$autoloadedPaths[$modulePath] = true; - spl_autoload_register(function (string $class) use ($modulePath): void { - // Strip namespace prefix — use only the short class name for file lookup. - // NB: for a GLOBAL class (no "\"), strrpos() returns false → (int)false+1 - // would be 1 and drop the first character ("WatchService" → "atchService"). - $pos = strrpos($class, '\\'); - $short = $pos === false ? $class : substr($class, $pos + 1); + $prefix = $baseNamespace . '\\'; + $prefixLen = strlen($prefix); - // Flat: modules/{name}/MyClass.php - $flat = $modulePath . '/' . $short . '.php'; - if (file_exists($flat)) { - require_once $flat; + spl_autoload_register(function (string $class) use ($modulePath, $prefix, $prefixLen): void { + // Only resolve classes under this module's namespace; everything else + // falls through to the next autoloader (Composer / legacy scanner). + if (strncmp($class, $prefix, $prefixLen) !== 0) { return; } - - // One level deep: modules/{name}/*/MyClass.php - foreach (glob($modulePath . '/*/' . $short . '.php') ?: [] as $found) { - require_once $found; - return; + $relative = str_replace('\\', '/', substr($class, $prefixLen)); + $file = $modulePath . '/' . $relative . '.php'; + if (is_file($file)) { + require_once $file; } }); } diff --git a/tests/Unit/ModuleLoaderPsr4ResolverTest.php b/tests/Unit/ModuleLoaderPsr4ResolverTest.php new file mode 100644 index 00000000..2c155dc5 --- /dev/null +++ b/tests/Unit/ModuleLoaderPsr4ResolverTest.php @@ -0,0 +1,144 @@ +root = sys_get_temp_dir() . '/xc_vm_psr4test_' . bin2hex(random_bytes(6)); + mkdir($this->root . '/modules', 0775, true); + } + + protected function tearDown(): void { + $this->removeTree($this->root); + ServiceContainer::resetInstance(); + Router::resetInstance(); + NavbarRegistry::reset(); + EventDispatcher::resetInstance(); + } + + public function testSubNamespaceClassResolvesViaPsr4(): void { + // Module 'psr4alpha' with a sub-namespaced class at Service/AlphaService.php. + $this->createModule('psr4alpha'); + $this->writeSubClass('psr4alpha', 'Service', 'AlphaService', 'alpha-service'); + + $loader = new ModuleLoader(); + $loader->loadAll($this->root . '/modules'); + $this->assertTrue($loader->isLoaded('psr4alpha')); + + $fqcn = 'XcVm\\Module\\Psr4alpha\\Service\\AlphaService'; + // Autoload ON: proves the module's PSR-4 autoloader maps the FQCN onto + // {modulePath}/Service/AlphaService.php. + $this->assertTrue(class_exists($fqcn), 'Sub-namespace class must resolve via PSR-4'); + $this->assertSame('alpha-service', constant($fqcn . '::ORIGIN')); + } + + public function testSameShortNameSubClassesDoNotCollide(): void { + // One module, two classes both named "Widget" in different sub-namespaces. + // The old short-name + glob would load only one file for both FQCNs. + $this->createModule('psr4delta'); + $this->writeSubClass('psr4delta', 'Service', 'Widget', 'from-service'); + $this->writeSubClass('psr4delta', 'Repository', 'Widget', 'from-repository'); + + $loader = new ModuleLoader(); + $loader->loadAll($this->root . '/modules'); + $this->assertTrue($loader->isLoaded('psr4delta')); + + $service = 'XcVm\\Module\\Psr4delta\\Service\\Widget'; + $repository = 'XcVm\\Module\\Psr4delta\\Repository\\Widget'; + $this->assertTrue(class_exists($service), 'Service\\Widget must resolve'); + $this->assertTrue(class_exists($repository), 'Repository\\Widget must resolve'); + $this->assertSame('from-service', constant($service . '::ORIGIN')); + $this->assertSame('from-repository', constant($repository . '::ORIGIN')); + } + + public function testForeignNamespaceFallsThrough(): void { + // A class outside the module namespace must NOT be claimed by the module + // autoloader (it returns without requiring anything → no fatal). + $this->createModule('psr4omega'); + $loader = new ModuleLoader(); + $loader->loadAll($this->root . '/modules'); + + $this->assertFalse( + class_exists('XcVm\\Module\\SomethingElse\\Nope', true), + 'Foreign namespace must fall through, not be force-resolved' + ); + } + + // ── Helpers ─────────────────────────────────────────────────── + + private function createModule(string $name): void { + $dir = $this->root . '/modules/' . $name; + mkdir($dir, 0775, true); + file_put_contents($dir . '/module.json', json_encode([ + 'name' => $name, + 'description' => 'psr4 resolver test', + 'version' => '1.0.0', + 'requires_core' => '>=2.0', + 'environment' => 'main', + 'dependencies' => [], + 'has_navbar' => false, + 'has_settings' => false, + ])); + + $pascal = ucfirst($name); + $cls = $pascal . 'Module'; + $namespace = 'XcVm\\Module\\' . $pascal; + file_put_contents($dir . '/' . $cls . '.php', "root . '/modules/' . $name . '/' . $subDir; + if (!is_dir($dir)) { + mkdir($dir, 0775, true); + } + file_put_contents($dir . '/' . $class . '.php', "removeTree($path) : unlink($path); + } + rmdir($dir); + } +}