From eaae273bf35928cea350e5748ec4db97806203a4 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Fri, 1 May 2026 12:37:41 +0300 Subject: [PATCH 1/3] feat: support first-class callable for method and function invocations - DynamicTraitAliasMethodInvocation: accept Closure $closureToCall; uses ReflectionFunction around it to reconstruct ReflectionMethod; calls ReflectionMethod->invokeArgs to support by-reference parameters in methods - StaticTraitAliasMethodInvocation: accept Closure $closureToCall; wraps it in a forward_static_call_array shim so bindTo(null, $scope) correctly forwards late-static-binding class. - ReflectionFunctionInvocation: accept Closure $closureToCall; calls it directly instead of ReflectionFunction::invokeArgs(). - InterceptorInjector: add required $closureToCall parameter to forMethod(), forStaticMethod(), and forFunction(); passes it through to invocation ctors. - ClassProxyGenerator: pass $this->__aop__method(...) or self::__aop__method(...) as 4th arg when method is declared in the proxied class (has a trait alias). - TraitProxyGenerator / EnumProxyGenerator: always pass trait-alias callable since all intercepted methods in traits/enums have __aop__ aliases. - FunctionProxyGenerator: pass \functionName(...) (global function reference) as 3rd arg to avoid calling namespace-scoped proxy recursively. - Update all proxy snapshot fixture files. - Add new tests for callable rebinding, LSB, and function invocation paths. - Add TraitAliasProxy helpers and TraitAliasProxied::getObjectId stub method. Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com> --- .../Framework/AbstractMethodInvocation.php | 18 ++-- .../DynamicTraitAliasMethodInvocation.php | 56 +++++++----- src/Aop/Framework/InterceptorInjector.php | 24 ++++-- .../ReflectionFunctionInvocation.php | 23 ++++- .../StaticTraitAliasMethodInvocation.php | 52 ++++++------ .../ClassLoading/AopComposerLoader.php | 2 +- src/Proxy/ClassProxyGenerator.php | 34 +++++++- src/Proxy/EnumProxyGenerator.php | 11 ++- src/Proxy/FunctionProxyGenerator.php | 10 ++- src/Proxy/TraitProxyGenerator.php | 12 ++- .../AbstractMethodInvocationTest.php | 4 +- .../DynamicTraitAliasMethodInvocationTest.php | 85 ++++++++++++++++--- .../ReflectionFunctionInvocationTest.php | 85 +++++++++++++++++++ .../StaticTraitAliasMethodInvocationTest.php | 61 ++++++++++--- .../Transformer/_files/class-proxy.php | 14 +-- .../_files/final-readonly-class-proxy.php | 6 +- .../Transformer/_files/php7-class-proxy.php | 34 ++++---- .../Transformer/_files/php81-enum-proxy.php | 2 +- .../_files/php83-override-proxy.php | 4 +- tests/Proxy/ClassProxyGeneratorTest.php | 50 +++++++++++ tests/Stubs/InheritedMethodProxy.php | 26 ++++++ tests/Stubs/TraitAliasProxied.php | 5 ++ tests/Stubs/TraitAliasProxy.php | 51 +++++++++++ 23 files changed, 546 insertions(+), 123 deletions(-) create mode 100644 tests/Aop/Framework/ReflectionFunctionInvocationTest.php diff --git a/src/Aop/Framework/AbstractMethodInvocation.php b/src/Aop/Framework/AbstractMethodInvocation.php index fa737137..16eea60c 100644 --- a/src/Aop/Framework/AbstractMethodInvocation.php +++ b/src/Aop/Framework/AbstractMethodInvocation.php @@ -40,10 +40,12 @@ abstract class AbstractMethodInvocation extends AbstractInvocation implements Me protected readonly ReflectionMethod $reflectionMethod; /** - * Pre-bound closure that calls the private `__aop__` alias on any instance of the proxy class. - * Created once in the constructor via Closure::bind bound to the proxy class scope. + * First-class callable pointing to the original method body. + * May be wrapped or rebound if needed in child classes or during the {@see proceed()} call. + * + * @link https://www.php.net/manual/en/functions.first_class_callable_syntax.php */ - protected Closure $closureToCall; + protected readonly Closure $closureToCall; /** * This static string variable holds the name of field to use to avoid extra "if" section in the __invoke method @@ -62,14 +64,16 @@ abstract class AbstractMethodInvocation extends AbstractInvocation implements Me /** * Constructor for method invocation * - * @param array $advices List of advices for this invocation - * @param class-string $className Class, containing method to invoke - * @param non-empty-string $methodName Name of the method to invoke + * @param array $advices List of advices for this invocation + * @param class-string $className Class, containing method to invoke + * @param non-empty-string $methodName Name of the method to invoke + * @param Closure $closureToCall First-class callable to the original method body. */ - public function __construct(array $advices, string $className, string $methodName) + public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) { parent::__construct($advices); $this->reflectionMethod = new ReflectionMethod($className, $methodName); + $this->closureToCall = $closureToCall; } final public function __invoke(object|string $instanceOrScope, array $arguments = [], array $variadicArguments = []): mixed diff --git a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php index efb63bdc..ee92c15c 100644 --- a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php +++ b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php @@ -12,14 +12,24 @@ namespace Go\Aop\Framework; +use Closure; use Go\Aop\Intercept\DynamicMethodInvocation; +use Go\Aop\Intercept\Interceptor; +use ReflectionFunction; use ReflectionMethod; /** - * Dynamic trait-alias method invocation calls instance methods via a pre-bound Closure::bind closure - * targeting the private `__aop__` alias created in the proxy's trait-use block. + * Dynamic trait-alias method invocation calls instance methods via reflection. * - * The closure is built once at construction time so that every invocation needs zero reflection. + * The callable is provided by the generated proxy code and points to the original method body: + * - For methods declared in the proxied class: `$this->__aop__(...)` — the private + * alias created in the proxy's trait-use block. + * - For inherited methods (no trait alias): `parent::(...)`. + * + * Note: `ReflectionMethod::invokeArgs()` is used in {@see proceed()} because it is faster than + * `Closure::call()` (see https://3v4l.org/DYj84) and reliably handles pass-by-reference + * parameters (unlike `Closure::call()` which has known issues with by-ref args, see + * https://bugs.php.net/bug.php?id=72326). * * @template T of object = object Declares the instance type of the method invocation. * @template V = mixed Declares the generic return type of the method invocation. @@ -43,29 +53,34 @@ final class DynamicTraitAliasMethodInvocation extends AbstractMethodInvocation i protected object $instance; /** - * We may have either original method in the same class via trait alias or prototype method - * from one of our parents. + * ReflectionMethod pointing to the original method body: + * - For methods with a trait alias: the private `__aop__` alias. + * - For inherited methods without a trait alias: the prototype method from the parent class. */ - private ReflectionMethod $originalMethodToCall; + private readonly ReflectionMethod $originalMethodToCall; /** - * Constructor for method invocation - * - * @param class-string $className Class, containing method to invoke + * @param array $advices + * @param class-string $className + * @param non-empty-string $methodName + * @param Closure $closureToCall First-class callable to the original method body, + * e.g. `$this->__aop__method(...)` for trait-aliased + * methods or `parent::method(...)` for inherited ones. */ - public function __construct(array $advices, string $className, string $methodName) + public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) { - parent::__construct($advices, $className, $methodName); - $aliasName = self::TRAIT_ALIAS_PREFIX . $methodName; - if (method_exists($className, $aliasName)) { - $methodToCall = new ReflectionMethod($className, $aliasName); - } elseif ($this->reflectionMethod->hasPrototype()) { - $methodToCall = $this->reflectionMethod->getPrototype(); - } else { - throw new \LogicException("Cannot proceed with method invocation for {$methodName}: no trait alias and no method prototype found for {$className}"); + parent::__construct($advices, $className, $methodName, $closureToCall); + + // Logic with reflection is used as workaround for PHP bug https://bugs.php.net/bug.php?id=72326 + $reflectionClosure = new ReflectionFunction($closureToCall); + $closureScopeClass = $reflectionClosure->getClosureScopeClass(); + if ($closureScopeClass === null) { + throw new \RuntimeException('Cannot determine the scope class of the closure'); } - $this->originalMethodToCall = $methodToCall; - $this->closureToCall = static fn(object $instanceToCall, array $argumentsToCall): mixed => $methodToCall->invokeArgs($instanceToCall, $argumentsToCall); + $this->originalMethodToCall = new ReflectionMethod( + $closureScopeClass->getName(), + $reflectionClosure->getName() + ); } /** @@ -79,7 +94,6 @@ public function proceed(): mixed return $currentInterceptor->invoke($this); } - // Bypassing ($this->closureToCall)($this->instance, $this->arguments) for performance reasons return $this->originalMethodToCall->invokeArgs($this->instance, $this->arguments); } diff --git a/src/Aop/Framework/InterceptorInjector.php b/src/Aop/Framework/InterceptorInjector.php index 61b8d627..b780041b 100644 --- a/src/Aop/Framework/InterceptorInjector.php +++ b/src/Aop/Framework/InterceptorInjector.php @@ -12,6 +12,7 @@ namespace Go\Aop\Framework; +use Closure; use Go\Aop\Intercept\ClassJoinpoint; use Go\Aop\Intercept\ConstructorInvocation; use Go\Aop\Intercept\DynamicMethodInvocation; @@ -34,14 +35,18 @@ final class InterceptorInjector * @param class-string $className * @param non-empty-string $methodName * @param non-empty-list $advisorNames + * @param Closure $closureToCall First-class callable to the original method body, + * e.g. `$this->__aop__method(...)` for trait-aliased methods or + * `parent::method(...)` for inherited methods. * @return DynamicMethodInvocation */ - public static function forMethod(string $className, string $methodName, array $advisorNames): DynamicMethodInvocation + public static function forMethod(string $className, string $methodName, array $advisorNames, Closure $closureToCall): DynamicMethodInvocation { return new DynamicTraitAliasMethodInvocation( self::fillInterceptors($advisorNames), $className, - $methodName + $methodName, + $closureToCall ); } @@ -50,14 +55,18 @@ public static function forMethod(string $className, string $methodName, array $a * @param class-string $className * @param non-empty-string $methodName * @param non-empty-list $advisorNames + * @param Closure $closureToCall First-class callable to the original static method body, + * e.g. `self::__aop__method(...)` for trait-aliased methods or + * `parent::method(...)` for inherited methods. * @return StaticMethodInvocation */ - public static function forStaticMethod(string $className, string $methodName, array $advisorNames): StaticMethodInvocation + public static function forStaticMethod(string $className, string $methodName, array $advisorNames, Closure $closureToCall): StaticMethodInvocation { return new StaticTraitAliasMethodInvocation( self::fillInterceptors($advisorNames), $className, - $methodName + $methodName, + $closureToCall ); } @@ -80,12 +89,15 @@ public static function forProperty(string $className, string $propertyName, arra /** * @param non-empty-string $functionName * @param non-empty-list $advisorNames + * @param Closure $closureToCall First-class callable to the original global function + * (e.g. `\file_get_contents(...)`). */ - public static function forFunction(string $functionName, array $advisorNames): FunctionInvocation + public static function forFunction(string $functionName, array $advisorNames, Closure $closureToCall): FunctionInvocation { return new ReflectionFunctionInvocation( self::fillInterceptors($advisorNames), - $functionName + $functionName, + $closureToCall ); } diff --git a/src/Aop/Framework/ReflectionFunctionInvocation.php b/src/Aop/Framework/ReflectionFunctionInvocation.php index 018f2741..505b4fc4 100644 --- a/src/Aop/Framework/ReflectionFunctionInvocation.php +++ b/src/Aop/Framework/ReflectionFunctionInvocation.php @@ -12,6 +12,7 @@ namespace Go\Aop\Framework; +use Closure; use Go\Aop\Intercept\FunctionInvocation; use Go\Aop\Intercept\Interceptor; use ReflectionException; @@ -21,6 +22,14 @@ /** * Function invocation implementation * + * Uses a first-class callable (3rd constructor argument, required) to call the original + * function directly in {@see proceed()} without any reflection overhead. + * + * The callable should be a fully-qualified global function reference, e.g. `\file_get_contents(...)`, + * with a leading backslash to avoid calling the namespace-scoped proxy recursively. Without the + * backslash, PHP would resolve the function name relative to the namespace of the generated proxy + * file, which would call the proxy itself and cause infinite recursion. + * * @template V = mixed Declares the generic return type of the result. * @implements FunctionInvocation */ @@ -38,17 +47,25 @@ final class ReflectionFunctionInvocation extends AbstractInvocation implements F */ private readonly ReflectionFunction $reflectionFunction; + /** + * First-class callable to the original global function. + */ + private readonly Closure $closureToCall; + /** * Constructor for function invocation * - * @param array $advices List of advices for this invocation + * @param array $advices List of advices for this invocation + * @param Closure $closureToCall First-class callable to the original global function + * (e.g. `\file_get_contents(...)`). * * @throws ReflectionException */ - public function __construct(array $advices, string $functionName) + public function __construct(array $advices, string $functionName, Closure $closureToCall) { parent::__construct($advices); $this->reflectionFunction = new ReflectionFunction($functionName); + $this->closureToCall = $closureToCall; } /** @@ -62,7 +79,7 @@ public function proceed(): mixed return $currentInterceptor->invoke($this); } - return $this->reflectionFunction->invokeArgs($this->arguments); + return ($this->closureToCall)(...$this->arguments); } public function getFunction(): ReflectionFunction diff --git a/src/Aop/Framework/StaticTraitAliasMethodInvocation.php b/src/Aop/Framework/StaticTraitAliasMethodInvocation.php index ce982fb8..398d4eb4 100644 --- a/src/Aop/Framework/StaticTraitAliasMethodInvocation.php +++ b/src/Aop/Framework/StaticTraitAliasMethodInvocation.php @@ -16,10 +16,17 @@ use Go\Aop\Intercept\StaticMethodInvocation; /** - * Static trait-alias method invocation calls static methods via a pre-bound Closure::bind closure - * targeting the private `__aop__` alias created in the proxy's trait-use block. + * Static trait-alias method invocation calls static methods via a first-class callable + * that is rebound to each caller's late-static-binding scope on every invocation. * - * The closure is built once at construction time so that every invocation needs zero reflection. + * The callable is provided by the generated proxy code and points to the original method body: + * - For methods declared in the proxied class: `self::__aop__(...)` — the private + * alias created in the proxy's trait-use block. + * - For inherited methods (no trait alias): `parent::(...)`. + * + * In both cases the callable is wrapped in a `static fn(array $args) => forward_static_call($callable, ...$args)` + * shim (see constructor). This shim can be rebound via {@see Closure::bindTo()} on every call so that + * `static::class` (late-static-binding) inside the original method body resolves to the correct subclass. * * @template T of object = object Declares the instance type of the method invocation. * @template V = mixed Declares the generic return type of the method invocation. @@ -42,29 +49,24 @@ final class StaticTraitAliasMethodInvocation extends AbstractMethodInvocation im protected string $scope; /** - * Constructor for method invocation + * Constructor for static method invocation. + * + * Wraps the provided callable in a `static fn(array $args): mixed => forward_static_call($closureToCall, ...$args)` + * shim so that `Closure::bindTo(null, $scope)` can forward the correct late-static-binding class to the + * original method body without requiring the original closure to be rebindable. * - * @param class-string $className Class, containing method to invoke + * @param class-string $className Class, containing method to invoke + * @param Closure $closureToCall First-class callable to the original static method body, + * e.g. `self::__aop__method(...)` or `parent::method(...)`. */ - public function __construct(array $advices, string $className, string $methodName) + public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) { - parent::__construct($advices, $className, $methodName); - $aliasName = self::TRAIT_ALIAS_PREFIX . $methodName; - if (method_exists($className, $aliasName)) { - $scopeToCall = $className; - $methodToCall = $aliasName; - } elseif ($this->reflectionMethod->hasPrototype()) { - $scopeToCall = $this->reflectionMethod->getPrototype()->getDeclaringClass()->getName(); - $methodToCall = $methodName; - } else { - throw new \LogicException("Cannot proceed with method invocation for {$methodName}: no trait alias and no method prototype found for {$className}"); - } - - $this->closureToCall = Closure::bind( - static fn(string $classToCall, array $argumentsToCall): mixed => forward_static_call_array($scopeToCall::$methodToCall(...), $argumentsToCall), - null, - $className - ); + // Wrap in a static closure so that when we bindTo(null, $scope) in proceed(), + // forward_static_call will use $scope as the late-static-binding class. + // We cannot rebind $closureToCall directly because first-class callables from static + // methods have a fixed scope. + $shim = static fn(array $argumentsToCall): mixed => forward_static_call($closureToCall, ...$argumentsToCall); + parent::__construct($advices, $className, $methodName, $shim); } /** @@ -78,7 +80,9 @@ public function proceed(): mixed return $currentInterceptor->invoke($this); } - return ($this->closureToCall)($this->scope, $this->arguments); + // Bind the wrapper to the current scope so forward_static_call forwards the + // correct late-static-binding class (supports child-class static invocations). + return $this->closureToCall->bindTo(null, $this->scope)->__invoke($this->arguments); } /** diff --git a/src/Instrument/ClassLoading/AopComposerLoader.php b/src/Instrument/ClassLoading/AopComposerLoader.php index 4039340d..4b76d937 100644 --- a/src/Instrument/ClassLoading/AopComposerLoader.php +++ b/src/Instrument/ClassLoading/AopComposerLoader.php @@ -106,7 +106,7 @@ public static function init(array $options, AspectContainer $container): bool foreach ($loaders as &$loader) { $loaderToUnregister = $loader; if (is_array($loader)) { - $originalLoader = $loader[0] ?? null; + $originalLoader = $loader[0]; if ($originalLoader instanceof ClassLoader) { $loader[0] = new AopComposerLoader($originalLoader, $container, $options); self::$wasInitialized = true; diff --git a/src/Proxy/ClassProxyGenerator.php b/src/Proxy/ClassProxyGenerator.php index 502b5e2b..c0c21068 100644 --- a/src/Proxy/ClassProxyGenerator.php +++ b/src/Proxy/ClassProxyGenerator.php @@ -235,7 +235,7 @@ protected function interceptMethods(ReflectionClass $originalClass, array $metho $interceptedMethods = []; foreach ($methodNames as $methodName) { $reflectionMethod = $originalClass->getMethod($methodName); - $methodBody = $this->getJoinpointInvocationBody($reflectionMethod); + $methodBody = $this->getJoinpointInvocationBody($reflectionMethod, $originalClass); $interceptedMethods[$methodName] = new InterceptedMethodGenerator( $reflectionMethod, @@ -278,8 +278,13 @@ private function interceptProperties(ReflectionClass $originalClass, array $prop /** * Creates string definition for method body by method reflection + * + * @param ReflectionClass|null $originalClass The original class being proxied. When provided, + * it is used to determine if the method has a trait alias + * (method declared in the class itself) or is inherited + * (uses parent:: callable wrapper). */ - protected function getJoinpointInvocationBody(ReflectionMethod $method): string + protected function getJoinpointInvocationBody(ReflectionMethod $method, ?ReflectionClass $originalClass = null): string { $isStatic = $method->isStatic(); $invocationArguments = $isStatic ? 'static::class' : '$this'; @@ -320,9 +325,32 @@ protected function getJoinpointInvocationBody(ReflectionMethod $method): string ? 'StaticMethodInvocation' : 'DynamicMethodInvocation'; + // Determine the first-class callable expression for the original method. + // + // Methods declared in the proxied class have a private `__aop__` alias in the + // proxy's trait-use block. These first-class callables are rebound per-call. + // + // Inherited methods have no such alias. For static calls, `parent::method(...)` is used + // directly — StaticTraitAliasMethodInvocation wraps it in a forward_static_call shim anyway. + // For dynamic calls, raw `parent::method(...)` first-class callables CANNOT be rebound via + // Closure::call() (PHP limitation), so we wrap them in a \Closure::bind'd anonymous function + // that is rebindable and delegates to the parent method body. + $hasTraitAlias = $originalClass !== null && ($method->class === $originalClass->name); + if ($hasTraitAlias) { + $callableExpression = $isStatic + ? ', self::' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)' + : ', $this->' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)'; + } else { + // Inherited method (no trait alias): use parent:: first-class callable for both static and dynamic. + // DynamicTraitAliasMethodInvocation uses ReflectionMethod internally, so the callable is stored + // but not used for the actual dispatch. StaticTraitAliasMethodInvocation wraps it in a + // forward_static_call shim to preserve late-static-binding. + $callableExpression = ', parent::' . $method->name . '(...)'; + } + $body = <<name}', {$advicesCode}); + static \$__joinPoint = InterceptorInjector::{$injectorMethod}(self::class, '{$method->name}', {$advicesCode}{$callableExpression}); {$return}\$__joinPoint->__invoke($invocationArguments); BODY; diff --git a/src/Proxy/EnumProxyGenerator.php b/src/Proxy/EnumProxyGenerator.php index e06fa752..6acfc427 100644 --- a/src/Proxy/EnumProxyGenerator.php +++ b/src/Proxy/EnumProxyGenerator.php @@ -195,8 +195,10 @@ public function generate(): string * * This mirrors TraitProxyGenerator::getJoinpointInvocationBody() because enums, * like traits, cannot hold a class-level $__joinPoints property. + * + * All intercepted enum methods have __aop__ aliases from the enum's trait-use block. */ - protected function getJoinpointInvocationBody(ReflectionMethod $method): string + protected function getJoinpointInvocationBody(ReflectionMethod $method, ?ReflectionClass $originalClass = null): string { $isStatic = $method->isStatic(); $scope = $isStatic ? 'static::class' : '$this'; @@ -234,9 +236,14 @@ protected function getJoinpointInvocationBody(ReflectionMethod $method): string ? 'StaticMethodInvocation' : 'DynamicMethodInvocation'; + // All intercepted enum methods have __aop__ aliases from the enum's trait-use block. + $callableExpression = $isStatic + ? 'self::' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)' + : '$this->' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)'; + return <<name}', {$advicesCode}); + static \$__joinPoint = InterceptorInjector::{$injectorMethod}(self::class, '{$method->name}', {$advicesCode}, {$callableExpression}); {$return}\$__joinPoint->__invoke($argumentCode); BODY; } diff --git a/src/Proxy/FunctionProxyGenerator.php b/src/Proxy/FunctionProxyGenerator.php index 69454ec3..4dbb5824 100644 --- a/src/Proxy/FunctionProxyGenerator.php +++ b/src/Proxy/FunctionProxyGenerator.php @@ -83,6 +83,10 @@ public function generate(): string /** * Creates string definition for function method body by function reflection + * + * The callable expression uses a fully-qualified global function reference (leading backslash) + * to ensure that proceed() calls the original built-in function, not the proxy defined in + * the current namespace. */ protected function getJoinpointInvocationBody(ReflectionFunction $function): string { @@ -104,9 +108,13 @@ protected function getJoinpointInvocationBody(ReflectionFunction $function): str $advicesCode = $advicesArray->generate(); $returnTypeString = $function->hasReturnType() ? '<' . TypeGenerator::renderTypeForPhpDoc($function->getReturnType()) . '>' : ''; + // Use a fully-qualified (global) callable so proceed() calls the original built-in + // function rather than the proxy defined in this namespace. + $callableExpression = '\\' . $function->getName() . '(...)'; + return <<name}', {$advicesCode}); + static \$__joinPoint = InterceptorInjector::forFunction('{$function->name}', {$advicesCode}, {$callableExpression}); {$return}\$__joinPoint->__invoke($argumentCode); BODY; } diff --git a/src/Proxy/TraitProxyGenerator.php b/src/Proxy/TraitProxyGenerator.php index 528e06c9..f59ad4ee 100644 --- a/src/Proxy/TraitProxyGenerator.php +++ b/src/Proxy/TraitProxyGenerator.php @@ -105,8 +105,11 @@ public function __construct( /** * Creates string definition for trait method body by method reflection + * + * In a trait proxy, all intercepted methods always have a private __aop__ alias in the + * trait-use block (from the parent trait). So the callable always references the alias. */ - protected function getJoinpointInvocationBody(ReflectionMethod $method): string + protected function getJoinpointInvocationBody(ReflectionMethod $method, ?ReflectionClass $originalClass = null): string { $isStatic = $method->isStatic(); $scope = $isStatic ? 'static::class' : '$this'; @@ -145,9 +148,14 @@ protected function getJoinpointInvocationBody(ReflectionMethod $method): string ? 'StaticMethodInvocation' : 'DynamicMethodInvocation'; + // All intercepted methods in a trait proxy have __aop__ aliases from the parent trait. + $callableExpression = $isStatic + ? 'self::' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)' + : '$this->' . AbstractMethodInvocation::TRAIT_ALIAS_PREFIX . $method->name . '(...)'; + return <<name}', {$advicesCode}); + static \$__joinPoint = InterceptorInjector::{$injectorMethod}(self::class, '{$method->name}', {$advicesCode}, {$callableExpression}); {$return}\$__joinPoint->__invoke($argumentCode); BODY; } diff --git a/tests/Aop/Framework/AbstractMethodInvocationTest.php b/tests/Aop/Framework/AbstractMethodInvocationTest.php index d2236f16..9e374b54 100644 --- a/tests/Aop/Framework/AbstractMethodInvocationTest.php +++ b/tests/Aop/Framework/AbstractMethodInvocationTest.php @@ -14,7 +14,7 @@ class AbstractMethodInvocationTest extends TestCase public function setUp(): void { $this->invocation = $this->getMockBuilder(AbstractMethodInvocation::class) - ->setConstructorArgs([[], self::class, __FUNCTION__]) + ->setConstructorArgs([[], self::class, __FUNCTION__, static fn() => null]) ->onlyMethods(['proceed', 'isDynamic', 'getThis', 'getScope']) ->getMock(); } @@ -43,7 +43,7 @@ public function testInstanceIsInitialized(): void public function __construct() { - parent::__construct([new AroundInterceptor(function () {})], AbstractMethodInvocationTest::class, 'testInstanceIsInitialized'); + parent::__construct([new AroundInterceptor(function () {})], AbstractMethodInvocationTest::class, 'testInstanceIsInitialized', static fn() => null); } public function isDynamic(): bool diff --git a/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php b/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php index d0135def..8583a236 100644 --- a/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php +++ b/tests/Aop/Framework/DynamicTraitAliasMethodInvocationTest.php @@ -28,7 +28,7 @@ class DynamicTraitAliasMethodInvocationTest extends TestCase public function testDynamicMethodInvocation(string $methodName, int $expectedResult): void { $instance = new TraitAliasProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, $methodName); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, $methodName, $instance->getCallableFor($methodName)); $result = $invocation($instance); $this->assertSame($expectedResult, $result); @@ -41,7 +41,7 @@ public function testDynamicMethodInvocation(string $methodName, int $expectedRes public function testPrivateMethodCanBeIntercepted(): void { $instance = new TraitAliasProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'privateMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'privateMethod', $instance->getCallableFor('privateMethod')); $result = $invocation($instance); $this->assertSame(T_PRIVATE, $result); @@ -60,7 +60,7 @@ public function testAdviceIsCalledBeforeProceeding(): void }); $instance = new TraitAliasProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod', $instance->getCallableFor('publicMethod')); $result = $invocation($instance); $this->assertTrue($called); @@ -70,7 +70,7 @@ public function testAdviceIsCalledBeforeProceeding(): void public function testVariadicArgumentsAreForwarded(): void { $instance = new TraitAliasProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'variadicArgsTest'); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'variadicArgsTest', $instance->getCallableFor('variadicArgsTest')); $args = []; $expected = ''; @@ -85,7 +85,7 @@ public function testVariadicArgumentsAreForwarded(): void public function testPassByReferenceIsForwarded(): void { $instance = new TraitAliasProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'passByReference'); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'passByReference', $instance->getCallableFor('passByReference')); $value = 'original'; $result = $invocation($instance, [&$value]); @@ -106,14 +106,15 @@ public function testGetThisReturnsPassedInstance(): void }); $GLOBALS['__test_instance'] = $instance; - $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod', $instance->getCallableFor('publicMethod')); $invocation($instance); unset($GLOBALS['__test_instance']); } public function testIsDynamicReturnsTrue(): void { - $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'publicMethod'); + $instance = new TraitAliasProxy(); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'publicMethod', $instance->getCallableFor('publicMethod')); $this->assertTrue($invocation->isDynamic()); } @@ -129,7 +130,7 @@ public function testGetScopeReturnsProxyClassName(): void return $inv->proceed(); }); - $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'publicMethod', $instance->getCallableFor('publicMethod')); $invocation($instance); } @@ -143,12 +144,76 @@ public static function dynamicMethodsBatch(): array ]; } - public function testInheritedMethodInvocationWithoutTraitAliasUsesParentMethod(): void + /** + * An inherited method that has no trait alias uses the parent:: first-class callable + * (generated by ClassProxyGenerator). DynamicTraitAliasMethodInvocation resolves the + * prototype via reflection and calls it directly via ReflectionMethod::invokeArgs. + */ + public function testInheritedMethodInvocationWithParentCallable(): void { $instance = new InheritedMethodProxy(); - $invocation = new DynamicTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedPublicMethod'); + $callable = $instance->getInheritedCallable('inheritedPublicMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedPublicMethod', $callable); $result = $invocation($instance); $this->assertSame(T_PUBLIC, $result); } + + // --- ReflectionMethod-based dispatch path --- + + /** + * When the joinpoint is invoked with multiple instances in sequence, ReflectionMethod::invokeArgs + * must correctly dispatch to each instance (no $this capture issue, since we use reflection). + */ + public function testReflectionDispatchCallsEachInstanceCorrectly(): void + { + $first = new TraitAliasProxy(); + $second = new TraitAliasProxy(); + + $callable = $first->createGetObjectIdCallable(); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'getObjectId', $callable); + + $resultFirst = $invocation($first); + $resultSecond = $invocation($second); + + // Each call must return the spl_object_id of the respective instance. + $this->assertSame(spl_object_id($first), $resultFirst); + $this->assertSame(spl_object_id($second), $resultSecond); + $this->assertNotSame($resultFirst, $resultSecond, 'Different instances must produce different object IDs'); + } + + /** + * The dispatch must route through the original method body (the __aop__ alias), not through + * the overridden public method that returns the sentinel -1. + */ + public function testDispatchInvokesOriginalMethodBody(): void + { + $instance = new TraitAliasProxy(); + + $callable = $instance->createGetObjectIdCallable(); + $invocation = new DynamicTraitAliasMethodInvocation([], TraitAliasProxy::class, 'getObjectId', $callable); + + $result = $invocation($instance); + $this->assertSame(spl_object_id($instance), $result); + } + + /** + * For inherited methods, ReflectionMethod::invokeArgs must correctly dispatch to each + * instance, using the prototype method from the parent class. + */ + public function testInheritedMethodDispatchCallsEachInstanceCorrectly(): void + { + $first = new InheritedMethodProxy(); + $second = new InheritedMethodProxy(); + + $callable = $first->getInheritedCallable('inheritedPublicMethod'); + $invocation = new DynamicTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedPublicMethod', $callable); + + $resultFirst = $invocation($first); + $resultSecond = $invocation($second); + + // Both instances should return T_PUBLIC (the parent method body returns T_PUBLIC) + $this->assertSame(T_PUBLIC, $resultFirst); + $this->assertSame(T_PUBLIC, $resultSecond); + } } diff --git a/tests/Aop/Framework/ReflectionFunctionInvocationTest.php b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php new file mode 100644 index 00000000..ee0f7382 --- /dev/null +++ b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php @@ -0,0 +1,85 @@ + + * + * This source file is subject to the license that is bundled + * with this source code in the file LICENSE. + */ + +namespace Go\Aop\Framework; + +use Go\Aop\Intercept\FunctionInvocation; +use Go\Aop\Intercept\Interceptor; +use PHPUnit\Framework\Attributes\AllowMockObjectsWithoutExpectations; +use PHPUnit\Framework\TestCase; + +/** + * Tests for ReflectionFunctionInvocation — all paths use first-class callables. + */ +#[AllowMockObjectsWithoutExpectations] +class ReflectionFunctionInvocationTest extends TestCase +{ + /** + * Basic invocation via first-class callable (no advice). + * The callable `\strlen(...)` must be called directly without reflection. + */ + public function testInvokeCallsFirstClassCallableDirectly(): void + { + $invocation = new ReflectionFunctionInvocation([], 'strlen', \strlen(...)); + $result = $invocation(['hello']); + $this->assertSame(5, $result); + } + + /** + * With a first-class callable, proceed() calls the callable directly, bypassing reflection. + */ + public function testInvokeWithFirstClassCallableCallsItDirectly(): void + { + $invocation = new ReflectionFunctionInvocation([], 'strlen', \strlen(...)); + $result = $invocation(['world']); + $this->assertSame(5, $result); + } + + /** + * The first-class callable path must still route through advice interceptors. + */ + public function testAdviceIsCalledBeforeProceedingWithCallable(): void + { + $called = false; + $advice = $this->createMock(Interceptor::class); + $advice->expects($this->once()) + ->method('invoke') + ->willReturnCallback(function (FunctionInvocation $inv) use (&$called): mixed { + $called = true; + + return $inv->proceed(); + }); + + $invocation = new ReflectionFunctionInvocation([$advice], 'strlen', \strlen(...)); + $result = $invocation(['phpunit']); + + $this->assertTrue($called); + $this->assertSame(7, $result); + } + + /** + * Verify that the callable passed to the joinpoint is actually invoked and not the proxy. + * This mirrors the function proxy pattern where \file_get_contents(...) (global function) + * is passed to avoid calling the namespace-scoped proxy recursively. + */ + public function testCallableReceivesCorrectArguments(): void + { + $callable = static function (string $a, string $b): string { + return $a . $b; + }; + + $invocation = new ReflectionFunctionInvocation([], 'strlen', $callable); + $result = $invocation(['foo', 'bar']); + + $this->assertSame('foobar', $result); + } +} diff --git a/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php b/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php index bef8de5a..90a7e516 100644 --- a/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php +++ b/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php @@ -27,7 +27,8 @@ class StaticTraitAliasMethodInvocationTest extends TestCase */ public function testStaticMethodInvocation(): void { - $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); $result = $invocation(TraitAliasProxy::class); $this->assertSame(T_PUBLIC, $result); @@ -45,7 +46,8 @@ public function testAdviceIsCalledBeforeProceeding(): void return $inv->proceed(); }); - $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod', $callable); $result = $invocation(TraitAliasProxy::class); $this->assertTrue($called); @@ -54,7 +56,8 @@ public function testAdviceIsCalledBeforeProceeding(): void public function testVariadicArgumentsAreForwarded(): void { - $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticVariadicArgsTest'); + $callable = TraitAliasProxy::getStaticCallableFor('staticVariadicArgsTest'); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticVariadicArgsTest', $callable); $args = []; $expected = ''; @@ -77,13 +80,15 @@ public function testGetThisReturnsNull(): void return $inv->proceed(); }); - $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod', $callable); $invocation(TraitAliasProxy::class); } public function testIsDynamicReturnsFalse(): void { - $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); $this->assertFalse($invocation->isDynamic()); } @@ -98,7 +103,8 @@ public function testGetScopeReturnsProxyClassName(): void return $inv->proceed(); }); - $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod', $callable); $invocation(TraitAliasProxy::class); } @@ -110,7 +116,8 @@ public function testGetScopeReturnsProxyClassName(): void */ public function testLateStaticBindingWithSubclassScope(): void { - $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); // Passing the subclass as scope simulates `static::class` returning a child class $result = $invocation(TraitAliasProxyChild::class); @@ -128,13 +135,15 @@ public function testGetScopeReturnsSubclassWhenCalledWithSubclassScope(): void return $inv->proceed(); }); - $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod'); + $callable = TraitAliasProxy::getStaticCallableFor('staticPublicMethod'); + $invocation = new StaticTraitAliasMethodInvocation([$advice], TraitAliasProxy::class, 'staticPublicMethod', $callable); $invocation(TraitAliasProxyChild::class); } - public function testInheritedStaticMethodInvocationWithoutTraitAliasUsesParentMethod(): void + public function testInheritedStaticMethodInvocationWithParentCallable(): void { - $invocation = new StaticTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedStaticMethod'); + $callable = InheritedMethodProxy::getStaticInheritedCallable('inheritedStaticMethod'); + $invocation = new StaticTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedStaticMethod', $callable); $result = $invocation(InheritedMethodProxy::class); $this->assertSame(T_PUBLIC, $result); @@ -142,9 +151,39 @@ public function testInheritedStaticMethodInvocationWithoutTraitAliasUsesParentMe public function testInheritedStaticMethodInvocationWithLsbUsesChildClassScope(): void { - $invocation = new StaticTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedStaticLsbMethod'); + $callable = InheritedMethodProxy::getStaticInheritedCallable('inheritedStaticLsbMethod'); + $invocation = new StaticTraitAliasMethodInvocation([], InheritedMethodProxy::class, 'inheritedStaticLsbMethod', $callable); $result = $invocation(InheritedMethodProxy::class); $this->assertSame([InheritedMethodProxy::class, InheritedMethodProxy::class], $result); } + + // --- First-class callable path --- + + /** + * When a first-class callable is passed for a trait-aliased static method, the joinpoint + * wraps it in a forward_static_call shim and must invoke the original method body. + */ + public function testFirstClassCallableInvokesOriginalStaticMethodBody(): void + { + $callable = TraitAliasProxy::createStaticPublicMethodCallable(); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); + + $result = $invocation(TraitAliasProxy::class); + $this->assertSame(T_PUBLIC, $result); + } + + /** + * When the static joinpoint is called with a child-class scope, the first-class callable + * path must preserve late static binding: forward_static_call must forward the child scope. + */ + public function testFirstClassCallableLsbWithSubclassScope(): void + { + $callable = TraitAliasProxy::createStaticPublicMethodCallable(); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); + + // Passing the subclass as scope simulates `static::class` returning a child class. + $result = $invocation(TraitAliasProxyChild::class); + $this->assertSame(T_PUBLIC, $result); + } } diff --git a/tests/Instrument/Transformer/_files/class-proxy.php b/tests/Instrument/Transformer/_files/class-proxy.php index 0bcf41d7..4f3d06a9 100644 --- a/tests/Instrument/Transformer/_files/class-proxy.php +++ b/tests/Instrument/Transformer/_files/class-proxy.php @@ -18,43 +18,43 @@ class TestClass implements \Go\Aop\Proxy public function publicMethod() { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethod', ['advisor.Test\ns1\TestClass->publicMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethod', ['advisor.Test\ns1\TestClass->publicMethod'], $this->__aop__publicMethod(...)); return $__joinPoint->__invoke($this); } protected function protectedMethod() { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'protectedMethod', ['advisor.Test\ns1\TestClass->protectedMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'protectedMethod', ['advisor.Test\ns1\TestClass->protectedMethod'], $this->__aop__protectedMethod(...)); return $__joinPoint->__invoke($this); } public static function publicStaticMethod() { /** @var StaticMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'publicStaticMethod', ['advisor.Test\ns1\TestClass->publicStaticMethod']); + static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'publicStaticMethod', ['advisor.Test\ns1\TestClass->publicStaticMethod'], self::__aop__publicStaticMethod(...)); return $__joinPoint->__invoke(static::class); } protected static function protectedStaticMethod() { /** @var StaticMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'protectedStaticMethod', ['advisor.Test\ns1\TestClass->protectedStaticMethod']); + static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'protectedStaticMethod', ['advisor.Test\ns1\TestClass->protectedStaticMethod'], self::__aop__protectedStaticMethod(...)); return $__joinPoint->__invoke(static::class); } public function publicMethodDynamicArguments($a, &$b) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethodDynamicArguments', ['advisor.Test\ns1\TestClass->publicMethodDynamicArguments']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethodDynamicArguments', ['advisor.Test\ns1\TestClass->publicMethodDynamicArguments'], $this->__aop__publicMethodDynamicArguments(...)); return $__joinPoint->__invoke($this, [$a, &$b]); } public function publicMethodFixedArguments($a, $b, $c = null) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethodFixedArguments', ['advisor.Test\ns1\TestClass->publicMethodFixedArguments']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethodFixedArguments', ['advisor.Test\ns1\TestClass->publicMethodFixedArguments'], $this->__aop__publicMethodFixedArguments(...)); return $__joinPoint->__invoke($this, \array_slice([$a, $b, $c], 0, \func_num_args())); } public function methodWithSpecialTypeArguments(self $instance) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'methodWithSpecialTypeArguments', ['advisor.Test\ns1\TestClass->methodWithSpecialTypeArguments']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'methodWithSpecialTypeArguments', ['advisor.Test\ns1\TestClass->methodWithSpecialTypeArguments'], $this->__aop__methodWithSpecialTypeArguments(...)); return $__joinPoint->__invoke($this, [$instance]); } } diff --git a/tests/Instrument/Transformer/_files/final-readonly-class-proxy.php b/tests/Instrument/Transformer/_files/final-readonly-class-proxy.php index d4b3e4a9..1495118c 100644 --- a/tests/Instrument/Transformer/_files/final-readonly-class-proxy.php +++ b/tests/Instrument/Transformer/_files/final-readonly-class-proxy.php @@ -14,19 +14,19 @@ public function publicMethod(): string { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethod', ['advisor.Test\ns1\TestReadonlyClass->publicMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'publicMethod', ['advisor.Test\ns1\TestReadonlyClass->publicMethod'], $this->__aop__publicMethod(...)); return $__joinPoint->__invoke($this); } public function anotherMethod(int $x): int { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'anotherMethod', ['advisor.Test\ns1\TestReadonlyClass->anotherMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'anotherMethod', ['advisor.Test\ns1\TestReadonlyClass->anotherMethod'], $this->__aop__anotherMethod(...)); return $__joinPoint->__invoke($this, [$x]); } public static function staticMethod(): string { /** @var StaticMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'staticMethod', ['advisor.Test\ns1\TestReadonlyClass->staticMethod']); + static $__joinPoint = InterceptorInjector::forStaticMethod(self::class, 'staticMethod', ['advisor.Test\ns1\TestReadonlyClass->staticMethod'], self::__aop__staticMethod(...)); return $__joinPoint->__invoke(static::class); } } diff --git a/tests/Instrument/Transformer/_files/php7-class-proxy.php b/tests/Instrument/Transformer/_files/php7-class-proxy.php index cef0ef46..15d9b5b7 100644 --- a/tests/Instrument/Transformer/_files/php7-class-proxy.php +++ b/tests/Instrument/Transformer/_files/php7-class-proxy.php @@ -27,103 +27,103 @@ class TestPhp7Class implements \Go\Aop\Proxy public function stringSth(string $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'stringSth', ['advisor.Test\ns1\TestPhp7Class->stringSth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'stringSth', ['advisor.Test\ns1\TestPhp7Class->stringSth'], $this->__aop__stringSth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function floatSth(float $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'floatSth', ['advisor.Test\ns1\TestPhp7Class->floatSth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'floatSth', ['advisor.Test\ns1\TestPhp7Class->floatSth'], $this->__aop__floatSth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function boolSth(bool $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'boolSth', ['advisor.Test\ns1\TestPhp7Class->boolSth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'boolSth', ['advisor.Test\ns1\TestPhp7Class->boolSth'], $this->__aop__boolSth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function intSth(int $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'intSth', ['advisor.Test\ns1\TestPhp7Class->intSth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'intSth', ['advisor.Test\ns1\TestPhp7Class->intSth'], $this->__aop__intSth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function callableSth(callable $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'callableSth', ['advisor.Test\ns1\TestPhp7Class->callableSth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'callableSth', ['advisor.Test\ns1\TestPhp7Class->callableSth'], $this->__aop__callableSth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function arraySth(array $arg) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'arraySth', ['advisor.Test\ns1\TestPhp7Class->arraySth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'arraySth', ['advisor.Test\ns1\TestPhp7Class->arraySth'], $this->__aop__arraySth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function variadicStringSthByRef(string &...$args) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'variadicStringSthByRef', ['advisor.Test\ns1\TestPhp7Class->variadicStringSthByRef']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'variadicStringSthByRef', ['advisor.Test\ns1\TestPhp7Class->variadicStringSthByRef'], $this->__aop__variadicStringSthByRef(...)); return $__joinPoint->__invoke($this, $args); } public function exceptionArg(\Exception $exception, \Test\ns1\Exception $localException) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'exceptionArg', ['advisor.Test\ns1\TestPhp7Class->exceptionArg']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'exceptionArg', ['advisor.Test\ns1\TestPhp7Class->exceptionArg'], $this->__aop__exceptionArg(...)); return $__joinPoint->__invoke($this, [$exception, $localException]); } public function stringRth(string $arg): string { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'stringRth', ['advisor.Test\ns1\TestPhp7Class->stringRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'stringRth', ['advisor.Test\ns1\TestPhp7Class->stringRth'], $this->__aop__stringRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function floatRth(float $arg): float { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'floatRth', ['advisor.Test\ns1\TestPhp7Class->floatRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'floatRth', ['advisor.Test\ns1\TestPhp7Class->floatRth'], $this->__aop__floatRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function boolRth(bool $arg): bool { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'boolRth', ['advisor.Test\ns1\TestPhp7Class->boolRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'boolRth', ['advisor.Test\ns1\TestPhp7Class->boolRth'], $this->__aop__boolRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function intRth(int $arg): int { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'intRth', ['advisor.Test\ns1\TestPhp7Class->intRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'intRth', ['advisor.Test\ns1\TestPhp7Class->intRth'], $this->__aop__intRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function callableRth(callable $arg): callable { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'callableRth', ['advisor.Test\ns1\TestPhp7Class->callableRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'callableRth', ['advisor.Test\ns1\TestPhp7Class->callableRth'], $this->__aop__callableRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function arrayRth(array $arg): array { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'arrayRth', ['advisor.Test\ns1\TestPhp7Class->arrayRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'arrayRth', ['advisor.Test\ns1\TestPhp7Class->arrayRth'], $this->__aop__arrayRth(...)); return $__joinPoint->__invoke($this, [$arg]); } public function exceptionRth(\Exception $exception): \Exception { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'exceptionRth', ['advisor.Test\ns1\TestPhp7Class->exceptionRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'exceptionRth', ['advisor.Test\ns1\TestPhp7Class->exceptionRth'], $this->__aop__exceptionRth(...)); return $__joinPoint->__invoke($this, [$exception]); } public function noRth(\Test\ns1\LocalException $exception) { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'noRth', ['advisor.Test\ns1\TestPhp7Class->noRth']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'noRth', ['advisor.Test\ns1\TestPhp7Class->noRth'], $this->__aop__noRth(...)); return $__joinPoint->__invoke($this, [$exception]); } public function returnSelf(): self { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'returnSelf', ['advisor.Test\ns1\TestPhp7Class->returnSelf']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'returnSelf', ['advisor.Test\ns1\TestPhp7Class->returnSelf'], $this->__aop__returnSelf(...)); return $__joinPoint->__invoke($this); } } diff --git a/tests/Instrument/Transformer/_files/php81-enum-proxy.php b/tests/Instrument/Transformer/_files/php81-enum-proxy.php index fc9becd4..a34b9361 100644 --- a/tests/Instrument/Transformer/_files/php81-enum-proxy.php +++ b/tests/Instrument/Transformer/_files/php81-enum-proxy.php @@ -13,7 +13,7 @@ enum TestStatus : string implements \Go\Aop\Proxy public function label(): string { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'label', ['advisor.Test\ns1\TestStatus->label']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'label', ['advisor.Test\ns1\TestStatus->label'], $this->__aop__label(...)); return $__joinPoint->__invoke($this); } } diff --git a/tests/Instrument/Transformer/_files/php83-override-proxy.php b/tests/Instrument/Transformer/_files/php83-override-proxy.php index 8529964e..2644de5c 100644 --- a/tests/Instrument/Transformer/_files/php83-override-proxy.php +++ b/tests/Instrument/Transformer/_files/php83-override-proxy.php @@ -18,13 +18,13 @@ class TestClassWithOverride implements \Go\Aop\Proxy public function overriddenMethod(): string { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'overriddenMethod', ['advisor.Test\ns1\TestClassWithOverride->overriddenMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'overriddenMethod', ['advisor.Test\ns1\TestClassWithOverride->overriddenMethod'], $this->__aop__overriddenMethod(...)); return $__joinPoint->__invoke($this); } public function normalMethod(): int { /** @var DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = InterceptorInjector::forMethod(self::class, 'normalMethod', ['advisor.Test\ns1\TestClassWithOverride->normalMethod']); + static $__joinPoint = InterceptorInjector::forMethod(self::class, 'normalMethod', ['advisor.Test\ns1\TestClassWithOverride->normalMethod'], $this->__aop__normalMethod(...)); return $__joinPoint->__invoke($this); } } diff --git a/tests/Proxy/ClassProxyGeneratorTest.php b/tests/Proxy/ClassProxyGeneratorTest.php index 18dc2040..96f167c2 100644 --- a/tests/Proxy/ClassProxyGeneratorTest.php +++ b/tests/Proxy/ClassProxyGeneratorTest.php @@ -400,6 +400,9 @@ public function testGenerateProxyForClassUsingTraitMethods(): void * Regression: inherited methods should still be intercepted, but must not be aliased from the * woven trait because the trait only contains methods declared directly in the target class. * + * The generated proxy must use `parent::method(...)` as the first-class callable so that + * {@see DynamicTraitAliasMethodInvocation} can resolve the prototype via reflection. + * * @throws ReflectionException */ public function testGenerateProxyForInheritedMethodDoesNotCreateTraitAlias(): void @@ -422,6 +425,53 @@ public function testGenerateProxyForInheritedMethodDoesNotCreateTraitAlias(): vo "InterceptorInjector::forMethod(self::class, 'publicMethod'", $proxyFileContent ); + // Inherited instance method must use parent:: first-class callable (no __aop__ alias available) + $this->assertStringContainsString( + "parent::publicMethod(...)", + $proxyFileContent, + 'Inherited instance method must use parent::method(...) as first-class callable' + ); + } + + /** + * Inherited static methods have no trait alias in the proxy (the woven trait only contains + * methods declared in the intercepted class itself). The generated proxy must therefore use + * `parent::method(...)` as the callable so that {@see StaticTraitAliasMethodInvocation} can + * wrap it in a `forward_static_call` shim for correct late-static-binding support. + * + * @throws ReflectionException + */ + public function testGenerateProxyForInheritedStaticMethodUsesParentCallable(): void + { + $reflectionClass = new ReflectionClass(FirstStatic::class); + $classAdvices = [ + 'static' => [ + 'staticSelfPublic' => ['test'], + ], + ]; + + $generator = new ClassProxyGenerator($reflectionClass, 'FirstStatic__AopProxied', $classAdvices, false); + $proxyFileContent = "generate(); + + // No trait alias for inherited static method + $this->assertStringNotContainsString( + '__aop__staticSelfPublic', + $proxyFileContent, + 'Inherited static method must not produce a trait alias' + ); + + // Must delegate to the join-point chain + $this->assertStringContainsString( + "InterceptorInjector::forStaticMethod(self::class, 'staticSelfPublic'", + $proxyFileContent + ); + + // Inherited static method must use parent:: first-class callable + $this->assertStringContainsString( + "parent::staticSelfPublic(...)", + $proxyFileContent, + 'Inherited static method must use parent::method(...) as first-class callable' + ); } /** diff --git a/tests/Stubs/InheritedMethodProxy.php b/tests/Stubs/InheritedMethodProxy.php index 53cf14d7..0c5d7a2c 100644 --- a/tests/Stubs/InheritedMethodProxy.php +++ b/tests/Stubs/InheritedMethodProxy.php @@ -30,4 +30,30 @@ public static function inheritedStaticLsbMethod(): never { throw new \RuntimeException('This method should not be called. Engine should call parent method.'); } + + /** + * Returns a first-class callable to the given inherited instance method using parent:: syntax. + * This mirrors what ClassProxyGenerator generates for inherited dynamic methods. + */ + public function getInheritedCallable(string $method): \Closure + { + return match ($method) { + 'inheritedPublicMethod' => parent::inheritedPublicMethod(...), + default => throw new \InvalidArgumentException("No inherited instance method: '$method'"), + }; + } + + /** + * Returns a first-class callable to the given inherited static method. + * These are passed to StaticTraitAliasMethodInvocation which wraps them + * in a forward_static_call shim for proper late-static-binding support. + */ + public static function getStaticInheritedCallable(string $method): \Closure + { + return match ($method) { + 'inheritedStaticMethod' => parent::inheritedStaticMethod(...), + 'inheritedStaticLsbMethod' => parent::inheritedStaticLsbMethod(...), + default => throw new \InvalidArgumentException("No inherited static method: '$method'"), + }; + } } diff --git a/tests/Stubs/TraitAliasProxied.php b/tests/Stubs/TraitAliasProxied.php index cfcc8d84..c34f5b8c 100644 --- a/tests/Stubs/TraitAliasProxied.php +++ b/tests/Stubs/TraitAliasProxied.php @@ -25,6 +25,11 @@ public function publicMethod(): int return $this->public; } + public function getObjectId(): int + { + return spl_object_id($this); + } + protected function protectedMethod(): int { return T_PROTECTED; diff --git a/tests/Stubs/TraitAliasProxy.php b/tests/Stubs/TraitAliasProxy.php index adb740fb..411ca73c 100644 --- a/tests/Stubs/TraitAliasProxy.php +++ b/tests/Stubs/TraitAliasProxy.php @@ -23,6 +23,7 @@ class TraitAliasProxy { use TraitAliasProxied { TraitAliasProxied::publicMethod as private __aop__publicMethod; + TraitAliasProxied::getObjectId as private __aop__getObjectId; TraitAliasProxied::protectedMethod as private __aop__protectedMethod; TraitAliasProxied::privateMethod as private __aop__privateMethod; TraitAliasProxied::variadicArgsTest as private __aop__variadicArgsTest; @@ -45,4 +46,54 @@ public static function staticPublicMethod(): int { return -1; } + + /** + * Creates a first-class callable to the private __aop__getObjectId alias. + * Used by tests to verify that the static singleton joinpoint correctly rebinds + * $this to each new caller instance via Closure::call(). + */ + public function createGetObjectIdCallable(): \Closure + { + return $this->__aop__getObjectId(...); + } + + /** + * Creates a first-class callable to the private __aop__staticPublicMethod alias. + * Used by tests to verify that the static singleton joinpoint correctly handles LSB + * via forward_static_call(). + */ + public static function createStaticPublicMethodCallable(): \Closure + { + return self::__aop__staticPublicMethod(...); + } + + /** + * Returns a first-class callable to the private __aop__ alias for the given instance method. + * Used by unit tests to provide the required callable argument to invocation constructors. + */ + public function getCallableFor(string $method): \Closure + { + return match ($method) { + 'publicMethod' => $this->__aop__publicMethod(...), + 'getObjectId' => $this->__aop__getObjectId(...), + 'protectedMethod' => $this->__aop__protectedMethod(...), + 'privateMethod' => $this->__aop__privateMethod(...), + 'variadicArgsTest' => $this->__aop__variadicArgsTest(...), + 'passByReference' => $this->__aop__passByReference(...), + default => throw new \InvalidArgumentException("No __aop__ alias for '$method'"), + }; + } + + /** + * Returns a first-class callable to the private __aop__ alias for the given static method. + * Used by unit tests to provide the required callable argument to invocation constructors. + */ + public static function getStaticCallableFor(string $method): \Closure + { + return match ($method) { + 'staticPublicMethod' => self::__aop__staticPublicMethod(...), + 'staticVariadicArgsTest' => self::__aop__staticVariadicArgsTest(...), + default => throw new \InvalidArgumentException("No static __aop__ alias for '$method'"), + }; + } } From 4361f065654e14c7fc243db9a7700edd50dce1a3 Mon Sep 17 00:00:00 2001 From: Alexander Lisachenko Date: Fri, 1 May 2026 12:40:44 +0300 Subject: [PATCH 2/3] feat: Split implementation of AbstractMethodInvocation proceed into children classes --- CLAUDE.md | 7 +- .../Framework/AbstractMethodInvocation.php | 56 +------------- .../DynamicTraitAliasMethodInvocation.php | 49 ++++++++---- .../StaticTraitAliasMethodInvocation.php | 76 +++++++++++++------ src/Aop/Intercept/DynamicMethodInvocation.php | 11 +++ src/Aop/Intercept/MethodInvocation.php | 11 --- src/Aop/Intercept/StaticMethodInvocation.php | 11 +++ src/Aop/Pointcut/MatchInheritedPointcut.php | 5 +- .../AbstractMethodInvocationTest.php | 18 +---- .../StaticTraitAliasMethodInvocationTest.php | 23 ++++-- tests/Stubs/TraitAliasProxied.php | 11 ++- tests/Stubs/TraitAliasProxy.php | 6 +- 12 files changed, 153 insertions(+), 131 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afb285bf..b74315e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -80,11 +80,8 @@ class Foo extends OriginalParent implements OriginalInterfaces, \Go\Aop\Proxy } public function interceptedMethod(ArgType $arg): ReturnType { - /** @var \Go\Aop\Intercept\DynamicMethodInvocation|null $__joinPoint */ - static $__joinPoint; - if ($__joinPoint === null) { - $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...]); - } + /** @var \Go\Aop\Intercept\DynamicMethodInvocation $__joinPoint */ + static $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...]); return $__joinPoint->__invoke($this, [$arg]); } // ... one override per intercepted method diff --git a/src/Aop/Framework/AbstractMethodInvocation.php b/src/Aop/Framework/AbstractMethodInvocation.php index 16eea60c..9a5e430e 100644 --- a/src/Aop/Framework/AbstractMethodInvocation.php +++ b/src/Aop/Framework/AbstractMethodInvocation.php @@ -16,15 +16,10 @@ use Go\Aop\Intercept\Interceptor; use Go\Aop\Intercept\MethodInvocation; use ReflectionMethod; -use function array_merge; -use function array_pop; -use function count; /** * Abstract method invocation implementation * - * @phpstan-type MethodInvocationFrame array{list, mixed, int} - * * @template T of object Declares the instance type of the method invocation. * @template V Declares the generic return type of the method invocation. * @implements MethodInvocation @@ -40,27 +35,13 @@ abstract class AbstractMethodInvocation extends AbstractInvocation implements Me protected readonly ReflectionMethod $reflectionMethod; /** - * First-class callable pointing to the original method body. + * First-class callable pointing to the original method. * May be wrapped or rebound if needed in child classes or during the {@see proceed()} call. * * @link https://www.php.net/manual/en/functions.first_class_callable_syntax.php */ protected readonly Closure $closureToCall; - /** - * This static string variable holds the name of field to use to avoid extra "if" section in the __invoke method - * - * Overridden in children classes and initialized via LSB - */ - protected static string $propertyName; - - /** - * Stack frames to work with recursive calls or with cross-calls inside object - * - * @var array - */ - private array $stackFrames = []; - /** * Constructor for method invocation * @@ -72,42 +53,11 @@ abstract class AbstractMethodInvocation extends AbstractInvocation implements Me public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) { parent::__construct($advices); - $this->reflectionMethod = new ReflectionMethod($className, $methodName); $this->closureToCall = $closureToCall; + $this->reflectionMethod = new ReflectionMethod($className, $methodName); } - final public function __invoke(object|string $instanceOrScope, array $arguments = [], array $variadicArguments = []): mixed - { - if ($this->level > 0) { - $this->stackFrames[] = [$this->arguments, $this->{static::$propertyName}, $this->current]; - } - - if (count($variadicArguments) > 0) { - $arguments = array_merge($arguments, $variadicArguments); - } - - try { - ++$this->level; - - $this->current = 0; - $this->arguments = $arguments; - - $this->{static::$propertyName} = $instanceOrScope; - - return $this->proceed(); - } finally { - --$this->level; - - if ($this->level > 0 && ($stackFrame = array_pop($this->stackFrames))) { - [$this->arguments, $this->{static::$propertyName}, $this->current] = $stackFrame; - } else { - unset($this->{static::$propertyName}); - $this->arguments = []; - } - } - } - - public function getMethod(): ReflectionMethod + final public function getMethod(): ReflectionMethod { return $this->reflectionMethod; } diff --git a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php index ee92c15c..95023e3a 100644 --- a/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php +++ b/src/Aop/Framework/DynamicTraitAliasMethodInvocation.php @@ -35,22 +35,22 @@ * @template V = mixed Declares the generic return type of the method invocation. * @extends AbstractMethodInvocation * @implements DynamicMethodInvocation + * + * @phpstan-type DynamicMethodInvocationFrame array{list, T, int} */ final class DynamicTraitAliasMethodInvocation extends AbstractMethodInvocation implements DynamicMethodInvocation { /** - * For dynamic calls we store given argument as 'instance' property + * Stack frames to work with recursive calls or with cross-calls inside object * - * @see parent::__invoke() method to find out how this optimization works - * @see $instance Property, which is referenced by this static property + * @var array */ - protected static string $propertyName = 'instance'; + private array $stackFrames = []; /** - * @phpstan-var T Instance of object for invoking, should be protected as it's read in parent class - * @see parent::__invoke() where this variable is accessed via {@see $propertyName} value + * @phpstan-var T Instance of object for invoking */ - protected object $instance; + private object $instance; /** * ReflectionMethod pointing to the original method body: @@ -60,9 +60,9 @@ final class DynamicTraitAliasMethodInvocation extends AbstractMethodInvocation i private readonly ReflectionMethod $originalMethodToCall; /** - * @param array $advices - * @param class-string $className - * @param non-empty-string $methodName + * @param array $advices List of advices for this invocation + * @param class-string $className Class, containing method to invoke + * @param non-empty-string $methodName Name of the method to invoke * @param Closure $closureToCall First-class callable to the original method body, * e.g. `$this->__aop__method(...)` for trait-aliased * methods or `parent::method(...)` for inherited ones. @@ -83,15 +83,38 @@ public function __construct(array $advices, string $className, string $methodNam ); } + final public function __invoke(object $instance, array $arguments = [], array $variadicArguments = []): mixed + { + if ($this->level > 0) { + $this->stackFrames[] = [$this->arguments, $this->instance, $this->current]; + } + if ($variadicArguments !== []) { + $arguments = [...$arguments, ...$variadicArguments]; + } + try { + ++$this->level; + $this->current = 0; + $this->arguments = $arguments; + $this->instance = $instance; + return $this->proceed(); + } finally { + --$this->level; + if ($this->level > 0 && ($stackFrame = array_pop($this->stackFrames))) { + [$this->arguments, $this->instance, $this->current] = $stackFrame; + } else { + unset($this->instance); + $this->arguments = []; + } + } + } + /** * @return V Covariant, always mixed */ public function proceed(): mixed { if (isset($this->advices[$this->current])) { - $currentInterceptor = $this->advices[$this->current++]; - - return $currentInterceptor->invoke($this); + return $this->advices[$this->current++]->invoke($this); } return $this->originalMethodToCall->invokeArgs($this->instance, $this->arguments); diff --git a/src/Aop/Framework/StaticTraitAliasMethodInvocation.php b/src/Aop/Framework/StaticTraitAliasMethodInvocation.php index 398d4eb4..28bb7d0f 100644 --- a/src/Aop/Framework/StaticTraitAliasMethodInvocation.php +++ b/src/Aop/Framework/StaticTraitAliasMethodInvocation.php @@ -13,71 +13,103 @@ namespace Go\Aop\Framework; use Closure; +use Go\Aop\Intercept\Interceptor; use Go\Aop\Intercept\StaticMethodInvocation; /** * Static trait-alias method invocation calls static methods via a first-class callable - * that is rebound to each caller's late-static-binding scope on every invocation. + * that is rebound to each caller's late-static-binding scope on invocation. * * The callable is provided by the generated proxy code and points to the original method body: * - For methods declared in the proxied class: `self::__aop__(...)` — the private * alias created in the proxy's trait-use block. * - For inherited methods (no trait alias): `parent::(...)`. * - * In both cases the callable is wrapped in a `static fn(array $args) => forward_static_call($callable, ...$args)` - * shim (see constructor). This shim can be rebound via {@see Closure::bindTo()} on every call so that + * In both cases the callable is wrapped in a `static fn(array $args) => forward_static_call_array($callable, ...$args)` + * shim (see constructor). This shim can be rebound via {@see Closure::bindTo()} on every call so that * `static::class` (late-static-binding) inside the original method body resolves to the correct subclass. * * @template T of object = object Declares the instance type of the method invocation. * @template V = mixed Declares the generic return type of the method invocation. * @extends AbstractMethodInvocation * @implements StaticMethodInvocation + * + * @phpstan-type StaticMethodInvocationFrame array{list, class-string, int} */ final class StaticTraitAliasMethodInvocation extends AbstractMethodInvocation implements StaticMethodInvocation { /** - * For static calls we store given argument as 'scope' property - * - * @see parent::__invoke() method to find out how this optimization works - * @see $scope Property, which is referenced by this static property + * @var class-string Class name scope for static invocation */ - protected static string $propertyName = 'scope'; + private string $scope; /** - * @var class-string Class name scope for static invocation + * Stack frames to work with recursive calls or with cross-calls inside object + * + * @var array */ - protected string $scope; + private array $stackFrames = []; /** * Constructor for static method invocation. * - * Wraps the provided callable in a `static fn(array $args): mixed => forward_static_call($closureToCall, ...$args)` - * shim so that `Closure::bindTo(null, $scope)` can forward the correct late-static-binding class to the - * original method body without requiring the original closure to be rebindable. - * - * @param class-string $className Class, containing method to invoke - * @param Closure $closureToCall First-class callable to the original static method body, - * e.g. `self::__aop__method(...)` or `parent::method(...)`. + * @param array $advices List of advices for this invocation + * @param class-string $className Class, containing method to invoke + * @param non-empty-string $methodName Name of the method to invoke + * @param Closure $closureToCall First-class callable to the original static method body, + * e.g. `self::__aop__method(...)` or `parent::method(...)`. */ public function __construct(array $advices, string $className, string $methodName, Closure $closureToCall) { // Wrap in a static closure so that when we bindTo(null, $scope) in proceed(), - // forward_static_call will use $scope as the late-static-binding class. + // forward_static_call_array will use $scope as the late-static-binding class. // We cannot rebind $closureToCall directly because first-class callables from static // methods have a fixed scope. - $shim = static fn(array $argumentsToCall): mixed => forward_static_call($closureToCall, ...$argumentsToCall); + $shim = static fn(array $argumentsToCall): mixed => forward_static_call_array($closureToCall, $argumentsToCall); parent::__construct($advices, $className, $methodName, $shim); } + /** + * Invokes current method invocation with all interceptors + * + * @phpstan-param class-string $scope Static scope class name + * @param list $arguments List of arguments for method invocation + * @param list $variadicArguments Additional list of variadic arguments + * + * @return V Templated return type (mixed by default) + */ + final public function __invoke(string $scope, array $arguments = [], array $variadicArguments = []): mixed + { + if ($this->level > 0) { + $this->stackFrames[] = [$this->arguments, $this->scope, $this->current]; + } + if ($variadicArguments !== []) { + $arguments = [...$arguments, ...$variadicArguments]; + } + try { + ++$this->level; + $this->current = 0; + $this->arguments = $arguments; + $this->scope = $scope; + return $this->proceed(); + } finally { + --$this->level; + if ($this->level > 0 && ($stackFrame = array_pop($this->stackFrames))) { + [$this->arguments, $this->scope, $this->current] = $stackFrame; + } else { + unset($this->scope); + $this->arguments = []; + } + } + } + /** * @return V Covariant, always mixed */ public function proceed(): mixed { if (isset($this->advices[$this->current])) { - $currentInterceptor = $this->advices[$this->current++]; - - return $currentInterceptor->invoke($this); + return $this->advices[$this->current++]->invoke($this); } // Bind the wrapper to the current scope so forward_static_call forwards the diff --git a/src/Aop/Intercept/DynamicMethodInvocation.php b/src/Aop/Intercept/DynamicMethodInvocation.php index b8778197..e24e8e86 100644 --- a/src/Aop/Intercept/DynamicMethodInvocation.php +++ b/src/Aop/Intercept/DynamicMethodInvocation.php @@ -43,4 +43,15 @@ public function getThis(): object; * @return true Covariance, always true for dynamic method calls */ public function isDynamic(): true; + + /** + * Invokes current method invocation with all interceptors + * + * @phpstan-param T $instance Invocation instance + * @param list $arguments List of arguments for method invocation + * @param list $variadicArguments Additional list of variadic arguments + * + * @return V Templated return type (mixed by default) + */ + public function __invoke(object $instance, array $arguments = [], array $variadicArguments = []): mixed; } \ No newline at end of file diff --git a/src/Aop/Intercept/MethodInvocation.php b/src/Aop/Intercept/MethodInvocation.php index 13a2eed1..11d0a6de 100644 --- a/src/Aop/Intercept/MethodInvocation.php +++ b/src/Aop/Intercept/MethodInvocation.php @@ -49,15 +49,4 @@ interface MethodInvocation extends Invocation, ClassJoinpoint * @api */ public function getMethod(): ReflectionMethod; - - /** - * Invokes current method invocation with all interceptors - * - * @phpstan-param T|class-string $instanceOrScope Invocation instance (or class name for static methods) - * @param list $arguments List of arguments for method invocation - * @param list $variadicArguments Additional list of variadic arguments - * - * @return V Templated return type (mixed by default) - */ - public function __invoke(object|string $instanceOrScope, array $arguments = [], array $variadicArguments = []): mixed; } diff --git a/src/Aop/Intercept/StaticMethodInvocation.php b/src/Aop/Intercept/StaticMethodInvocation.php index 56aa1d31..92f6fa86 100644 --- a/src/Aop/Intercept/StaticMethodInvocation.php +++ b/src/Aop/Intercept/StaticMethodInvocation.php @@ -32,4 +32,15 @@ public function getThis(): null; * @return false Covariance, always false for static method calls */ public function isDynamic(): false; + + /** + * Invokes current method invocation with all interceptors + * + * @phpstan-param class-string $scope Static scope class name + * @param list $arguments List of arguments for method invocation + * @param list $variadicArguments Additional list of variadic arguments + * + * @return V Templated return type (mixed by default) + */ + public function __invoke(string $scope, array $arguments = [], array $variadicArguments = []): mixed; } \ No newline at end of file diff --git a/src/Aop/Pointcut/MatchInheritedPointcut.php b/src/Aop/Pointcut/MatchInheritedPointcut.php index 8adeb1aa..b1408332 100644 --- a/src/Aop/Pointcut/MatchInheritedPointcut.php +++ b/src/Aop/Pointcut/MatchInheritedPointcut.php @@ -47,9 +47,10 @@ public function matches( return false; } - $declaringClassName = $reflector->getDeclaringClass()->name; + $declaringClassName = $reflector->getDeclaringClass()->getName(); + $contextTraits = $context->getTraitNames(); - return $context->name !== $declaringClassName && $context->isSubclassOf($declaringClassName); + return $context->getName() !== $declaringClassName && ($context->isSubclassOf($declaringClassName) || in_array($declaringClassName, $contextTraits)); } public function getKind(): int diff --git a/tests/Aop/Framework/AbstractMethodInvocationTest.php b/tests/Aop/Framework/AbstractMethodInvocationTest.php index 9e374b54..c05dd762 100644 --- a/tests/Aop/Framework/AbstractMethodInvocationTest.php +++ b/tests/Aop/Framework/AbstractMethodInvocationTest.php @@ -30,17 +30,8 @@ public function testInvocationReturnsMethod(): void */ public function testInstanceIsInitialized(): void { - $this->expectNotToPerformAssertions(); - $o = new class extends AbstractMethodInvocation { - protected static string $propertyName = 'scope'; - - /** - * @var (string&class-string) Class name scope for static invocation - */ - protected string $scope; - public function __construct() { parent::__construct([new AroundInterceptor(function () {})], AbstractMethodInvocationTest::class, 'testInstanceIsInitialized', static fn() => null); @@ -61,14 +52,13 @@ public function getScope(): string return self::class; } - public function proceed(): void + public function proceed(): string { - if ($this->level < 3) { - $this->__invoke($this->scope); - } + return $this->reflectionMethod->getName(); } }; - $o->__invoke($o::class); + $result = $o->proceed(); + $this->assertEquals('testInstanceIsInitialized', $result); } } diff --git a/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php b/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php index 90a7e516..734e3036 100644 --- a/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php +++ b/tests/Aop/Framework/StaticTraitAliasMethodInvocationTest.php @@ -31,7 +31,7 @@ public function testStaticMethodInvocation(): void $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); $result = $invocation(TraitAliasProxy::class); - $this->assertSame(T_PUBLIC, $result); + $this->assertSame(TraitAliasProxy::class, $result); } public function testAdviceIsCalledBeforeProceeding(): void @@ -51,7 +51,7 @@ public function testAdviceIsCalledBeforeProceeding(): void $result = $invocation(TraitAliasProxy::class); $this->assertTrue($called); - $this->assertSame(T_PUBLIC, $result); + $this->assertSame(TraitAliasProxy::class, $result); } public function testVariadicArgumentsAreForwarded(): void @@ -121,7 +121,7 @@ public function testLateStaticBindingWithSubclassScope(): void // Passing the subclass as scope simulates `static::class` returning a child class $result = $invocation(TraitAliasProxyChild::class); - $this->assertSame(T_PUBLIC, $result); + $this->assertSame(TraitAliasProxyChild::class, $result); } public function testGetScopeReturnsSubclassWhenCalledWithSubclassScope(): void @@ -158,8 +158,6 @@ public function testInheritedStaticMethodInvocationWithLsbUsesChildClassScope(): $this->assertSame([InheritedMethodProxy::class, InheritedMethodProxy::class], $result); } - // --- First-class callable path --- - /** * When a first-class callable is passed for a trait-aliased static method, the joinpoint * wraps it in a forward_static_call shim and must invoke the original method body. @@ -170,7 +168,7 @@ public function testFirstClassCallableInvokesOriginalStaticMethodBody(): void $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPublicMethod', $callable); $result = $invocation(TraitAliasProxy::class); - $this->assertSame(T_PUBLIC, $result); + $this->assertSame(TraitAliasProxy::class, $result); } /** @@ -184,6 +182,17 @@ public function testFirstClassCallableLsbWithSubclassScope(): void // Passing the subclass as scope simulates `static::class` returning a child class. $result = $invocation(TraitAliasProxyChild::class); - $this->assertSame(T_PUBLIC, $result); + $this->assertSame(TraitAliasProxyChild::class, $result); + } + + public function testPassByReferenceIsForwarded(): void + { + $instance = new TraitAliasProxy(); + $invocation = new StaticTraitAliasMethodInvocation([], TraitAliasProxy::class, 'staticPassByReference', $instance->getStaticCallableFor('staticPassByReference')); + + $value = 'original'; + $result = $invocation(TraitAliasProxy::class, [&$value]); + $this->assertNull($result); + $this->assertNull($value); } } diff --git a/tests/Stubs/TraitAliasProxied.php b/tests/Stubs/TraitAliasProxied.php index c34f5b8c..e42c1d7b 100644 --- a/tests/Stubs/TraitAliasProxied.php +++ b/tests/Stubs/TraitAliasProxied.php @@ -52,9 +52,16 @@ public function passByReference(mixed &$ref): mixed return null; } - public static function staticPublicMethod(): int + public static function staticPassByReference(mixed &$ref): mixed { - return T_PUBLIC; + $ref = null; + + return null; + } + + public static function staticPublicMethod(): string + { + return static::class; } public static function staticVariadicArgsTest(mixed ...$args): string diff --git a/tests/Stubs/TraitAliasProxy.php b/tests/Stubs/TraitAliasProxy.php index 411ca73c..b34e2357 100644 --- a/tests/Stubs/TraitAliasProxy.php +++ b/tests/Stubs/TraitAliasProxy.php @@ -28,6 +28,7 @@ class TraitAliasProxy TraitAliasProxied::privateMethod as private __aop__privateMethod; TraitAliasProxied::variadicArgsTest as private __aop__variadicArgsTest; TraitAliasProxied::passByReference as private __aop__passByReference; + TraitAliasProxied::staticPassByReference as private __aop__staticPassByReference; TraitAliasProxied::staticPublicMethod as private __aop__staticPublicMethod; TraitAliasProxied::staticVariadicArgsTest as private __aop__staticVariadicArgsTest; } @@ -42,9 +43,9 @@ public function publicMethod(): int } /** @see publicMethod */ - public static function staticPublicMethod(): int + public static function staticPublicMethod(): string { - return -1; + return self::__aop__staticPublicMethod(); } /** @@ -91,6 +92,7 @@ public function getCallableFor(string $method): \Closure public static function getStaticCallableFor(string $method): \Closure { return match ($method) { + 'staticPassByReference' => self::__aop__staticPassByReference(...), 'staticPublicMethod' => self::__aop__staticPublicMethod(...), 'staticVariadicArgsTest' => self::__aop__staticVariadicArgsTest(...), default => throw new \InvalidArgumentException("No static __aop__ alias for '$method'"), From 547f3775b323d41e580c781a897b0e2a9eab2a7e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 1 May 2026 09:54:37 +0000 Subject: [PATCH 3/3] feat: add by-ref test for ReflectionFunctionInvocation, update CLAUDE.md and CHANGELOG with FCC changes Agent-Logs-Url: https://github.com/goaop/framework/sessions/cb25cbe1-a51e-454d-a45e-824c14372c49 Co-authored-by: lisachenko <640114+lisachenko@users.noreply.github.com> --- CHANGELOG.md | 3 ++- CLAUDE.md | 12 ++++++------ .../ReflectionFunctionInvocationTest.php | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b598113..4f415b67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,11 +3,12 @@ Changelog 4.0.0 (unreleased) * [BC BREAK] Requires PHP 8.4+ * [BC BREAK] Proxy engine switched from inheritance-based to **trait-based**: the original class body is converted to a PHP trait (`Foo__AopProxied`) and the proxy class uses it via `use` with private method aliases instead of extending the renamed class. This removes the `__AopProxied` parent from the inheritance chain. +* [BC BREAK] All invocation class constructors (`DynamicTraitAliasMethodInvocation`, `StaticTraitAliasMethodInvocation`, `ReflectionFunctionInvocation`) now require a `Closure $closureToCall` parameter (non-nullable). Generated proxy code always passes a first-class callable: `$this->__aop__method(...)` for own instance methods, `self::__aop__method(...)` for own static methods, `parent::method(...)` for inherited methods, and `\functionName(...)` for functions. * [Feature] **Private method interception** — both dynamic (`private function foo()`) and static (`private static function bar()`) private methods can now be intercepted by aspects. This was impossible with the old extend-based engine because PHP does not allow overriding private methods in subclasses. * [Feature] **PHP 8.1+ enum interception** — instance and static methods on both unit (pure) and backed enums can now be intercepted by aspects. The enum body is extracted into a trait (`Foo__AopProxied`); a proxy enum re-declares the cases and dispatches intercepted methods via per-method `static $__joinPoint` caching. Built-in enum methods (`cases`, `from`, `tryFrom`) and initialization joinpoints are never woven. * [Feature] `self::` in proxied classes now resolves to the proxy class naturally (via PHP trait semantics), removing the need for `SelfValueTransformer`. +* [Feature] **First-class callable syntax** — generated proxy code and invocation constructors use PHP 8.1+ first-class callable syntax (`$this->__aop__method(...)`, `parent::method(...)`, `\func(...)`) to reference original method and function bodies, eliminating the need for `Closure::bind` at construction time. * [Removed] `SelfValueTransformer` and `SelfValueVisitor` — no longer needed with the trait-based engine. -* [Performance] Pre-bound `Closure::bind` closures replace per-call `ReflectionMethod::getClosure()` + rebind in the method invocation proceed path, eliminating reflection overhead on every intercepted call. * [Performance] **Direct static joinpoint initialization** — leveraging PHP 8.3+ support for dynamic expressions in static variable initializers, all generated proxy method bodies now initialize their static joinpoint variables directly. 3.0.0 (December 4, 2019) diff --git a/CLAUDE.md b/CLAUDE.md index b74315e7..5c1568f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -81,7 +81,7 @@ class Foo extends OriginalParent implements OriginalInterfaces, \Go\Aop\Proxy public function interceptedMethod(ArgType $arg): ReturnType { /** @var \Go\Aop\Intercept\DynamicMethodInvocation $__joinPoint */ - static $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...]); + static $__joinPoint = \Go\Aop\Framework\InterceptorInjector::forMethod(self::class, 'interceptedMethod', [...], $this->__aop__interceptedMethod(...)); return $__joinPoint->__invoke($this, [$arg]); } // ... one override per intercepted method @@ -92,7 +92,7 @@ Key properties of this engine: - The proxy class **re-inherits** the original parent and interfaces (read from reflection, not from the woven source). - `self::` in the trait body resolves to `Foo` (the proxy class) — no rewriting needed. - **Private methods can be intercepted** (impossible with the old extend-based engine). -- Proceed path uses a pre-bound `Closure::bind($fn, null, Foo::class)` stored once per join point — zero per-call reflection. +- Each generated proxy method passes a **first-class callable** as the 4th arg to `InterceptorInjector`: `$this->__aop__method(...)` for own dynamic methods, `self::__aop__method(...)` for own static methods, `parent::method(...)` for inherited methods (no trait alias), and `\functionName(...)` for function proxies. ### Proxy generation (`src/Proxy/`) @@ -121,11 +121,11 @@ Key properties of this engine: - `FieldAccess` — `T` is the declaring class; `V` is the property type (narrows `getValue()`, `getValueToSet()`, `__invoke()` return) - The proxy generators (`ClassProxyGenerator`, `TraitProxyGenerator`, `EnumProxyGenerator`, `FunctionProxyGenerator`) use `TypeGenerator::renderTypeForPhpDoc()` to extract the return type from `ReflectionMethod`/`ReflectionFunction` and emit it as the second generic argument in the per-method `@var` annotation. This gives IDE and PHPStan full type-awareness on `$__joinPoint->__invoke(...)` calls. - `src/Aop/Framework/` — concrete invocation implementations: - - `AbstractMethodInvocation` — base class; holds `protected Closure $closureToCall` (set in each subclass constructor via `Closure::bind`); `TRAIT_ALIAS_PREFIX = '__aop__'` constant; manages recursive/cross-call stack frames - - `DynamicTraitAliasMethodInvocation` — instance-method invocation; builds `Closure::bind(fn($i, $a) => $i->__aop__method(...$a), null, $class)` once at construction; `proceed()` calls `($this->closureToCall)($this->instance, $this->arguments)` - - `StaticTraitAliasMethodInvocation` — static-method invocation; builds `Closure::bind(fn($c, $a) => $c::__aop__method(...$a), null, $class)` once at construction; `proceed()` calls `($this->closureToCall)($this->scope, $this->arguments)` + - `AbstractMethodInvocation` — base class; holds `protected readonly Closure $closureToCall` (required, 4th constructor argument) — a first-class callable to the original method body; `TRAIT_ALIAS_PREFIX = '__aop__'` constant; manages recursive/cross-call stack frames + - `DynamicTraitAliasMethodInvocation` — instance-method invocation; receives `$this->__aop__method(...)` (own methods) or `parent::method(...)` (inherited) as `$closureToCall`; resolves a `private ReflectionMethod $originalMethodToCall` from the `__aop__` alias or `getPrototype()` and calls `invokeArgs($this->instance, $this->arguments)` in `proceed()` — faster than `Closure::call()` and correctly handles pass-by-reference args + - `StaticTraitAliasMethodInvocation` — static-method invocation; wraps `$closureToCall` in a `static fn(array $args) => forward_static_call($callable, ...$args)` shim and stores it in `$closureToCall`; `bindTo(null, $scope)` forwards the correct LSB class per call; `proceed()` calls `($this->closureToCall)(...$this->arguments)` - `ReflectionConstructorInvocation` — constructor interception (used with `INTERCEPT_INITIALIZATIONS`); creates instance via `ReflectionClass::newInstanceWithoutConstructor()` then calls constructor - - `ReflectionFunctionInvocation` — function interception; `proceed()` calls `$this->reflectionFunction->invokeArgs($this->arguments)` + - `ReflectionFunctionInvocation` — function interception; receives a first-class callable to the original global function (e.g. `\strlen(...)` with leading `\` to avoid recursive proxy call); `proceed()` calls `($this->closureToCall)(...$this->arguments)` directly - `ClassFieldAccess` — property interception join point; used via generated native property hooks on proxied properties - `StaticInitializationJoinpoint` — fired once after proxy class is loaded via `injectJoinPoints()` - `src/Aop/Pointcut/` — LALR pointcut grammar (`PointcutGrammar`, `PointcutParser`, `PointcutLexer`, `PointcutParseTable`) and pointcut combinators (`AndPointcut`, `OrPointcut`, `NotPointcut`, `NamePointcut`, `AttributePointcut`, etc.) diff --git a/tests/Aop/Framework/ReflectionFunctionInvocationTest.php b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php index ee0f7382..f1085c4e 100644 --- a/tests/Aop/Framework/ReflectionFunctionInvocationTest.php +++ b/tests/Aop/Framework/ReflectionFunctionInvocationTest.php @@ -82,4 +82,21 @@ public function testCallableReceivesCorrectArguments(): void $this->assertSame('foobar', $result); } + + /** + * Verifies that by-reference arguments are correctly forwarded through the invocation chain. + * + * The proxy passes arguments as `[&$var]`, so `$this->arguments[0]` is a PHP reference. + * Unpacking a reference-bearing array with `...$args` preserves the reference binding, + * meaning the callable can modify the original caller's variable. + */ + public function testPassByReferenceIsForwarded(): void + { + $invocation = new ReflectionFunctionInvocation([], 'preg_match', \preg_match(...)); + + $matches = null; + $invocation(['/(\d+)/', 'abc123', &$matches]); + + $this->assertSame(['123', '123'], $matches); + } }