From 719330d6c5efe5ae8cbe17f3940edca00e96e1b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 00:03:31 +0000 Subject: [PATCH 1/2] Fix missing type narrowing after (string)$value comparison - Added handling in TypeSpecifier::resolveNormalizedIdentical for Cast\String_ expressions - When (string)$x === '' or (string)$x !== '', propagate narrowing to inner expression $x - Types that produce '' when cast to string (null, false, '') are used as the narrowing type - New regression test in tests/PHPStan/Analyser/nsrt/bug-8231.php --- src/Analyser/TypeSpecifier.php | 21 ++++++++++++ tests/PHPStan/Analyser/nsrt/bug-8231.php | 42 ++++++++++++++++++++++++ 2 files changed, 63 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-8231.php diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4f3e7f9c43..95610eb2d4 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2934,6 +2934,27 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } + // (string)$expr === '' - propagate narrowing to inner expression + if ( + !$context->null() + && $unwrappedLeftExpr instanceof Expr\Cast\String_ + ) { + $rightConstantStrings = $rightType->getConstantStrings(); + if (count($rightConstantStrings) === 1 && $rightConstantStrings[0]->getValue() === '') { + // Types that produce '' when cast to string: null, false, '' + $castToEmptyStringType = TypeCombinator::union( + new NullType(), + new ConstantBooleanType(false), + new ConstantStringType(''), + ); + $innerExpr = $unwrappedLeftExpr->expr; + $result = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); + return $result->unionWith( + $this->create($innerExpr, $castToEmptyStringType, $context, $scope)->setRootExpr($expr), + ); + } + } + $expressions = $this->findTypeExpressionsFromBinaryOperation($scope, $expr); if ($expressions !== null) { $exprNode = $expressions[0]; diff --git a/tests/PHPStan/Analyser/nsrt/bug-8231.php b/tests/PHPStan/Analyser/nsrt/bug-8231.php new file mode 100644 index 0000000000..7aaac90515 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-8231.php @@ -0,0 +1,42 @@ += 8.0 + +declare(strict_types = 1); + +namespace Bug8231; + +use function PHPStan\Testing\assertType; + +function foo(string $x): void {} + +function test(string|null $x): void { + if ((string)$x !== '') { + assertType('non-empty-string', $x); + foo($x); + } +} + +function testIdentical(string|null $x): void { + if ((string)$x === '') { + assertType("''|null", $x); + } else { + assertType('non-empty-string', $x); + } +} + +function testInt(int|null $x): void { + if ((string)$x !== '') { + assertType('int', $x); + } +} + +function testIntString(int|string|null $x): void { + if ((string)$x !== '') { + assertType('int|non-empty-string', $x); + } +} + +function testBool(bool|string|null $x): void { + if ((string)$x !== '') { + assertType('non-empty-string|true', $x); + } +} From b2a3bb0581e85e8aaa37b21ea5fc91cebeb8dcc4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Wed, 1 Apr 2026 18:35:02 +0000 Subject: [PATCH 2/2] Generalize cast type narrowing for (int) in addition to (string) Refactor the cast narrowing logic in TypeSpecifier::resolveNormalizedIdentical() to handle any Expr\Cast via a new determineCastProducingType() helper method. - (string)$expr === '' narrows to null|false|'' (unchanged behavior) - (int)$expr === 0 narrows to null|false|0|0.0|''|'0' This addresses the review feedback to generalize the approach beyond just string casts. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 52 +++++++++++++++++++----- tests/PHPStan/Analyser/nsrt/bug-8231.php | 33 +++++++++++++++ 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 95610eb2d4..3014868b99 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2934,23 +2934,17 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope } } - // (string)$expr === '' - propagate narrowing to inner expression + // (cast)$expr === value - propagate narrowing to inner expression if ( !$context->null() - && $unwrappedLeftExpr instanceof Expr\Cast\String_ + && $unwrappedLeftExpr instanceof Expr\Cast ) { - $rightConstantStrings = $rightType->getConstantStrings(); - if (count($rightConstantStrings) === 1 && $rightConstantStrings[0]->getValue() === '') { - // Types that produce '' when cast to string: null, false, '' - $castToEmptyStringType = TypeCombinator::union( - new NullType(), - new ConstantBooleanType(false), - new ConstantStringType(''), - ); + $castProducingType = $this->determineCastProducingType($unwrappedLeftExpr, $rightType); + if ($castProducingType !== null) { $innerExpr = $unwrappedLeftExpr->expr; $result = $this->create($leftExpr, $rightType, $context, $scope)->setRootExpr($expr); return $result->unionWith( - $this->create($innerExpr, $castToEmptyStringType, $context, $scope)->setRootExpr($expr), + $this->create($innerExpr, $castProducingType, $context, $scope)->setRootExpr($expr), ); } } @@ -3144,4 +3138,40 @@ private function resolveNormalizedIdentical(Expr\BinaryOp\Identical $expr, Scope return (new SpecifiedTypes([], []))->setRootExpr($expr); } + /** + * Given a cast expression and the value it's compared to, + * returns the union of types that produce that value when cast. + */ + private function determineCastProducingType(Expr\Cast $cast, Type $comparedType): ?Type + { + if ($cast instanceof Expr\Cast\String_) { + $constantStrings = $comparedType->getConstantStrings(); + if (count($constantStrings) === 1 && $constantStrings[0]->getValue() === '') { + // Types that produce '' when cast to string: null, false, '' + return TypeCombinator::union( + new NullType(), + new ConstantBooleanType(false), + new ConstantStringType(''), + ); + } + } + + if ($cast instanceof Expr\Cast\Int_) { + $constantScalars = $comparedType->getConstantScalarValues(); + if (count($constantScalars) === 1 && $constantScalars[0] === 0) { + // Types that produce 0 when cast to int: null, false, 0, 0.0, '', '0' + return TypeCombinator::union( + new NullType(), + new ConstantBooleanType(false), + new ConstantIntegerType(0), + new ConstantFloatType(0.0), + new ConstantStringType(''), + new ConstantStringType('0'), + ); + } + } + + return null; + } + } diff --git a/tests/PHPStan/Analyser/nsrt/bug-8231.php b/tests/PHPStan/Analyser/nsrt/bug-8231.php index 7aaac90515..7d3ab75a97 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-8231.php +++ b/tests/PHPStan/Analyser/nsrt/bug-8231.php @@ -40,3 +40,36 @@ function testBool(bool|string|null $x): void { assertType('non-empty-string|true', $x); } } + +// (int) cast narrowing +function testIntCast(int|null $x): void { + if ((int)$x !== 0) { + assertType('int|int<1, max>', $x); + } +} + +function testIntCastIdentical(int|null $x): void { + if ((int)$x === 0) { + assertType('0|null', $x); + } else { + assertType('int|int<1, max>', $x); + } +} + +function testIntCastWithString(int|string|null $x): void { + if ((int)$x !== 0) { + assertType("int|int<1, max>|non-falsy-string", $x); + } +} + +function testIntCastWithFloat(float|null $x): void { + if ((int)$x !== 0) { + assertType('float', $x); + } +} + +function testIntCastWithBool(bool|int|null $x): void { + if ((int)$x !== 0) { + assertType('int|int<1, max>|true', $x); + } +}