From 5b63a0a6365895316abd09bab41aee062e38e7c3 Mon Sep 17 00:00:00 2001 From: ondrejmirtes <104888+ondrejmirtes@users.noreply.github.com> Date: Thu, 12 Feb 2026 19:37:15 +0000 Subject: [PATCH] Narrow nullsafe target through cast comparisons - Added resolveNullsafeCastIdentical() to TypeSpecifier to detect when a cast of a nullsafe expression is compared to the null-equivalent value (e.g. (int)null=0, (string)null='', (float)null=0.0) - In the not-equal branch, propagate nullsafe narrowing so the object is narrowed to non-null (since the cast of null produces the constant) - New regression test in tests/PHPStan/Analyser/nsrt/bug-7981.php --- src/Analyser/TypeSpecifier.php | 50 ++++++++++++++++++++- tests/PHPStan/Analyser/nsrt/bug-7981.php | 57 ++++++++++++++++++++++++ 2 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-7981.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 64f9841cf0..ce3a1a889e 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2585,6 +2585,18 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } + // (int) $obj?->prop === 0, (string) $obj?->prop === '', (float) $obj?->prop === 0.0 + // Casting null produces a specific value (0, '', 0.0). When the cast result + // is compared with !== to that value, the inner nullsafe expression was not null. + if ($context->false()) { + $castNullsafeTypes = $this->resolveNullsafeCastIdentical($unwrappedLeftExpr, $rightType, $scope); + if ($castNullsafeTypes === null) { + $castNullsafeTypes = $this->resolveNullsafeCastIdentical($unwrappedRightExpr, $scope->getType($leftExpr), $scope); + } + } else { + $castNullsafeTypes = null; + } + // $a::class === 'Foo' if ( $context->true() && @@ -2661,7 +2673,11 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } else { $rightTypes = $this->create($rightExpr, $never, $contextForTypes, $scope)->setRootExpr($expr); } - return $leftTypes->unionWith($rightTypes); + $result = $leftTypes->unionWith($rightTypes); + if ($castNullsafeTypes !== null) { + $result = $result->unionWith($castNullsafeTypes); + } + return $result; } } @@ -2720,6 +2736,9 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } if ($types !== null) { + if ($castNullsafeTypes !== null) { + $types = $types->unionWith($castNullsafeTypes); + } return $types; } @@ -2746,11 +2765,38 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } return $leftTypes->unionWith($rightTypes); } elseif ($context->false()) { - return $this->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) + $result = $this->create($leftExpr, $leftType, $context, $scope)->setRootExpr($expr)->normalize($scope) ->intersectWith($this->create($rightExpr, $rightType, $context, $scope)->setRootExpr($expr)->normalize($scope)); + if ($castNullsafeTypes !== null) { + $result = $result->unionWith($castNullsafeTypes); + } + return $result; } return (new SpecifiedTypes([], []))->setRootExpr($expr); } + private function resolveNullsafeCastIdentical(Expr $castExpr, Type $constantType, Scope $scope): ?SpecifiedTypes + { + if (!$castExpr instanceof Expr\Cast) { + return null; + } + + $isNullEquivalent = false; + + if ($castExpr instanceof Expr\Cast\Int_ && $constantType->isConstantScalarValue()->yes()) { + $isNullEquivalent = (new ConstantIntegerType(0))->isSuperTypeOf($constantType)->yes(); + } elseif ($castExpr instanceof Expr\Cast\String_ && $constantType->isConstantScalarValue()->yes()) { + $isNullEquivalent = (new ConstantStringType(''))->isSuperTypeOf($constantType)->yes(); + } elseif ($castExpr instanceof Expr\Cast\Double && $constantType->isConstantScalarValue()->yes()) { + $isNullEquivalent = (new ConstantFloatType(0.0))->isSuperTypeOf($constantType)->yes(); + } + + if (!$isNullEquivalent) { + return null; + } + + return $this->createNullsafeTypes($castExpr->expr, $scope, TypeSpecifierContext::createFalse(), null); + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-7981.php b/tests/PHPStan/Analyser/nsrt/bug-7981.php new file mode 100644 index 0000000000..3d8b5f4831 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-7981.php @@ -0,0 +1,57 @@ +intValue !== 0) { + assertType('Bug7981\Obj', $obj); + } else { + assertType('Bug7981\Obj|null', $obj); + } +} + +function testIntCastEqual(?Obj $obj): void +{ + if ((int) $obj?->intValue === 0) { + assertType('Bug7981\Obj|null', $obj); + } else { + assertType('Bug7981\Obj', $obj); + } +} + +function testStringCast(?Obj $obj): void +{ + if ((string) $obj?->stringValue !== '') { + assertType('Bug7981\Obj', $obj); + } else { + assertType('Bug7981\Obj|null', $obj); + } +} + +function testBoolCast(?Obj $obj): void +{ + if ((bool) $obj?->intValue) { + assertType('Bug7981\Obj', $obj); + } else { + assertType('Bug7981\Obj|null', $obj); + } +} + +function testFloatCast(?Obj $obj): void +{ + if ((float) $obj?->intValue !== 0.0) { + assertType('Bug7981\Obj', $obj); + } else { + assertType('Bug7981\Obj|null', $obj); + } +}