Skip to content
Closed
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
8b4783e
Resolve PHP version checks in return-type, throw-type and type-specif…
staabm Sep 22, 2026
c006f1f
Resolve PHP version checks in round() and str_split() throw type exte…
phpstan-bot Sep 22, 2026
56816b0
Discover scope-aware extensions in the build rule instead of listing …
phpstan-bot Sep 22, 2026
037ddad
List the scope-aware extension interfaces in the build rule explicitly
phpstan-bot Sep 22, 2026
add8984
Branch on the version check with a plain if() instead of PhpVersions:…
phpstan-bot Sep 22, 2026
5ee6d34
Move the printf format parser change to its own pull request
phpstan-bot Sep 22, 2026
6dd96dd
Keep PrintfFormatParser reading the PHP version from the call site
phpstan-bot Sep 22, 2026
f6b2836
Resolve PHP version checks in the get_class(), array_chunk() and unse…
phpstan-bot Sep 22, 2026
b74fdd4
Fix the expected lines in NoInjectedPhpVersionInScopeAwareExtensionRu…
phpstan-bot Sep 22, 2026
892a037
fix build
staabm Sep 22, 2026
365835c
Keep the PHP version check inside RegexArrayShapeMatcher::containsUnm…
phpstan-bot Sep 22, 2026
e9d2c95
Do not infer null from version_compare() with a valid constant operator
phpstan-bot Sep 22, 2026
80619fa
Detect DateTime::modify() failure from its return value, not from the…
phpstan-bot Sep 22, 2026
6d24a23
Strip modifiers the host PCRE may not know before compiling the pattern
phpstan-bot Sep 22, 2026
b2db476
Test return-type extensions against a configured PHP version range
phpstan-bot Sep 22, 2026
a69e753
fix baseline
staabm Sep 22, 2026
3565169
Update scope-php-version-range-return-type-extensions.php
staabm Sep 22, 2026
ddc2b6f
Move the src/Type/Php extension changes to their own pull request
phpstan-bot Sep 23, 2026
0d8d599
Leave the Regex* classes out of this pull request
phpstan-bot Sep 23, 2026
c778a5d
Test that FilterVarRule follows PHP_VERSION_ID narrowing
phpstan-bot Sep 23, 2026
8cd6733
restore phpversions
staabm Sep 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
130 changes: 130 additions & 0 deletions build/PHPStan/Build/NoInjectedPhpVersionInScopeAwareExtensionRule.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php declare(strict_types = 1);

namespace PHPStan\Build;

use PhpParser\Node;
use PHPStan\Analyser\IgnoreErrorExtension;
use PHPStan\Analyser\Scope;
use PHPStan\Collectors\Collector;
use PHPStan\Node\InClassNode;
use PHPStan\Php\PhpVersion;
use PHPStan\Rules\RestrictedUsage\RestrictedClassConstantUsageExtension;
use PHPStan\Rules\RestrictedUsage\RestrictedClassNameUsageExtension;
use PHPStan\Rules\RestrictedUsage\RestrictedFunctionUsageExtension;
use PHPStan\Rules\RestrictedUsage\RestrictedMethodUsageExtension;
use PHPStan\Rules\RestrictedUsage\RestrictedPropertyUsageExtension;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\DynamicMethodThrowTypeExtension;
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
use PHPStan\Type\DynamicStaticMethodThrowTypeExtension;
use PHPStan\Type\ExpressionTypeResolverExtension;
use PHPStan\Type\FunctionParameterClosureThisExtension;
use PHPStan\Type\FunctionParameterClosureTypeExtension;
use PHPStan\Type\FunctionParameterOutTypeExtension;
use PHPStan\Type\FunctionTypeSpecifyingExtension;
use PHPStan\Type\MethodParameterClosureThisExtension;
use PHPStan\Type\MethodParameterClosureTypeExtension;
use PHPStan\Type\MethodParameterOutTypeExtension;
use PHPStan\Type\MethodTypeSpecifyingExtension;
use PHPStan\Type\StaticMethodParameterClosureThisExtension;
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
use PHPStan\Type\StaticMethodParameterOutTypeExtension;
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
use function sprintf;

/**
* Every one of these extension interfaces hands the call's Scope to the extension,
* so the analysed PHP version must be read from Scope::getPhpVersion(). A DI-injected
* PhpVersion always answers with the configured version and silently ignores both
* PHP_VERSION_ID narrowing in the analysed code and configured version ranges.
*
* The list holds every extension interface with a method taking the public Scope.
* Interfaces taking the engine-internal MutatingScope (ExprHandler, StmtHandler) are not
* extensions in this sense, and OperatorTypeSpecifyingExtension gets no Scope at all,
* so both are absent on purpose. An extension interface added later belongs here too.
*
* @implements Rule<InClassNode>
*/
final class NoInjectedPhpVersionInScopeAwareExtensionRule implements Rule
Comment thread
staabm marked this conversation as resolved.
Outdated
{

private const SCOPE_AWARE_EXTENSIONS = [
DynamicFunctionReturnTypeExtension::class,
DynamicMethodReturnTypeExtension::class,
DynamicStaticMethodReturnTypeExtension::class,
DynamicFunctionThrowTypeExtension::class,
DynamicMethodThrowTypeExtension::class,
DynamicStaticMethodThrowTypeExtension::class,
FunctionTypeSpecifyingExtension::class,
MethodTypeSpecifyingExtension::class,
StaticMethodTypeSpecifyingExtension::class,
FunctionParameterOutTypeExtension::class,
MethodParameterOutTypeExtension::class,
StaticMethodParameterOutTypeExtension::class,
FunctionParameterClosureTypeExtension::class,
MethodParameterClosureTypeExtension::class,
StaticMethodParameterClosureTypeExtension::class,
FunctionParameterClosureThisExtension::class,
MethodParameterClosureThisExtension::class,
StaticMethodParameterClosureThisExtension::class,
RestrictedClassConstantUsageExtension::class,
RestrictedClassNameUsageExtension::class,
RestrictedFunctionUsageExtension::class,
RestrictedMethodUsageExtension::class,
RestrictedPropertyUsageExtension::class,
ExpressionTypeResolverExtension::class,
IgnoreErrorExtension::class,
Collector::class,
];

public function getNodeType(): string
{
return InClassNode::class;
}

public function processNode(Node $node, Scope $scope): array
{
$classReflection = $node->getClassReflection();
if (!$classReflection->hasConstructor()) {
return [];
}

$implementedExtension = null;
foreach (self::SCOPE_AWARE_EXTENSIONS as $extensionInterface) {
if (!$classReflection->is($extensionInterface)) {
continue;
}

$implementedExtension = $extensionInterface;
break;
}

if ($implementedExtension === null) {
return [];
}

$constructorVariant = $classReflection->getConstructor()->getOnlyVariant();
$errors = [];
foreach ($constructorVariant->getParameters() as $parameter) {
foreach ($parameter->getType()->getObjectClassNames() as $className) {
if ($className !== PhpVersion::class) {
continue;
}

$errors[] = RuleErrorBuilder::message(sprintf(
'%s implements %s and must not inject %s - read the analysed PHP version from Scope::getPhpVersion() instead.',
$classReflection->getDisplayName(),
$implementedExtension,
PhpVersion::class,
))->identifier('phpstanBuild.phpVersionInExtension')->build();
}
}

return $errors;
}

}
1 change: 1 addition & 0 deletions build/phpstan.neon
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,7 @@ parameters:
rules:
- PHPStan\Build\FinalClassRule
- PHPStan\Build\NoScopeTypeReadInEngineRule
- PHPStan\Build\NoInjectedPhpVersionInScopeAwareExtensionRule
- PHPStan\Build\AttributeNamedArgumentsRule
- PHPStan\Build\NamedArgumentsRule
- PHPStan\Build\OverrideAttributeThirdPartyMethodRule
Expand Down
6 changes: 6 additions & 0 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -1548,6 +1548,12 @@ parameters:
count: 2
path: src/Type/Php/MinMaxFunctionReturnTypeExtension.php

-
rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and must not inject PHPStan\Php\PhpVersion - read the analysed PHP version from Scope::getPhpVersion() instead.'
identifier: phpstanBuild.phpVersionInExtension
count: 1
path: src/Type/Php/PDOConnectReturnTypeExtension.php

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PDOConnectReturnTypeExtension requires php-src builtin classes, only available in PHP 8.4+ (its functionality does not depend on a configured php version but runtime capabilities)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've made the change and committed it. make phpstan is clean and the targeted tests pass; the full make tests run is still going, and I'll push once it finishes.

Summary of what I did for the review comment:

PDOConnectReturnTypeExtension no longer injects PhpVersion at all — it gates on the reflection capability your comment describes, so the baseline entry is gone rather than papered over.

PDO::connect() and PDO\Mysql/Firebird/Dblib/Odbc/Pgsql/Sqlite are php-src builtins that only exist on 8.4+, and reflection does not narrow along with a PHP_VERSION_ID check — which is exactly why Scope::getPhpVersion() can't answer here. Two checks replace hasPDOSubclasses():

  • isStaticMethodSupported() matches only connect() declared by the builtin PDO. That method resolves only when reflection has the 8.4 PDO, so it subsumes the version gate. Verified: at phpVersion: 80300 the call is Call to an undefined static method PDO::connect() and the extension is never consulted.
  • each subclass has to be a class ReflectionProvider actually knows before it is named in the return type.

PhpVersions::hasPDOSubclasses() lost its only caller and is removed. PhpVersion::hasPDOSubclasses() stays — it's @api and released.

One behaviour change fell out, in the right direction. Restricting to the declaring class stops the extension overriding a connect() that a PDO subclass declares itself — that method is the subclass's own. Before, at 8.4, MyPdo::connect('sqlite:foo') returning static was reported as PDO\Sqlite. I pinned it in tests/PHPStan/Analyser/nsrt/pdo-connect-php84.php (the file is // lint >= 8.4, so it only runs on 8.4+ runners); the assertion fails with Actual: PDO\Sqlite on the pre-change source and passes after. Everything below 8.4 infers exactly what it did before — I checked a user subclass at phpVersion: 80300 on both sides.

-
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
identifier: phpstanApi.instanceofType
Expand Down
97 changes: 97 additions & 0 deletions src/Php/PhpVersions.php
Original file line number Diff line number Diff line change
Expand Up @@ -188,9 +188,106 @@ public function supportsDeprecatedTraits(): TrinaryLogic
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function arrayFunctionsReturnNullWithNonArray(): TrinaryLogic
{
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
}

public function hasDateTimeExceptions(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80300, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function hasFilterThrowOnFailureConstant(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80500, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function hasPDOSubclasses(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80400, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function hasStricterRoundFunctions(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function highlightStringDoesNotReturnFalse(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80400, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function isEmptyStringValidAliasForNoneInMbSubstituteCharacter(): TrinaryLogic
{
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
}

public function isNullValidArgInMbSubstituteCharacter(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function isNumericStringValidArgInMbSubstituteCharacter(): TrinaryLogic
{
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
}

public function strSplitReturnsEmptyArray(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80200, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function substrReturnFalseInsteadOfEmptyString(): TrinaryLogic
{
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsAllUnicodeScalarCodePointsInMbSubstituteCharacter(): TrinaryLogic
{
return IntegerRangeType::fromInterval(70200, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsHhPrintfSpecifier(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsPassNoneEncodings(): TrinaryLogic
{
return IntegerRangeType::fromInterval(null, 70299)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsPregCaptureOnlyNamedGroups(): TrinaryLogic
{
// https://php.watch/versions/8.2/preg-n-no-capture-modifier
return IntegerRangeType::fromInterval(80200, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function supportsPregUnmatchedAsNull(): TrinaryLogic
{
// while PREG_UNMATCHED_AS_NULL is defined in php-src since 7.2.x it starts working as expected with 7.4.x
// https://3v4l.org/v3HE4
return IntegerRangeType::fromInterval(70400, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function throwsOnInvalidMbStringEncoding(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
}

public function throwsValueErrorForInvalidRoundingMode(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80400, null)->isSuperTypeOf($this->phpVersions)->result;
}

/**
* PHPStan's BetterReflection adapters keep their narrowed native return types only on PHP 8+;
* the downgraded PHP 7 build widens them back to the core Reflection ones.
*/
public function supportsNativeReflectionAdapterReturnTypes(): TrinaryLogic
{
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
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;
Expand All @@ -22,7 +21,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)
{
}

Expand All @@ -41,7 +40,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
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;
Expand All @@ -27,10 +26,6 @@
final class AdapterReflectionEnumDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
{

public function __construct(private PhpVersion $phpVersion)
{
}

public function getClass(): string
{
return ReflectionEnum::class;
Expand All @@ -52,7 +47,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
return null;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
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\ObjectType;
Expand All @@ -17,7 +16,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)
{
}

Expand All @@ -33,7 +32,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool

public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
{
if ($this->phpVersion->getVersionId() >= 80000) {
if (!$scope->getPhpVersion()->supportsNativeReflectionAdapterReturnTypes()->no()) {
return null;
}

Expand Down
4 changes: 1 addition & 3 deletions src/Rules/Functions/FilterVarRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use PHPStan\DependencyInjection\RegisteredRule;
use PHPStan\Php\PhpVersion;
use PHPStan\Reflection\ReflectionProvider;
use PHPStan\Rules\Rule;
use PHPStan\Rules\RuleErrorBuilder;
Expand All @@ -25,7 +24,6 @@ public function __construct(
private ReflectionProvider $reflectionProvider,
private FilterFunctionReturnTypeHelper $filterFunctionReturnTypeHelper,
private FilterFunctionFlagsHelper $filterFunctionFlagsHelper,
private PhpVersion $phpVersion,
)
{
}
Expand All @@ -51,7 +49,7 @@ public function processNode(Node $node, Scope $scope): array
}

if (
!$this->phpVersion->hasFilterThrowOnFailureConstant()
$scope->getPhpVersion()->hasFilterThrowOnFailureConstant()->no()
|| !$this->reflectionProvider->hasConstant(new Name\FullyQualified('FILTER_THROW_ON_FAILURE'), null)
) {
return [];
Expand Down
Loading
Loading