From 3886d99c7f977c450f8725e281acf60b5f985ca3 Mon Sep 17 00:00:00 2001 From: staabm <120441+staabm@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:12:46 +0000 Subject: [PATCH 1/9] Resolve PHP version checks in remaining scope-aware extensions from `Scope::getPhpVersion()` and forbid injecting `PhpVersion` into them - StrSplitFunctionReturnTypeExtension: str_split('') yields array{}|array{''} when the analysed versions straddle 8.2; ValueError/false and empty-array handling use TrinaryLogic from the scope - MbFunctionsReturnTypeExtension, MbStrlenFunctionReturnTypeExtension: invalid-encoding never/false decided per scope - MbFunctionsReturnTypeExtensionTrait: caches the full encoding list, filters PASS/NONE per call from the scope's PHP version - MbSubstituteCharacterDynamicReturnTypeExtension: computes code points valid on all vs. on some analysed versions, so version ranges produce bool instead of a wrong constant; new PhpVersion(s)::isZeroValidCodePointInMbSubstituteCharacter() - PDOConnectReturnTypeExtension: hasPDOSubclasses() checked against the scope - AdapterReflectionEnum(Case)DynamicReturnTypeExtension, NativeReflectionEnumReturnDynamicReturnTypeExtension: >= 8.0 check against the scope - New build rule NoPhpVersionInjectionInScopeAwareExtensionRule reports scope-aware extensions (return type, throw type, type-specifying, closure type/this, param-out, expression type resolver) whose constructor takes PhpVersion - Not converted: BcMath operator type-specifying extensions (OperatorTypeSpecifyingExtension gets no Scope), ArrayUnpackingHelper (engine helper), RegexArrayShapeMatcher/RegexGroupParser (no Scope at the check site) --- ...sionInjectionInScopeAwareExtensionRule.php | 114 ++++++++++++++++++ build/phpstan.neon | 1 + src/Php/PhpVersion.php | 5 + src/Php/PhpVersions.php | 5 + ...tionEnumCaseDynamicReturnTypeExtension.php | 6 +- ...flectionEnumDynamicReturnTypeExtension.php | 8 +- ...onEnumReturnDynamicReturnTypeExtension.php | 6 +- .../Php/MbFunctionsReturnTypeExtension.php | 9 +- .../MbFunctionsReturnTypeExtensionTrait.php | 35 ++++-- .../MbStrlenFunctionReturnTypeExtension.php | 15 +-- ...uteCharacterDynamicReturnTypeExtension.php | 91 ++++++++------ .../Php/PDOConnectReturnTypeExtension.php | 14 +-- .../StrSplitFunctionReturnTypeExtension.php | 37 +++--- ...p-version-range-return-type-extensions.php | 22 ++++ tests/PHPStan/Analyser/nsrt/bug-15287.php | 69 +++++++++++ ...InjectionInScopeAwareExtensionRuleTest.php | 29 +++++ .../Build/data/no-php-version-injection.php | 78 ++++++++++++ 17 files changed, 438 insertions(+), 106 deletions(-) create mode 100644 build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php create mode 100644 tests/PHPStan/Analyser/nsrt/bug-15287.php create mode 100644 tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php create mode 100644 tests/PHPStan/Build/data/no-php-version-injection.php diff --git a/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php new file mode 100644 index 00000000000..050169e598d --- /dev/null +++ b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php @@ -0,0 +1,114 @@ + + */ +final class NoPhpVersionInjectionInScopeAwareExtensionRule implements Rule +{ + + private const SCOPE_AWARE_EXTENSIONS = [ + DynamicFunctionReturnTypeExtension::class, + DynamicMethodReturnTypeExtension::class, + DynamicStaticMethodReturnTypeExtension::class, + DynamicFunctionThrowTypeExtension::class, + DynamicMethodThrowTypeExtension::class, + DynamicStaticMethodThrowTypeExtension::class, + FunctionTypeSpecifyingExtension::class, + MethodTypeSpecifyingExtension::class, + StaticMethodTypeSpecifyingExtension::class, + FunctionParameterClosureTypeExtension::class, + MethodParameterClosureTypeExtension::class, + StaticMethodParameterClosureTypeExtension::class, + FunctionParameterClosureThisExtension::class, + MethodParameterClosureThisExtension::class, + StaticMethodParameterClosureThisExtension::class, + FunctionParameterOutTypeExtension::class, + MethodParameterOutTypeExtension::class, + StaticMethodParameterOutTypeExtension::class, + ExpressionTypeResolverExtension::class, + ]; + + public function getNodeType(): string + { + return InClassNode::class; + } + + public function processNode(Node $node, Scope $scope): array + { + $classReflection = $node->getClassReflection(); + $implementedExtension = null; + foreach (self::SCOPE_AWARE_EXTENSIONS as $extension) { + if ($classReflection->implementsInterface($extension)) { + $implementedExtension = $extension; + break; + } + } + if ($implementedExtension === null) { + return []; + } + + $constructor = $node->getOriginalNode()->getMethod('__construct'); + if ($constructor === null) { + return []; + } + + $errors = []; + foreach ($constructor->params as $param) { + $type = $param->type; + if ($type instanceof NullableType) { + $type = $type->type; + } + if (!$type instanceof Name || $type->toString() !== PhpVersion::class) { + continue; + } + + $errors[] = RuleErrorBuilder::message(sprintf( + '%s implements %s and should not inject %s. Use Scope::getPhpVersion() instead.', + $classReflection->getDisplayName(), + $implementedExtension, + PhpVersion::class, + )) + ->identifier('phpstan.phpVersionInjection') + ->line($param->getStartLine()) + ->nonIgnorable() + ->build(); + } + + return $errors; + } + +} diff --git a/build/phpstan.neon b/build/phpstan.neon index 5226880e48c..c56533ae6cb 100644 --- a/build/phpstan.neon +++ b/build/phpstan.neon @@ -207,6 +207,7 @@ rules: - PHPStan\Build\SkipTestsWithRequiresPhpAttributeRule - PHPStan\Build\MemoizationPropertyRule - PHPStan\Build\OrChainIdenticalComparisonToInArrayRule + - PHPStan\Build\NoPhpVersionInjectionInScopeAwareExtensionRule services: - diff --git a/src/Php/PhpVersion.php b/src/Php/PhpVersion.php index 0b6a5efd949..6667a5916d9 100644 --- a/src/Php/PhpVersion.php +++ b/src/Php/PhpVersion.php @@ -213,6 +213,11 @@ public function isNullValidArgInMbSubstituteCharacter(): bool return $this->versionId >= 80000; } + public function isZeroValidCodePointInMbSubstituteCharacter(): bool + { + return $this->versionId >= 80000; + } + public function isInterfaceConstantImplicitlyFinal(): bool { return $this->versionId < 80100; diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index f6c8bfc2f18..adcc50b73be 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -228,6 +228,11 @@ public function isNullValidArgInMbSubstituteCharacter(): TrinaryLogic return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; } + public function isZeroValidCodePointInMbSubstituteCharacter(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + public function isNumericStringValidArgInMbSubstituteCharacter(): TrinaryLogic { return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result; diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php index 4e1cfbcea63..b814726a333 100644 --- a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php @@ -5,10 +5,10 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionType; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; @@ -22,7 +22,7 @@ final class AdapterReflectionEnumCaseDynamicReturnTypeExtension implements Dynam /** * @param class-string $class */ - public function __construct(private PhpVersion $phpVersion, private string $class) + public function __construct(private string $class) { } @@ -41,7 +41,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if ($this->phpVersion->getVersionId() >= 80000) { + if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { return null; } diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php index 2d3920e771c..d487c31b2f0 100644 --- a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php @@ -9,11 +9,11 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionNamedType; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\NullType; @@ -27,10 +27,6 @@ final class AdapterReflectionEnumDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension { - public function __construct(private PhpVersion $phpVersion) - { - } - public function getClass(): string { return ReflectionEnum::class; @@ -52,7 +48,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if ($this->phpVersion->getVersionId() >= 80000) { + if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { return null; } diff --git a/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php index fc9b44f79a6..e9b4665818a 100644 --- a/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php +++ b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php @@ -5,9 +5,9 @@ use PhpParser\Node\Expr\MethodCall; use PHPStan\Analyser\Scope; use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicMethodReturnTypeExtension; +use PHPStan\Type\IntegerRangeType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -17,7 +17,7 @@ final class NativeReflectionEnumReturnDynamicReturnTypeExtension implements Dyna /** * @param class-string $className */ - public function __construct(private PhpVersion $phpVersion, private string $className, private string $methodName) + public function __construct(private string $className, private string $methodName) { } @@ -33,7 +33,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if ($this->phpVersion->getVersionId() >= 80000) { + if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { return null; } diff --git a/src/Type/Php/MbFunctionsReturnTypeExtension.php b/src/Type/Php/MbFunctionsReturnTypeExtension.php index 659bd9f0072..f76f9231f09 100644 --- a/src/Type/Php/MbFunctionsReturnTypeExtension.php +++ b/src/Type/Php/MbFunctionsReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\BooleanType; @@ -38,10 +37,6 @@ final class MbFunctionsReturnTypeExtension implements DynamicFunctionReturnTypeE 'mb_ord' => 2, ]; - public function __construct(private PhpVersion $phpVersion) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return array_key_exists($functionReflection->getName(), $this->encodingPositionMap); @@ -62,7 +57,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $strings = $scope->getType($args[$positionEncodingParam - 1]->value)->getConstantStrings(); - $results = array_unique(array_map(fn (ConstantStringType $encoding): bool => $this->isSupportedEncoding($encoding->getValue()), $strings)); + $results = array_unique(array_map(fn (ConstantStringType $encoding): bool => $this->isSupportedEncoding($encoding->getValue(), $scope->getPhpVersion()), $strings)); if ($returnType->equals(new UnionType([new StringType(), new BooleanType()]))) { return count($results) === 1 ? new ConstantBooleanType($results[0]) : new BooleanType(); @@ -70,7 +65,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if (count($results) === 1) { $invalidEncodingReturn = new ConstantBooleanType(false); - if ($this->phpVersion->throwsOnInvalidMbStringEncoding()) { + if ($scope->getPhpVersion()->throwsOnInvalidMbStringEncoding()->yes()) { $invalidEncodingReturn = new NeverType(); } diff --git a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php index 64036c984b8..958f2d0d568 100644 --- a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php +++ b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php @@ -2,10 +2,12 @@ namespace PHPStan\Type\Php; +use PHPStan\Php\PhpVersions; use PHPStan\ShouldNotHappenException; use function array_filter; use function array_map; use function array_merge; +use function array_values; use function function_exists; use function in_array; use function is_null; @@ -19,13 +21,29 @@ trait MbFunctionsReturnTypeExtensionTrait /** @var string[]|null */ private ?array $supportedEncodings = null; - private function isSupportedEncoding(string $encoding): bool + private function isSupportedEncoding(string $encoding, PhpVersions $phpVersion): bool { - return in_array(strtoupper($encoding), $this->getSupportedEncodings(), true); + return in_array(strtoupper($encoding), $this->getSupportedEncodings($phpVersion), true); } /** @return string[] */ - private function getSupportedEncodings(): array + private function getSupportedEncodings(PhpVersions $phpVersion): array + { + $supportedEncodings = $this->getAllEncodings(); + + // PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3 + if (!$phpVersion->supportsPassNoneEncodings()->yes()) { + $supportedEncodings = array_values(array_filter( + $supportedEncodings, + static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true), + )); + } + + return $supportedEncodings; + } + + /** @return string[] */ + private function getAllEncodings(): array { if (!is_null($this->supportedEncodings)) { return $this->supportedEncodings; @@ -41,17 +59,8 @@ private function getSupportedEncodings(): array $supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]); } } - $this->supportedEncodings = array_map('strtoupper', $supportedEncodings); - - // PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3 - if (!$this->phpVersion->supportsPassNoneEncodings()) { - $this->supportedEncodings = array_filter( - $this->supportedEncodings, - static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true), - ); - } - return $this->supportedEncodings; + return $this->supportedEncodings = array_map('strtoupper', $supportedEncodings); } } diff --git a/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php b/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php index ee57cc3f16f..89e8fecffb8 100644 --- a/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php +++ b/src/Type/Php/MbStrlenFunctionReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\ShouldNotHappenException; @@ -42,10 +41,6 @@ final class MbStrlenFunctionReturnTypeExtension implements DynamicFunctionReturn use MbFunctionsReturnTypeExtensionTrait; - public function __construct(private PhpVersion $phpVersion) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'mb_strlen'; @@ -76,7 +71,7 @@ public function getTypeFromFunctionCall( if (count($encodings) > 0) { for ($i = 0; $i < count($encodings); $i++) { - if ($this->isSupportedEncoding($encodings[$i])) { + if ($this->isSupportedEncoding($encodings[$i], $scope->getPhpVersion())) { continue; } $encodings[$i] = self::UNSUPPORTED_ENCODING; @@ -85,13 +80,13 @@ public function getTypeFromFunctionCall( $encodings = array_unique($encodings); if (in_array(self::UNSUPPORTED_ENCODING, $encodings, true) && count($encodings) === 1) { - if ($this->phpVersion->throwsOnInvalidMbStringEncoding()) { + if ($scope->getPhpVersion()->throwsOnInvalidMbStringEncoding()->yes()) { return new NeverType(); } return new ConstantBooleanType(false); } } else { // if there aren't encoding constants, use all available encodings - $encodings = array_merge($this->getSupportedEncodings(), [self::UNSUPPORTED_ENCODING]); + $encodings = array_merge($this->getSupportedEncodings($scope->getPhpVersion()), [self::UNSUPPORTED_ENCODING]); } $argType = $scope->getType($args[0]->value); @@ -102,7 +97,7 @@ public function getTypeFromFunctionCall( $stringScalar = (string) $constantScalar; foreach ($encodings as $encoding) { - if (!$this->isSupportedEncoding($encoding)) { + if (!$this->isSupportedEncoding($encoding, $scope->getPhpVersion())) { continue; } @@ -145,7 +140,7 @@ public function getTypeFromFunctionCall( ); } - if (!$this->phpVersion->throwsOnInvalidMbStringEncoding() && in_array(self::UNSUPPORTED_ENCODING, $encodings, true)) { + if (!$scope->getPhpVersion()->throwsOnInvalidMbStringEncoding()->yes() && in_array(self::UNSUPPORTED_ENCODING, $encodings, true)) { return TypeCombinator::union($range, new ConstantBooleanType(false)); } return $range; diff --git a/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php index 7b14c0cce7f..be7be4041f8 100644 --- a/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php +++ b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php @@ -5,7 +5,7 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; +use PHPStan\Php\PhpVersions; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -23,10 +23,6 @@ final class MbSubstituteCharacterDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension { - public function __construct(private PhpVersion $phpVersion) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return $functionReflection->getName() === 'mb_substitute_character'; @@ -34,24 +30,19 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { - $minCodePoint = $this->phpVersion->getVersionId() < 80000 ? 1 : 0; - $maxCodePoint = $this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter() ? 0x10FFFF : 0xFFFE; - $ranges = []; + $phpVersion = $scope->getPhpVersion(); - if ($this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()) { - // Surrogates aren't valid in PHP 7.2+ - $ranges[] = IntegerRangeType::fromInterval($minCodePoint, 0xD7FF); - $ranges[] = IntegerRangeType::fromInterval(0xE000, $maxCodePoint); - } else { - $ranges[] = IntegerRangeType::fromInterval($minCodePoint, $maxCodePoint); - } + // valid code points on every analysed PHP version + $validCodePoints = $this->createCodePointsType($phpVersion, true); + // valid code points on at least one analysed PHP version + $possibleCodePoints = $this->createCodePointsType($phpVersion, false); if (!isset($functionCall->getArgs()[0])) { return TypeCombinator::union( new ConstantStringType('none'), new ConstantStringType('long'), new ConstantStringType('entity'), - ...$ranges, + $possibleCodePoints, ); } @@ -61,7 +52,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $isInteger = $argType->isInteger(); if ($isString->no() && $isNull->no() && $isInteger->no()) { - if ($this->phpVersion->throwsTypeErrorForInternalFunctions()) { + if ($phpVersion->throwsTypeErrorForInternalFunctions()->yes()) { return new NeverType(); } @@ -69,20 +60,12 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($isInteger->yes()) { - $invalidRanges = []; - - foreach ($ranges as $range) { - $isInRange = $range->isSuperTypeOf($argType); - - if ($isInRange->yes()) { - return new ConstantBooleanType(true); - } - - $invalidRanges[] = $isInRange->no(); + if ($validCodePoints->isSuperTypeOf($argType)->yes()) { + return new ConstantBooleanType(true); } - if ($argType instanceof ConstantIntegerType || !in_array(false, $invalidRanges, true)) { - if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + if ($possibleCodePoints->isSuperTypeOf($argType)->no()) { + if ($phpVersion->throwsValueErrorForInternalFunctions()->yes()) { return new NeverType(); } @@ -91,14 +74,14 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } elseif ($isString->yes()) { if ($argType->isNonEmptyString()->no()) { // The empty string was a valid alias for "none" in PHP < 8. - if ($this->phpVersion->isEmptyStringValidAliasForNoneInMbSubstituteCharacter()) { + if (!$phpVersion->isEmptyStringValidAliasForNoneInMbSubstituteCharacter()->no()) { return new ConstantBooleanType(true); } return new NeverType(); } - if (!$this->phpVersion->isNumericStringValidArgInMbSubstituteCharacter() && $argType->isNumericString()->yes()) { + if ($phpVersion->isNumericStringValidArgInMbSubstituteCharacter()->no() && $argType->isNumericString()->yes()) { return new NeverType(); } @@ -110,17 +93,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($argType->isNumericString()->yes()) { - $codePoint = (int) $value; - $isValid = $codePoint >= $minCodePoint && $codePoint <= $maxCodePoint; - - if ($this->phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()) { - $isValid = $isValid && ($codePoint < 0xD800 || $codePoint > 0xDFFF); + $codePoint = new ConstantIntegerType((int) $value); + if ($validCodePoints->isSuperTypeOf($codePoint)->yes()) { + return new ConstantBooleanType(true); + } + if ($possibleCodePoints->isSuperTypeOf($codePoint)->no()) { + return new ConstantBooleanType(false); } - return new ConstantBooleanType($isValid); + return new BooleanType(); } - if ($this->phpVersion->throwsValueErrorForInternalFunctions()) { + if ($phpVersion->throwsValueErrorForInternalFunctions()->yes()) { return new NeverType(); } @@ -128,10 +112,39 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } elseif ($isNull->yes()) { // The $substitute_character arg is nullable in PHP 8+ - return new ConstantBooleanType($this->phpVersion->isNullValidArgInMbSubstituteCharacter()); + return $phpVersion->isNullValidArgInMbSubstituteCharacter()->toBooleanType(); } return new BooleanType(); } + /** + * @param bool $onAllVersions Whether the code points must be valid on every analysed PHP version, or on at least one + */ + private function createCodePointsType(PhpVersions $phpVersion, bool $onAllVersions): Type + { + $zeroValid = $phpVersion->isZeroValidCodePointInMbSubstituteCharacter(); + $supportsAllUnicodeScalars = $phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter(); + + if ($onAllVersions) { + $minCodePoint = $zeroValid->yes() ? 0 : 1; + $maxCodePoint = $supportsAllUnicodeScalars->yes() ? 0x10FFFF : 0xFFFE; + $excludeSurrogates = !$supportsAllUnicodeScalars->no(); + } else { + $minCodePoint = $zeroValid->no() ? 1 : 0; + $maxCodePoint = $supportsAllUnicodeScalars->no() ? 0xFFFE : 0x10FFFF; + $excludeSurrogates = $supportsAllUnicodeScalars->yes(); + } + + if ($excludeSurrogates) { + // Surrogates aren't valid in PHP 7.2+ + return TypeCombinator::union( + IntegerRangeType::fromInterval($minCodePoint, 0xD7FF), + IntegerRangeType::fromInterval(0xE000, $maxCodePoint), + ); + } + + return IntegerRangeType::fromInterval($minCodePoint, $maxCodePoint); + } + } diff --git a/src/Type/Php/PDOConnectReturnTypeExtension.php b/src/Type/Php/PDOConnectReturnTypeExtension.php index 82ceb6118ee..4f24eb069cf 100644 --- a/src/Type/Php/PDOConnectReturnTypeExtension.php +++ b/src/Type/Php/PDOConnectReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; use PHPStan\Type\ObjectType; @@ -22,12 +21,6 @@ final class PDOConnectReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { - public function __construct( - private PhpVersion $phpVersion, - ) - { - } - public function getClass(): string { return 'PDO'; @@ -35,8 +28,7 @@ public function getClass(): string public function isStaticMethodSupported(MethodReflection $methodReflection): bool { - return $this->phpVersion->hasPDOSubclasses() - && $methodReflection->getName() === 'connect'; + return $methodReflection->getName() === 'connect'; } public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type @@ -45,6 +37,10 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } + if ($scope->getPhpVersion()->hasPDOSubclasses()->no()) { + return null; + } + $valueType = $scope->getType($methodCall->getArgs()[0]->value); $constantStrings = $valueType->getConstantStrings(); if (count($constantStrings) === 0) { diff --git a/src/Type/Php/StrSplitFunctionReturnTypeExtension.php b/src/Type/Php/StrSplitFunctionReturnTypeExtension.php index 9738902437e..f3302f2b6d6 100644 --- a/src/Type/Php/StrSplitFunctionReturnTypeExtension.php +++ b/src/Type/Php/StrSplitFunctionReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersion; use PHPStan\Reflection\FunctionReflection; use PHPStan\ShouldNotHappenException; use PHPStan\TrinaryLogic; @@ -40,10 +39,6 @@ final class StrSplitFunctionReturnTypeExtension implements DynamicFunctionReturn use MbFunctionsReturnTypeExtensionTrait; - public function __construct(private PhpVersion $phpVersion) - { - } - public function isFunctionSupported(FunctionReflection $functionReflection): bool { return in_array($functionReflection->getName(), ['str_split', 'mb_str_split'], true); @@ -56,6 +51,10 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return null; } + $phpVersion = $scope->getPhpVersion(); + $throwsValueError = $phpVersion->throwsValueErrorForInternalFunctions(); + $returnsEmptyArray = $phpVersion->strSplitReturnsEmptyArray(); + if (count($args) >= 2) { $splitLengthType = $scope->getType($args[1]->value); } else { @@ -65,7 +64,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($splitLengthType instanceof ConstantIntegerType) { $splitLength = $splitLengthType->getValue(); if ($splitLength < 1) { - return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false); + return $throwsValueError->yes() ? new NeverType() : new ConstantBooleanType(false); } } @@ -77,8 +76,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if (count($values) === 1) { $encoding = $values[0]; - if (!$this->isSupportedEncoding($encoding)) { - return $this->phpVersion->throwsValueErrorForInternalFunctions() ? new NeverType() : new ConstantBooleanType(false); + if (!$this->isSupportedEncoding($encoding, $scope->getPhpVersion())) { + return $throwsValueError->yes() ? new NeverType() : new ConstantBooleanType(false); } } } else { @@ -99,13 +98,19 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($encoding === null && $value === '') { // Simulate the str_split call with the analysed PHP Version instead of the runtime one. - $items = $this->phpVersion->strSplitReturnsEmptyArray() ? [] : ['']; - } else { - $items = $encoding === null - ? str_split($value, $splitLength) - : @mb_str_split($value, $splitLength, $encoding); + if (!$returnsEmptyArray->no()) { + $results[] = self::createConstantArrayFrom([], $scope); + } + if (!$returnsEmptyArray->yes()) { + $results[] = self::createConstantArrayFrom([''], $scope); + } + continue; } + $items = $encoding === null + ? str_split($value, $splitLength) + : @mb_str_split($value, $splitLength, $encoding); + $results[] = self::createConstantArrayFrom($items, $scope); } @@ -115,7 +120,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $isInputNonEmptyString = $stringType->isNonEmptyString()->yes(); - if ($isInputNonEmptyString || $this->phpVersion->strSplitReturnsEmptyArray()) { + if ($isInputNonEmptyString || $returnsEmptyArray->yes()) { $returnValueType = new IntersectionType([new StringType(), new AccessoryNonEmptyStringType()]); } else { $returnValueType = new StringType(); @@ -126,13 +131,13 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, // Non-empty-string will return an array with at least an element $isInputNonEmptyString // str_split('', 1) returns [''] on old PHP version and [] on new ones - || ($functionReflection->getName() === 'str_split' && !$this->phpVersion->strSplitReturnsEmptyArray()) + || ($functionReflection->getName() === 'str_split' && $returnsEmptyArray->no()) ) { $returnType = TypeCombinator::intersect($returnType, new NonEmptyArrayType()); } if ( // Length parameter accepts int<1, max> or throws a ValueError/return false based on PHP Version. - !$this->phpVersion->throwsValueErrorForInternalFunctions() + !$throwsValueError->yes() && !IntegerRangeType::fromInterval(1, null)->isSuperTypeOf($splitLengthType)->yes() ) { $returnType = new UnionType([$returnType, new ConstantBooleanType(false)]); diff --git a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php index 8d291943f35..22ee20c2810 100644 --- a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php +++ b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php @@ -14,3 +14,25 @@ function validOperatorNeverReturnsNull(string $a, string $b, string $s): void assertType('(bool|null)', version_compare($a, $b, 'nope')); assertType('(bool|null)', version_compare($a, $b, $s)); } + +function strSplitAndMbFunctions(string $s, int $i): void +{ + assertType("array{}|array{''}", str_split('')); + assertType('list', str_split($s)); + assertType('false', str_split($s, 0)); + assertType('list|false', str_split($s, $i)); + assertType('false', mb_strlen($s, 'foo')); + assertType('false', mb_ord($s, 'foo')); +} + +function mbSubstituteCharacter(): void +{ + assertType('bool', mb_substitute_character(0)); + assertType('true', mb_substitute_character(1)); + assertType('bool', mb_substitute_character(null)); + assertType('true', mb_substitute_character('')); + assertType('bool', mb_substitute_character(new \stdClass())); + assertType('false', mb_substitute_character('foo')); + assertType('false', mb_substitute_character(0x110000)); + assertType("'entity'|'long'|'none'|int<0, 55295>|int<57344, 1114111>", mb_substitute_character()); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-15287.php b/tests/PHPStan/Analyser/nsrt/bug-15287.php new file mode 100644 index 00000000000..2c62dfe2f59 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-15287.php @@ -0,0 +1,69 @@ += 80200) { + assertType('array{}', str_split('')); + assertType('list', str_split($s)); + assertType('*NEVER*', str_split($s, 0)); + assertType('list', str_split($s, $i)); + } elseif (PHP_VERSION_ID >= 80000) { + assertType('array{\'\'}', str_split('')); + assertType('non-empty-list', str_split($s)); + assertType('*NEVER*', str_split($s, 0)); + assertType('non-empty-list', str_split($s, $i)); + } else { + assertType('array{\'\'}', str_split('')); + assertType('non-empty-list', str_split($s)); + assertType('false', str_split($s, 0)); + assertType('non-empty-list|false', str_split($s, $i)); + } +} + +function mbFunctions(string $s): void +{ + if (PHP_VERSION_ID >= 80000) { + assertType('*NEVER*', mb_str_split($s, 1, 'foo')); + assertType('*NEVER*', mb_strlen($s, 'foo')); + assertType('*NEVER*', mb_ord($s, 'foo')); + } else { + assertType('false', mb_str_split($s, 1, 'foo')); + assertType('false', mb_strlen($s, 'foo')); + assertType('false', mb_ord($s, 'foo')); + } +} + +function mbSubstituteCharacter(): void +{ + if (PHP_VERSION_ID >= 80000) { + assertType('true', mb_substitute_character(0)); + assertType('true', mb_substitute_character(null)); + assertType('*NEVER*', mb_substitute_character('')); + assertType('*NEVER*', mb_substitute_character('123')); + assertType('*NEVER*', mb_substitute_character(0x110000)); + assertType('*NEVER*', mb_substitute_character('foo')); + assertType('*NEVER*', mb_substitute_character(new \stdClass())); + assertType("'entity'|'long'|'none'|int<0, 55295>|int<57344, 1114111>", mb_substitute_character()); + } else { + assertType('false', mb_substitute_character(0)); + assertType('false', mb_substitute_character(null)); + assertType('true', mb_substitute_character('')); + assertType('true', mb_substitute_character('123')); + assertType('false', mb_substitute_character('0')); + assertType('false', mb_substitute_character(0x110000)); + assertType('false', mb_substitute_character('foo')); + assertType('bool', mb_substitute_character(new \stdClass())); + assertType("'entity'|'long'|'none'|int<1, 1114111>", mb_substitute_character()); + } +} + +function pdoConnect(): void +{ + if (PHP_VERSION_ID >= 80400) { + assertType('PDO\Sqlite', \PDO::connect('sqlite:foo')); + } +} diff --git a/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php new file mode 100644 index 00000000000..05b5a4c70a1 --- /dev/null +++ b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php @@ -0,0 +1,29 @@ + + */ +final class NoPhpVersionInjectionInScopeAwareExtensionRuleTest extends RuleTestCase +{ + + protected function getRule(): Rule + { + return new NoPhpVersionInjectionInScopeAwareExtensionRule(); + } + + public function testRule(): void + { + $this->analyse([__DIR__ . '/data/no-php-version-injection.php'], [ + [ + 'NoPhpVersionInjection\InjectsPhpVersion implements PHPStan\Type\DynamicFunctionReturnTypeExtension and should not inject PHPStan\Php\PhpVersion. Use Scope::getPhpVersion() instead.', + 24, + ], + ]); + } + +} diff --git a/tests/PHPStan/Build/data/no-php-version-injection.php b/tests/PHPStan/Build/data/no-php-version-injection.php new file mode 100644 index 00000000000..9fd9b03995d --- /dev/null +++ b/tests/PHPStan/Build/data/no-php-version-injection.php @@ -0,0 +1,78 @@ +name = $name; + $this->phpVersion = $phpVersion; + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return $functionReflection->getName() === $this->name; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + return new MixedType(); + } + +} + +final class UsesScope implements DynamicFunctionReturnTypeExtension +{ + + /** @var string */ + private $name; + + public function __construct(string $name) + { + $this->name = $name; + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return $functionReflection->getName() === $this->name; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + $scope->getPhpVersion(); + return new MixedType(); + } + +} + +final class NotAnExtension +{ + + /** @var PhpVersion|null */ + private $phpVersion; + + public function __construct(?PhpVersion $phpVersion) + { + $this->phpVersion = $phpVersion; + } + +} From cd078438006e5857b99ca7806438196035ecdbba Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:26:36 +0000 Subject: [PATCH 2/9] Add `PhpVersions::hasCorrectReflectionEnumAdapterReturnTypes()` for the reflection enum return type extensions Co-Authored-By: Claude Opus 5.5 --- src/Php/PhpVersions.php | 6 ++++++ .../AdapterReflectionEnumCaseDynamicReturnTypeExtension.php | 3 +-- .../AdapterReflectionEnumDynamicReturnTypeExtension.php | 3 +-- ...NativeReflectionEnumReturnDynamicReturnTypeExtension.php | 3 +-- 4 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Php/PhpVersions.php b/src/Php/PhpVersions.php index adcc50b73be..b8ca174f905 100644 --- a/src/Php/PhpVersions.php +++ b/src/Php/PhpVersions.php @@ -233,6 +233,12 @@ public function isZeroValidCodePointInMbSubstituteCharacter(): TrinaryLogic return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; } + /** On PHP 8.0+ the Reflection* classes extended by the BetterReflection enum adapters declare the correct return types. */ + public function hasCorrectReflectionEnumAdapterReturnTypes(): TrinaryLogic + { + return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result; + } + public function isNumericStringValidArgInMbSubstituteCharacter(): TrinaryLogic { return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result; diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php index b814726a333..1ed0cb3e557 100644 --- a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php @@ -8,7 +8,6 @@ use PHPStan\Reflection\MethodReflection; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\IntegerRangeType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; use PHPStan\Type\StringType; @@ -41,7 +40,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { + if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) { return null; } diff --git a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php index d487c31b2f0..35cb57462a4 100644 --- a/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php +++ b/src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php @@ -13,7 +13,6 @@ use PHPStan\Type\Accessory\AccessoryNonEmptyStringType; use PHPStan\Type\Constant\ConstantBooleanType; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\IntegerRangeType; use PHPStan\Type\IntegerType; use PHPStan\Type\IntersectionType; use PHPStan\Type\NullType; @@ -48,7 +47,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { + if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) { return null; } diff --git a/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php index e9b4665818a..3fb85bec51d 100644 --- a/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php +++ b/src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php @@ -7,7 +7,6 @@ use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicMethodReturnTypeExtension; -use PHPStan\Type\IntegerRangeType; use PHPStan\Type\ObjectType; use PHPStan\Type\Type; @@ -33,7 +32,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type { - if (IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($scope->getPhpVersion()->getType())->yes()) { + if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) { return null; } From 71a3320e52a107a7ab8f9e10c16d69771017f70f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:31:26 +0000 Subject: [PATCH 3/9] Keep injected `PhpVersion` in `PDOConnectReturnTypeExtension` PDO driver subclasses only exist in the runtime on PHP 8.4+, so the extension depends on the runtime version rather than the scope's one. The build rule is now ignorable so this exception can be marked inline. Co-Authored-By: Claude Opus 5.5 --- ...pVersionInjectionInScopeAwareExtensionRule.php | 1 - src/Type/Php/PDOConnectReturnTypeExtension.php | 15 ++++++++++----- tests/PHPStan/Analyser/nsrt/bug-15287.php | 7 ------- 3 files changed, 10 insertions(+), 13 deletions(-) diff --git a/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php index 050169e598d..7432d7c46f9 100644 --- a/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php +++ b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php @@ -104,7 +104,6 @@ public function processNode(Node $node, Scope $scope): array )) ->identifier('phpstan.phpVersionInjection') ->line($param->getStartLine()) - ->nonIgnorable() ->build(); } diff --git a/src/Type/Php/PDOConnectReturnTypeExtension.php b/src/Type/Php/PDOConnectReturnTypeExtension.php index 4f24eb069cf..f73d318aa44 100644 --- a/src/Type/Php/PDOConnectReturnTypeExtension.php +++ b/src/Type/Php/PDOConnectReturnTypeExtension.php @@ -5,6 +5,7 @@ use PhpParser\Node\Expr\StaticCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; +use PHPStan\Php\PhpVersion; use PHPStan\Reflection\MethodReflection; use PHPStan\Type\DynamicStaticMethodReturnTypeExtension; use PHPStan\Type\ObjectType; @@ -21,6 +22,13 @@ final class PDOConnectReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { + public function __construct( + // @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+) + private PhpVersion $phpVersion, + ) + { + } + public function getClass(): string { return 'PDO'; @@ -28,7 +36,8 @@ public function getClass(): string public function isStaticMethodSupported(MethodReflection $methodReflection): bool { - return $methodReflection->getName() === 'connect'; + return $this->phpVersion->hasPDOSubclasses() + && $methodReflection->getName() === 'connect'; } public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, StaticCall $methodCall, Scope $scope): ?Type @@ -37,10 +46,6 @@ public function getTypeFromStaticMethodCall(MethodReflection $methodReflection, return null; } - if ($scope->getPhpVersion()->hasPDOSubclasses()->no()) { - return null; - } - $valueType = $scope->getType($methodCall->getArgs()[0]->value); $constantStrings = $valueType->getConstantStrings(); if (count($constantStrings) === 0) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-15287.php b/tests/PHPStan/Analyser/nsrt/bug-15287.php index 2c62dfe2f59..b5e4d8f0f91 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15287.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15287.php @@ -60,10 +60,3 @@ function mbSubstituteCharacter(): void assertType("'entity'|'long'|'none'|int<1, 1114111>", mb_substitute_character()); } } - -function pdoConnect(): void -{ - if (PHP_VERSION_ID >= 80400) { - assertType('PDO\Sqlite', \PDO::connect('sqlite:foo')); - } -} From f69c8bae72e5e1cf0f200ce948d579538f4b0260 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:45:32 +0000 Subject: [PATCH 4/9] Check constructor parameters in `NoPhpVersionInjectionInScopeAwareExtensionRule` via reflection and `ObjectType::isSuperTypeOf()` Co-Authored-By: Claude Opus 5.5 --- ...sionInjectionInScopeAwareExtensionRule.php | 20 ++++++++----------- .../Php/PDOConnectReturnTypeExtension.php | 2 +- ...InjectionInScopeAwareExtensionRuleTest.php | 4 ++-- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php index 7432d7c46f9..8f8cb71ccdb 100644 --- a/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php +++ b/build/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRule.php @@ -3,8 +3,6 @@ namespace PHPStan\Build; use PhpParser\Node; -use PhpParser\Node\Name; -use PhpParser\Node\NullableType; use PHPStan\Analyser\Scope; use PHPStan\Node\InClassNode; use PHPStan\Php\PhpVersion; @@ -25,10 +23,12 @@ use PHPStan\Type\MethodParameterClosureTypeExtension; use PHPStan\Type\MethodParameterOutTypeExtension; use PHPStan\Type\MethodTypeSpecifyingExtension; +use PHPStan\Type\ObjectType; use PHPStan\Type\StaticMethodParameterClosureThisExtension; use PHPStan\Type\StaticMethodParameterClosureTypeExtension; use PHPStan\Type\StaticMethodParameterOutTypeExtension; use PHPStan\Type\StaticMethodTypeSpecifyingExtension; +use PHPStan\Type\TypeCombinator; use function sprintf; /** @@ -81,29 +81,25 @@ public function processNode(Node $node, Scope $scope): array return []; } - $constructor = $node->getOriginalNode()->getMethod('__construct'); - if ($constructor === null) { + if (!$classReflection->hasConstructor()) { return []; } + $phpVersionType = new ObjectType(PhpVersion::class); $errors = []; - foreach ($constructor->params as $param) { - $type = $param->type; - if ($type instanceof NullableType) { - $type = $type->type; - } - if (!$type instanceof Name || $type->toString() !== PhpVersion::class) { + foreach ($classReflection->getConstructor()->getOnlyVariant()->getParameters() as $parameter) { + if (!$phpVersionType->isSuperTypeOf(TypeCombinator::removeNull($parameter->getType()))->yes()) { continue; } $errors[] = RuleErrorBuilder::message(sprintf( - '%s implements %s and should not inject %s. Use Scope::getPhpVersion() instead.', + '%s implements %s and should not inject %s via constructor parameter $%s. Use Scope::getPhpVersion() instead.', $classReflection->getDisplayName(), $implementedExtension, PhpVersion::class, + $parameter->getName(), )) ->identifier('phpstan.phpVersionInjection') - ->line($param->getStartLine()) ->build(); } diff --git a/src/Type/Php/PDOConnectReturnTypeExtension.php b/src/Type/Php/PDOConnectReturnTypeExtension.php index f73d318aa44..1e2f6ce3542 100644 --- a/src/Type/Php/PDOConnectReturnTypeExtension.php +++ b/src/Type/Php/PDOConnectReturnTypeExtension.php @@ -18,12 +18,12 @@ * @see https://wiki.php.net/rfc/pdo_driver_specific_subclasses * @see https://github.com/php/php-src/pull/12804 */ +// @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+) #[AutowiredService] final class PDOConnectReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { public function __construct( - // @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+) private PhpVersion $phpVersion, ) { diff --git a/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php index 05b5a4c70a1..d31323d8be0 100644 --- a/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php +++ b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php @@ -20,8 +20,8 @@ public function testRule(): void { $this->analyse([__DIR__ . '/data/no-php-version-injection.php'], [ [ - 'NoPhpVersionInjection\InjectsPhpVersion implements PHPStan\Type\DynamicFunctionReturnTypeExtension and should not inject PHPStan\Php\PhpVersion. Use Scope::getPhpVersion() instead.', - 24, + 'NoPhpVersionInjection\InjectsPhpVersion implements PHPStan\Type\DynamicFunctionReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.', + 13, ], ]); } From 7186c8f6ca56e2fe82d7c8f5c9b32d36161fbfd8 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:48:58 +0000 Subject: [PATCH 5/9] Test nullable `PhpVersion` constructor parameter in `NoPhpVersionInjectionInScopeAwareExtensionRule` Co-Authored-By: Claude Opus 5.5 --- ...InjectionInScopeAwareExtensionRuleTest.php | 4 ++++ .../Build/data/no-php-version-injection.php | 23 +++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php index d31323d8be0..b0737a0e479 100644 --- a/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php +++ b/tests/PHPStan/Build/NoPhpVersionInjectionInScopeAwareExtensionRuleTest.php @@ -23,6 +23,10 @@ public function testRule(): void 'NoPhpVersionInjection\InjectsPhpVersion implements PHPStan\Type\DynamicFunctionReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.', 13, ], + [ + 'NoPhpVersionInjection\\InjectsNullablePhpVersion implements PHPStan\\Type\\DynamicFunctionReturnTypeExtension and should not inject PHPStan\\Php\\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.', + 80, + ], ]); } diff --git a/tests/PHPStan/Build/data/no-php-version-injection.php b/tests/PHPStan/Build/data/no-php-version-injection.php index 9fd9b03995d..0f8031543c3 100644 --- a/tests/PHPStan/Build/data/no-php-version-injection.php +++ b/tests/PHPStan/Build/data/no-php-version-injection.php @@ -76,3 +76,26 @@ public function __construct(?PhpVersion $phpVersion) } } + +final class InjectsNullablePhpVersion implements DynamicFunctionReturnTypeExtension +{ + + /** @var PhpVersion|null */ + private $phpVersion; + + public function __construct(?PhpVersion $phpVersion) + { + $this->phpVersion = $phpVersion; + } + + public function isFunctionSupported(FunctionReflection $functionReflection): bool + { + return false; + } + + public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type + { + return new MixedType(); + } + +} From 30f79b07c4052653945e7edff3e780def3b15a5f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:45:36 +0000 Subject: [PATCH 6/9] Reduce `MbSubstituteCharacterDynamicReturnTypeExtension` and `MbFunctionsReturnTypeExtensionTrait` changes to only introduce `PhpVersions` Co-Authored-By: Claude Opus 5.5 --- .../MbFunctionsReturnTypeExtensionTrait.php | 42 +++++------ ...uteCharacterDynamicReturnTypeExtension.php | 73 +++++++------------ ...p-version-range-return-type-extensions.php | 2 - tests/PHPStan/Analyser/nsrt/bug-15287.php | 4 +- 4 files changed, 47 insertions(+), 74 deletions(-) diff --git a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php index 958f2d0d568..d58276c309f 100644 --- a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php +++ b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php @@ -7,7 +7,6 @@ use function array_filter; use function array_map; use function array_merge; -use function array_values; use function function_exists; use function in_array; use function is_null; @@ -29,38 +28,31 @@ private function isSupportedEncoding(string $encoding, PhpVersions $phpVersion): /** @return string[] */ private function getSupportedEncodings(PhpVersions $phpVersion): array { - $supportedEncodings = $this->getAllEncodings(); + if (is_null($this->supportedEncodings)) { + $supportedEncodings = []; + if (function_exists('mb_list_encodings')) { + foreach (mb_list_encodings() as $encoding) { + $aliases = @mb_encoding_aliases($encoding); + if ($aliases === false) { + throw new ShouldNotHappenException(); + } + $supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]); + } + } + $this->supportedEncodings = array_map('strtoupper', $supportedEncodings); + } + + $supportedEncodings = $this->supportedEncodings; // PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3 if (!$phpVersion->supportsPassNoneEncodings()->yes()) { - $supportedEncodings = array_values(array_filter( + $supportedEncodings = array_filter( $supportedEncodings, static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true), - )); + ); } return $supportedEncodings; } - /** @return string[] */ - private function getAllEncodings(): array - { - if (!is_null($this->supportedEncodings)) { - return $this->supportedEncodings; - } - - $supportedEncodings = []; - if (function_exists('mb_list_encodings')) { - foreach (mb_list_encodings() as $encoding) { - $aliases = @mb_encoding_aliases($encoding); - if ($aliases === false) { - throw new ShouldNotHappenException(); - } - $supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]); - } - } - - return $this->supportedEncodings = array_map('strtoupper', $supportedEncodings); - } - } diff --git a/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php index be7be4041f8..fd927bbf57a 100644 --- a/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php +++ b/src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php @@ -5,7 +5,6 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\DependencyInjection\AutowiredService; -use PHPStan\Php\PhpVersions; use PHPStan\Reflection\FunctionReflection; use PHPStan\Type\BooleanType; use PHPStan\Type\Constant\ConstantBooleanType; @@ -31,18 +30,24 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): Type { $phpVersion = $scope->getPhpVersion(); + $minCodePoint = $phpVersion->isZeroValidCodePointInMbSubstituteCharacter()->yes() ? 0 : 1; + $maxCodePoint = $phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()->yes() ? 0x10FFFF : 0xFFFE; + $ranges = []; - // valid code points on every analysed PHP version - $validCodePoints = $this->createCodePointsType($phpVersion, true); - // valid code points on at least one analysed PHP version - $possibleCodePoints = $this->createCodePointsType($phpVersion, false); + if ($phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()->yes()) { + // Surrogates aren't valid in PHP 7.2+ + $ranges[] = IntegerRangeType::fromInterval($minCodePoint, 0xD7FF); + $ranges[] = IntegerRangeType::fromInterval(0xE000, $maxCodePoint); + } else { + $ranges[] = IntegerRangeType::fromInterval($minCodePoint, $maxCodePoint); + } if (!isset($functionCall->getArgs()[0])) { return TypeCombinator::union( new ConstantStringType('none'), new ConstantStringType('long'), new ConstantStringType('entity'), - $possibleCodePoints, + ...$ranges, ); } @@ -60,11 +65,19 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($isInteger->yes()) { - if ($validCodePoints->isSuperTypeOf($argType)->yes()) { - return new ConstantBooleanType(true); + $invalidRanges = []; + + foreach ($ranges as $range) { + $isInRange = $range->isSuperTypeOf($argType); + + if ($isInRange->yes()) { + return new ConstantBooleanType(true); + } + + $invalidRanges[] = $isInRange->no(); } - if ($possibleCodePoints->isSuperTypeOf($argType)->no()) { + if ($argType instanceof ConstantIntegerType || !in_array(false, $invalidRanges, true)) { if ($phpVersion->throwsValueErrorForInternalFunctions()->yes()) { return new NeverType(); } @@ -93,15 +106,14 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($argType->isNumericString()->yes()) { - $codePoint = new ConstantIntegerType((int) $value); - if ($validCodePoints->isSuperTypeOf($codePoint)->yes()) { - return new ConstantBooleanType(true); - } - if ($possibleCodePoints->isSuperTypeOf($codePoint)->no()) { - return new ConstantBooleanType(false); + $codePoint = (int) $value; + $isValid = $codePoint >= $minCodePoint && $codePoint <= $maxCodePoint; + + if ($phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter()->yes()) { + $isValid = $isValid && ($codePoint < 0xD800 || $codePoint > 0xDFFF); } - return new BooleanType(); + return new ConstantBooleanType($isValid); } if ($phpVersion->throwsValueErrorForInternalFunctions()->yes()) { @@ -118,33 +130,4 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new BooleanType(); } - /** - * @param bool $onAllVersions Whether the code points must be valid on every analysed PHP version, or on at least one - */ - private function createCodePointsType(PhpVersions $phpVersion, bool $onAllVersions): Type - { - $zeroValid = $phpVersion->isZeroValidCodePointInMbSubstituteCharacter(); - $supportsAllUnicodeScalars = $phpVersion->supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter(); - - if ($onAllVersions) { - $minCodePoint = $zeroValid->yes() ? 0 : 1; - $maxCodePoint = $supportsAllUnicodeScalars->yes() ? 0x10FFFF : 0xFFFE; - $excludeSurrogates = !$supportsAllUnicodeScalars->no(); - } else { - $minCodePoint = $zeroValid->no() ? 1 : 0; - $maxCodePoint = $supportsAllUnicodeScalars->no() ? 0xFFFE : 0x10FFFF; - $excludeSurrogates = $supportsAllUnicodeScalars->yes(); - } - - if ($excludeSurrogates) { - // Surrogates aren't valid in PHP 7.2+ - return TypeCombinator::union( - IntegerRangeType::fromInterval($minCodePoint, 0xD7FF), - IntegerRangeType::fromInterval(0xE000, $maxCodePoint), - ); - } - - return IntegerRangeType::fromInterval($minCodePoint, $maxCodePoint); - } - } diff --git a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php index 22ee20c2810..7c084741d5f 100644 --- a/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php +++ b/tests/PHPStan/Analyser/data/scope-php-version-range-return-type-extensions.php @@ -27,12 +27,10 @@ function strSplitAndMbFunctions(string $s, int $i): void function mbSubstituteCharacter(): void { - assertType('bool', mb_substitute_character(0)); assertType('true', mb_substitute_character(1)); assertType('bool', mb_substitute_character(null)); assertType('true', mb_substitute_character('')); assertType('bool', mb_substitute_character(new \stdClass())); assertType('false', mb_substitute_character('foo')); assertType('false', mb_substitute_character(0x110000)); - assertType("'entity'|'long'|'none'|int<0, 55295>|int<57344, 1114111>", mb_substitute_character()); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-15287.php b/tests/PHPStan/Analyser/nsrt/bug-15287.php index b5e4d8f0f91..3cc3bfe3a95 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-15287.php +++ b/tests/PHPStan/Analyser/nsrt/bug-15287.php @@ -48,7 +48,7 @@ function mbSubstituteCharacter(): void assertType('*NEVER*', mb_substitute_character('foo')); assertType('*NEVER*', mb_substitute_character(new \stdClass())); assertType("'entity'|'long'|'none'|int<0, 55295>|int<57344, 1114111>", mb_substitute_character()); - } else { + } elseif (PHP_VERSION_ID >= 70200) { assertType('false', mb_substitute_character(0)); assertType('false', mb_substitute_character(null)); assertType('true', mb_substitute_character('')); @@ -57,6 +57,6 @@ function mbSubstituteCharacter(): void assertType('false', mb_substitute_character(0x110000)); assertType('false', mb_substitute_character('foo')); assertType('bool', mb_substitute_character(new \stdClass())); - assertType("'entity'|'long'|'none'|int<1, 1114111>", mb_substitute_character()); + assertType("'entity'|'long'|'none'|int<1, 55295>|int<57344, 1114111>", mb_substitute_character()); } } From 32c22190e59562850e98662be12ab9e517c3c948 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 12:47:01 +0000 Subject: [PATCH 7/9] Move the `PDOConnectReturnTypeExtension` `PhpVersion` injection ignore into the baseline Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 6 ++++++ src/Type/Php/PDOConnectReturnTypeExtension.php | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index ba770ed22c9..514bdee3377 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1530,6 +1530,12 @@ parameters: count: 1 path: src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php + - + rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.' + identifier: phpstan.phpVersionInjection + count: 1 + path: src/Type/Php/PDOConnectReturnTypeExtension.php + - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' identifier: phpstanApi.instanceofType diff --git a/src/Type/Php/PDOConnectReturnTypeExtension.php b/src/Type/Php/PDOConnectReturnTypeExtension.php index 1e2f6ce3542..82ceb6118ee 100644 --- a/src/Type/Php/PDOConnectReturnTypeExtension.php +++ b/src/Type/Php/PDOConnectReturnTypeExtension.php @@ -18,7 +18,6 @@ * @see https://wiki.php.net/rfc/pdo_driver_specific_subclasses * @see https://github.com/php/php-src/pull/12804 */ -// @phpstan-ignore phpstan.phpVersionInjection (PDO subclasses only exist in the runtime when running on PHP 8.4+) #[AutowiredService] final class PDOConnectReturnTypeExtension implements DynamicStaticMethodReturnTypeExtension { From 6c662cb5217d23838b2e4aaac7ee1bca74cedf39 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 13:37:56 +0000 Subject: [PATCH 8/9] Regenerate baseline Co-Authored-By: Claude Opus 5.5 --- phpstan-baseline.neon | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 514bdee3377..b8d66a77227 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1530,12 +1530,6 @@ parameters: count: 1 path: src/Type/Php/MbSubstituteCharacterDynamicReturnTypeExtension.php - - - rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.' - identifier: phpstan.phpVersionInjection - count: 1 - path: src/Type/Php/PDOConnectReturnTypeExtension.php - - rawMessage: 'Doing instanceof PHPStan\Type\ConstantScalarType is error-prone and deprecated. Use Type::isConstantScalarValue() or Type::getConstantScalarTypes() or Type::getConstantScalarValues() instead.' identifier: phpstanApi.instanceofType @@ -1548,6 +1542,12 @@ parameters: count: 2 path: src/Type/Php/MinMaxFunctionReturnTypeExtension.php + - + rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.' + identifier: phpstan.phpVersionInjection + count: 1 + path: src/Type/Php/PDOConnectReturnTypeExtension.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType From ec89b7d5a7921eec2fdbbed4764f84a7e8fff99b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 23 Sep 2026 13:38:01 +0000 Subject: [PATCH 9/9] Keep `PASS` and `NONE` encodings when the analysed PHP version range may support them Co-Authored-By: Claude Opus 5.5 --- src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php index d58276c309f..ec0d635a2b2 100644 --- a/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php +++ b/src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php @@ -45,7 +45,7 @@ private function getSupportedEncodings(PhpVersions $phpVersion): array $supportedEncodings = $this->supportedEncodings; // PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3 - if (!$phpVersion->supportsPassNoneEncodings()->yes()) { + if ($phpVersion->supportsPassNoneEncodings()->no()) { $supportedEncodings = array_filter( $supportedEncodings, static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true),