diff --git a/src/Proxy/Generator/ClassGenerator.php b/src/Proxy/Generator/ClassGenerator.php index 094d230e..bd795c20 100644 --- a/src/Proxy/Generator/ClassGenerator.php +++ b/src/Proxy/Generator/ClassGenerator.php @@ -153,6 +153,24 @@ public function getName(): string return $this->name; } + /** + * Builds the AST name node for a class-like reference (parent, interface, + * trait, alias) with a single universal rule: explicitly rooted + * ("\Stringable") and multi-segment names are fully qualified; a bare + * short name resolves in the generated class's own namespace (e.g. the + * Foo__AopProxied body trait). Global-namespace names coming from + * ::class constants are rooted upstream (AdviceMatcher, proxy generators) + * before they reach this generator. + */ + private static function classNameNode(string $name): Name + { + $normalized = ltrim($name, '\\'); + + return str_starts_with($name, '\\') || str_contains($normalized, '\\') + ? new Name\FullyQualified($normalized) + : new Name($normalized); + } + /** * Returns the class AST node only — no namespace or use wrappers. * Suitable for direct injection into a cloned file AST. @@ -176,58 +194,38 @@ public function getNode(): ClassNode } if ($this->parentClass !== null && $this->parentClass !== '') { - // Use FullyQualified when the name contains a namespace separator to avoid - // ambiguity in the generated file's namespace context. - $parentName = ltrim($this->parentClass, '\\'); - $parentNode = str_contains($parentName, '\\') - ? new Name\FullyQualified($parentName) - : $parentName; - $builder->extend($parentNode); + $builder->extend(self::classNameNode($this->parentClass)); } foreach ($this->interfaces as $interface) { if ($interface !== '') { - $ifaceName = ltrim($interface, '\\'); - $ifaceNode = str_contains($ifaceName, '\\') - ? new Name\FullyQualified($ifaceName) - : $ifaceName; - $builder->implement($ifaceNode); + $builder->implement(self::classNameNode($interface)); } } - // Traits (always use FQN to avoid namespace ambiguity) if (!empty($this->traits)) { // Collect unique trait names (preserving order of first occurrence) $seen = []; - $traitFqcns = []; + $traitNames = []; foreach ($this->traits as $trait) { $normalized = ltrim($trait, '\\'); if (!isset($seen[$normalized])) { $seen[$normalized] = true; - $traitFqcns[] = $normalized; + $traitNames[] = self::classNameNode($trait); } } // Build adaptations for all aliases $adaptations = []; foreach ($this->traitAliases as $info) { - $traitNameNode = str_contains($info['trait'], '\\') - ? new Name\FullyQualified($info['trait']) - : new Name($info['trait']); $adaptations[] = new TraitUseAdaptation\Alias( - $traitNameNode, + self::classNameNode($info['trait']), new Identifier($info['method']), $this->mapVisibility($info['visibility']), new Identifier($info['alias']) ); } - $traitNames = array_map( - static fn(string $t) => str_contains($t, '\\') - ? new Name\FullyQualified($t) - : new Name($t), - $traitFqcns - ); $builder->addStmt(new TraitUse($traitNames, $adaptations)); } diff --git a/tests/Proxy/Generator/ClassGeneratorTest.php b/tests/Proxy/Generator/ClassGeneratorTest.php index e0201ed4..3d7c189b 100644 --- a/tests/Proxy/Generator/ClassGeneratorTest.php +++ b/tests/Proxy/Generator/ClassGeneratorTest.php @@ -76,6 +76,33 @@ public function testImplementsMultipleInterfaces(): void $this->assertStringContainsString('Iterator', $output); } + public function testImplementsGlobalInterfaceIsFullyQualifiedInNamespace(): void + { + // A global-namespace interface (single segment) must not resolve + // relative to the generated class namespace + $gen = new ClassGenerator('MyClass', 'My\Namespace', null, null, ['\Stringable']); + $output = $gen->generate(); + $this->assertStringContainsString('implements \Stringable', $output); + } + + public function testExtendsExplicitlyRootedGlobalParentStaysFullyQualified(): void + { + $gen = new ClassGenerator('MyClass', 'My\Namespace', null, '\Exception'); + $output = $gen->generate(); + $this->assertStringContainsString('extends \Exception', $output); + } + + public function testUsesExplicitlyRootedGlobalTraitStaysFullyQualified(): void + { + $gen = new ClassGenerator('MyClass', 'My\Namespace', null, null); + $gen->addTraits(['\GlobalHelperTrait', 'MyClass__AopProxied']); + $output = $gen->generate(); + $this->assertStringContainsString('\GlobalHelperTrait', $output); + // Deliberate short names keep referring to the class' own namespace + $this->assertStringContainsString('MyClass__AopProxied', $output); + $this->assertStringNotContainsString('\MyClass__AopProxied', $output); + } + public function testWithMethod(): void { $method = MethodGenerator::fromReflection(new ReflectionMethod(