From b94b5d67564019775af58c13fc1363f846899ab0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 10 Mar 2026 14:19:24 +0000 Subject: [PATCH 01/20] Fix false positives for is_a() and instanceof checks on $this in traits - When analyzing trait methods, $this is bound to each using class separately - is_a($this, SomeClass::class) was reported as "always true" in SomeClass context and "always false" in SomeClass2 context, but both are false positives - Added trait-aware check in ImpossibleCheckTypeHelper to treat $this as uncertain when inside a trait, since the same code is shared across multiple classes - New regression test in tests/PHPStan/Rules/Comparison/data/bug-13023.php Closes https://github.com/phpstan/phpstan/issues/13023 --- .../Comparison/ImpossibleCheckTypeHelper.php | 10 ++++++++ ...mpossibleCheckTypeFunctionCallRuleTest.php | 6 +++++ .../Rules/Comparison/data/bug-13023.php | 25 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13023.php diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 127cae01cd9..555df067fa8 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -310,6 +310,11 @@ public function findSpecifiedType( continue; } + if ($scope->isInTrait() && $sureType[0] instanceof Expr\Variable && $sureType[0]->name === 'this') { + $results[] = TrinaryLogic::createMaybe(); + continue; + } + if ($this->treatPhpDocTypesAsCertain) { $argumentType = $scope->getType($sureType[0]); } else { @@ -336,6 +341,11 @@ public function findSpecifiedType( continue; } + if ($scope->isInTrait() && $sureNotType[0] instanceof Expr\Variable && $sureNotType[0]->name === 'this') { + $results[] = TrinaryLogic::createMaybe(); + continue; + } + if ($this->treatPhpDocTypesAsCertain) { $argumentType = $scope->getType($sureNotType[0]); } else { diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 69e992139d8..8f0f4843c27 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1207,4 +1207,10 @@ public function testBug13799(): void ]); } + public function testBug13023(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13023.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php new file mode 100644 index 00000000000..cba8bf02263 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -0,0 +1,25 @@ + Date: Sat, 14 Mar 2026 09:37:28 +0000 Subject: [PATCH 02/20] Generalize trait $this check to cover property fetches and method calls The previous fix only handled `$this` directly in trait context, but similar false positives occur with expressions like `$this->foo` when different classes using the trait define properties with different types. Introduced `isExpressionDependentOnThis()` helper that recursively checks if an expression is rooted in `$this`, covering Variable, PropertyFetch, and MethodCall nodes. Co-Authored-By: Claude Opus 4.6 --- .../Comparison/ImpossibleCheckTypeHelper.php | 21 +++++++++++++-- .../Rules/Comparison/data/bug-13023.php | 27 +++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 555df067fa8..cc0fe697c81 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -310,7 +310,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && $sureType[0] instanceof Expr\Variable && $sureType[0]->name === 'this') { + if ($scope->isInTrait() && self::isExpressionDependentOnThis($sureType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -341,7 +341,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && $sureNotType[0] instanceof Expr\Variable && $sureNotType[0]->name === 'this') { + if ($scope->isInTrait() && self::isExpressionDependentOnThis($sureNotType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -391,6 +391,23 @@ private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool ) && $scope->hasExpressionType($expr)->yes(); } + private static function isExpressionDependentOnThis(Expr $expr): bool + { + if ($expr instanceof Expr\Variable && $expr->name === 'this') { + return true; + } + + if ($expr instanceof Expr\PropertyFetch) { + return self::isExpressionDependentOnThis($expr->var); + } + + if ($expr instanceof Expr\MethodCall) { + return self::isExpressionDependentOnThis($expr->var); + } + + return false; + } + /** * @param Node\Arg[] $args */ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index cba8bf02263..2b68670e1e4 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -23,3 +23,30 @@ public function getRandom(): int return $value; } } + +class SomeClass3 +{ + use MyTrait2; + + public string $foo = 'foo'; +} + +class SomeClass4 +{ + use MyTrait2; + + public int $foo = 1; +} + +trait MyTrait2 +{ + public function getRandom(): int + { + $value = random_int(1, 100); + if (\is_int($this->foo)) { + return $value * $value; + } + + return $value; + } +} From af1247cc40f8967b1b2658022afd55ce78983ade Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 09:53:34 +0000 Subject: [PATCH 03/20] Handle static access, nullsafe access, and $this:: in trait $this check Extend isExpressionDependentOnThis() to also recognize: - NullsafePropertyFetch ($this?->foo) - NullsafeMethodCall ($this?->method()) - StaticPropertyFetch (self::$foo, static::$foo, $this::$foo) - StaticCall (self::method(), static::method(), $this::method()) Co-Authored-By: Claude Opus 4.6 --- .../Comparison/ImpossibleCheckTypeHelper.php | 13 +++- .../Rules/Comparison/data/bug-13023.php | 60 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index cc0fe697c81..18141403bd1 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -397,14 +397,23 @@ private static function isExpressionDependentOnThis(Expr $expr): bool return true; } - if ($expr instanceof Expr\PropertyFetch) { + if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { return self::isExpressionDependentOnThis($expr->var); } - if ($expr instanceof Expr\MethodCall) { + if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\NullsafeMethodCall) { return self::isExpressionDependentOnThis($expr->var); } + if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) { + if ($expr->class instanceof Expr) { + return self::isExpressionDependentOnThis($expr->class); + } + + $className = $expr->class->toString(); + return in_array($className, ['self', 'static', 'parent'], true); + } + return false; } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index 2b68670e1e4..e967ef93d2b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -50,3 +50,63 @@ public function getRandom(): int return $value; } } + +class SomeClass5 +{ + use MyTrait3; + + public static string $bar = 'bar'; +} + +class SomeClass6 +{ + use MyTrait3; + + public static int $bar = 1; +} + +trait MyTrait3 +{ + public function getRandom(): int + { + $value = random_int(1, 100); + if (\is_int(self::$bar)) { + return $value * $value; + } + if (\is_int(static::$bar)) { + return $value * $value; + } + if (\is_int($this::$bar)) { + return $value * $value; + } + + return $value; + } +} + +class SomeClass7 +{ + use MyTrait4; + + public ?string $baz = 'baz'; +} + +class SomeClass8 +{ + use MyTrait4; + + public ?int $baz = 1; +} + +trait MyTrait4 +{ + public function getRandom(): int + { + $value = random_int(1, 100); + if (\is_int($this?->baz)) { + return $value * $value; + } + + return $value; + } +} From 6b9535daac903caed7a6309b89caafa4dc268014 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 10:07:53 +0000 Subject: [PATCH 04/20] Fix BooleanNot false positive for $this-dependent checks in traits When ImpossibleCheckTypeHelper returns null (uncertain) for trait+$this function calls, ConstantConditionRuleHelper fell through to scope type resolution which still gave a definitive answer. This caused BooleanNotConstantConditionRule to report "always false" for negated type checks like `! is_string($this->message)` in traits. The fix skips function calls with $this-dependent arguments in trait context in ConstantConditionRuleHelper::shouldSkip(), consistent with how ImpossibleCheckTypeHelper already handles these cases. Co-Authored-By: Claude Opus 4.6 --- .../ConstantConditionRuleHelper.php | 8 +++ .../Comparison/ImpossibleCheckTypeHelper.php | 2 +- .../BooleanNotConstantConditionRuleTest.php | 6 ++ .../Rules/Comparison/data/bug-13023.php | 56 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 30a08f665dc..d680baaffa4 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -61,6 +61,14 @@ private function shouldSkip(Scope $scope, Expr $expr): bool if ($isAlways !== null) { return true; } + + if ($scope->isInTrait()) { + foreach ($expr->getArgs() as $arg) { + if (ImpossibleCheckTypeHelper::isExpressionDependentOnThis($arg->value)) { + return true; + } + } + } } return false; diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 18141403bd1..30901ac5f6a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -391,7 +391,7 @@ private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool ) && $scope->hasExpressionType($expr)->yes(); } - private static function isExpressionDependentOnThis(Expr $expr): bool + public static function isExpressionDependentOnThis(Expr $expr): bool { if ($expr instanceof Expr\Variable && $expr->name === 'this') { return true; diff --git a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php index 3cf3f2a8bbc..f265e02db89 100644 --- a/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/BooleanNotConstantConditionRuleTest.php @@ -236,4 +236,10 @@ public function testBug6702(): void $this->analyse([__DIR__ . '/data/bug-6702.php'], []); } + public function testBug13023(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13023.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index e967ef93d2b..5b1bbd1a6a4 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -110,3 +110,59 @@ public function getRandom(): int return $value; } } + +class HelloWorld +{ + use SomeTrait; + + public string $message = 'Hello'; + + public function foo(): void + { + $this->bar(); + } +} + +class EmptyClass { + use SomeTrait; +} + +trait SomeTrait { + public function bar(): void + { + if (property_exists($this, 'message')) { + if (! is_string($this->message)) { + return; + } + + echo $this->message . "\n"; + } + } +} + +class SomeClass9 +{ + use MyTrait5; + + public string $prop = 'foo'; +} + +class SomeClass10 +{ + use MyTrait5; + + public int $prop = 1; +} + +trait MyTrait5 +{ + public function getRandom(): int + { + $value = random_int(1, 100); + if (!\is_int($this->prop)) { + return $value; + } + + return $value * $value; + } +} From 5ac5e5f65d061b10510c1c26079c06a6b09b5fec Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:10:42 +0000 Subject: [PATCH 05/20] Move isExpressionDependentOnThis to ExpressionDependsOnThisHelper Extract the static method into a standalone helper class to avoid ConstantConditionRuleHelper depending on ImpossibleCheckTypeHelper. Co-Authored-By: Claude Opus 4.6 --- .../ConstantConditionRuleHelper.php | 2 +- .../ExpressionDependsOnThisHelper.php | 36 +++++++++++++++++++ .../Comparison/ImpossibleCheckTypeHelper.php | 34 ++++-------------- 3 files changed, 43 insertions(+), 29 deletions(-) create mode 100644 src/Rules/Comparison/ExpressionDependsOnThisHelper.php diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index d680baaffa4..183862b59b9 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -64,7 +64,7 @@ private function shouldSkip(Scope $scope, Expr $expr): bool if ($scope->isInTrait()) { foreach ($expr->getArgs() as $arg) { - if (ImpossibleCheckTypeHelper::isExpressionDependentOnThis($arg->value)) { + if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($arg->value)) { return true; } } diff --git a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php new file mode 100644 index 00000000000..ef555cf5ec1 --- /dev/null +++ b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php @@ -0,0 +1,36 @@ +name === 'this') { + return true; + } + + if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + return self::isExpressionDependentOnThis($expr->var); + } + + if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\NullsafeMethodCall) { + return self::isExpressionDependentOnThis($expr->var); + } + + if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) { + if ($expr->class instanceof Expr) { + return self::isExpressionDependentOnThis($expr->class); + } + + $className = $expr->class->toString(); + return in_array($className, ['self', 'static', 'parent'], true); + } + + return false; + } + +} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 30901ac5f6a..c22c3efca49 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -198,6 +198,10 @@ public function findSpecifiedType( } } elseif ($functionName === 'method_exists' && $argsCount >= 2) { $objectArg = $args[0]->value; + + if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($objectArg)) { + return null; + } $objectType = $this->treatPhpDocTypesAsCertain ? $scope->getType($objectArg) : $scope->getNativeType($objectArg); if ($objectType instanceof ConstantStringType @@ -310,7 +314,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && self::isExpressionDependentOnThis($sureType[0])) { + if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($sureType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -341,7 +345,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && self::isExpressionDependentOnThis($sureNotType[0])) { + if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($sureNotType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -391,32 +395,6 @@ private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool ) && $scope->hasExpressionType($expr)->yes(); } - public static function isExpressionDependentOnThis(Expr $expr): bool - { - if ($expr instanceof Expr\Variable && $expr->name === 'this') { - return true; - } - - if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { - return self::isExpressionDependentOnThis($expr->var); - } - - if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\NullsafeMethodCall) { - return self::isExpressionDependentOnThis($expr->var); - } - - if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) { - if ($expr->class instanceof Expr) { - return self::isExpressionDependentOnThis($expr->class); - } - - $className = $expr->class->toString(); - return in_array($className, ['self', 'static', 'parent'], true); - } - - return false; - } - /** * @param Node\Arg[] $args */ From ba6e8f3fdc4d5efe984c6d8e28559ea3a062320f Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:10:47 +0000 Subject: [PATCH 06/20] Fix method_exists($this) false positives in traits and PHP 7.4 lint Add early return for method_exists() with $this-dependent expressions in trait context, and add // lint >= 8.0 comment to bug-13023 test file for nullsafe operator compatibility. Co-Authored-By: Claude Opus 4.6 --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 45 ++++++++++++++----- .../Rules/Comparison/data/bug-13023.php | 2 +- 2 files changed, 34 insertions(+), 13 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 8f0f4843c27..c7148ae01aa 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -183,18 +183,6 @@ public function testImpossibleCheckTypeFunctionCall(): void 'Call to function method_exists() with CheckTypeFunctionCall\MethodExists and \'testWithNewObjectIn…\' will always evaluate to true.', 635, ], - [ - 'Call to function method_exists() with $this(CheckTypeFunctionCall\MethodExistsWithTrait) and \'method\' will always evaluate to true.', - 650, - ], - [ - 'Call to function method_exists() with $this(CheckTypeFunctionCall\MethodExistsWithTrait) and \'someAnother\' will always evaluate to true.', - 653, - ], - [ - 'Call to function method_exists() with $this(CheckTypeFunctionCall\MethodExistsWithTrait) and \'unknown\' will always evaluate to false.', - 656, - ], [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'method\' will always evaluate to true.', 659, @@ -1213,4 +1201,37 @@ public function testBug13023(): void $this->analyse([__DIR__ . '/data/bug-13023.php'], []); } + public function testBug7599(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-7599.php'], [ + [ + 'Call to function method_exists() with Bug7599\SecondEnum::Baz and \'barMethod\' will always evaluate to true.', + 13, + ], + [ + 'Call to function method_exists() with Bug7599\TestEnum::Bar|Bug7599\TestEnum::Foo and \'barMethod\' will always evaluate to false.', + 13, + ], + ]); + } + + public function testBug9095(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-9095.php'], []); + } + + public function testBug13474(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13474.php'], []); + } + + public function testBug13687(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-13687.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index 5b1bbd1a6a4..2183704383f 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -1,4 +1,4 @@ -= 8.0 namespace Bug13023; From b18a0b053a3237c4cff7002d5f4eb8b8b487119b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:10:51 +0000 Subject: [PATCH 07/20] Add regression tests for trait-related false positives Add test data files for phpstan/phpstan#7599, #9095, #13474, and #13687 to document and prevent regressions in trait type checking behavior. Co-Authored-By: Claude Opus 4.6 --- .../Rules/Comparison/data/bug-13474.php | 86 +++++++++++++++++++ .../Rules/Comparison/data/bug-13687.php | 29 +++++++ .../Rules/Comparison/data/bug-7599.php | 39 +++++++++ .../Rules/Comparison/data/bug-9095.php | 23 +++++ 4 files changed, 177 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13474.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-13687.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-7599.php create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-9095.php diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13474.php b/tests/PHPStan/Rules/Comparison/data/bug-13474.php new file mode 100644 index 00000000000..db93fd400ff --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13474.php @@ -0,0 +1,86 @@ += 8.0 + +namespace Bug13474; + +/** + * @template TValue of mixed + */ +interface ModelInterface { + /** + * @return TValue + */ + public function getValue(): mixed; +} + +/** + * @implements ModelInterface + */ +class ModelA implements ModelInterface +{ + public function getValue(): int + { + return 0; + } +} + +/** + * @implements ModelInterface + */ +class ModelB implements ModelInterface +{ + public function getValue(): string + { + return 'foo'; + } +} + +/** + * @template T of ModelInterface + */ +trait ModelTrait +{ + /** + * @return T + */ + abstract function model(): ModelInterface; + + /** + * @return template-type + */ + public function getValue(): mixed + { + return $this->model()->getValue(); + } + + public function test(): void + { + if (is_string($this->getValue())) { + echo 'string'; + return; + } + + echo 'other'; + } +} + +class TestA +{ + /** @use ModelTrait */ + use ModelTrait; + + function model(): ModelA + { + return new ModelA(); + } +} + +class TestB +{ + /** @use ModelTrait */ + use ModelTrait; + + function model(): ModelB + { + return new ModelB(); + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13687.php b/tests/PHPStan/Rules/Comparison/data/bug-13687.php new file mode 100644 index 00000000000..08927d78c6a --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-13687.php @@ -0,0 +1,29 @@ +bar(); + } + + if (property_exists($this, 'baz')) { + $a = $this->baz; + } + } +} + +class A { + use MyTrait; + + public string $baz = 'baz'; +} + +class B { + use MyTrait; + + public function bar(): void { + echo 'bar'; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-7599.php b/tests/PHPStan/Rules/Comparison/data/bug-7599.php new file mode 100644 index 00000000000..c4fa4b3fd78 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-7599.php @@ -0,0 +1,39 @@ += 8.1 + +namespace Bug7599; + +trait TraitForEnum +{ + /** + * @return array + */ + public static function fooMethod(): array + { + return array_map( + fn(self $enum): string => method_exists($enum, 'barMethod') + ? $enum->barMethod() + : $enum->name, + static::cases() + ); + } +} + +enum TestEnum: string +{ + use TraitForEnum; + + case Foo = 'foo'; + case Bar = 'bar'; +} + +enum SecondEnum: string +{ + use TraitForEnum; + + case Baz = 'baz'; + + public function barMethod(): string + { + return 'blah'; + } +} diff --git a/tests/PHPStan/Rules/Comparison/data/bug-9095.php b/tests/PHPStan/Rules/Comparison/data/bug-9095.php new file mode 100644 index 00000000000..4b9b84f8586 --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-9095.php @@ -0,0 +1,23 @@ +createdAt) && + $this->createdAt instanceof \DateTimeInterface + ) { + return $this->createdAt; + } + return null; + } +} + +final class Event +{ + use EventTrait; +} From 4f8d0b3255643aeb441ceda3470f6d64bbf7df7b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:29:30 +0000 Subject: [PATCH 08/20] Still report method_exists($this) as always true when method is defined in the trait When a trait defines a method itself, method_exists($this, 'method') will always be true regardless of which class uses the trait. Only suppress the report when the method is NOT defined in the trait, since then the result depends on the using class. Co-Authored-By: Claude Opus 4.6 --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 11 ++++++++++- .../ImpossibleCheckTypeFunctionCallRuleTest.php | 4 ++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index c22c3efca49..558cead2048 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -200,7 +200,16 @@ public function findSpecifiedType( $objectArg = $args[0]->value; if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($objectArg)) { - return null; + $traitReflection = $scope->getTraitReflection(); + $methodArgValue = $args[1]->value; + $methodArgType = $this->treatPhpDocTypesAsCertain ? $scope->getType($methodArgValue) : $scope->getNativeType($methodArgValue); + $constantMethodNames = $methodArgType->getConstantStrings(); + if ( + count($constantMethodNames) !== 1 + || !$traitReflection->hasNativeMethod($constantMethodNames[0]->getValue()) + ) { + return null; + } } $objectType = $this->treatPhpDocTypesAsCertain ? $scope->getType($objectArg) : $scope->getNativeType($objectArg); diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index c7148ae01aa..a1534fed98a 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -183,6 +183,10 @@ public function testImpossibleCheckTypeFunctionCall(): void 'Call to function method_exists() with CheckTypeFunctionCall\MethodExists and \'testWithNewObjectIn…\' will always evaluate to true.', 635, ], + [ + 'Call to function method_exists() with $this(CheckTypeFunctionCall\MethodExistsWithTrait) and \'method\' will always evaluate to true.', + 650, + ], [ 'Call to function method_exists() with \'CheckTypeFunctionCall\\\\MethodExistsWithTrait\' and \'method\' will always evaluate to true.', 659, From 6d73f01d227f33fb908483314e20247443d8082a Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 11:54:24 +0000 Subject: [PATCH 09/20] Extend trait context detection to cover self-typed variables and fix multiple method names - Add isExpressionDependentOnTraitContext() that checks both AST-level ($this, self::, static::) and type-level (object type matches using class) dependency on trait context - Fix method_exists handling to support multiple constant method names (e.g. 'foo'|'bar') instead of requiring exactly one - Add null check for getTraitReflection() - Update ConstantConditionRuleHelper with same type-level check - Fix testBug7599: method_exists($enum, 'barMethod') where $enum is typed as self in a trait should not report errors Co-Authored-By: Claude Opus 4.6 --- .../ConstantConditionRuleHelper.php | 10 +++++ .../Comparison/ImpossibleCheckTypeHelper.php | 44 ++++++++++++++++--- ...mpossibleCheckTypeFunctionCallRuleTest.php | 11 +---- 3 files changed, 48 insertions(+), 17 deletions(-) diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 183862b59b9..3f15c28e760 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -67,6 +67,16 @@ private function shouldSkip(Scope $scope, Expr $expr): bool if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($arg->value)) { return true; } + + $classReflection = $scope->getClassReflection(); + if ($classReflection !== null) { + $argType = $this->treatPhpDocTypesAsCertain ? $scope->getType($arg->value) : $scope->getNativeType($arg->value); + foreach ($argType->getObjectClassNames() as $className) { + if ($className === $classReflection->getName()) { + return true; + } + } + } } } } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 558cead2048..af01efbb1f8 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -199,17 +199,22 @@ public function findSpecifiedType( } elseif ($functionName === 'method_exists' && $argsCount >= 2) { $objectArg = $args[0]->value; - if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($objectArg)) { + if ($this->isExpressionDependentOnTraitContext($scope, $objectArg)) { $traitReflection = $scope->getTraitReflection(); + if ($traitReflection === null) { + return null; + } $methodArgValue = $args[1]->value; $methodArgType = $this->treatPhpDocTypesAsCertain ? $scope->getType($methodArgValue) : $scope->getNativeType($methodArgValue); $constantMethodNames = $methodArgType->getConstantStrings(); - if ( - count($constantMethodNames) !== 1 - || !$traitReflection->hasNativeMethod($constantMethodNames[0]->getValue()) - ) { + if (count($constantMethodNames) === 0) { return null; } + foreach ($constantMethodNames as $constantMethodName) { + if (!$traitReflection->hasNativeMethod($constantMethodName->getValue())) { + return null; + } + } } $objectType = $this->treatPhpDocTypesAsCertain ? $scope->getType($objectArg) : $scope->getNativeType($objectArg); @@ -323,7 +328,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($sureType[0])) { + if ($this->isExpressionDependentOnTraitContext($scope, $sureType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -354,7 +359,7 @@ public function findSpecifiedType( continue; } - if ($scope->isInTrait() && ExpressionDependsOnThisHelper::isExpressionDependentOnThis($sureNotType[0])) { + if ($this->isExpressionDependentOnTraitContext($scope, $sureNotType[0])) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -379,6 +384,31 @@ public function findSpecifiedType( return $result->maybe() ? null : $result->yes(); } + private function isExpressionDependentOnTraitContext(Scope $scope, Expr $expr): bool + { + if (!$scope->isInTrait()) { + return false; + } + + if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($expr)) { + return true; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + return false; + } + + $type = $this->treatPhpDocTypesAsCertain ? $scope->getType($expr) : $scope->getNativeType($expr); + foreach ($type->getObjectClassNames() as $className) { + if ($className === $classReflection->getName()) { + return true; + } + } + + return false; + } + private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool { if ($expr === $node) { diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index a1534fed98a..2d6b3a0199e 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1208,16 +1208,7 @@ public function testBug13023(): void public function testBug7599(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-7599.php'], [ - [ - 'Call to function method_exists() with Bug7599\SecondEnum::Baz and \'barMethod\' will always evaluate to true.', - 13, - ], - [ - 'Call to function method_exists() with Bug7599\TestEnum::Bar|Bug7599\TestEnum::Foo and \'barMethod\' will always evaluate to false.', - 13, - ], - ]); + $this->analyse([__DIR__ . '/data/bug-7599.php'], []); } public function testBug9095(): void From 44458d8fe89d5fb01812283b8b4e7393d3d77c30 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 12:12:34 +0000 Subject: [PATCH 10/20] Fix lint errors and skip testBug7599 on PHP < 8.1 - Use early exit in ConstantConditionRuleHelper to reduce nesting (SlevomatCodingStandard.ControlStructures.EarlyExit) - Use \in_array() instead of fallback global name in ExpressionDependsOnThisHelper (SlevomatCodingStandard.Namespaces.ReferenceUsedNamesOnly) - Skip testBug7599 on PHP < 8.1 using #[RequiresPhp] attribute (enums require PHP 8.1) Co-Authored-By: Claude Opus 4.6 --- .../ConstantConditionRuleHelper.php | 30 +++++++++++-------- .../ExpressionDependsOnThisHelper.php | 2 +- ...mpossibleCheckTypeFunctionCallRuleTest.php | 1 + 3 files changed, 19 insertions(+), 14 deletions(-) diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 3f15c28e760..95f6934e5ba 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -62,20 +62,24 @@ private function shouldSkip(Scope $scope, Expr $expr): bool return true; } - if ($scope->isInTrait()) { - foreach ($expr->getArgs() as $arg) { - if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($arg->value)) { - return true; - } + if (!$scope->isInTrait()) { + return false; + } + + foreach ($expr->getArgs() as $arg) { + if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($arg->value)) { + return true; + } + + $classReflection = $scope->getClassReflection(); + if ($classReflection === null) { + continue; + } - $classReflection = $scope->getClassReflection(); - if ($classReflection !== null) { - $argType = $this->treatPhpDocTypesAsCertain ? $scope->getType($arg->value) : $scope->getNativeType($arg->value); - foreach ($argType->getObjectClassNames() as $className) { - if ($className === $classReflection->getName()) { - return true; - } - } + $argType = $this->treatPhpDocTypesAsCertain ? $scope->getType($arg->value) : $scope->getNativeType($arg->value); + foreach ($argType->getObjectClassNames() as $className) { + if ($className === $classReflection->getName()) { + return true; } } } diff --git a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php index ef555cf5ec1..638d8172656 100644 --- a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php +++ b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php @@ -27,7 +27,7 @@ public static function isExpressionDependentOnThis(Expr $expr): bool } $className = $expr->class->toString(); - return in_array($className, ['self', 'static', 'parent'], true); + return \in_array($className, ['self', 'static', 'parent'], true); } return false; diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 2d6b3a0199e..3204b2ee05f 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1205,6 +1205,7 @@ public function testBug13023(): void $this->analyse([__DIR__ . '/data/bug-13023.php'], []); } + #[RequiresPhp('>= 8.1')] public function testBug7599(): void { $this->treatPhpDocTypesAsCertain = true; From ee69212cc470d475bdbb4ad28e97e6a141c9cc94 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 14 Mar 2026 12:31:36 +0000 Subject: [PATCH 11/20] Add regression test for phpstan/phpstan#12798 Test is_subclass_of in enum traits with BackedEnum and custom interface checks. Verifies no false positive function.alreadyNarrowedType or function.impossibleType errors are reported. Co-Authored-By: Claude Opus 4.6 --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 7 +++ .../Rules/Comparison/data/bug-12798.php | 49 +++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/PHPStan/Rules/Comparison/data/bug-12798.php diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 3204b2ee05f..34fdb5c4b1b 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1230,4 +1230,11 @@ public function testBug13687(): void $this->analyse([__DIR__ . '/data/bug-13687.php'], []); } + #[RequiresPhp('>= 8.1')] + public function testBug12798(): void + { + $this->treatPhpDocTypesAsCertain = true; + $this->analyse([__DIR__ . '/data/bug-12798.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12798.php b/tests/PHPStan/Rules/Comparison/data/bug-12798.php new file mode 100644 index 00000000000..f93d0077a5d --- /dev/null +++ b/tests/PHPStan/Rules/Comparison/data/bug-12798.php @@ -0,0 +1,49 @@ += 8.1 + +namespace Bug12798; + +interface Colorable +{ + public function color(): string; +} + +trait HasColors +{ + /** @return array */ + public static function colors(): array { + return array_reduce(self::cases(), function (array $colors, self $case) { + $key = is_subclass_of($case, \BackedEnum::class) ? $case->value : $case->name; + $color = is_subclass_of($case, Colorable::class) ? $case->color() : 'gray'; + + $colors[$key] = $color; + return $colors; + }, []); + } +} + +enum AlertLevelBacked: int implements Colorable +{ + use HasColors; + + case Low = 1; + case Medium = 2; + case Critical = 3; + + public function color(): string + { + return match ($this) { + self::Low => 'green', + self::Medium => 'yellow', + self::Critical => 'red', + }; + } +} + +enum AlertLevel +{ + use HasColors; + + case Low; + case Medium; + case Critical; +} From 8f559bc1815186dbdd1e0089f1b4e492e06b8481 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 13:55:30 +0100 Subject: [PATCH 12/20] Add test --- src/Rules/Comparison/ExpressionDependsOnThisHelper.php | 3 ++- .../Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php index 638d8172656..223f2c9aa3a 100644 --- a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php +++ b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php @@ -3,6 +3,7 @@ namespace PHPStan\Rules\Comparison; use PhpParser\Node\Expr; +use function in_array; final class ExpressionDependsOnThisHelper { @@ -27,7 +28,7 @@ public static function isExpressionDependentOnThis(Expr $expr): bool } $className = $expr->class->toString(); - return \in_array($className, ['self', 'static', 'parent'], true); + return in_array($className, ['self', 'static', 'parent'], true); } return false; diff --git a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php index e61164f515d..832f01f53e9 100644 --- a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php @@ -144,6 +144,11 @@ public function testBug12273(): void ]); } + public function testBug12798(): void + { + $this->analyse([__DIR__ . '/../Comparison/data/bug-12798.php'], []); + } + public function testBug12981(): void { $this->analyse([__DIR__ . '/data/bug-12981.php'], [ From cb69e3eac23dd107d9114f49be92abd082135f25 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 22:17:55 +0100 Subject: [PATCH 13/20] Rework --- .../Comparison/ConstantConditionRuleHelper.php | 2 +- .../ImpossibleCheckTypeFunctionCallRule.php | 4 ++-- .../Comparison/ImpossibleCheckTypeHelper.php | 16 +++++++++++++--- .../ImpossibleCheckTypeMethodCallRule.php | 4 ++-- .../ImpossibleCheckTypeStaticMethodCallRule.php | 4 ++-- ...ifyingFunctionsDynamicReturnTypeExtension.php | 1 + .../PHPStan/Rules/Comparison/data/bug-12798.php | 1 + 7 files changed, 22 insertions(+), 10 deletions(-) diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 95f6934e5ba..06ca065e894 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -57,7 +57,7 @@ private function shouldSkip(Scope $scope, Expr $expr): bool || $expr instanceof Expr\StaticCall ) && !$expr->isFirstClassCallable() ) { - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $expr); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $expr, true); if ($isAlways !== null) { return true; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php index 61e3b57c303..eaebc42672d 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php @@ -43,7 +43,7 @@ public function processNode(Node $node, Scope $scope): array } $functionName = (string) $node->name; - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, true); if ($isAlways === null) { return []; } @@ -53,7 +53,7 @@ public function processNode(Node $node, Scope $scope): array return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node, true); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index af01efbb1f8..42e75360eb5 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -61,6 +61,7 @@ public function __construct( public function findSpecifiedType( Scope $scope, Expr $node, + bool $ignoreTraitContext, ): ?bool { if ($node instanceof FuncCall) { @@ -199,7 +200,10 @@ public function findSpecifiedType( } elseif ($functionName === 'method_exists' && $argsCount >= 2) { $objectArg = $args[0]->value; - if ($this->isExpressionDependentOnTraitContext($scope, $objectArg)) { + if ( + $ignoreTraitContext + && $this->isExpressionDependentOnTraitContext($scope, $objectArg) + ) { $traitReflection = $scope->getTraitReflection(); if ($traitReflection === null) { return null; @@ -328,7 +332,10 @@ public function findSpecifiedType( continue; } - if ($this->isExpressionDependentOnTraitContext($scope, $sureType[0])) { + if ( + $ignoreTraitContext + && $this->isExpressionDependentOnTraitContext($scope, $sureType[0]) + ) { $results[] = TrinaryLogic::createMaybe(); continue; } @@ -359,7 +366,10 @@ public function findSpecifiedType( continue; } - if ($this->isExpressionDependentOnTraitContext($scope, $sureNotType[0])) { + if ( + $ignoreTraitContext + && $this->isExpressionDependentOnTraitContext($scope, $sureNotType[0]) + ) { $results[] = TrinaryLogic::createMaybe(); continue; } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php index bc8284d1111..92265b5051a 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, true); if ($isAlways === null) { return []; } @@ -55,7 +55,7 @@ public function processNode(Node $node, Scope $scope): array return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node, true); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } diff --git a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php index 3c24b381762..647c89ab235 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeStaticMethodCallRule.php @@ -45,7 +45,7 @@ public function processNode(Node $node, Scope $scope): array return []; } - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $node, true); if ($isAlways === null) { return []; } @@ -55,7 +55,7 @@ public function processNode(Node $node, Scope $scope): array return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } - $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node); + $isAlways = $this->impossibleCheckTypeHelper->doNotTreatPhpDocTypesAsCertain()->findSpecifiedType($scope, $node, true); if ($isAlways !== null) { return $this->possiblyImpureTipHelper->addTip($scope, $node, $ruleErrorBuilder); } diff --git a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php index 8ed3c6b6056..81f1af6b7d6 100644 --- a/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php +++ b/src/Type/Php/TypeSpecifyingFunctionsDynamicReturnTypeExtension.php @@ -66,6 +66,7 @@ public function getTypeFromFunctionCall( $isAlways = $this->getHelper()->findSpecifiedType( $scope, $functionCall, + false, ); if ($isAlways === null) { return null; diff --git a/tests/PHPStan/Rules/Comparison/data/bug-12798.php b/tests/PHPStan/Rules/Comparison/data/bug-12798.php index f93d0077a5d..7bc404d240a 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-12798.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-12798.php @@ -16,6 +16,7 @@ public static function colors(): array { $color = is_subclass_of($case, Colorable::class) ? $case->color() : 'gray'; $colors[$key] = $color; + $colors[$color] = $color; return $colors; }, []); } From cb0a2e5efc5417e1f5009b7e17a5e99f0e2284bd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 22:25:47 +0100 Subject: [PATCH 14/20] Simplify --- .../ConstantConditionRuleHelper.php | 24 +------------------ 1 file changed, 1 insertion(+), 23 deletions(-) diff --git a/src/Rules/Comparison/ConstantConditionRuleHelper.php b/src/Rules/Comparison/ConstantConditionRuleHelper.php index 06ca065e894..48fe9b0d843 100644 --- a/src/Rules/Comparison/ConstantConditionRuleHelper.php +++ b/src/Rules/Comparison/ConstantConditionRuleHelper.php @@ -57,32 +57,10 @@ private function shouldSkip(Scope $scope, Expr $expr): bool || $expr instanceof Expr\StaticCall ) && !$expr->isFirstClassCallable() ) { - $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $expr, true); + $isAlways = $this->impossibleCheckTypeHelper->findSpecifiedType($scope, $expr, false); if ($isAlways !== null) { return true; } - - if (!$scope->isInTrait()) { - return false; - } - - foreach ($expr->getArgs() as $arg) { - if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($arg->value)) { - return true; - } - - $classReflection = $scope->getClassReflection(); - if ($classReflection === null) { - continue; - } - - $argType = $this->treatPhpDocTypesAsCertain ? $scope->getType($arg->value) : $scope->getNativeType($arg->value); - foreach ($argType->getObjectClassNames() as $className) { - if ($className === $classReflection->getName()) { - return true; - } - } - } } return false; From 9ba6146c5624e2545e6af4b601e0b1b038e449d5 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 22:27:20 +0100 Subject: [PATCH 15/20] Remove dedicated class --- .../ExpressionDependsOnThisHelper.php | 37 ------------------- .../Comparison/ImpossibleCheckTypeHelper.php | 28 +++++++++++++- 2 files changed, 27 insertions(+), 38 deletions(-) delete mode 100644 src/Rules/Comparison/ExpressionDependsOnThisHelper.php diff --git a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php b/src/Rules/Comparison/ExpressionDependsOnThisHelper.php deleted file mode 100644 index 223f2c9aa3a..00000000000 --- a/src/Rules/Comparison/ExpressionDependsOnThisHelper.php +++ /dev/null @@ -1,37 +0,0 @@ -name === 'this') { - return true; - } - - if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { - return self::isExpressionDependentOnThis($expr->var); - } - - if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\NullsafeMethodCall) { - return self::isExpressionDependentOnThis($expr->var); - } - - if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) { - if ($expr->class instanceof Expr) { - return self::isExpressionDependentOnThis($expr->class); - } - - $className = $expr->class->toString(); - return in_array($className, ['self', 'static', 'parent'], true); - } - - return false; - } - -} diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 42e75360eb5..39088e3c322 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -400,7 +400,7 @@ private function isExpressionDependentOnTraitContext(Scope $scope, Expr $expr): return false; } - if (ExpressionDependsOnThisHelper::isExpressionDependentOnThis($expr)) { + if (self::isExpressionDependentOnThis($expr)) { return true; } @@ -419,6 +419,32 @@ private function isExpressionDependentOnTraitContext(Scope $scope, Expr $expr): return false; } + public static function isExpressionDependentOnThis(Expr $expr): bool + { + if ($expr instanceof Expr\Variable && $expr->name === 'this') { + return true; + } + + if ($expr instanceof Expr\PropertyFetch || $expr instanceof Expr\NullsafePropertyFetch) { + return self::isExpressionDependentOnThis($expr->var); + } + + if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\NullsafeMethodCall) { + return self::isExpressionDependentOnThis($expr->var); + } + + if ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) { + if ($expr->class instanceof Expr) { + return self::isExpressionDependentOnThis($expr->class); + } + + $className = $expr->class->toString(); + return in_array($className, ['self', 'static', 'parent'], true); + } + + return false; + } + private static function isSpecified(Scope $scope, Expr $node, Expr $expr): bool { if ($expr === $node) { From 1c6d39384a282cdc4c257b4c8a414ba45c20101a Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sat, 14 Mar 2026 22:31:41 +0100 Subject: [PATCH 16/20] Fix test --- tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php index 832f01f53e9..c7b2144c9f3 100644 --- a/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php +++ b/tests/PHPStan/Rules/Arrays/InvalidKeyInArrayDimFetchRuleTest.php @@ -144,6 +144,7 @@ public function testBug12273(): void ]); } + #[RequiresPhp('>= 8.1')] public function testBug12798(): void { $this->analyse([__DIR__ . '/../Comparison/data/bug-12798.php'], []); From 53eb552743ce57db2419876b0d1b963cb7d5db85 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Mar 2026 10:38:16 +0100 Subject: [PATCH 17/20] Reduce visibility --- src/Rules/Comparison/ImpossibleCheckTypeHelper.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php index 39088e3c322..aae5b9759d8 100644 --- a/src/Rules/Comparison/ImpossibleCheckTypeHelper.php +++ b/src/Rules/Comparison/ImpossibleCheckTypeHelper.php @@ -419,7 +419,7 @@ private function isExpressionDependentOnTraitContext(Scope $scope, Expr $expr): return false; } - public static function isExpressionDependentOnThis(Expr $expr): bool + private static function isExpressionDependentOnThis(Expr $expr): bool { if ($expr instanceof Expr\Variable && $expr->name === 'this') { return true; From c86721eaec1ceb6a7a710cf127b47766d14b48e4 Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Mar 2026 12:23:31 +0100 Subject: [PATCH 18/20] Add test --- .../Rules/Comparison/data/bug-13023.php | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index 2183704383f..abfbcccc89b 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -166,3 +166,25 @@ public function getRandom(): int return $value * $value; } } + +class SomeClass11 +{ + use MyTrait6; +} + +trait MyTrait6 +{ + public function getBar(): array + { + return []; + } + + public function getRandom(): int + { + if (!\is_int(count($this->getBar()))) { + return 1; + } + + return 0; + } +} From 9fa10d3b3ee95fac7083e7f571b1b3f98c036fcd Mon Sep 17 00:00:00 2001 From: Vincent Langlet Date: Sun, 15 Mar 2026 12:40:02 +0100 Subject: [PATCH 19/20] Add tests --- ...mpossibleCheckTypeFunctionCallRuleTest.php | 11 +++++++++- ...isonOperatorsConstantConditionRuleTest.php | 5 +++++ .../Rules/Comparison/data/bug-13023.php | 20 ++++++++++++++++++- 3 files changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php index 34fdb5c4b1b..d118f920406 100644 --- a/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php @@ -1202,7 +1202,16 @@ public function testBug13799(): void public function testBug13023(): void { $this->treatPhpDocTypesAsCertain = true; - $this->analyse([__DIR__ . '/data/bug-13023.php'], []); + $this->analyse([__DIR__ . '/data/bug-13023.php'], [ + [ + 'Call to function is_int() with 0 will always evaluate to true.', + 198, + ], + [ + 'Call to function is_int() with int<1, max> will always evaluate to true.', + 198, + ], + ]); } #[RequiresPhp('>= 8.1')] diff --git a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php index 5400e3edee6..1801b0e65dc 100644 --- a/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php +++ b/tests/PHPStan/Rules/Comparison/NumberComparisonOperatorsConstantConditionRuleTest.php @@ -288,6 +288,11 @@ public function testBug13874(): void $this->analyse([__DIR__ . '/data/bug-13874.php'], []); } + public function testBug13023(): void + { + $this->analyse([__DIR__ . '/data/bug-13023.php'], []); + } + public function testBug12163(): void { $this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-12163.php'], [ diff --git a/tests/PHPStan/Rules/Comparison/data/bug-13023.php b/tests/PHPStan/Rules/Comparison/data/bug-13023.php index abfbcccc89b..ba3783e2443 100644 --- a/tests/PHPStan/Rules/Comparison/data/bug-13023.php +++ b/tests/PHPStan/Rules/Comparison/data/bug-13023.php @@ -170,14 +170,28 @@ public function getRandom(): int class SomeClass11 { use MyTrait6; + + /** @return non-empty-array */ + public function getBar(): array + { + return ['foo']; + } } -trait MyTrait6 +class SomeClass12 { + use MyTrait6; + + /** @return array{} */ public function getBar(): array { return []; } +} + +trait MyTrait6 +{ + abstract public function getBar(): array; public function getRandom(): int { @@ -185,6 +199,10 @@ public function getRandom(): int return 1; } + if (count($this->getBar()) > 0) { + return 1; + } + return 0; } } From 7e49d261d51ad23b20cf557755d2829042c472ea Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sun, 15 Mar 2026 12:03:29 +0000 Subject: [PATCH 20/20] Add trait context awareness to comparison rules Create TraitContextHelper with deep $this-dependency check for comparison operands. Apply to NumberComparisonOperatorsConstantConditionRule, StrictComparisonOfDifferentTypesRule, and ConstantLooseComparisonRule to suppress false positives when comparison operand types depend on which class uses the trait (e.g. count($this->getBar()) > 0). Co-Authored-By: Claude Opus 4.6 --- .../ConstantLooseComparisonRule.php | 4 ++ ...mparisonOperatorsConstantConditionRule.php | 4 ++ .../StrictComparisonOfDifferentTypesRule.php | 4 ++ src/Rules/Comparison/TraitContextHelper.php | 69 +++++++++++++++++++ 4 files changed, 81 insertions(+) create mode 100644 src/Rules/Comparison/TraitContextHelper.php diff --git a/src/Rules/Comparison/ConstantLooseComparisonRule.php b/src/Rules/Comparison/ConstantLooseComparisonRule.php index dde16af74d6..a34b4f3147b 100644 --- a/src/Rules/Comparison/ConstantLooseComparisonRule.php +++ b/src/Rules/Comparison/ConstantLooseComparisonRule.php @@ -42,6 +42,10 @@ public function processNode(Node $node, Scope $scope): array return []; } + if (TraitContextHelper::isBinaryOpDependentOnTraitContext($scope, $node->left, $node->right)) { + return []; + } + $nodeType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if (!$nodeType->isTrue()->yes() && !$nodeType->isFalse()->yes()) { return []; diff --git a/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php index 5cd0ab417e3..b68e61a40a0 100644 --- a/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php +++ b/src/Rules/Comparison/NumberComparisonOperatorsConstantConditionRule.php @@ -51,6 +51,10 @@ public function processNode( return []; } + if (TraitContextHelper::isBinaryOpDependentOnTraitContext($scope, $node->left, $node->right)) { + return []; + } + $exprType = $this->treatPhpDocTypesAsCertain ? $scope->getType($node) : $scope->getNativeType($node); if ($exprType instanceof ConstantBooleanType) { $addTip = function (RuleErrorBuilder $ruleErrorBuilder) use ($scope, $node): RuleErrorBuilder { diff --git a/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php index b8b0ca09db2..65649772310 100644 --- a/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php +++ b/src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php @@ -57,6 +57,10 @@ public function processNode(Node $node, Scope $scope): array return []; } + if (TraitContextHelper::isBinaryOpDependentOnTraitContext($scope, $node->left, $node->right)) { + return []; + } + $nodeType = $nodeTypeResult->type; if (!$nodeType instanceof ConstantBooleanType) { return []; diff --git a/src/Rules/Comparison/TraitContextHelper.php b/src/Rules/Comparison/TraitContextHelper.php new file mode 100644 index 00000000000..869d29904db --- /dev/null +++ b/src/Rules/Comparison/TraitContextHelper.php @@ -0,0 +1,69 @@ +isInTrait()) { + return false; + } + + return self::containsThisDependentExpression($left) + || self::containsThisDependentExpression($right); + } + + private static function containsThisDependentExpression(Expr $expr): bool + { + if ($expr instanceof Expr\Variable) { + return $expr->name === 'this'; + } + + if ( + ($expr instanceof Expr\StaticPropertyFetch || $expr instanceof Expr\StaticCall) + && $expr->class instanceof Name + ) { + $className = $expr->class->toString(); + if (in_array($className, ['self', 'static', 'parent'], true)) { + return true; + } + } + + foreach ($expr->getSubNodeNames() as $name) { + $subNode = $expr->$name; + if ($subNode instanceof Expr) { + if (self::containsThisDependentExpression($subNode)) { + return true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if ($item instanceof Expr && self::containsThisDependentExpression($item)) { + return true; + } + if ($item instanceof Arg && self::containsThisDependentExpression($item->value)) { + return true; + } + } + } + } + + return false; + } + +}