From d97f0a62c4eb3f7d70c45c6c82d3db30597e10e8 Mon Sep 17 00:00:00 2001 From: Navarr Date: Wed, 9 Jul 2025 16:17:28 -0400 Subject: [PATCH 1/7] Add InterceptorAutoloader --- composer.json | 2 +- phpstan.neon | 5 +- .../Autoload/InterceptorAutoloader.php | 286 ++++++++++++++++++ 3 files changed, 291 insertions(+), 2 deletions(-) create mode 100644 src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php diff --git a/composer.json b/composer.json index 853b830..1912c12 100644 --- a/composer.json +++ b/composer.json @@ -20,7 +20,7 @@ } ], "require": { - "php": "^7.2.0 || ^8.1.0", + "php": "^8.1.0", "ext-dom": "*", "laminas/laminas-code": "~3.3.0 || ~3.4.1 || ~3.5.1 || ^4.5 || ^4.10", "phpstan/phpstan": "^2.0", diff --git a/phpstan.neon b/phpstan.neon index c94cc2b..30047be 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -22,6 +22,9 @@ parameters: - message: '~is not covered by backward compatibility promise.~' path: src/bitExpert/PHPStan/Magento/Autoload/ExtensionInterfaceAutoloader.php + - + message: '~is not covered by backward compatibility promise.~' + path: src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php - message: '~is not covered by backward compatibility promise.~' path: src/bitExpert/PHPStan/Magento/Reflection/Framework/Session/SessionManagerMagicMethodReflectionExtension.php @@ -69,4 +72,4 @@ parameters: path: tests/bitExpert/PHPStan/Magento/Type/TestFrameworkObjectManagerDynamicReturnTypeExtensionUnitTest.php - message: '~PHPDoc tag @var assumes the expression with type~' - path: tests/bitExpert/PHPStan/Magento/Type/TestFrameworkObjectManagerDynamicReturnTypeExtensionUnitTest.php + path: tests/bitExpert/PHPStan/Magento/Type/TestFrameworkObjectManagerDynamicReturnTypeExtensionUnitTest.php \ No newline at end of file diff --git a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php new file mode 100644 index 0000000..fba7bc6 --- /dev/null +++ b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php @@ -0,0 +1,286 @@ +} + * @phpstan-type ParameterInfo array{name:non-empty-string, passedByReference:bool, variadic?:true, type?:string, defaultValue?:mixed} + */ +class InterceptorAutoloader implements Autoloader +{ + use GetParameterClassTrait, + GetReflectionMethodReturnTypeValueTrait; + + public function __construct(private Cache $cache, private ClassLoaderProvider $classLoaderProvider) + { + } + + /** + * @param string $class + * @throws \ReflectionException + */ + public function autoload(string $class): void + { + if (preg_match('#\\\Interceptor#', $class) !== 1) { + return; + } + + // fix for PHPStan 1.7.5 and later: Classes generated by autoloaders are supposed to "win" against + // local classes in your project. We need to check first if classes exists locally before generating them! + $pathToLocalClass = $this->classLoaderProvider->findFile($class); + if ($pathToLocalClass === false) { + $pathToLocalClass = $this->cache->load($class, ''); + if ($pathToLocalClass === null) { + $this->cache->save($class, '', $this->getFileContents($class)); + $pathToLocalClass = $this->cache->load($class, ''); + } + } + + require_once($pathToLocalClass); + } + + /** + * Generate the proxy file content as Magento would. + * + * @param string $class + * @return string + * @throws \ReflectionException + */ + protected function getFileContents(string $class): string + { + $namespace = explode('\\', ltrim($class, '\\')); + array_pop($namespace); // Remove "Interceptor" from the class name + $originalClassname = implode('\\', $namespace); + + if (!class_exists($originalClassname)) { + throw new \RuntimeException("Class ${class} for Interceptor does not exist"); + } + $reflectionClass = new ReflectionClass($originalClassname); + + $methods = [$this->_getDefaultConstructorDefinition($reflectionClass)]; + $publicMethods = $reflectionClass->getMethods(\ReflectionMethod::IS_PUBLIC); + foreach ($publicMethods as $method) { + if (!$method->isInternal() && $this->isInterceptedMethod($method)) { + $methods[] = $this->_getMethodInfo($method); + } + } + + $generator = new ClassGenerator(); + $generator->setName($class) + ->addProperties([]) + ->addMethods($methods); + + $code = $generator->generate(); + return $this->_fixCodeStyle($code); + } + + protected function _fixCodeStyle(string $sourceCode): string + { + $sourceCode = str_replace(' array (', ' array(', $sourceCode); + $sourceCode = preg_replace("/{\n{2,}/m", "{\n", $sourceCode) ?? ''; + return preg_replace("/\n{2,}}/m", "\n}", $sourceCode) ?? ''; + } + + /** + * @return MethodDef + */ + protected function _getMethodInfo(\ReflectionMethod $method): array + { + $parameters = array_map([$this, '_getMethodParameterInfo'], $method->getParameters()); + + $returnTypeValue = $this->getReturnTypeValue($method); + $methodInfo = [ + 'name' => ($method->returnsReference() ? '& ' : '') . $method->getName(), + 'parameters' => $parameters, + 'body' => str_replace( + [ + '%method%', + '%return%', + '%parameters%' + ], + [ + $method->getName(), + $returnTypeValue === 'void' ? '' : 'return ', + $this->_getParameterList($parameters) + ], + <<<'METHOD_BODY' +$pluginInfo = $this->pluginList->getNext($this->subjectType, '%method%'); +%return%$pluginInfo ? $this->___callPlugins('%method%', func_get_args(), $pluginInfo) : parent::%method%(%parameters%); +METHOD_BODY + ), + 'returnType' => $returnTypeValue, + 'docblock' => ['shortDescription' => '{@inheritdoc}'], + ]; + + return $methodInfo; + } + + /** + * @param ReflectionClass $reflectionClass + * @return MethodDef + */ + private function _getDefaultConstructorDefinition(ReflectionClass $reflectionClass) + { + $constructor = $reflectionClass->getConstructor(); + $parameters = []; + $body = "\$this->___init();\n"; + if ($constructor !== null) { + $parameters = array_map($this->_getMethodParameterInfo(...), $constructor->getParameters()); + + $body .= count($parameters) > 0 + ? "parent::__construct({$this->_getParameterList($parameters)});" + : "parent::__construct();"; + } + + return [ + 'name' => '__construct', + 'parameters' => $parameters, + 'body' => $body + ]; + } + + /** + * @param \ReflectionParameter $parameter + * @return ParameterInfo + */ + protected function _getMethodParameterInfo(\ReflectionParameter $parameter) + { + $parameterInfo = [ + 'name' => $parameter->getName(), + 'passedByReference' => $parameter->isPassedByReference() + ]; + if ($parameter->isVariadic()) { + $parameterInfo['variadic'] = $parameter->isVariadic(); + } + + if (($type = $this->extractParameterType($parameter)) !== null) { + $parameterInfo['type'] = $type; + } + if (($default = $this->extractParameterDefaultValue($parameter)) !== null) { + $parameterInfo['defaultValue'] = $default; + } + + return $parameterInfo; + } + + private function extractParameterDefaultValue( + \ReflectionParameter $parameter + ): ?ValueGenerator { + $value = null; + if ($parameter->isOptional() && $parameter->isDefaultValueAvailable()) { + $valueType = ValueGenerator::TYPE_AUTO; + $defaultValue = $parameter->getDefaultValue(); + if ($defaultValue === null) { + $valueType = ValueGenerator::TYPE_NULL; + } + $value = new ValueGenerator($defaultValue, $valueType); + } + + return $value; + } + + /** + * @param ParameterInfo[] $parameters + * @return string + */ + protected function _getParameterList(array $parameters) + { + return implode( + ', ', + array_map( + function ($item) { + $output = ''; + if (!isset($item['variadic'])) { + $output .= '... '; + } + + $output .= "\${$item['name']}"; + return $output; + }, + $parameters + ) + ); + } + + private function extractParameterType( + \ReflectionParameter $parameter + ): ?string { + if (!$parameter->hasType()) { + return null; + } + + $parameterType = $parameter->getType(); + + if ($parameterType === null) { + return null; + } elseif ($parameterType instanceof ReflectionUnionType) { + $parameterType = $parameterType->getTypes(); + $parameterType = implode('|', $parameterType); + } elseif ($parameterType instanceof ReflectionIntersectionType) { + $parameterType = $parameterType->getTypes(); + $parameterType = implode('&', $parameterType); + } elseif ($parameterType instanceof ReflectionNamedType) { + $parameterType = $parameterType->getName(); + } + + $typeName = null; + if ($parameterType === 'array') { + $typeName = 'array'; + } elseif (($parameterClass = $this->getParameterClass($parameter)) !== null) { + $typeName = $this->_getFullyQualifiedClassName($parameterClass->getName()); + } elseif ($parameterType === 'callable') { + $typeName = 'callable'; + } elseif (is_string($parameterType)) { + $typeName = $parameterType; + } + + // Type "?array|string|null" is a union type, and therefore cannot be also marked nullable with the "?" prefix + if ($parameter->allowsNull() && $typeName !== 'mixed') { + $typeName = $typeName === null || str_contains($typeName, "null") ? $typeName : '?' . $typeName; + } + + return $typeName; + } + + protected function isInterceptedMethod(\ReflectionMethod $method): bool + { + return !($method->isConstructor() || $method->isFinal() || $method->isStatic() || $method->isDestructor()) && + !in_array($method->getName(), ['__sleep', '__wakeup', '__clone', '_resetState'], true); + } + + protected function _getFullyQualifiedClassName(string $className): string + { + return $className !== '' ? '\\' . ltrim($className, '\\') : ''; + } + + public function register(): void + { + \spl_autoload_register($this->autoload(...), true, false); + } + + public function unregister(): void + { + \spl_autoload_unregister($this->autoload(...)); + } +} From 2d776a19d349b9402696dbe97379f4fb9c41d298 Mon Sep 17 00:00:00 2001 From: Navarr Date: Wed, 9 Jul 2025 16:41:40 -0400 Subject: [PATCH 2/7] Make changes to InterceptorAutoloader to up compatibility with older Magento framework versions --- .../Autoload/InterceptorAutoloader.php | 42 ++++++++++++++++++- 1 file changed, 40 insertions(+), 2 deletions(-) diff --git a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php index fba7bc6..7069d15 100644 --- a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php +++ b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php @@ -29,8 +29,7 @@ */ class InterceptorAutoloader implements Autoloader { - use GetParameterClassTrait, - GetReflectionMethodReturnTypeValueTrait; + use GetParameterClassTrait; public function __construct(private Cache $cache, private ClassLoaderProvider $classLoaderProvider) { @@ -274,6 +273,45 @@ protected function _getFullyQualifiedClassName(string $className): string return $className !== '' ? '\\' . ltrim($className, '\\') : ''; } + private function getReturnTypeValue(\ReflectionMethod $method): ?string + { + $returnTypeValue = null; + $returnType = $method->getReturnType(); + if ($returnType !== null) { + if ($returnType instanceof ReflectionUnionType || $returnType instanceof ReflectionIntersectionType) { + return $this->getReturnTypeValues($returnType); + } + if (!$returnType instanceof \ReflectionNamedType) { + return null; + } + + $className = $method->getDeclaringClass()->getName(); + $returnTypeValue = ($returnType->allowsNull() && $returnType->getName() !== 'mixed' ? '?' : ''); + $returnTypeValue .= ($returnType->getName() === 'self') + ? ltrim($className, '\\') + : $returnType->getName(); + } + + return $returnTypeValue; + } + + private function getReturnTypeValues( + ReflectionIntersectionType|ReflectionUnionType $returnType, + ): string { + $returnTypeValue = []; + + foreach ($returnType->getTypes() as $type) { + if ($type instanceof ReflectionNamedType) { + $returnTypeValue[] = $type->getName(); + } + } + + return implode( + $returnType instanceof ReflectionUnionType ? '|' : '&', + $returnTypeValue + ); + } + public function register(): void { \spl_autoload_register($this->autoload(...), true, false); From 214a6777855a81631ae9332ed8fcdac13c2f13b6 Mon Sep 17 00:00:00 2001 From: Navarr Date: Wed, 9 Jul 2025 16:44:01 -0400 Subject: [PATCH 3/7] Add copyright details to InterceptorAutoloader --- .../PHPStan/Magento/Autoload/InterceptorAutoloader.php | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php index 7069d15..d0d263b 100644 --- a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php +++ b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php @@ -7,6 +7,10 @@ * * For the full copyright and license information, please view the LICENSE * file that was distributed with this source code. + * + * This file uses modified excerpts from the Magento Framework. That code + * is Copyright Magento, Inc. which is licensed under OSL 3.0. You can + * contact license@magentocommerce.com for a copy. */ declare(strict_types=1); @@ -16,7 +20,6 @@ use Laminas\Code\Generator\ValueGenerator; use Magento\Framework\Code\Generator\ClassGenerator; use Magento\Framework\GetParameterClassTrait; -use Magento\Framework\GetReflectionMethodReturnTypeValueTrait; use PHPStan\Cache\Cache; use ReflectionClass; use ReflectionIntersectionType; From 362e5087727088d8c61c2e8d76ef31fef082b917 Mon Sep 17 00:00:00 2001 From: Navarr Date: Thu, 10 Jul 2025 13:48:06 -0400 Subject: [PATCH 4/7] Add Interceptor Unit Tests --- .../PHPStan/Magento/Autoload/Helper.php | 20 ++++ .../Magento/Autoload/HelperInterceptor.php | 20 ++++ .../InterceptorAutoloaderUnitTest.php | 99 +++++++++++++++++++ .../Autoload/ProxyAutoloaderUnitTest.php | 4 - 4 files changed, 139 insertions(+), 4 deletions(-) create mode 100644 tests/bitExpert/PHPStan/Magento/Autoload/Helper.php create mode 100644 tests/bitExpert/PHPStan/Magento/Autoload/HelperInterceptor.php create mode 100644 tests/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloaderUnitTest.php diff --git a/tests/bitExpert/PHPStan/Magento/Autoload/Helper.php b/tests/bitExpert/PHPStan/Magento/Autoload/Helper.php new file mode 100644 index 0000000..026f10c --- /dev/null +++ b/tests/bitExpert/PHPStan/Magento/Autoload/Helper.php @@ -0,0 +1,20 @@ +storage = $this->createMock(CacheStorage::class); + $this->classLoader = $this->createMock(ClassLoaderProvider::class); + + $this->autoloader = new InterceptorAutoloader(new Cache($this->storage), $this->classLoader); + } + + /** + * @test + */ + public function autoloaderIgnoresClassesWithoutInterceptorPostfix(): void + { + $this->classLoader->expects(self::never()) + ->method('findFile'); + $this->storage->expects(self::never()) + ->method('load'); + + $this->autoloader->autoload('SomeClass'); + } + + /** + * @test + */ + public function autoloaderPrefersLocalFile(): void + { + $this->classLoader->expects(self::once()) + ->method('findFile') + ->willReturn(__DIR__ . '/HelperInterceptor.php'); + $this->storage->expects(self::never()) + ->method('load'); + + $this->autoloader->autoload('\bitExpert\PHPStan\Magento\Autoload\Helper\Interceptor'); + + self::assertTrue(class_exists(HelperInterceptor::class, false)); + } + + /** + * @test + */ + public function autoloaderUsesCachedFileWhenFound(): void + { + $this->classLoader->expects(self::once()) + ->method('findFile') + ->willReturn(false); + $this->storage->expects(self::once()) + ->method('load') + ->willReturn(__DIR__ . '/HelperInterceptor.php'); + + $this->autoloader->autoload('\bitExpert\PHPStan\Magento\Autoload\Helper\Interceptor'); + + self::assertTrue(class_exists(HelperInterceptor::class, false)); + } + + /** + * @test + */ + public function autoloaderGeneratesCacheFileWhenNotFoundInCache(): void + { + $this->classLoader->expects(self::once()) + ->method('findFile') + ->willReturn(false); + $this->storage->expects(self::atMost(2)) + ->method('load') + ->willReturnOnConsecutiveCalls(null, __DIR__ . '/HelperInterceptor.php'); + $this->storage->expects(self::once()) + ->method('save'); + + $this->autoloader->autoload('\bitExpert\PHPStan\Magento\Autoload\Helper\Interceptor'); + + self::assertTrue(class_exists(HelperInterceptor::class, false)); + } +} \ No newline at end of file diff --git a/tests/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloaderUnitTest.php b/tests/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloaderUnitTest.php index e2815f8..4a2fddc 100644 --- a/tests/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloaderUnitTest.php +++ b/tests/bitExpert/PHPStan/Magento/Autoload/ProxyAutoloaderUnitTest.php @@ -91,10 +91,6 @@ public function autoloaderUsesCachedFileWhenFound(): void */ public function autoloaderGeneratesCacheFileWhenNotFoundInCache(): void { - // little hack: the proxy autoloader will use Reflection to look for a class without the \Proxy prefix, - // to avoid having another stub class file, we define an class alias here - class_alias('\bitExpert\PHPStan\Magento\Autoload\HelperProxy', '\bitExpert\PHPStan\Magento\Autoload\Helper'); - $this->classLoader->expects(self::once()) ->method('findFile') ->willReturn(false); From c12221e703588fb81151d93f7258a03f874eb89f Mon Sep 17 00:00:00 2001 From: Navarr Date: Thu, 10 Jul 2025 14:08:14 -0400 Subject: [PATCH 5/7] Add Interceptor Autoloader to phpstan extension --- extension.neon | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/extension.neon b/extension.neon index aa90e9d..36ed746 100644 --- a/extension.neon +++ b/extension.neon @@ -102,6 +102,13 @@ services: classLoaderProvider: @classLoaderProvider tags: - phpstan.magento.autoloader + interceptorAutoloader: + class: bitExpert\PHPStan\Magento\Autoload\InterceptorAutoloader + arguments: + cache: @autoloaderCache + classLoaderProvider: @classLoaderProvider + tags: + - phpstan.magento.autoloader extensionInterfaceAutoloader: class: bitExpert\PHPStan\Magento\Autoload\ExtensionInterfaceAutoloader arguments: From b6f2c46eec355c7dd11ac6da2f262df5b70acb53 Mon Sep 17 00:00:00 2001 From: Navarr Date: Fri, 11 Jul 2025 10:08:10 -0400 Subject: [PATCH 6/7] Resolve Interceptor generation bugs - Fix non-variadic parameters being variadic - Ensure it extends the original class --- .../PHPStan/Magento/Autoload/InterceptorAutoloader.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php index d0d263b..ddc392a 100644 --- a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php +++ b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php @@ -90,6 +90,7 @@ protected function getFileContents(string $class): string $generator = new ClassGenerator(); $generator->setName($class) + ->setExtendedClass($originalClassname) ->addProperties([]) ->addMethods($methods); @@ -213,7 +214,7 @@ protected function _getParameterList(array $parameters) array_map( function ($item) { $output = ''; - if (!isset($item['variadic'])) { + if (isset($item['variadic']) && $item['variadic']) { $output .= '... '; } From 70c62b1ad62d9e75199e4ff6145332701ef67cd4 Mon Sep 17 00:00:00 2001 From: Navarr Date: Fri, 11 Jul 2025 15:56:18 -0400 Subject: [PATCH 7/7] Resolve Interceptor generation bugs - If intercepting an interface, set implemented interfaces - otherwise set extended class - Add Interceptor trait --- .../Magento/Autoload/InterceptorAutoloader.php | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php index ddc392a..581d37d 100644 --- a/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php +++ b/src/bitExpert/PHPStan/Magento/Autoload/InterceptorAutoloader.php @@ -76,7 +76,7 @@ protected function getFileContents(string $class): string $originalClassname = implode('\\', $namespace); if (!class_exists($originalClassname)) { - throw new \RuntimeException("Class ${class} for Interceptor does not exist"); + throw new \RuntimeException("Class ${originalClassname} for Interceptor does not exist"); } $reflectionClass = new ReflectionClass($originalClassname); @@ -90,12 +90,20 @@ protected function getFileContents(string $class): string $generator = new ClassGenerator(); $generator->setName($class) - ->setExtendedClass($originalClassname) - ->addProperties([]) ->addMethods($methods); + $interfaces = []; + if ($reflectionClass->isInterface()) { + $interfaces[] = $originalClassname; + } else { + $generator->setExtendedClass($originalClassname); + } + $generator->addTrait('\\' . \Magento\Framework\Interception\Interceptor::class); + $interfaces[] = '\\' . \Magento\Framework\Interception\InterceptorInterface::class; + $generator->setImplementedInterfaces($interfaces); + $code = $generator->generate(); - return $this->_fixCodeStyle($code); + return '_fixCodeStyle($code); } protected function _fixCodeStyle(string $sourceCode): string