From f8fb9b5a45727da1ef375cb826184bd17a668887 Mon Sep 17 00:00:00 2001 From: Michael Schiemer Date: Mon, 3 Nov 2025 19:51:26 +0100 Subject: [PATCH] refactor(di): add `InitializerDependencyAnalyzer` for enhanced dependency analysis and messaging - Introduce `InitializerDependencyAnalyzer` to analyze constructor and `container->get()` dependencies. - Enhance `CyclicDependencyException` with warnings for `container->get()` usage and explicit guidance for resolving cycles. - Improve error messaging with detailed dependency sources and actionable best practices. --- .../Exceptions/CyclicDependencyException.php | 182 +++++++++----- .../DI/InitializerDependencyAnalyzer.php | 226 ++++++++++++++++++ 2 files changed, 348 insertions(+), 60 deletions(-) create mode 100644 src/Framework/DI/InitializerDependencyAnalyzer.php diff --git a/src/Framework/DI/Exceptions/CyclicDependencyException.php b/src/Framework/DI/Exceptions/CyclicDependencyException.php index 7bc9553f..da826792 100644 --- a/src/Framework/DI/Exceptions/CyclicDependencyException.php +++ b/src/Framework/DI/Exceptions/CyclicDependencyException.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Framework\DI\Exceptions; use App\Framework\DI\Initializer; +use App\Framework\DI\InitializerDependencyAnalyzer; use App\Framework\Discovery\Results\DiscoveryRegistry; use App\Framework\Exception\ExceptionContext; @@ -105,6 +106,21 @@ final class CyclicDependencyException extends ContainerException if ($initializerInfo['isInitializerCycle']) { $message .= "⚠️ Initializer-Zyklus erkannt!\n\n"; + $dependencyAnalysis = $initializerInfo['dependencyAnalysis'] ?? null; + $hasContainerGetCalls = $dependencyAnalysis !== null && $dependencyAnalysis['hasContainerGetCalls']; + + // Warnung über container->get() Anti-Pattern + if ($hasContainerGetCalls) { + $message .= " ⚠️ WICHTIG: Initializer verwendet container->get() Aufrufe!\n\n"; + $message .= " Problem: container->get() Aufrufe umgehen die Dependency-Analyse.\n"; + $message .= " Dadurch werden zyklische Abhängigkeiten schwer erkennbar und\n"; + $message .= " die Fehlermeldung kann unvollständig sein.\n\n"; + $message .= " 💡 Best Practice:\n"; + $message .= " • Verwende Constructor Injection statt container->get()\n"; + $message .= " • Das macht Dependencies explizit und nachvollziehbar\n"; + $message .= " • Erleichtert die Dependency-Analyse und Fehlerdiagnose\n\n"; + } + // Finde welche Dependency das Interface benötigt $problematicInfo = $this->findProblematicDependency( $initializerInfo['initializerDependencies'] ?? [], @@ -123,7 +139,13 @@ final class CyclicDependencyException extends ContainerException $message .= " Der Initializer '{$initializerInfo['initializerClass']}' benötigt\n"; $message .= " das Interface/abstrakte Klasse '{$this->cycleStart}',\n"; $message .= " welches wiederum den Initializer benötigt.\n"; - $message .= " (Konnte die problematische Dependency nicht eindeutig identifizieren)\n\n"; + if ($hasContainerGetCalls) { + $message .= " Hinweis: Da container->get() verwendet wird, könnte die problematische\n"; + $message .= " Dependency in den container->get() Aufrufen versteckt sein.\n"; + } else { + $message .= " (Konnte die problematische Dependency nicht eindeutig identifizieren)\n"; + } + $message .= "\n"; } $message .= "🔧 Spezifische Lösung für Initializer-Zyklen:\n"; @@ -131,7 +153,11 @@ final class CyclicDependencyException extends ContainerException if ($problematicDependency !== null) { $message .= " • '{$problematicDependency}' sollte '{$this->cycleStart}' NICHT im Constructor benötigen\n"; $message .= " • Verwende Lazy Loading für '{$problematicDependency}' → '{$this->cycleStart}'\n"; - $message .= " (z.B. über Container->get() statt Constructor-Injection)\n"; + $message .= " (z.B. über Container->get() im __invoke(), NICHT im Constructor)\n"; + } + if ($hasContainerGetCalls) { + $message .= " • Refactoriere zu Constructor Injection statt container->get()\n"; + $message .= " (macht Dependencies explizit und vermeidet versteckte Zyklen)\n"; } $message .= " • Verwende Container als Parameter und hole das Interface erst im __invoke()\n"; $message .= " • Oder: Refactoriere den Initializer, sodass er keine zirkuläre Abhängigkeit hat\n\n"; @@ -153,7 +179,8 @@ final class CyclicDependencyException extends ContainerException private function buildInitializerChainMessage(string $firstClass, array $initializerInfo): string { $initializerClass = $initializerInfo['initializerClass']; - $dependencies = $initializerInfo['initializerDependencies'] ?? []; + $allDependencies = $initializerInfo['initializerDependencies'] ?? []; + $dependencyAnalysis = $initializerInfo['dependencyAnalysis'] ?? null; $interface = $this->cycleStart; $message = "📋 Vollständige Abhängigkeitskette:\n\n"; @@ -162,18 +189,36 @@ final class CyclicDependencyException extends ContainerException $message .= " ↓ wird erstellt durch Initializer\n"; $message .= " 3. '{$initializerClass}' (Initializer)\n"; - if (!empty($dependencies)) { + // Warnung wenn container->get() verwendet wird + if ($dependencyAnalysis !== null && $dependencyAnalysis['hasContainerGetCalls']) { + $message .= " ⚠️ WARNUNG: Initializer verwendet container->get() Aufrufe!\n"; + $message .= " Dies umgeht die Dependency-Analyse und macht Zyklen schwer erkennbar.\n\n"; + } + + if (!empty($allDependencies)) { $message .= " ↓ benötigt folgende Dependencies:\n"; // Finde problematische Dependency für Hervorhebung - $problematicInfo = $this->findProblematicDependency($dependencies, $interface); + $problematicInfo = $this->findProblematicDependency($allDependencies, $interface); $problematicDependency = $problematicInfo['problematicDependency']; - foreach ($dependencies as $index => $dependency) { - $isLast = $index === count($dependencies) - 1; + $constructorDeps = $dependencyAnalysis !== null ? $dependencyAnalysis['constructorDeps'] : []; + $containerGetDeps = $dependencyAnalysis !== null ? $dependencyAnalysis['containerGetDeps'] : []; + + foreach ($allDependencies as $index => $dependency) { + $isLast = $index === count($allDependencies) - 1; $arrow = $isLast ? '└─→' : '├─→'; + + // Bestimme Quelle der Dependency + $source = ''; + if (in_array($dependency, $containerGetDeps, true)) { + $source = ' (via container->get())'; + } elseif (in_array($dependency, $constructorDeps, true)) { + $source = ' (Constructor)'; + } + $highlight = ($dependency === $problematicDependency) ? ' ⚠️ (benötigt Interface!)' : ''; - $message .= " {$arrow} '{$dependency}'{$highlight}\n"; + $message .= " {$arrow} '{$dependency}'{$source}{$highlight}\n"; } } @@ -317,7 +362,12 @@ final class CyclicDependencyException extends ContainerException } if (!$isInterfaceOrAbstract) { - return ['isInitializerCycle' => false, 'initializerClass' => null, 'initializerDependencies' => null]; + return [ + 'isInitializerCycle' => false, + 'initializerClass' => null, + 'initializerDependencies' => null, + 'dependencyAnalysis' => null, + ]; } // Suche nach Initializer-Klassen in der Kette @@ -335,16 +385,31 @@ final class CyclicDependencyException extends ContainerException } if ($initializerClass === null) { - return ['isInitializerCycle' => false, 'initializerClass' => null, 'initializerDependencies' => null]; + return [ + 'isInitializerCycle' => false, + 'initializerClass' => null, + 'initializerDependencies' => null, + 'dependencyAnalysis' => null, + ]; } // Analysiere Dependencies des Initializers - $dependencies = $this->analyzeInitializerDependencies($initializerClass); + $analyzer = new InitializerDependencyAnalyzer(); + $dependencyAnalysis = $analyzer->analyze($initializerClass); + + // Kombiniere Constructor- und container->get() Dependencies + $allDependencies = array_merge( + $dependencyAnalysis['constructorDeps'], + $dependencyAnalysis['containerGetDeps'] + ); + $allDependencies = array_unique($allDependencies); + $allDependencies = array_values($allDependencies); return [ 'isInitializerCycle' => true, 'initializerClass' => $initializerClass, - 'initializerDependencies' => $dependencies, + 'initializerDependencies' => $allDependencies, + 'dependencyAnalysis' => $dependencyAnalysis, ]; } @@ -355,30 +420,60 @@ final class CyclicDependencyException extends ContainerException */ private function findInitializerForInterface(string $interface): ?string { - // Versuche über DiscoveryRegistry - try { - // Prüfe ob DiscoveryRegistry global verfügbar ist (z.B. über Container) - // Da wir keinen direkten Zugriff haben, versuchen wir eine Namenskonvention - // oder schauen ob wir DiscoveryRegistry über einen Singleton-Pattern erreichen können - } catch (\Throwable) { - // Ignoriere Fehler - } + // Versuche über DiscoveryRegistry (falls verfügbar) + // Note: DiscoveryRegistry benötigt selbst Dependencies, daher kann es hier fehlschlagen + // Wir verwenden daher primär Namenskonvention - // Fallback: Namenskonvention - // ComponentRegistryInterface -> ComponentRegistryInitializer + // Namenskonvention: ComponentRegistryInterface -> ComponentRegistryInitializer $interfaceName = basename(str_replace('\\', '/', $interface)); $suggestedName = str_replace('Interface', '', $interfaceName) . 'Initializer'; - - // Suche in gleichem Namespace $namespace = substr($interface, 0, strrpos($interface, '\\')); - $suggestedClass = $namespace . '\\' . $suggestedName; + // Strategie 1: Suche im gleichen Namespace (falls Interface nicht in Contracts ist) + $suggestedClass = $namespace . '\\' . $suggestedName; if (class_exists($suggestedClass)) { return $suggestedClass; } + + // Strategie 2: Entferne "Contracts" aus dem Namespace (Initializer sind normalerweise nicht im Contracts-Namespace) + // App\Framework\LiveComponents\Contracts\ComponentRegistryInterface + // -> App\Framework\LiveComponents\ComponentRegistryInitializer + if (str_ends_with($namespace, '\\Contracts')) { + $parentNamespace = substr($namespace, 0, -10); // Entferne '\Contracts' + $suggestedClass = $parentNamespace . '\\' . $suggestedName; + if (class_exists($suggestedClass)) { + return $suggestedClass; + } + } + + // Strategie 3: Suche im übergeordneten Namespace (einen Level höher) + if (strrpos($namespace, '\\') !== false) { + $parentNamespace = substr($namespace, 0, strrpos($namespace, '\\')); + $suggestedClass = $parentNamespace . '\\' . $suggestedName; + if (class_exists($suggestedClass)) { + return $suggestedClass; + } + } + + // Strategie 4: Suche mit vollständigem App\Framework\ Präfix + // Für: App\Framework\LiveComponents\Contracts\ComponentRegistryInterface + // Suche: App\Framework\LiveComponents\ComponentRegistryInitializer + $interfaceParts = explode('\\', $interface); + if (count($interfaceParts) >= 3 && $interfaceParts[0] === 'App' && $interfaceParts[1] === 'Framework') { + // Entferne 'Contracts' falls vorhanden + $filteredParts = array_filter($interfaceParts, fn($part) => $part !== 'Contracts'); + $filteredParts = array_values($filteredParts); + + // Baue Namespace ohne Interface-Name, aber mit Initializer-Name + $baseNamespace = implode('\\', array_slice($filteredParts, 0, -1)); + $suggestedClass = $baseNamespace . '\\' . $suggestedName; + if (class_exists($suggestedClass)) { + return $suggestedClass; + } + } - // Versuche auch ohne Namespace-Präfix - $suggestedClassShort = 'App\\Framework\\' . str_replace('Interface', '', $interfaceName) . 'Initializer'; + // Strategie 5: Fallback - direkter App\Framework\ Präfix + $suggestedClassShort = 'App\\Framework\\' . $suggestedName; if (class_exists($suggestedClassShort)) { return $suggestedClassShort; } @@ -386,39 +481,6 @@ final class CyclicDependencyException extends ContainerException return null; } - /** - * Analysiere Dependencies eines Initializers über Reflection - * - * @return array - */ - private function analyzeInitializerDependencies(string $initializerClass): array - { - try { - if (!class_exists($initializerClass)) { - return []; - } - - $reflection = new \ReflectionClass($initializerClass); - $constructor = $reflection->getConstructor(); - - if ($constructor === null) { - return []; - } - - $dependencies = []; - foreach ($constructor->getParameters() as $parameter) { - $type = $parameter->getType(); - if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { - $dependencies[] = $type->getName(); - } - } - - return $dependencies; - } catch (\Throwable) { - return []; - } - } - /** * Get the detected cycle (without full dependency chain) */ diff --git a/src/Framework/DI/InitializerDependencyAnalyzer.php b/src/Framework/DI/InitializerDependencyAnalyzer.php new file mode 100644 index 00000000..aa4706a1 --- /dev/null +++ b/src/Framework/DI/InitializerDependencyAnalyzer.php @@ -0,0 +1,226 @@ +get() Aufrufe + * und prüft ob Container verfügbar ist, bevor nach container->get() gesucht wird. + */ +final readonly class InitializerDependencyAnalyzer +{ + /** + * Analysiere Dependencies eines Initializers + * + * @return array{ + * constructorDeps: array, + * containerGetDeps: array, + * hasContainerGetCalls: bool, + * hasContainerAvailable: bool + * } + */ + public function analyze(string $initializerClass): array + { + try { + if (!class_exists($initializerClass)) { + return [ + 'constructorDeps' => [], + 'containerGetDeps' => [], + 'hasContainerGetCalls' => false, + 'hasContainerAvailable' => false, + ]; + } + + $reflection = new \ReflectionClass($initializerClass); + $constructorDeps = []; + + // 1. Analysiere Constructor-Dependencies + $constructor = $reflection->getConstructor(); + $hasContainerInConstructor = false; + + if ($constructor !== null) { + foreach ($constructor->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + $constructorDeps[] = $typeName; + + // Prüfe ob Container verfügbar ist + if ($typeName === Container::class || $typeName === 'App\Framework\DI\Container') { + $hasContainerInConstructor = true; + } + } + } + } + + // 2. Analysiere __invoke() Method-Dependencies via Code-Parsing + $containerGetDeps = []; + $hasContainerGetCalls = false; + $hasContainerInInvoke = false; + + try { + $invokeMethod = $reflection->getMethod('__invoke'); + + // Prüfe ob Container als Parameter in __invoke() verfügbar ist + foreach ($invokeMethod->getParameters() as $parameter) { + $type = $parameter->getType(); + if ($type instanceof \ReflectionNamedType && !$type->isBuiltin()) { + $typeName = $type->getName(); + if ($typeName === Container::class || $typeName === 'App\Framework\DI\Container') { + $hasContainerInInvoke = true; + } + + // Füge auch zu constructorDeps hinzu wenn nicht schon vorhanden + if (!in_array($typeName, $constructorDeps, true)) { + $constructorDeps[] = $typeName; + } + } + } + + // Nur nach container->get() suchen wenn Container verfügbar ist + $hasContainerAvailable = $hasContainerInConstructor || $hasContainerInInvoke; + + if ($hasContainerAvailable) { + // Parse Code der __invoke() Methode um container->get() Aufrufe zu finden + $parsedDeps = $this->parseContainerGetCalls($invokeMethod); + $containerGetDeps = $parsedDeps['dependencies']; + $hasContainerGetCalls = $parsedDeps['hasCalls']; + } + } catch (\ReflectionException) { + // __invoke() existiert nicht oder ist nicht analysierbar - das ist okay + $hasContainerAvailable = $hasContainerInConstructor; + } + + return [ + 'constructorDeps' => $constructorDeps, + 'containerGetDeps' => $containerGetDeps, + 'hasContainerGetCalls' => $hasContainerGetCalls, + 'hasContainerAvailable' => $hasContainerAvailable ?? $hasContainerInConstructor, + ]; + } catch (\Throwable) { + return [ + 'constructorDeps' => [], + 'containerGetDeps' => [], + 'hasContainerGetCalls' => false, + 'hasContainerAvailable' => false, + ]; + } + } + + /** + * Parse container->get() Aufrufe aus einer Method + * + * @return array{dependencies: array, hasCalls: bool} + */ + private function parseContainerGetCalls(\ReflectionMethod $method): array + { + try { + $fileName = $method->getFileName(); + if ($fileName === false || !file_exists($fileName)) { + return ['dependencies' => [], 'hasCalls' => false]; + } + + $fileContent = file_get_contents($fileName); + if ($fileContent === false) { + return ['dependencies' => [], 'hasCalls' => false]; + } + + $startLine = $method->getStartLine(); + $endLine = $method->getEndLine(); + + if ($startLine === false || $endLine === false) { + return ['dependencies' => [], 'hasCalls' => false]; + } + + // Extrahiere nur die Method + $lines = explode("\n", $fileContent); + $methodLines = array_slice($lines, $startLine - 1, $endLine - $startLine + 1); + $methodCode = implode("\n", $methodLines); + + // Finde container->get(...) Aufrufe + // Pattern: $container->get(ClassName::class) oder $this->container->get(ClassName::class) + // Unterstützt auch ::class Notation + $pattern = '/(?:\$container|\$this->container)->get\(([^,\)]+::class|[^,\)]+)\)/'; + preg_match_all($pattern, $methodCode, $matches); + + $dependencies = []; + if (!empty($matches[1])) { + foreach ($matches[1] as $match) { + $match = trim($match); + + // Entferne ::class falls vorhanden + $className = str_replace('::class', '', $match); + $className = trim($className, '\'"'); + + // Prüfe ob es ein gültiger Klassenname ist (beginnt mit \ oder Namespace) + if (preg_match('/^\\\\?[A-Z][A-Za-z0-9\\\\]*$/', $className)) { + // Normalisiere (füge \ am Anfang hinzu wenn nicht vorhanden) + if (!str_starts_with($className, '\\')) { + // Versuche vollständigen Namespace zu finden (z.B. über use statements) + $fullClassName = $this->resolveClassNameFromMethod($className, $fileContent, $method); + if ($fullClassName !== null) { + $dependencies[] = $fullClassName; + } else { + // Fallback: Verwende wie angegeben + $dependencies[] = $className; + } + } else { + $dependencies[] = $className; + } + } + } + + // Entferne Duplikate + $dependencies = array_unique($dependencies); + $dependencies = array_values($dependencies); + } + + return [ + 'dependencies' => $dependencies, + 'hasCalls' => !empty($matches[0]), + ]; + } catch (\Throwable) { + return ['dependencies' => [], 'hasCalls' => false]; + } + } + + /** + * Versuche vollständigen Klassenname aus use statements zu resolven + */ + private function resolveClassNameFromMethod(string $shortName, string $fileContent, \ReflectionMethod $method): ?string + { + try { + // Finde use statements im File + preg_match_all('/^use\s+([^;]+);/m', $fileContent, $useMatches); + + // Suche nach exakter Übereinstimmung oder Alias + foreach ($useMatches[1] as $useStatement) { + $useStatement = trim($useStatement); + + // Prüfe auf Alias (use Full\Class\Name as Alias) + if (preg_match('/^(.+?)\s+as\s+(\w+)$/', $useStatement, $aliasMatch)) { + $fullClassName = $aliasMatch[1]; + $alias = $aliasMatch[2]; + if ($alias === $shortName) { + return $fullClassName; + } + } else { + // Prüfe ob letzter Teil übereinstimmt + $parts = explode('\\', $useStatement); + $lastPart = end($parts); + if ($lastPart === $shortName) { + return $useStatement; + } + } + } + + return null; + } catch (\Throwable) { + return null; + } + } +} +