Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/Proxy/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
- ClassProxyGenerator — trait-based proxy for regular classes
- Constructor takes $traitName (FooOriginalTrait FQCN) as 2nd arg
- Always emits `use $traitName` (even for introduction-only aspects)
- Parent and interfaces from reflection are always rooted (`\`); only the OriginalTrait name is a deliberate short
relative name (#759)
- FunctionProxyGenerator — function wrappers
- TraitProxyGenerator — trait proxies
- EnumProxyGenerator — enum proxies (trait extraction + case re-declaration)
Expand Down
19 changes: 14 additions & 5 deletions src/Proxy/ClassProxyGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,15 @@ public function __construct(
$propertyAdvices = $classAdviceNames[AspectContainer::PROPERTY_PREFIX] ?? [];
$interceptedMethods = array_keys($dynamicMethodAdvices + $staticMethodAdvices);
$interceptedProperties = array_keys($propertyAdvices);
$introducedInterfaces = array_values(array_filter(
// Introduced names are class names, root them so a global name never resolves in the proxy namespace
$introducedInterfaces = array_map(self::rootClassName(...), array_values(array_filter(
$classAdviceNames[AspectContainer::INTRODUCTION_INTERFACE_PREFIX]['root'] ?? [],
is_string(...),
));
$introducedTraits = array_values(array_filter(
)));
$introducedTraits = array_map(self::rootClassName(...), array_values(array_filter(
$classAdviceNames[AspectContainer::INTRODUCTION_TRAIT_PREFIX]['root'] ?? [],
is_string(...),
));
)));

$staticInitializationAdvices = $classAdviceNames[AspectContainer::STATIC_INIT_PREFIX]['root'] ?? [];
$initializationAdvices = $classAdviceNames[AspectContainer::INIT_PREFIX]['root'] ?? [];
Expand Down Expand Up @@ -184,7 +185,7 @@ public function __construct(

// Proxy parent = original class parent (not the trait — there is no inheritance layer)
$parentClass = $originalClass->getParentClass();
$parentClassName = $parentClass !== false ? $parentClass->getName() : null;
$parentClassName = $parentClass !== false ? '\\' . $parentClass->getName() : null;

// Proxy modifiers: preserve final/abstract/readonly from original class.
$modifiers = [];
Expand Down Expand Up @@ -570,4 +571,12 @@ protected static function shortClassName(string $className): string

return $lastSeparator === false ? $className : substr($className, $lastSeparator + 1);
}

/**
* Returns the class name rooted with a leading backslash, so that it is emitted fully qualified
*/
private static function rootClassName(string $className): string
{
return '\\' . ltrim($className, '\\');
}
}
6 changes: 4 additions & 2 deletions src/Proxy/Generator/ClassGenerator.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,8 @@ public function addTraitAlias(string $traitFqcn, string $methodName, string $ali
{
$this->traits[] = $traitFqcn;
$this->traitAliases[] = [
'trait' => ltrim($traitFqcn, '\\'),
// Kept as given: a leading backslash roots the name in classNameNode()
'trait' => $traitFqcn,
'method' => $methodName,
'alias' => $alias,
'visibility' => $visibility,
Expand All @@ -122,7 +123,8 @@ public function addTraitAlias(string $traitFqcn, string $methodName, string $ali
* short name resolves in the generated class's own namespace (e.g. the
* FooOriginalTrait body trait). Global-namespace names coming from
* ::class constants are rooted upstream (AdviceMatcher, proxy generators)
* before they reach this generator.
* before they reach this generator; ClassProxyGenerator also roots the
* parent class name it takes from reflection (#759).
*/
private static function classNameNode(string $name): Name
{
Expand Down
72 changes: 72 additions & 0 deletions tests/Instrument/Transformer/WeavingTransformerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,78 @@ public function testWovenEnumWithConstantExpressionCaseValuesWorksAtRuntime(): v
}
}

/**
* A namespaced class extending a global class must get a proxy that extends the parent fully
* qualified, otherwise the parent resolves in the proxy namespace (issue #759).
*/
public function testWeaverRootsGlobalParentClassOfNamespacedClass(): void
{
$transformer = $this->createTransformerWithAdvices([
AspectContainer::METHOD_PREFIX => [
'hello' => ['advisor.Test\ns1\GlobalParentCollection->hello' => new BeforeInterceptor(static function (): void {})],
],
]);
$metadata = $this->loadTestMetadata('global-parent-class');
$transformer->transform($metadata);

$actual = $this->normalizeWhitespaces($metadata->source);
$expected = $this->normalizeWhitespaces($this->loadTestMetadata('global-parent-class-woven')->source);
$this->assertEquals($expected, $actual);
$this->assertSame(1, preg_match("/AOP_CACHE_DIR . '(.+)';$/m", $actual, $matches));

$proxyContent = (string) file_get_contents('vfs://' . $matches[1]);
$this->assertEquals(
$this->normalizeWhitespaces($this->loadTestMetadata('global-parent-class-proxy')->source),
$this->normalizeWhitespaces($proxyContent),
);
$this->assertPhpCompiles($proxyContent);
}

/**
* Functional check for issue #759: the woven trait plus the generated proxy must load, and the proxy
* must keep extending the global parent class.
*/
public function testWovenClassWithGlobalParentClassWorksAtRuntime(): void
{
$transformer = $this->createTransformerWithAdvices([
AspectContainer::METHOD_PREFIX => [
'hello' => ['advisor.Test\ns1\GlobalParentCollection->hello' => new BeforeInterceptor(static function (): void {})],
],
]);
$metadata = $this->loadTestMetadata('global-parent-class');
$transformer->transform($metadata);

$this->assertSame(1, preg_match("/AOP_CACHE_DIR . '(.+)';$/m", $metadata->source, $matches));
$proxyContent = file_get_contents('vfs://' . $matches[1]);

// The woven trait source, without the include_once tail (the proxy is included manually)
$traitSource = preg_replace('/^include_once AOP_CACHE_DIR.*$/m', '', $metadata->source);

$runtimeDir = static::$fileSystem->path('/runtime');
if (!is_dir($runtimeDir)) {
mkdir($runtimeDir, 0777, true);
}
$traitFile = $runtimeDir . '/aop_global_parent_trait.php';
$proxyFile = $runtimeDir . '/aop_global_parent_proxy.php';
try {
file_put_contents($traitFile, $traitSource);
file_put_contents($proxyFile, $proxyContent);
include $traitFile;
include $proxyFile;

// The class only exists after the runtime includes above
$className = 'Test\\ns1\\GlobalParentCollection';
$this->assertTrue(class_exists($className, false));
$this->assertTrue(is_subclass_of($className, \ArrayObject::class));
$instance = new $className(['a' => 1]);
$this->assertCount(1, $instance);
$this->assertSame(1, $instance['a']);
} finally {
unlink($traitFile);
unlink($proxyFile);
}
}

/**
* PHP 8.3 #[\Override] attribute must be stripped from intercepted methods.
*
Expand Down
27 changes: 27 additions & 0 deletions tests/Instrument/Transformer/_files/global-parent-class-proxy.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Test\ns1;

use Go\Aop\Framework\InterceptorInjector;
use Go\Aop\Framework\Interceptor;
use Go\Aop\Framework\The;
use Go\Aop\Intercept\DynamicMethodInvocation;
class GlobalParentCollection extends \ArrayObject implements \IteratorAggregate, \Traversable, \ArrayAccess, \Serializable, \Countable, \Go\Aop\Proxy
{
use GlobalParentCollectionOriginalTrait {
GlobalParentCollectionOriginalTrait::hello as private helloOriginalAlias;
}
public function hello(): string
{
/** @var DynamicMethodInvocation<self, string> $__joinPoint */
static $__joinPoint = InterceptorInjector::forMethod(
self::class,
'hello',
[
Interceptor::before(The::advice('advisor.Test\ns1\GlobalParentCollection->hello')),
],
$this->helloOriginalAlias(...),
);
return $__joinPoint->__invoke($this);
}
}
12 changes: 12 additions & 0 deletions tests/Instrument/Transformer/_files/global-parent-class-woven.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace Test\ns1;

trait GlobalParentCollectionOriginalTrait
{
public function hello(): string
{
return 'hello';
}
}
include_once AOP_CACHE_DIR . '/Transformer/_files/global-parent-class.php';
11 changes: 11 additions & 0 deletions tests/Instrument/Transformer/_files/global-parent-class.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php
declare(strict_types=1);
namespace Test\ns1;

class GlobalParentCollection extends \ArrayObject implements \Countable
{
public function hello(): string
{
return 'hello';
}
}
54 changes: 54 additions & 0 deletions tests/Proxy/ClassProxyGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
use Go\Stubs\ClassWithMixedSources;
use Go\Stubs\First;
use Go\Stubs\FirstStatic;
use Go\Stubs\GlobalParentCollection;
use Go\Stubs\PropertyInheritanceChild;
use Go\Stubs\ByReferenceStub;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -678,6 +679,59 @@ public function testTraitAdoptionUsesFqcnWhenDifferentNamespace(): void
$this->assertStringNotContainsString('use FirstOriginalTrait {', $output);
}

/**
* A namespaced class extending a global class: the parent from reflection has no leading backslash
* and must be emitted fully qualified, otherwise it resolves in the proxy namespace (issue #759).
* The original body trait keeps its deliberate short name.
*
* @throws ReflectionException
*/
public function testParentFromGlobalNamespaceIsFullyQualified(): void
{
$reflectionClass = new ReflectionClass(GlobalParentCollection::class);
$classAdvices = [
'method' => [
'hello' => [self::testAdvice()],
],
];

$generator = new ClassProxyGenerator($reflectionClass, 'Go\\Stubs\\GlobalParentCollectionOriginalTrait', $classAdvices);
$output = "<?php\n" . $generator->generate();

$this->assertStringContainsString('class GlobalParentCollection extends \\ArrayObject implements', $output);
$this->assertStringContainsString('use GlobalParentCollectionOriginalTrait {', $output);
$this->assertStringContainsString(
'GlobalParentCollectionOriginalTrait::hello as private helloOriginalAlias',
$output,
);
$this->assertPhpCompiles($output);
}

/**
* A trait or interface introduced from the global namespace must stay fully qualified, whether the
* caller roots the name or not (direct callers may bypass AdviceMatcher, which roots it).
*
* @throws ReflectionException
*/
#[\PHPUnit\Framework\Attributes\TestWith(['GlobalHelperTrait', 'GlobalHelperInterface'])]
#[\PHPUnit\Framework\Attributes\TestWith(['\\GlobalHelperTrait', '\\GlobalHelperInterface'])]
public function testIntroducedNamesFromGlobalNamespaceAreFullyQualified(string $trait, string $interface): void
{
$reflectionClass = new ReflectionClass(GlobalParentCollection::class);
$classAdvices = [
'interface' => ['root' => [$interface]],
'trait' => ['root' => [$trait]],
];

$generator = new ClassProxyGenerator($reflectionClass, 'Go\\Stubs\\GlobalParentCollectionOriginalTrait', $classAdvices);
$output = "<?php\n" . $generator->generate();

$this->assertStringContainsString('use GlobalParentCollectionOriginalTrait, \\GlobalHelperTrait;', $output);
$this->assertStringContainsString(', \\GlobalHelperInterface', $output);
$this->assertStringNotContainsString(' GlobalHelperInterface', $output);
$this->assertPhpCompiles($output);
}

private static function testAdvice(): GeneratedInterceptor
{
return GeneratedInterceptor::fromAdvice('test', new BeforeInterceptor(static function (): void {}));
Expand Down
10 changes: 10 additions & 0 deletions tests/Proxy/Generator/ClassGeneratorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,16 @@ public function testUsesExplicitlyRootedGlobalTraitStaysFullyQualified(): void
$this->assertStringNotContainsString('\MyClassOriginalTrait', $output);
}

public function testTraitAliasOfExplicitlyRootedGlobalTraitStaysFullyQualified(): void
{
$gen = new ClassGenerator('MyClass', 'My\Namespace', [], null);
$gen->addTraitAlias('\GlobalHelperTrait', 'help', 'helpOriginalAlias', Visibility::Private);
$output = $gen->generate();
$this->assertStringContainsString('use \GlobalHelperTrait {', $output);
$this->assertStringContainsString('\GlobalHelperTrait::help as private helpOriginalAlias;', $output);
$this->assertStringNotContainsString(' GlobalHelperTrait::help', $output);
}

public function testWithMethod(): void
{
$method = MethodGenerator::fromReflection(new ReflectionMethod(
Expand Down
26 changes: 26 additions & 0 deletions tests/Stubs/GlobalParentCollection.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?php

declare(strict_types=1);
/*
* Go! AOP framework
*
* @copyright Copyright 2026, Lisachenko Alexander <lisachenko.it@gmail.com>
*
* This source file is subject to the license that is bundled
* with this source code in the file LICENSE.
*/

namespace Go\Stubs;

/**
* Namespaced class extending a class from the global namespace (issue #759)
*
* @extends \ArrayObject<array-key, mixed>
*/
class GlobalParentCollection extends \ArrayObject
{
public function hello(): string
{
return 'hello';
}
}
Loading