content] for every .php file under src/modules/, * optionally skipping top-level module subdirectories by name. * * @param string[] $excludeModuleDirs Top-level dir names to skip (e.g. ['ministra']). * @return iterable */ private function moduleFiles(array $excludeModuleDirs = []): iterable { $baseReal = realpath(self::MODULES_DIR); $excludeReal = []; foreach ($excludeModuleDirs as $dir) { $p = realpath(self::MODULES_DIR . '/' . $dir); if ($p !== false) { $excludeReal[] = $p; } } $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator(self::MODULES_DIR, FilesystemIterator::SKIP_DOTS) ); foreach ($iterator as $file) { /** @var SplFileInfo $file */ if ($file->getExtension() !== 'php') { continue; } $realPath = (string) $file->getRealPath(); foreach ($excludeReal as $excluded) { if (str_starts_with($realPath, $excluded . DIRECTORY_SEPARATOR)) { continue 2; } } $relative = substr($realPath, strlen($baseReal) + 1); yield $relative => (string) file_get_contents($realPath); } } // ── tests ───────────────────────────────────────────────────────────────── /** * No module file may resolve the container via the static singleton. * Modules receive ServiceContainer through boot(ServiceContainer $c). */ public function testNoModuleUsesServiceLocator(): void { foreach ($this->moduleFiles() as $relative => $content) { $this->assertStringNotContainsString( 'ServiceContainer::getInstance()', $content, "modules/{$relative} must not call ServiceContainer::getInstance() — use boot(ServiceContainer \$c) instead" ); } } /** * Web-context module files must not use `global $db`. * * Exemptions: * - ministra/ — BoundaryInterface subsystem with its own portal bootstrap (permanent) * - CLI_GLOBAL_DB_EXEMPT — cron/CLI classes using admin.php bootstrap (temporary, see R4-3) */ public function testNoWebContextModuleUsesGlobalDb(): void { foreach ($this->moduleFiles(excludeModuleDirs: ['ministra']) as $relative => $content) { $basename = basename($relative); if (in_array($basename, self::CLI_GLOBAL_DB_EXEMPT, true)) { continue; } if (in_array($basename, self::MIGRATION_FALLBACK_EXEMPT, true)) { continue; } $this->assertStringNotContainsString( 'global $db', $content, "modules/{$relative} must not use global \$db — inject via boot(ServiceContainer \$c)" ); } } /** * Every module entry-point (*Module.php) must declare the canonical namespace. * * Convention: XcVm\Module\{Pascal} where Pascal = PascalCase of the directory name. * Non-entry-point files (controllers, services, cron) are not yet required — see R4-3. */ public function testModuleEntryPointsHaveCorrectNamespace(): void { $modulesDir = new FilesystemIterator(self::MODULES_DIR, FilesystemIterator::SKIP_DOTS); $checked = 0; foreach ($modulesDir as $entry) { /** @var SplFileInfo $entry */ if (!$entry->isDir()) { continue; } $dirName = $entry->getBasename(); $pascal = implode('', array_map('ucfirst', explode('-', $dirName))); $moduleFile = $entry->getRealPath() . '/' . $pascal . 'Module.php'; if (!file_exists($moduleFile)) { continue; } $expected = "namespace XcVm\\Module\\{$pascal};"; $content = (string) file_get_contents($moduleFile); $this->assertStringContainsString( $expected, $content, "{$pascal}Module.php must declare `{$expected}`" ); $checked++; } $this->assertGreaterThan(0, $checked, 'No *Module.php files found — check MODULES_DIR path'); } /** * Sanity: every module directory must contain a module.json manifest. * * Catches accidental directory clutter in src/modules/. */ public function testEveryModuleDirectoryHasManifest(): void { $modulesDir = new FilesystemIterator(self::MODULES_DIR, FilesystemIterator::SKIP_DOTS); $checked = 0; foreach ($modulesDir as $entry) { /** @var SplFileInfo $entry */ if (!$entry->isDir()) { continue; } $manifest = $entry->getRealPath() . '/module.json'; $this->assertFileExists( $manifest, "modules/{$entry->getBasename()}/ must contain a module.json manifest" ); $checked++; } $this->assertGreaterThan(0, $checked, 'No module directories found'); } }