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.
This commit is contained in:
2025-11-03 19:51:26 +01:00
parent 522e76e86a
commit f8fb9b5a45
2 changed files with 348 additions and 60 deletions

View File

@@ -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;
}
// Versuche auch ohne Namespace-Präfix
$suggestedClassShort = 'App\\Framework\\' . str_replace('Interface', '', $interfaceName) . 'Initializer';
// 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;
}
}
// 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<string>
*/
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)
*/

View File

@@ -0,0 +1,226 @@
<?php
declare(strict_types=1);
namespace App\Framework\DI;
/**
* Analysiert Dependencies von Initializern
*
* Erkennt sowohl Constructor-Dependencies als auch container->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<string>,
* containerGetDeps: array<string>,
* 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<string>, 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;
}
}
}