Skip to content

Commit c20a20c

Browse files
phpstan-botstaabmclaude
authored
Resolve PHP version checks in remaining scope-aware extensions from Scope::getPhpVersion() and forbid injecting PhpVersion into them (#6563)
Co-authored-by: staabm <120441+staabm@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent d807830 commit c20a20c

17 files changed

Lines changed: 412 additions & 79 deletions
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Build;
4+
5+
use PhpParser\Node;
6+
use PHPStan\Analyser\Scope;
7+
use PHPStan\Node\InClassNode;
8+
use PHPStan\Php\PhpVersion;
9+
use PHPStan\Rules\Rule;
10+
use PHPStan\Rules\RuleErrorBuilder;
11+
use PHPStan\Type\DynamicFunctionReturnTypeExtension;
12+
use PHPStan\Type\DynamicFunctionThrowTypeExtension;
13+
use PHPStan\Type\DynamicMethodReturnTypeExtension;
14+
use PHPStan\Type\DynamicMethodThrowTypeExtension;
15+
use PHPStan\Type\DynamicStaticMethodReturnTypeExtension;
16+
use PHPStan\Type\DynamicStaticMethodThrowTypeExtension;
17+
use PHPStan\Type\ExpressionTypeResolverExtension;
18+
use PHPStan\Type\FunctionParameterClosureThisExtension;
19+
use PHPStan\Type\FunctionParameterClosureTypeExtension;
20+
use PHPStan\Type\FunctionParameterOutTypeExtension;
21+
use PHPStan\Type\FunctionTypeSpecifyingExtension;
22+
use PHPStan\Type\MethodParameterClosureThisExtension;
23+
use PHPStan\Type\MethodParameterClosureTypeExtension;
24+
use PHPStan\Type\MethodParameterOutTypeExtension;
25+
use PHPStan\Type\MethodTypeSpecifyingExtension;
26+
use PHPStan\Type\ObjectType;
27+
use PHPStan\Type\StaticMethodParameterClosureThisExtension;
28+
use PHPStan\Type\StaticMethodParameterClosureTypeExtension;
29+
use PHPStan\Type\StaticMethodParameterOutTypeExtension;
30+
use PHPStan\Type\StaticMethodTypeSpecifyingExtension;
31+
use PHPStan\Type\TypeCombinator;
32+
use function sprintf;
33+
34+
/**
35+
* Extensions that receive a Scope must ask Scope::getPhpVersion() about the analysed
36+
* PHP version so that PHP_VERSION_ID checks in the analysed code narrow their answers.
37+
*
38+
* @implements Rule<InClassNode>
39+
*/
40+
final class NoPhpVersionInjectionInScopeAwareExtensionRule implements Rule
41+
{
42+
43+
private const SCOPE_AWARE_EXTENSIONS = [
44+
DynamicFunctionReturnTypeExtension::class,
45+
DynamicMethodReturnTypeExtension::class,
46+
DynamicStaticMethodReturnTypeExtension::class,
47+
DynamicFunctionThrowTypeExtension::class,
48+
DynamicMethodThrowTypeExtension::class,
49+
DynamicStaticMethodThrowTypeExtension::class,
50+
FunctionTypeSpecifyingExtension::class,
51+
MethodTypeSpecifyingExtension::class,
52+
StaticMethodTypeSpecifyingExtension::class,
53+
FunctionParameterClosureTypeExtension::class,
54+
MethodParameterClosureTypeExtension::class,
55+
StaticMethodParameterClosureTypeExtension::class,
56+
FunctionParameterClosureThisExtension::class,
57+
MethodParameterClosureThisExtension::class,
58+
StaticMethodParameterClosureThisExtension::class,
59+
FunctionParameterOutTypeExtension::class,
60+
MethodParameterOutTypeExtension::class,
61+
StaticMethodParameterOutTypeExtension::class,
62+
ExpressionTypeResolverExtension::class,
63+
];
64+
65+
public function getNodeType(): string
66+
{
67+
return InClassNode::class;
68+
}
69+
70+
public function processNode(Node $node, Scope $scope): array
71+
{
72+
$classReflection = $node->getClassReflection();
73+
$implementedExtension = null;
74+
foreach (self::SCOPE_AWARE_EXTENSIONS as $extension) {
75+
if ($classReflection->implementsInterface($extension)) {
76+
$implementedExtension = $extension;
77+
break;
78+
}
79+
}
80+
if ($implementedExtension === null) {
81+
return [];
82+
}
83+
84+
if (!$classReflection->hasConstructor()) {
85+
return [];
86+
}
87+
88+
$phpVersionType = new ObjectType(PhpVersion::class);
89+
$errors = [];
90+
foreach ($classReflection->getConstructor()->getOnlyVariant()->getParameters() as $parameter) {
91+
if (!$phpVersionType->isSuperTypeOf(TypeCombinator::removeNull($parameter->getType()))->yes()) {
92+
continue;
93+
}
94+
95+
$errors[] = RuleErrorBuilder::message(sprintf(
96+
'%s implements %s and should not inject %s via constructor parameter $%s. Use Scope::getPhpVersion() instead.',
97+
$classReflection->getDisplayName(),
98+
$implementedExtension,
99+
PhpVersion::class,
100+
$parameter->getName(),
101+
))
102+
->identifier('phpstan.phpVersionInjection')
103+
->build();
104+
}
105+
106+
return $errors;
107+
}
108+
109+
}

‎build/phpstan.neon‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,7 @@ rules:
207207
- PHPStan\Build\SkipTestsWithRequiresPhpAttributeRule
208208
- PHPStan\Build\MemoizationPropertyRule
209209
- PHPStan\Build\OrChainIdenticalComparisonToInArrayRule
210+
- PHPStan\Build\NoPhpVersionInjectionInScopeAwareExtensionRule
210211

211212
services:
212213
-

‎phpstan-baseline.neon‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1542,6 +1542,12 @@ parameters:
15421542
count: 2
15431543
path: src/Type/Php/MinMaxFunctionReturnTypeExtension.php
15441544

1545+
-
1546+
rawMessage: 'PHPStan\Type\Php\PDOConnectReturnTypeExtension implements PHPStan\Type\DynamicStaticMethodReturnTypeExtension and should not inject PHPStan\Php\PhpVersion via constructor parameter $phpVersion. Use Scope::getPhpVersion() instead.'
1547+
identifier: phpstan.phpVersionInjection
1548+
count: 1
1549+
path: src/Type/Php/PDOConnectReturnTypeExtension.php
1550+
15451551
-
15461552
rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.'
15471553
identifier: phpstanApi.instanceofType

‎src/Php/PhpVersion.php‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,11 @@ public function isNullValidArgInMbSubstituteCharacter(): bool
213213
return $this->versionId >= 80000;
214214
}
215215

216+
public function isZeroValidCodePointInMbSubstituteCharacter(): bool
217+
{
218+
return $this->versionId >= 80000;
219+
}
220+
216221
public function isInterfaceConstantImplicitlyFinal(): bool
217222
{
218223
return $this->versionId < 80100;

‎src/Php/PhpVersions.php‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,17 @@ public function isNullValidArgInMbSubstituteCharacter(): TrinaryLogic
228228
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
229229
}
230230

231+
public function isZeroValidCodePointInMbSubstituteCharacter(): TrinaryLogic
232+
{
233+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
234+
}
235+
236+
/** On PHP 8.0+ the Reflection* classes extended by the BetterReflection enum adapters declare the correct return types. */
237+
public function hasCorrectReflectionEnumAdapterReturnTypes(): TrinaryLogic
238+
{
239+
return IntegerRangeType::fromInterval(80000, null)->isSuperTypeOf($this->phpVersions)->result;
240+
}
241+
231242
public function isNumericStringValidArgInMbSubstituteCharacter(): TrinaryLogic
232243
{
233244
return IntegerRangeType::fromInterval(null, 79999)->isSuperTypeOf($this->phpVersions)->result;

‎src/Reflection/BetterReflection/Type/AdapterReflectionEnumCaseDynamicReturnTypeExtension.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\MethodCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionType;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\MethodReflection;
109
use PHPStan\Type\Constant\ConstantBooleanType;
1110
use PHPStan\Type\DynamicMethodReturnTypeExtension;
@@ -22,7 +21,7 @@ final class AdapterReflectionEnumCaseDynamicReturnTypeExtension implements Dynam
2221
/**
2322
* @param class-string $class
2423
*/
25-
public function __construct(private PhpVersion $phpVersion, private string $class)
24+
public function __construct(private string $class)
2625
{
2726
}
2827

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

4241
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
4342
{
44-
if ($this->phpVersion->getVersionId() >= 80000) {
43+
if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) {
4544
return null;
4645
}
4746

‎src/Reflection/BetterReflection/Type/AdapterReflectionEnumDynamicReturnTypeExtension.php‎

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionEnum;
1010
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionNamedType;
1111
use PHPStan\DependencyInjection\AutowiredService;
12-
use PHPStan\Php\PhpVersion;
1312
use PHPStan\Reflection\MethodReflection;
1413
use PHPStan\Type\Accessory\AccessoryNonEmptyStringType;
1514
use PHPStan\Type\Constant\ConstantBooleanType;
@@ -27,10 +26,6 @@
2726
final class AdapterReflectionEnumDynamicReturnTypeExtension implements DynamicMethodReturnTypeExtension
2827
{
2928

30-
public function __construct(private PhpVersion $phpVersion)
31-
{
32-
}
33-
3429
public function getClass(): string
3530
{
3631
return ReflectionEnum::class;
@@ -52,7 +47,7 @@ public function isMethodSupported(MethodReflection $methodReflection): bool
5247

5348
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
5449
{
55-
if ($this->phpVersion->getVersionId() >= 80000) {
50+
if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) {
5651
return null;
5752
}
5853

‎src/Reflection/PHPStan/NativeReflectionEnumReturnDynamicReturnTypeExtension.php‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\MethodCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\BetterReflection\Reflection\Adapter\ReflectionClass;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\MethodReflection;
109
use PHPStan\Type\DynamicMethodReturnTypeExtension;
1110
use PHPStan\Type\ObjectType;
@@ -17,7 +16,7 @@ final class NativeReflectionEnumReturnDynamicReturnTypeExtension implements Dyna
1716
/**
1817
* @param class-string $className
1918
*/
20-
public function __construct(private PhpVersion $phpVersion, private string $className, private string $methodName)
19+
public function __construct(private string $className, private string $methodName)
2120
{
2221
}
2322

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

3433
public function getTypeFromMethodCall(MethodReflection $methodReflection, MethodCall $methodCall, Scope $scope): ?Type
3534
{
36-
if ($this->phpVersion->getVersionId() >= 80000) {
35+
if ($scope->getPhpVersion()->hasCorrectReflectionEnumAdapterReturnTypes()->yes()) {
3736
return null;
3837
}
3938

‎src/Type/Php/MbFunctionsReturnTypeExtension.php‎

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@
55
use PhpParser\Node\Expr\FuncCall;
66
use PHPStan\Analyser\Scope;
77
use PHPStan\DependencyInjection\AutowiredService;
8-
use PHPStan\Php\PhpVersion;
98
use PHPStan\Reflection\FunctionReflection;
109
use PHPStan\Reflection\ParametersAcceptorSelector;
1110
use PHPStan\Type\BooleanType;
@@ -38,10 +37,6 @@ final class MbFunctionsReturnTypeExtension implements DynamicFunctionReturnTypeE
3837
'mb_ord' => 2,
3938
];
4039

41-
public function __construct(private PhpVersion $phpVersion)
42-
{
43-
}
44-
4540
public function isFunctionSupported(FunctionReflection $functionReflection): bool
4641
{
4742
return array_key_exists($functionReflection->getName(), $this->encodingPositionMap);
@@ -62,15 +57,15 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection,
6257
}
6358

6459
$strings = $scope->getType($args[$positionEncodingParam - 1]->value)->getConstantStrings();
65-
$results = array_unique(array_map(fn (ConstantStringType $encoding): bool => $this->isSupportedEncoding($encoding->getValue()), $strings));
60+
$results = array_unique(array_map(fn (ConstantStringType $encoding): bool => $this->isSupportedEncoding($encoding->getValue(), $scope->getPhpVersion()), $strings));
6661

6762
if ($returnType->equals(new UnionType([new StringType(), new BooleanType()]))) {
6863
return count($results) === 1 ? new ConstantBooleanType($results[0]) : new BooleanType();
6964
}
7065

7166
if (count($results) === 1) {
7267
$invalidEncodingReturn = new ConstantBooleanType(false);
73-
if ($this->phpVersion->throwsOnInvalidMbStringEncoding()) {
68+
if ($scope->getPhpVersion()->throwsOnInvalidMbStringEncoding()->yes()) {
7469
$invalidEncodingReturn = new NeverType();
7570
}
7671

‎src/Type/Php/MbFunctionsReturnTypeExtensionTrait.php‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Type\Php;
44

5+
use PHPStan\Php\PhpVersions;
56
use PHPStan\ShouldNotHappenException;
67
use function array_filter;
78
use function array_map;
@@ -19,39 +20,39 @@ trait MbFunctionsReturnTypeExtensionTrait
1920
/** @var string[]|null */
2021
private ?array $supportedEncodings = null;
2122

22-
private function isSupportedEncoding(string $encoding): bool
23+
private function isSupportedEncoding(string $encoding, PhpVersions $phpVersion): bool
2324
{
24-
return in_array(strtoupper($encoding), $this->getSupportedEncodings(), true);
25+
return in_array(strtoupper($encoding), $this->getSupportedEncodings($phpVersion), true);
2526
}
2627

2728
/** @return string[] */
28-
private function getSupportedEncodings(): array
29+
private function getSupportedEncodings(PhpVersions $phpVersion): array
2930
{
30-
if (!is_null($this->supportedEncodings)) {
31-
return $this->supportedEncodings;
32-
}
33-
34-
$supportedEncodings = [];
35-
if (function_exists('mb_list_encodings')) {
36-
foreach (mb_list_encodings() as $encoding) {
37-
$aliases = @mb_encoding_aliases($encoding);
38-
if ($aliases === false) {
39-
throw new ShouldNotHappenException();
31+
if (is_null($this->supportedEncodings)) {
32+
$supportedEncodings = [];
33+
if (function_exists('mb_list_encodings')) {
34+
foreach (mb_list_encodings() as $encoding) {
35+
$aliases = @mb_encoding_aliases($encoding);
36+
if ($aliases === false) {
37+
throw new ShouldNotHappenException();
38+
}
39+
$supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]);
4040
}
41-
$supportedEncodings = array_merge($supportedEncodings, $aliases, [$encoding]);
4241
}
42+
$this->supportedEncodings = array_map('strtoupper', $supportedEncodings);
4343
}
44-
$this->supportedEncodings = array_map('strtoupper', $supportedEncodings);
44+
45+
$supportedEncodings = $this->supportedEncodings;
4546

4647
// PHP 7.3 and 7.4 claims 'pass' and its alias 'none' to be supported, but actually 'pass' was removed in 7.3
47-
if (!$this->phpVersion->supportsPassNoneEncodings()) {
48-
$this->supportedEncodings = array_filter(
49-
$this->supportedEncodings,
48+
if ($phpVersion->supportsPassNoneEncodings()->no()) {
49+
$supportedEncodings = array_filter(
50+
$supportedEncodings,
5051
static fn (string $enc) => !in_array($enc, ['PASS', 'NONE'], true),
5152
);
5253
}
5354

54-
return $this->supportedEncodings;
55+
return $supportedEncodings;
5556
}
5657

5758
}

0 commit comments

Comments
 (0)