From 539fad0ac20392927c73c493a17b6a552f16753d Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 18 Aug 2026 09:09:15 +0000 Subject: [PATCH] Detect polyfills by their conditional declaration instead of a `polyfill` substring in the file path - `NativeFunctionReflectionProvider::findFunctionReflection()` no longer drops the native signature just because a userland file declares the function; it keeps it when the declaration is conditional (the polyfill idiom) and phpstorm-stubs says PHP provides the function at the configured PHP version. - New `ConditionallyDeclaredSymbolDetector` parses the declaring file and reports which functions, classes and `define()`d constants are declared inside a conditional block (`function_exists()`, `class_exists()`, `defined()`, `PHP_VERSION_ID` comparisons, ...). - `SkipPolyfillSourceLocator` applies the same rule to the analogous cases: conditionally declared classes/interfaces/traits/enums and `define()`d constants that PHP provides natively are hidden so the internal stub wins, next to the existing symfony/polyfill path list. - Functions are deliberately not hidden at the source-locator level - that would also hide their existence on PHP versions without them natively. - An unconditional userland declaration of a native-sounding name (the `swf_actiongotoframe` case from #13556) keeps winning over the signature map. --- .github/workflows/e2e-tests.yml | 2 + e2e/shadowed-native-function/helpers.php | 38 +++++ e2e/shadowed-native-function/phpstan.neon | 6 + e2e/shadowed-native-function/src/test.php | 10 ++ .../BetterReflectionSourceLocatorFactory.php | 4 +- .../SkipPolyfillSourceLocator.php | 36 +++- .../ConditionallyDeclaredSymbolDetector.php | 156 ++++++++++++++++++ .../NativeFunctionReflectionProvider.php | 22 ++- src/Testing/TestCaseSourceLocatorFactory.php | 4 +- .../nsrt/shadowed-native-function.php | 13 ++ .../SkipPolyfillSourceLocatorTest.php | 70 ++++++++ ...onditionallyDeclaredSymbolDetectorTest.php | 75 +++++++++ ...tionally-declared-symbols-no-namespace.php | 23 +++ .../data/conditionally-declared-symbols.php | 43 +++++ .../notAutoloaded/shadowed-native-symbols.php | 32 ++++ 15 files changed, 529 insertions(+), 5 deletions(-) create mode 100644 e2e/shadowed-native-function/helpers.php create mode 100644 e2e/shadowed-native-function/phpstan.neon create mode 100644 e2e/shadowed-native-function/src/test.php create mode 100644 src/Reflection/ConditionallyDeclaredSymbolDetector.php create mode 100644 tests/PHPStan/Analyser/nsrt/shadowed-native-function.php create mode 100644 tests/PHPStan/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocatorTest.php create mode 100644 tests/PHPStan/Reflection/ConditionallyDeclaredSymbolDetectorTest.php create mode 100644 tests/PHPStan/Reflection/data/conditionally-declared-symbols-no-namespace.php create mode 100644 tests/PHPStan/Reflection/data/conditionally-declared-symbols.php create mode 100644 tests/notAutoloaded/shadowed-native-symbols.php diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index b9df7364e26..39a14193317 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -559,6 +559,8 @@ jobs: extensions: "" - script: "bin/phpstan analyse e2e/only-files-not-analysed-trait/src -c e2e/only-files-not-analysed-trait/ignore.neon" extensions: "" + - script: "bin/phpstan analyse -c e2e/shadowed-native-function/phpstan.neon" + extensions: "" - script: "bin/phpstan analyse e2e/only-files-not-analysed-trait/src/Foo.php e2e/only-files-not-analysed-trait/src/BarTrait.php -c e2e/only-files-not-analysed-trait/no-ignore.neon" extensions: "" - script: | diff --git a/e2e/shadowed-native-function/helpers.php b/e2e/shadowed-native-function/helpers.php new file mode 100644 index 00000000000..9dac85f9663 --- /dev/null +++ b/e2e/shadowed-native-function/helpers.php @@ -0,0 +1,38 @@ + 0) { - $fileLocators[] = new SkipPolyfillSourceLocator(new AggregateSourceLocator($composerLocators), $this->phpVersion); + $fileLocators[] = new SkipPolyfillSourceLocator(new AggregateSourceLocator($composerLocators), $this->phpVersion, $this->conditionallyDeclaredSymbolDetector, $this->phpstormStubsSourceStubber); } if (extension_loaded('phar')) { diff --git a/src/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocator.php b/src/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocator.php index 769704c7552..3959f61edeb 100644 --- a/src/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocator.php +++ b/src/Reflection/BetterReflection/SourceLocator/SkipPolyfillSourceLocator.php @@ -10,15 +10,22 @@ use PHPStan\BetterReflection\Reflection\ReflectionConstant; use PHPStan\BetterReflection\Reflection\ReflectionFunction; use PHPStan\BetterReflection\Reflector\Reflector; +use PHPStan\BetterReflection\SourceLocator\SourceStubber\PhpStormStubsSourceStubber; use PHPStan\BetterReflection\SourceLocator\Type\SourceLocator; use PHPStan\Php\PhpVersion; +use PHPStan\Reflection\ConditionallyDeclaredSymbolDetector; use function str_contains; use function str_replace; final class SkipPolyfillSourceLocator implements SourceLocator { - public function __construct(private SourceLocator $sourceLocator, private PhpVersion $phpVersion) + public function __construct( + private SourceLocator $sourceLocator, + private PhpVersion $phpVersion, + private ConditionallyDeclaredSymbolDetector $conditionallyDeclaredSymbolDetector, + private PhpStormStubsSourceStubber $phpstormStubsSourceStubber, + ) { } @@ -52,12 +59,39 @@ public function locateIdentifier(Reflector $reflector, Identifier $identifier): if (str_contains($normalized, '/symfony/polyfill-php85/') && $this->phpVersion->getVersionId() >= 80500) { return null; } + if ($this->isShadowingNativeSymbol($reflection, $fileName)) { + return null; + } } } return $reflection; } + /** + * A polyfill guards its declaration so that it never runs when PHP provides + * the symbol natively. Its shape is then only an approximation of the real + * one and must not be reflected instead of it. + * + * Functions are left alone here: hiding them would also hide their + * existence from a PHP version that does not have them natively yet. + * NativeFunctionReflectionProvider prefers the native signature instead. + */ + private function isShadowingNativeSymbol(ReflectionClass|ReflectionFunction|ReflectionConstant $reflection, string $fileName): bool + { + if ($reflection instanceof ReflectionClass) { + return $this->conditionallyDeclaredSymbolDetector->isConditionallyDeclaredClass($fileName, $reflection->getName()) + && $this->phpstormStubsSourceStubber->isPresentClass($reflection->getName()) === true; + } + + if ($reflection instanceof ReflectionConstant) { + return $this->conditionallyDeclaredSymbolDetector->isConditionallyDeclaredConstant($fileName, $reflection->getName()) + && $this->phpstormStubsSourceStubber->generateConstantStub($reflection->getName()) !== null; + } + + return false; + } + #[Override] public function locateIdentifiersByType(Reflector $reflector, IdentifierType $identifierType): array { diff --git a/src/Reflection/ConditionallyDeclaredSymbolDetector.php b/src/Reflection/ConditionallyDeclaredSymbolDetector.php new file mode 100644 index 00000000000..f926b6b3065 --- /dev/null +++ b/src/Reflection/ConditionallyDeclaredSymbolDetector.php @@ -0,0 +1,156 @@ +, classes: array, constants: array} + */ +#[AutowiredService] +final class ConditionallyDeclaredSymbolDetector +{ + + private const FILE_CACHE_LIMIT = 128; + + /** @var array */ + private array $cache = []; + + public function __construct( + #[AutowiredParameter(ref: '@php8Parser')] + private Parser $parser, + ) + { + } + + public function isConditionallyDeclaredFunction(string $fileName, string $functionName): bool + { + return array_key_exists(strtolower($functionName), $this->getSymbols($fileName)['functions']); + } + + public function isConditionallyDeclaredClass(string $fileName, string $className): bool + { + return array_key_exists(strtolower($className), $this->getSymbols($fileName)['classes']); + } + + /** Constant names are case-sensitive, unlike function and class names. */ + public function isConditionallyDeclaredConstant(string $fileName, string $constantName): bool + { + return array_key_exists($constantName, $this->getSymbols($fileName)['constants']); + } + + /** + * @return Symbols + */ + private function getSymbols(string $fileName): array + { + if (array_key_exists($fileName, $this->cache)) { + return $this->cache[$fileName]; + } + + $symbols = [ + 'functions' => [], + 'classes' => [], + 'constants' => [], + ]; + try { + $this->findInStmts($this->parser->parseFile($fileName), false, $symbols); + } catch (Throwable) { + // an unparseable or unreadable file tells us nothing + } + + if (count($this->cache) >= self::FILE_CACHE_LIMIT) { + $this->cache = []; + } + + return $this->cache[$fileName] = $symbols; + } + + /** + * @param Stmt[] $stmts + * @param Symbols $symbols + */ + private function findInStmts(array $stmts, bool $conditional, array &$symbols): void + { + foreach ($stmts as $stmt) { + if ($stmt instanceof Stmt\Function_) { + if ($conditional) { + $symbols['functions'][strtolower((string) ($stmt->namespacedName ?? $stmt->name))] = true; + } + continue; + } + + if ($stmt instanceof Stmt\ClassLike) { + if ($conditional && $stmt->name !== null) { + $symbols['classes'][strtolower((string) ($stmt->namespacedName ?? $stmt->name))] = true; + } + continue; + } + + if ($stmt instanceof Stmt\Expression) { + if ($conditional) { + $this->findDefineCall($stmt->expr, $symbols); + } + continue; + } + + if ( + $stmt instanceof Stmt\Namespace_ + || $stmt instanceof Stmt\Declare_ + || $stmt instanceof Stmt\Block + ) { + $this->findInStmts($stmt->stmts ?? [], $conditional, $symbols); + continue; + } + + if (!$stmt instanceof Stmt\If_) { + continue; + } + + $this->findInStmts($stmt->stmts, true, $symbols); + foreach ($stmt->elseifs as $elseIf) { + $this->findInStmts($elseIf->stmts, true, $symbols); + } + if ($stmt->else === null) { + continue; + } + + $this->findInStmts($stmt->else->stmts, true, $symbols); + } + } + + /** + * @param Symbols $symbols + */ + private function findDefineCall(Node\Expr $expr, array &$symbols): void + { + if (!$expr instanceof Node\Expr\FuncCall) { + return; + } + + if (!$expr->name instanceof Node\Name || $expr->name->toLowerString() !== 'define') { + return; + } + + $args = $expr->getArgs(); + if (!isset($args[0]) || !$args[0]->value instanceof Node\Scalar\String_) { + return; + } + + $symbols['constants'][$args[0]->value->value] = true; + } + +} diff --git a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php index 6b40422c1d4..3290d68873d 100644 --- a/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php +++ b/src/Reflection/SignatureMap/NativeFunctionReflectionProvider.php @@ -6,12 +6,14 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionFunction; use PHPStan\BetterReflection\Reflector\Exception\IdentifierNotFound; use PHPStan\BetterReflection\Reflector\Reflector; +use PHPStan\BetterReflection\SourceLocator\SourceStubber\PhpStormStubsSourceStubber; use PHPStan\DependencyInjection\AutowiredParameter; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\PhpDoc\ResolvedPhpDocBlock; use PHPStan\PhpDoc\StubPhpDocProvider; use PHPStan\Reflection\Assertions; use PHPStan\Reflection\AttributeReflectionFactory; +use PHPStan\Reflection\ConditionallyDeclaredSymbolDetector; use PHPStan\Reflection\ExtendedFunctionVariant; use PHPStan\Reflection\InitializerExprContext; use PHPStan\Reflection\Native\ExtendedNativeParameterReflection; @@ -25,7 +27,6 @@ use PHPStan\Type\TypehintHelper; use function array_key_exists; use function array_map; -use function str_contains; use function strtolower; #[AutowiredService] @@ -43,6 +44,8 @@ public function __construct( private StubPhpDocProvider $stubPhpDocProvider, private AttributeReflectionFactory $attributeReflectionFactory, private ParameterAllowedConstantsMapProvider $allowedConstantsMapProvider, + private ConditionallyDeclaredSymbolDetector $conditionallyDeclaredSymbolDetector, + private PhpStormStubsSourceStubber $phpstormStubsSourceStubber, ) { } @@ -78,7 +81,7 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef $isDeprecated = $reflectionFunction->isDeprecated(); if ($reflectionFunction->getFileName() !== null) { $fileName = $reflectionFunction->getFileName(); - if (!$reflectionFunctionAdapter->isInternal() && !str_contains(strtolower($fileName), 'polyfill')) { + if (!$reflectionFunctionAdapter->isInternal() && !$this->isPolyfill($fileName, $realFunctionName)) { return null; } $docComment = $reflectionFunction->getDocComment(); @@ -196,6 +199,21 @@ public function findFunctionReflection(string $functionName): ?NativeFunctionRef return $functionReflection; } + /** + * A userland declaration of a function PHP provides natively can only be a + * polyfill - PHP would fatal on the redeclaration otherwise - so it is + * guarded by a conditional and never runs. Its PHPDoc must not replace the + * native signature. + */ + private function isPolyfill(string $fileName, string $functionName): bool + { + if ($this->phpstormStubsSourceStubber->isPresentFunction($functionName) !== true) { + return false; + } + + return $this->conditionallyDeclaredSymbolDetector->isConditionallyDeclaredFunction($fileName, $functionName); + } + private function getReturnTypeFromPhpDoc(ResolvedPhpDocBlock $phpDoc): ?Type { $returnTag = $phpDoc->getReturnTag(); diff --git a/src/Testing/TestCaseSourceLocatorFactory.php b/src/Testing/TestCaseSourceLocatorFactory.php index 3b01851e462..4f3be6ca7fd 100644 --- a/src/Testing/TestCaseSourceLocatorFactory.php +++ b/src/Testing/TestCaseSourceLocatorFactory.php @@ -18,6 +18,7 @@ use PHPStan\Reflection\BetterReflection\SourceLocator\OptimizedSingleFileSourceLocatorRepository; use PHPStan\Reflection\BetterReflection\SourceLocator\PhpVersionBlacklistSourceLocator; use PHPStan\Reflection\BetterReflection\SourceLocator\SkipPolyfillSourceLocator; +use PHPStan\Reflection\ConditionallyDeclaredSymbolDetector; use ReflectionClass; use function dirname; use function hash; @@ -44,6 +45,7 @@ public function __construct( private PhpStormStubsSourceStubber $phpstormStubsSourceStubber, private ReflectionSourceStubber $reflectionSourceStubber, private PhpVersion $phpVersion, + private ConditionallyDeclaredSymbolDetector $conditionallyDeclaredSymbolDetector, private array $fileExtensions, private ?array $excludePaths, ) @@ -84,7 +86,7 @@ public function create(): SourceLocator $composerLocators[] = $composerSourceLocator; } - self::$composerSourceLocatorsCache[$cacheKey] = [new SkipPolyfillSourceLocator(new AggregateSourceLocator($composerLocators), $this->phpVersion)]; + self::$composerSourceLocatorsCache[$cacheKey] = [new SkipPolyfillSourceLocator(new AggregateSourceLocator($composerLocators), $this->phpVersion, $this->conditionallyDeclaredSymbolDetector, $this->phpstormStubsSourceStubber)]; } $locators = self::$composerSourceLocatorsCache[$cacheKey] ?? []; diff --git a/tests/PHPStan/Analyser/nsrt/shadowed-native-function.php b/tests/PHPStan/Analyser/nsrt/shadowed-native-function.php new file mode 100644 index 00000000000..1b3fc5137c9 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/shadowed-native-function.php @@ -0,0 +1,13 @@ +expectException(IdentifierNotFound::class); + $this->createReflector()->reflectClass($className); + } + + public static function dataKeptClasses(): iterable + { + yield ['SkipPolyfillNotNativeClass']; + yield ['SkipPolyfillUnconditionalClass']; + } + + #[DataProvider('dataKeptClasses')] + public function testOtherClassesAreKept(string $className): void + { + $this->assertSame($className, $this->createReflector()->reflectClass($className)->getName()); + } + + public function testPolyfilledNativeConstantIsSkipped(): void + { + $this->expectException(IdentifierNotFound::class); + $this->createReflector()->reflectConstant('JSON_THROW_ON_ERROR'); + } + + public function testOtherConstantsAreKept(): void + { + $this->assertSame( + 'SKIP_POLYFILL_NOT_NATIVE_CONSTANT', + $this->createReflector()->reflectConstant('SKIP_POLYFILL_NOT_NATIVE_CONSTANT')->getName(), + ); + } + + private function createReflector(): Reflector + { + $container = self::getContainer(); + $locator = $container->getByType(OptimizedSingleFileSourceLocatorFactory::class) + ->create(__DIR__ . '/../../../../notAutoloaded/shadowed-native-symbols.php'); + + return new DefaultReflector(new SkipPolyfillSourceLocator( + $locator, + $container->getByType(PhpVersion::class), + $container->getByType(ConditionallyDeclaredSymbolDetector::class), + $container->getByType(PhpStormStubsSourceStubber::class), + )); + } + +} diff --git a/tests/PHPStan/Reflection/ConditionallyDeclaredSymbolDetectorTest.php b/tests/PHPStan/Reflection/ConditionallyDeclaredSymbolDetectorTest.php new file mode 100644 index 00000000000..975fc6c9e6a --- /dev/null +++ b/tests/PHPStan/Reflection/ConditionallyDeclaredSymbolDetectorTest.php @@ -0,0 +1,75 @@ +assertSame($expected, $this->getDetector()->isConditionallyDeclaredFunction($fileName, $functionName)); + } + + public static function dataIsConditionallyDeclaredClass(): iterable + { + $namespaced = __DIR__ . '/data/conditionally-declared-symbols.php'; + $global = __DIR__ . '/data/conditionally-declared-symbols-no-namespace.php'; + + yield [$namespaced, 'ConditionallyDeclaredSymbols\GuardedClass', true]; + yield [$namespaced, 'conditionallydeclaredsymbols\guardedinterface', true]; + yield [$namespaced, 'ConditionallyDeclaredSymbols\GuardedTrait', true]; + yield [$namespaced, 'ConditionallyDeclaredSymbols\UnconditionalClass', false]; + + yield [$global, 'ConditionallyDeclaredClassWithoutNamespace', true]; + } + + #[DataProvider('dataIsConditionallyDeclaredClass')] + public function testIsConditionallyDeclaredClass(string $fileName, string $className, bool $expected): void + { + $this->assertSame($expected, $this->getDetector()->isConditionallyDeclaredClass($fileName, $className)); + } + + public static function dataIsConditionallyDeclaredConstant(): iterable + { + $global = __DIR__ . '/data/conditionally-declared-symbols-no-namespace.php'; + + yield [$global, 'GUARDED_DEFINE', true]; + yield [$global, 'guarded_define', false]; + yield [$global, 'UNCONDITIONAL_CONST', false]; + } + + #[DataProvider('dataIsConditionallyDeclaredConstant')] + public function testIsConditionallyDeclaredConstant(string $fileName, string $constantName, bool $expected): void + { + $this->assertSame($expected, $this->getDetector()->isConditionallyDeclaredConstant($fileName, $constantName)); + } + + private function getDetector(): ConditionallyDeclaredSymbolDetector + { + return self::getContainer()->getByType(ConditionallyDeclaredSymbolDetector::class); + } + +} diff --git a/tests/PHPStan/Reflection/data/conditionally-declared-symbols-no-namespace.php b/tests/PHPStan/Reflection/data/conditionally-declared-symbols-no-namespace.php new file mode 100644 index 00000000000..9b4181c0788 --- /dev/null +++ b/tests/PHPStan/Reflection/data/conditionally-declared-symbols-no-namespace.php @@ -0,0 +1,23 @@ +