Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b94b5d6
Fix false positives for is_a() and instanceof checks on $this in traits
github-actions[bot] Mar 10, 2026
4fc6007
Generalize trait $this check to cover property fetches and method calls
phpstan-bot Mar 14, 2026
af1247c
Handle static access, nullsafe access, and $this:: in trait $this check
phpstan-bot Mar 14, 2026
6b9535d
Fix BooleanNot false positive for $this-dependent checks in traits
phpstan-bot Mar 14, 2026
5ac5e5f
Move isExpressionDependentOnThis to ExpressionDependsOnThisHelper
phpstan-bot Mar 14, 2026
ba6e8f3
Fix method_exists($this) false positives in traits and PHP 7.4 lint
phpstan-bot Mar 14, 2026
b18a0b0
Add regression tests for trait-related false positives
phpstan-bot Mar 14, 2026
4f8d0b3
Still report method_exists($this) as always true when method is defin…
phpstan-bot Mar 14, 2026
6d73f01
Extend trait context detection to cover self-typed variables and fix …
phpstan-bot Mar 14, 2026
44458d8
Fix lint errors and skip testBug7599 on PHP < 8.1
phpstan-bot Mar 14, 2026
ee69212
Add regression test for phpstan/phpstan#12798
phpstan-bot Mar 14, 2026
8f559bc
Add test
VincentLanglet Mar 14, 2026
cb69e3e
Rework
VincentLanglet Mar 14, 2026
cb0a2e5
Simplify
VincentLanglet Mar 14, 2026
9ba6146
Remove dedicated class
VincentLanglet Mar 14, 2026
1c6d393
Fix test
VincentLanglet Mar 14, 2026
53eb552
Reduce visibility
VincentLanglet Mar 15, 2026
c86721e
Add test
VincentLanglet Mar 15, 2026
9fa10d3
Add tests
VincentLanglet Mar 15, 2026
7e49d26
Add trait context awareness to comparison rules
phpstan-bot Mar 15, 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
2 changes: 1 addition & 1 deletion src/Rules/Comparison/ConstantConditionRuleHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, false);
if ($isAlways !== null) {
return true;
}
Expand Down
4 changes: 4 additions & 0 deletions src/Rules/Comparison/ConstantLooseComparisonRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
4 changes: 2 additions & 2 deletions src/Rules/Comparison/ImpossibleCheckTypeFunctionCallRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand All @@ -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);
}
Expand Down
89 changes: 89 additions & 0 deletions src/Rules/Comparison/ImpossibleCheckTypeHelper.php
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public function __construct(
public function findSpecifiedType(
Scope $scope,
Expr $node,
bool $ignoreTraitContext,
): ?bool
{
if ($node instanceof FuncCall) {
Expand Down Expand Up @@ -198,6 +199,27 @@ public function findSpecifiedType(
}
} elseif ($functionName === 'method_exists' && $argsCount >= 2) {
$objectArg = $args[0]->value;

if (
$ignoreTraitContext
&& $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) === 0) {
return null;
}
foreach ($constantMethodNames as $constantMethodName) {
if (!$traitReflection->hasNativeMethod($constantMethodName->getValue())) {
return null;
}
}
}
$objectType = $this->treatPhpDocTypesAsCertain ? $scope->getType($objectArg) : $scope->getNativeType($objectArg);

if ($objectType instanceof ConstantStringType
Expand Down Expand Up @@ -310,6 +332,14 @@ public function findSpecifiedType(
continue;
}

if (
$ignoreTraitContext
&& $this->isExpressionDependentOnTraitContext($scope, $sureType[0])
) {
$results[] = TrinaryLogic::createMaybe();
continue;
}

if ($this->treatPhpDocTypesAsCertain) {
$argumentType = $scope->getType($sureType[0]);
} else {
Expand All @@ -336,6 +366,14 @@ public function findSpecifiedType(
continue;
}

if (
$ignoreTraitContext
&& $this->isExpressionDependentOnTraitContext($scope, $sureNotType[0])
) {
$results[] = TrinaryLogic::createMaybe();
continue;
}

if ($this->treatPhpDocTypesAsCertain) {
$argumentType = $scope->getType($sureNotType[0]);
} else {
Expand All @@ -356,6 +394,57 @@ public function findSpecifiedType(
return $result->maybe() ? null : $result->yes();
}

private function isExpressionDependentOnTraitContext(Scope $scope, Expr $expr): bool
{
if (!$scope->isInTrait()) {
return false;
}

if (self::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 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) {
Expand Down
4 changes: 2 additions & 2 deletions src/Rules/Comparison/ImpossibleCheckTypeMethodCallRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
}
Expand All @@ -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);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 4 additions & 0 deletions src/Rules/Comparison/StrictComparisonOfDifferentTypesRule.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down
69 changes: 69 additions & 0 deletions src/Rules/Comparison/TraitContextHelper.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php declare(strict_types = 1);

namespace PHPStan\Rules\Comparison;

use PhpParser\Node\Arg;
use PhpParser\Node\Expr;
use PhpParser\Node\Name;
use PHPStan\Analyser\Scope;
use function in_array;
use function is_array;

final class TraitContextHelper
{

/**
* Checks if a comparison expression's result may differ across different
* class contexts when used inside a trait.
*
* Uses a deep traversal to find $this, self::, static::, or parent:: references
* in any sub-expression.
*/
public static function isBinaryOpDependentOnTraitContext(Scope $scope, Expr $left, Expr $right): bool
{
if (!$scope->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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public function getTypeFromFunctionCall(
$isAlways = $this->getHelper()->findSpecifiedType(
$scope,
$functionCall,
false,
);
if ($isAlways === null) {
return null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,12 @@ public function testBug12273(): void
]);
}

#[RequiresPhp('>= 8.1')]
public function testBug12798(): void
{
$this->analyse([__DIR__ . '/../Comparison/data/bug-12798.php'], []);
}

public function testBug12981(): void
{
$this->analyse([__DIR__ . '/data/bug-12981.php'], [
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'], []);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -187,14 +187,6 @@ public function testImpossibleCheckTypeFunctionCall(): void
'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,
Expand Down Expand Up @@ -1207,4 +1199,51 @@ public function testBug13799(): void
]);
}

public function testBug13023(): void
{
$this->treatPhpDocTypesAsCertain = true;
$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')]
public function testBug7599(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-7599.php'], []);
}

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'], []);
}

#[RequiresPhp('>= 8.1')]
public function testBug12798(): void
{
$this->treatPhpDocTypesAsCertain = true;
$this->analyse([__DIR__ . '/data/bug-12798.php'], []);
}

}
Loading
Loading