From eb47308591f61bbffc5787e98171d448dec21dda Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:54:39 +0000 Subject: [PATCH 1/2] Treat `Expr\UnaryMinus` with scalar operand as scalar literal in `isExprSafeToProjectThroughVariable` and comparison type specifying - In PHP Parser v5, negative literals like `-1` are represented as `Expr\UnaryMinus(Scalar\Int_(1))`, not `Scalar\Int_(-1)`. The `isExprSafeToProjectThroughVariable` filter in AssignHandler only checked for `instanceof Node\Scalar`, missing the `UnaryMinus` wrapper. This allowed a sure type with expression string `"-1"` (a numeric string) to enter the conditional expressions array. PHP's array key autocasting then turned it into an integer key, causing a TypeError when passed to `addConditionalExpressions(string $exprString, ...)`. - Added `Expr\UnaryMinus && $expr->expr instanceof Node\Scalar` to the filter in `isExprSafeToProjectThroughVariable`, matching what `MutatingScope::filterBySpecifiedTypes` already does. - Added the same `UnaryMinus(Scalar)` check to TypeSpecifier's `Smaller`/`SmallerOrEqual` handling, which skips creating sure types for scalar literals but was missing the negated-scalar case. - Added defensive `(string)` casts in three locations in AssignHandler where expression string keys from `getSureTypes()`/`getSureNotTypes()` are consumed, matching the pattern already used in `MutatingScope::filterBySpecifiedTypes`. --- phpstan-baseline.neon | 6 ++++++ src/Analyser/ExprHandler/AssignHandler.php | 8 ++++++-- src/Analyser/TypeSpecifier.php | 8 ++++---- .../Analyser/AnalyserIntegrationTest.php | 6 ++++++ tests/PHPStan/Analyser/data/bug-14542.php | 17 +++++++++++++++++ 5 files changed, 39 insertions(+), 6 deletions(-) create mode 100644 tests/PHPStan/Analyser/data/bug-14542.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 9f83d85139e..a835d25e10f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -48,6 +48,12 @@ parameters: count: 1 path: src/Analyser/Ignore/IgnoredErrorHelperResult.php + - + rawMessage: Casting to string something that's already string. + identifier: cast.useless + count: 3 + path: src/Analyser/ExprHandler/AssignHandler.php + - rawMessage: Casting to string something that's already string. identifier: cast.useless diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 1db8061d9ee..5d2a084ec04 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -319,7 +319,7 @@ public function processAssignVar( $nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage); $scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes()); foreach ($conditionalExpressions as $exprString => $holders) { - $scope = $scope->addConditionalExpressions($exprString, $holders); + $scope = $scope->addConditionalExpressions((string) $exprString, $holders); } if ($assignedExpr instanceof Expr\Array_) { @@ -861,6 +861,8 @@ private function processSureTypesForConditionalExpressionsAfterAssign(Scope $sco continue; } + $exprString = (string) $exprString; + if (!isset($conditionalExpressions[$exprString])) { $conditionalExpressions[$exprString] = []; } @@ -889,6 +891,8 @@ private function processSureNotTypesForConditionalExpressionsAfterAssign(Scope $ continue; } + $exprString = (string) $exprString; + if (!isset($conditionalExpressions[$exprString])) { $conditionalExpressions[$exprString] = []; } @@ -938,7 +942,7 @@ private function isExprSafeToProjectThroughVariable(Expr $expr, string $variable // narrowing targets at a usage site — skip them so they don't collide with PHP's // numeric-string array-key autocast or leak internal virtual expressions into the // conditional-expression map. - if ($expr instanceof Node\Scalar || $expr instanceof ConstFetch || $expr instanceof VirtualNode) { + if ($expr instanceof Node\Scalar || $expr instanceof ConstFetch || $expr instanceof VirtualNode || $expr instanceof Expr\UnaryMinus && $expr->expr instanceof Node\Scalar) { return false; } diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 4a22a57950c..24903779b3f 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -494,7 +494,7 @@ public function specifyTypesInCondition( } if ($context->true()) { - if (!$expr->left instanceof Node\Scalar) { + if (!$expr->left instanceof Node\Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Node\Scalar)) { $result = $result->unionWith( $this->create( $expr->left, @@ -504,7 +504,7 @@ public function specifyTypesInCondition( )->setRootExpr($expr), ); } - if (!$expr->right instanceof Node\Scalar) { + if (!$expr->right instanceof Node\Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Node\Scalar)) { $result = $result->unionWith( $this->create( $expr->right, @@ -515,7 +515,7 @@ public function specifyTypesInCondition( ); } } elseif ($context->false()) { - if (!$expr->left instanceof Node\Scalar) { + if (!$expr->left instanceof Node\Scalar && !($expr->left instanceof Expr\UnaryMinus && $expr->left->expr instanceof Node\Scalar)) { $result = $result->unionWith( $this->create( $expr->left, @@ -525,7 +525,7 @@ public function specifyTypesInCondition( )->setRootExpr($expr), ); } - if (!$expr->right instanceof Node\Scalar) { + if (!$expr->right instanceof Node\Scalar && !($expr->right instanceof Expr\UnaryMinus && $expr->right->expr instanceof Node\Scalar)) { $result = $result->unionWith( $this->create( $expr->right, diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index 6826995d4db..f276866a1b2 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1543,6 +1543,12 @@ public function testBug14501(): void $this->assertNoErrors($errors); } + public function testBug14542(): void + { + $errors = $this->runAnalyse(__DIR__ . '/data/bug-14542.php'); + $this->assertNoErrors($errors); + } + /** * @param string[]|null $allAnalysedFiles * @return list diff --git a/tests/PHPStan/Analyser/data/bug-14542.php b/tests/PHPStan/Analyser/data/bug-14542.php new file mode 100644 index 00000000000..e24bdfd194f --- /dev/null +++ b/tests/PHPStan/Analyser/data/bug-14542.php @@ -0,0 +1,17 @@ + $ids + */ + public function compare(mixed $a, mixed $b, array $ids): int + { + $indexA = ($index = \array_search($a, $ids)) > -1 ? $index : \PHP_INT_MAX; + $indexB = ($index = \array_search($b, $ids)) > -1 ? $index : \PHP_INT_MAX; + + return \strnatcmp((string) $indexA, (string) $indexB); + } +} From d7924c69fcce7168ba635446c419c1c6bb800fec Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Tue, 28 Apr 2026 15:36:59 +0000 Subject: [PATCH 2/2] Add RequiresPhp >= 8.0.0 to testBug14542 and regenerate baseline The test uses `mixed` type which requires PHP 8.0+. Baseline regenerated with `make phpstan-generate-baseline` to fix entry ordering. Co-Authored-By: Claude Opus 4.6 --- phpstan-baseline.neon | 12 ++++++------ tests/PHPStan/Analyser/AnalyserIntegrationTest.php | 1 + 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a835d25e10f..ec7779bfbed 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,6 +18,12 @@ parameters: count: 1 path: src/Analyser/ArgumentsNormalizer.php + - + rawMessage: Casting to string something that's already string. + identifier: cast.useless + count: 3 + path: src/Analyser/ExprHandler/AssignHandler.php + - rawMessage: 'Doing instanceof PHPStan\Type\Constant\ConstantStringType is error-prone and deprecated. Use Type::getConstantStrings() instead.' identifier: phpstanApi.instanceofType @@ -48,12 +54,6 @@ parameters: count: 1 path: src/Analyser/Ignore/IgnoredErrorHelperResult.php - - - rawMessage: Casting to string something that's already string. - identifier: cast.useless - count: 3 - path: src/Analyser/ExprHandler/AssignHandler.php - - rawMessage: Casting to string something that's already string. identifier: cast.useless diff --git a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php index f276866a1b2..135aad26f8b 100644 --- a/tests/PHPStan/Analyser/AnalyserIntegrationTest.php +++ b/tests/PHPStan/Analyser/AnalyserIntegrationTest.php @@ -1543,6 +1543,7 @@ public function testBug14501(): void $this->assertNoErrors($errors); } + #[RequiresPhp('>= 8.0.0')] public function testBug14542(): void { $errors = $this->runAnalyse(__DIR__ . '/data/bug-14542.php');