From 3e914362da35e0e00479e17f1f1123e612e85f8a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 15:34:27 +0000 Subject: [PATCH 1/2] fix: emit fully qualified names for global-namespace symbols in class proxies A DeclareParents introduction of a global-namespace interface (e.g. Stringable) or trait generated "implements Stringable" inside the proxy's namespace, where the name resolved relative to that namespace and crashed class loading with "Interface ...\Stringable not found". ClassGenerator now always emits interfaces fully qualified (they are FQCNs by contract, matching EnumGenerator) and honours an explicit leading backslash on parent/trait names, while bare short trait names (Foo__AopProxied) keep resolving in the proxy's own namespace. Reported in goaop/goaop-laravel-bridge#21. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KtrBW3xd8DQkxmHvX9qr4p --- src/Proxy/Generator/ClassGenerator.php | 34 +++++++++----------- tests/Proxy/Generator/ClassGeneratorTest.php | 27 ++++++++++++++++ 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/Proxy/Generator/ClassGenerator.php b/src/Proxy/Generator/ClassGenerator.php index 094d230e..43e98aef 100644 --- a/src/Proxy/Generator/ClassGenerator.php +++ b/src/Proxy/Generator/ClassGenerator.php @@ -176,10 +176,11 @@ 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. + // Use FullyQualified when the name is explicitly rooted ("\Exception") or + // contains a namespace separator; a bare short name stays relative so + // same-namespace parents keep resolving in the generated file's context. $parentName = ltrim($this->parentClass, '\\'); - $parentNode = str_contains($parentName, '\\') + $parentNode = str_starts_with($this->parentClass, '\\') || str_contains($parentName, '\\') ? new Name\FullyQualified($parentName) : $parentName; $builder->extend($parentNode); @@ -187,24 +188,27 @@ public function getNode(): ClassNode foreach ($this->interfaces as $interface) { if ($interface !== '') { - $ifaceName = ltrim($interface, '\\'); - $ifaceNode = str_contains($ifaceName, '\\') - ? new Name\FullyQualified($ifaceName) - : $ifaceName; - $builder->implement($ifaceNode); + // Interfaces are FQCNs by contract — always emit fully qualified so a + // global-namespace interface (e.g. an introduced 'Stringable') is not + // mis-resolved against the generated file's namespace. See #21 in the + // laravel bridge and the identical handling in EnumGenerator. + $builder->implement(new Name\FullyQualified(ltrim($interface, '\\'))); } } - // Traits (always use FQN to avoid namespace ambiguity) + // Traits (use FQN unless the caller passed a deliberate short name, which + // refers to a trait in the proxy's own namespace, e.g. Foo__AopProxied) 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; + $seen[$normalized] = true; + $traitNames[] = str_starts_with($trait, '\\') || str_contains($normalized, '\\') + ? new Name\FullyQualified($normalized) + : new Name($normalized); } } @@ -222,12 +226,6 @@ public function getNode(): ClassNode ); } - $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( From 72b14d3d4f71aaae10f35ffa603f64bf2b108d89 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 16:19:53 +0000 Subject: [PATCH 2/2] refactor: unify class-string name emission behind a single helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review feedback on #584: extends/implements/traits/aliases now all go through one classNameNode() rule — explicitly rooted or multi-segment names are emitted fully qualified, bare short names resolve in the generated class's own namespace. Global-namespace names from ::class constants are already rooted upstream (AdviceMatcher normalizes introduced trait/interface names with a leading backslash). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01KtrBW3xd8DQkxmHvX9qr4p --- src/Proxy/Generator/ClassGenerator.php | 46 +++++++++++++------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/src/Proxy/Generator/ClassGenerator.php b/src/Proxy/Generator/ClassGenerator.php index 43e98aef..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,28 +194,15 @@ public function getNode(): ClassNode } if ($this->parentClass !== null && $this->parentClass !== '') { - // Use FullyQualified when the name is explicitly rooted ("\Exception") or - // contains a namespace separator; a bare short name stays relative so - // same-namespace parents keep resolving in the generated file's context. - $parentName = ltrim($this->parentClass, '\\'); - $parentNode = str_starts_with($this->parentClass, '\\') || str_contains($parentName, '\\') - ? new Name\FullyQualified($parentName) - : $parentName; - $builder->extend($parentNode); + $builder->extend(self::classNameNode($this->parentClass)); } foreach ($this->interfaces as $interface) { if ($interface !== '') { - // Interfaces are FQCNs by contract — always emit fully qualified so a - // global-namespace interface (e.g. an introduced 'Stringable') is not - // mis-resolved against the generated file's namespace. See #21 in the - // laravel bridge and the identical handling in EnumGenerator. - $builder->implement(new Name\FullyQualified(ltrim($interface, '\\'))); + $builder->implement(self::classNameNode($interface)); } } - // Traits (use FQN unless the caller passed a deliberate short name, which - // refers to a trait in the proxy's own namespace, e.g. Foo__AopProxied) if (!empty($this->traits)) { // Collect unique trait names (preserving order of first occurrence) $seen = []; @@ -205,21 +210,16 @@ public function getNode(): ClassNode foreach ($this->traits as $trait) { $normalized = ltrim($trait, '\\'); if (!isset($seen[$normalized])) { - $seen[$normalized] = true; - $traitNames[] = str_starts_with($trait, '\\') || str_contains($normalized, '\\') - ? new Name\FullyQualified($normalized) - : new Name($normalized); + $seen[$normalized] = true; + $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'])