chore(psr4): phase 2 — real PSR-4 resolver in ModuleLoader

Rewrite ModuleLoader::registerModuleAutoloader() from a lossy short-name + glob
lookup into a true per-module PSR-4 resolver: the module's base namespace
(XcVm\Module\{Name}) maps onto its directory, and the namespace remainder
becomes the sub-path.

  XcVm\Module\Watch\WatchModule          → {modulePath}/WatchModule.php
  XcVm\Module\Watch\Service\WatchService → {modulePath}/Service/WatchService.php

- Only classes under the module's own namespace are claimed; everything else
  (global legacy classes, other modules, core) falls through untouched. This
  removes the short-name glob, so two modules — or two sub-namespaces in one
  module — can declare same-named classes without colliding.
- load() now derives the base namespace from the resolved FQCN and registers the
  autoloader after class resolution (reordered, still before the main require).
- Marketplace slug dirs unaffected (real $modulePath is used); encrypted-file
  handling preserved (require_once + zend_compile_file decrypt hook).
- resolveClassName()/load() already targeted XcVm\Module\{Pascal}\{Pascal}Module
  (done in earlier work) — left as is.

console.php FQCN discovery is intentionally NOT changed here: the Cli layer is
still global (CommandInterface, *Command, *CronJob), so switching discovery to
\XcVm\Cli\... would break it. Per the plan it switches atomically with the Cli
layer namespacing (phases 3..N).

New test tests/Unit/ModuleLoaderPsr4ResolverTest.php: sub-namespace resolution,
same-short-name no-collision, foreign-namespace fall-through.

Verified: php -l clean; PHPUnit 295/295 (+3); PHPStan no errors; all 4 real
modules (plex/watch/tmdb/ministra) load and their *Module FQCNs resolve.
This commit is contained in:
Divarion-D
2026-06-24 20:20:30 +03:00
parent 1a296d0985
commit 1bd9438820
2 changed files with 177 additions and 27 deletions
+144
View File
@@ -0,0 +1,144 @@
<?php
use PHPUnit\Framework\TestCase;
/**
* ModuleLoader — PSR-4 module autoloader tests (phase 2).
*
* Verifies that registerModuleAutoloader() resolves a module's classes by
* mapping the namespace remainder onto a sub-path under the module directory
* (true PSR-4), instead of the previous lossy short-name + glob lookup:
*
* XcVm\Module\{Name}\Service\Foo → {modulePath}/Service/Foo.php
*
* The key regression guard is that two sub-classes sharing a short name but
* living in different sub-namespaces resolve to their OWN files — the old glob
* (modulePath/{*}/Foo.php) returned whichever matched first and silently loaded
* the wrong one.
*/
final class ModuleLoaderPsr4ResolverTest extends TestCase {
private string $root;
protected function setUp(): void {
ServiceContainer::resetInstance();
Router::resetInstance();
NavbarRegistry::reset();
EventDispatcher::resetInstance();
$this->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', "<?php\n"
. "namespace {$namespace};\n"
. "use BaseModule;\n"
. "class {$cls} extends BaseModule {\n"
. "\tpublic function getName(): string { return '{$name}'; }\n"
. "\tpublic function getVersion(): string { return '1.0.0'; }\n"
. "}\n");
}
private function writeSubClass(string $name, string $subDir, string $class, string $origin): void {
$pascal = ucfirst($name);
$namespace = 'XcVm\\Module\\' . $pascal . '\\' . $subDir;
$dir = $this->root . '/modules/' . $name . '/' . $subDir;
if (!is_dir($dir)) {
mkdir($dir, 0775, true);
}
file_put_contents($dir . '/' . $class . '.php', "<?php\n"
. "namespace {$namespace};\n"
. "class {$class} {\n"
. "\tpublic const ORIGIN = '{$origin}';\n"
. "}\n");
}
private function removeTree(string $dir): void {
if (!is_dir($dir)) {
return;
}
foreach (scandir($dir) as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . '/' . $item;
is_dir($path) ? $this->removeTree($path) : unlink($path);
}
rmdir($dir);
}
}