From e9ba0324e78986e4848c04237399fb12d418b4bd Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Thu, 14 May 2026 22:55:38 +0000 Subject: [PATCH 01/11] Invalidate maybe-impure function return values after impure method/static calls When a maybe-impure function call's return value was narrowed via assert() (e.g. assert(count(MyRecord::find()) === 1)), the narrowed type persisted even after an impure method call like $msg2->insert(). This happened because invalidateExpression() only invalidated expressions containing the specific callee variable, not unrelated maybe-impure expressions whose results could have been affected by the side effects. Add invalidateAllMaybeImpureFunctionReturnValues() to MutatingScope which walks stored expression types and removes any that contain maybe-impure function/method/static calls. Call it from MethodCallHandler and StaticCallHandler when processing calls with definite side effects (hasSideEffects()->yes()), skipping $this-> calls which already invalidate via invalidateExpression(). Closes https://github.com/phpstan/phpstan/issues/13416 --- .../ExprHandler/MethodCallHandler.php | 3 + .../ExprHandler/StaticCallHandler.php | 12 ++ src/Analyser/MutatingScope.php | 95 +++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13416.php | 113 ++++++++++++++++++ 4 files changed, 223 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-13416.php diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 28e0181c331..981cf56d662 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -152,6 +152,9 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); + if (!($normalizedExpr->var instanceof Expr\Variable && $normalizedExpr->var->name === 'this')) { + $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); + } } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index 2869dcee52f..ad427b4b189 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -227,6 +227,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName()) ) { $scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass()); + $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); + } elseif ( + $methodReflection !== null + && ( + ( + !$methodReflection->isStatic() + && $methodReflection->getName() === '__construct' + ) + || $methodReflection->hasSideEffects()->yes() + ) + ) { + $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); } elseif ( $expr->class instanceof Name && $methodReflection !== null diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index daf5bb9eb37..4e97b150ac8 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2885,6 +2885,101 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType()); } + public function invalidateAllMaybeImpureFunctionReturnValues(): self + { + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + $invalidated = false; + + foreach ($expressionTypes as $exprString => $exprTypeHolder) { + $expr = $exprTypeHolder->getExpr(); + if (!$this->expressionContainsMaybeImpureCall($expr)) { + continue; + } + + unset($expressionTypes[$exprString]); + unset($nativeExpressionTypes[$exprString]); + $invalidated = true; + } + + if (!$invalidated) { + return $this; + } + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->inFirstLevelStatement, + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + [], + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + + private function expressionContainsMaybeImpureCall(Expr $expr): bool + { + $nodeFinder = new NodeFinder(); + $found = $nodeFinder->findFirst([$expr], function (Node $node): bool { + if (!$node instanceof Expr) { + return false; + } + + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($node->name, $this)) { + return true; + } + return !$this->reflectionProvider->getFunction($node->name, $this)->hasSideEffects()->no(); + } + return true; + } + + if ($node instanceof MethodCall) { + if ($node->name instanceof Identifier) { + $calledOnType = $this->getType($node->var); + $methodReflection = $this->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + return !$methodReflection->hasSideEffects()->no(); + } + return true; + } + + if ($node instanceof Expr\StaticCall) { + if ($node->name instanceof Identifier) { + if ($node->class instanceof Name) { + $calledOnType = $this->resolveTypeByName($node->class); + } elseif ($node->class instanceof Expr) { + $calledOnType = $this->getType($node->class); + } else { + return true; + } + $methodReflection = $this->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + return !$methodReflection->hasSideEffects()->no(); + } + return true; + } + + return false; + }); + + return $found !== null; + } + public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self { $expressionTypes = $this->expressionTypes; diff --git a/tests/PHPStan/Analyser/nsrt/bug-13416.php b/tests/PHPStan/Analyser/nsrt/bug-13416.php new file mode 100644 index 00000000000..c78c23bdb04 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-13416.php @@ -0,0 +1,113 @@ + */ + public static function find(): array + { + return []; + } + + public function insert(): void + { + } + + /** @return non-empty-string */ + public function getName(): string + { + return 'test'; + } +} + +class Repository +{ + /** @return list */ + public function findAll(): array + { + return []; + } + + public function save(MyRecord $record): void + { + } +} + +function testStaticCallInvalidatedByMethodCall(): void +{ + assert(count(MyRecord::find()) === 1); + assertType('1', count(MyRecord::find())); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assertType('int<0, max>', count(MyRecord::find())); +} + +function testMethodCallInvalidatedByMethodCall(): void +{ + $repo = new Repository(); + + assert(count($repo->findAll()) === 1); + assertType('1', count($repo->findAll())); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assertType('int<0, max>', count($repo->findAll())); +} + +function testStrlenOfImpureCall(): void +{ + $record = new MyRecord(); + + assert(strlen($record->getName()) === 3); + assertType('3', strlen($record->getName())); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assertType('int<1, max>', strlen($record->getName())); +} + +function testCountNotInvalidatedByPureFunction(): void +{ + assert(count(MyRecord::find()) === 1); + assertType('1', count(MyRecord::find())); + + $x = rand(0, 10); + + assertType('1', count(MyRecord::find())); +} + +class ServiceWithImpureCall +{ + public function testMethodCallInvalidation(): void + { + $repo = new Repository(); + + assert(count($repo->findAll()) === 1); + assertType('1', count($repo->findAll())); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assertType('int<0, max>', count($repo->findAll())); + } + + public function testStaticCallInvalidation(): void + { + assert(count(MyRecord::find()) === 1); + assertType('1', count(MyRecord::find())); + + $msg2 = new MyRecord(); + $msg2->insert(); + + assertType('int<0, max>', count(MyRecord::find())); + } +} From 923d2b1815221a39553abdd0b27c6ccc78b646d9 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 08:07:26 +0000 Subject: [PATCH 02/11] Prevent type narrowing of expressions containing impure calls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of invalidating stored expression types after impure calls, prevent them from being stored in the first place. TypeSpecifier::createForExpr() now walks the expression tree and returns empty SpecifiedTypes when any sub-expression is a non-pure function/method/static call. This fixes cases like strlen(impure()) === 3 where the outer call (strlen) is pure but the inner argument is impure — the previous approach only checked the top-level expression. Closes https://github.com/phpstan/phpstan/issues/13416 Co-Authored-By: Claude Opus 4.6 --- .../ExprHandler/MethodCallHandler.php | 3 - .../ExprHandler/StaticCallHandler.php | 12 --- src/Analyser/MutatingScope.php | 95 ------------------- src/Analyser/TypeSpecifier.php | 82 ++++++++++++++++ tests/PHPStan/Analyser/nsrt/bug-13416.php | 85 +++++++---------- 5 files changed, 114 insertions(+), 163 deletions(-) diff --git a/src/Analyser/ExprHandler/MethodCallHandler.php b/src/Analyser/ExprHandler/MethodCallHandler.php index 981cf56d662..28e0181c331 100644 --- a/src/Analyser/ExprHandler/MethodCallHandler.php +++ b/src/Analyser/ExprHandler/MethodCallHandler.php @@ -152,9 +152,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex if ($methodReflection->getName() === '__construct' || $methodReflection->hasSideEffects()->yes()) { $nodeScopeResolver->callNodeCallback($nodeCallback, new InvalidateExprNode($normalizedExpr->var), $scope, $storage); $scope = $scope->invalidateExpression($normalizedExpr->var, true, $methodReflection->getDeclaringClass()); - if (!($normalizedExpr->var instanceof Expr\Variable && $normalizedExpr->var->name === 'this')) { - $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); - } } elseif ($this->rememberPossiblyImpureFunctionValues && $methodReflection->hasSideEffects()->maybe() && !$methodReflection->getDeclaringClass()->isBuiltin()) { $scope = $scope->assignExpression( new PossiblyImpureCallExpr($normalizedExpr, $normalizedExpr->var, sprintf('%s::%s()', $methodReflection->getDeclaringClass()->getDisplayName(), $methodReflection->getName())), diff --git a/src/Analyser/ExprHandler/StaticCallHandler.php b/src/Analyser/ExprHandler/StaticCallHandler.php index ad427b4b189..2869dcee52f 100644 --- a/src/Analyser/ExprHandler/StaticCallHandler.php +++ b/src/Analyser/ExprHandler/StaticCallHandler.php @@ -227,18 +227,6 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex && $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName()) ) { $scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass()); - $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); - } elseif ( - $methodReflection !== null - && ( - ( - !$methodReflection->isStatic() - && $methodReflection->getName() === '__construct' - ) - || $methodReflection->hasSideEffects()->yes() - ) - ) { - $scope = $scope->invalidateAllMaybeImpureFunctionReturnValues(); } elseif ( $expr->class instanceof Name && $methodReflection !== null diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 4e97b150ac8..daf5bb9eb37 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2885,101 +2885,6 @@ public function assignInitializedProperty(Type $fetchedOnType, string $propertyN return $this->assignExpression(new PropertyInitializationExpr($propertyName), new MixedType(), new MixedType()); } - public function invalidateAllMaybeImpureFunctionReturnValues(): self - { - $expressionTypes = $this->expressionTypes; - $nativeExpressionTypes = $this->nativeExpressionTypes; - $invalidated = false; - - foreach ($expressionTypes as $exprString => $exprTypeHolder) { - $expr = $exprTypeHolder->getExpr(); - if (!$this->expressionContainsMaybeImpureCall($expr)) { - continue; - } - - unset($expressionTypes[$exprString]); - unset($nativeExpressionTypes[$exprString]); - $invalidated = true; - } - - if (!$invalidated) { - return $this; - } - - return $this->scopeFactory->create( - $this->context, - $this->isDeclareStrictTypes(), - $this->getFunction(), - $this->getNamespace(), - $expressionTypes, - $nativeExpressionTypes, - $this->conditionalExpressions, - $this->inClosureBindScopeClasses, - $this->anonymousFunctionReflection, - $this->inFirstLevelStatement, - $this->currentlyAssignedExpressions, - $this->currentlyAllowedUndefinedExpressions, - [], - $this->afterExtractCall, - $this->parentScope, - $this->nativeTypesPromoted, - ); - } - - private function expressionContainsMaybeImpureCall(Expr $expr): bool - { - $nodeFinder = new NodeFinder(); - $found = $nodeFinder->findFirst([$expr], function (Node $node): bool { - if (!$node instanceof Expr) { - return false; - } - - if ($node instanceof FuncCall) { - if ($node->name instanceof Name) { - if (!$this->reflectionProvider->hasFunction($node->name, $this)) { - return true; - } - return !$this->reflectionProvider->getFunction($node->name, $this)->hasSideEffects()->no(); - } - return true; - } - - if ($node instanceof MethodCall) { - if ($node->name instanceof Identifier) { - $calledOnType = $this->getType($node->var); - $methodReflection = $this->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return true; - } - return !$methodReflection->hasSideEffects()->no(); - } - return true; - } - - if ($node instanceof Expr\StaticCall) { - if ($node->name instanceof Identifier) { - if ($node->class instanceof Name) { - $calledOnType = $this->resolveTypeByName($node->class); - } elseif ($node->class instanceof Expr) { - $calledOnType = $this->getType($node->class); - } else { - return true; - } - $methodReflection = $this->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return true; - } - return !$methodReflection->hasSideEffects()->no(); - } - return true; - } - - return false; - }); - - return $found !== null; - } - public function invalidateExpression(Expr $expressionToInvalidate, bool $requireMoreCharacters = false, ?ClassReflection $invalidatingClass = null): self { $expressionTypes = $this->expressionTypes; diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 75000f1956b..3fb293928c4 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -18,7 +18,9 @@ use PhpParser\Node\Expr\PropertyFetch; use PhpParser\Node\Expr\StaticCall; use PhpParser\Node\Expr\StaticPropertyFetch; +use PhpParser\Node\Identifier; use PhpParser\Node\Name; +use PhpParser\NodeFinder; use PHPStan\Analyser\ExprHandler\BooleanAndHandler; use PHPStan\DependencyInjection\AutowiredService; use PHPStan\Node\Expr\AlwaysRememberedExpr; @@ -2626,6 +2628,14 @@ private function createForExpr( } } + if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); + } + + return new SpecifiedTypes([], []); + } + $sureTypes = []; $sureNotTypes = []; if ($context->false()) { @@ -2654,6 +2664,78 @@ private function createForExpr( return $types; } + private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool + { + $nodeFinder = new NodeFinder(); + $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool { + if (!$node instanceof Expr) { + return false; + } + + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return true; + } + $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); + } + + $nameType = $scope->getType($node->name); + if ($nameType->isCallable()->yes()) { + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + if ($isPure !== null) { + return $isPure->no() + || (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()); + } + } + + return false; + } + + if ($node instanceof MethodCall) { + if ($node->name instanceof Identifier) { + $calledOnType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); + } + return true; + } + + if ($node instanceof StaticCall) { + if ($node->name instanceof Identifier) { + if ($node->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($node->class); + } else { + $calledOnType = $scope->getType($node->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); + } + return true; + } + + return false; + }); + + return $found !== null; + } + private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes { if ($expr instanceof Expr\NullsafePropertyFetch) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-13416.php b/tests/PHPStan/Analyser/nsrt/bug-13416.php index c78c23bdb04..f8324f6edf3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13416.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13416.php @@ -8,17 +8,28 @@ class MyRecord { - /** @return list */ + /** @var list */ + private static array $storage = []; + + /** + * @return list + * @phpstan-impure + */ public static function find(): array { - return []; + return self::$storage; } + /** @phpstan-impure */ public function insert(): void { + self::$storage[] = $this; } - /** @return non-empty-string */ + /** + * @return non-empty-string + * @phpstan-impure + */ public function getName(): string { return 'test'; @@ -27,87 +38,55 @@ public function getName(): string class Repository { - /** @return list */ + /** + * @return list + * @phpstan-impure + */ public function findAll(): array { return []; } + /** @phpstan-impure */ public function save(MyRecord $record): void { } } -function testStaticCallInvalidatedByMethodCall(): void +function testImpureStaticCallNotNarrowedByCount(): void { assert(count(MyRecord::find()) === 1); - assertType('1', count(MyRecord::find())); - - $msg2 = new MyRecord(); - $msg2->insert(); - + // Impure call result should not be narrowed assertType('int<0, max>', count(MyRecord::find())); } -function testMethodCallInvalidatedByMethodCall(): void +function testImpureMethodCallNotNarrowedByCount(): void { $repo = new Repository(); assert(count($repo->findAll()) === 1); - assertType('1', count($repo->findAll())); - - $msg2 = new MyRecord(); - $msg2->insert(); - + // Impure call result should not be narrowed assertType('int<0, max>', count($repo->findAll())); } -function testStrlenOfImpureCall(): void +function testStrlenOfImpureCallNotNarrowed(): void { $record = new MyRecord(); assert(strlen($record->getName()) === 3); - assertType('3', strlen($record->getName())); - - $msg2 = new MyRecord(); - $msg2->insert(); - + // strlen wrapping an impure call should not be narrowed assertType('int<1, max>', strlen($record->getName())); } -function testCountNotInvalidatedByPureFunction(): void +function testPureFunctionStaysNarrowed(): void { - assert(count(MyRecord::find()) === 1); - assertType('1', count(MyRecord::find())); + /** @var list $arr */ + $arr = [1]; + assert(count($arr) === 1); + assertType('1', count($arr)); $x = rand(0, 10); - assertType('1', count(MyRecord::find())); -} - -class ServiceWithImpureCall -{ - public function testMethodCallInvalidation(): void - { - $repo = new Repository(); - - assert(count($repo->findAll()) === 1); - assertType('1', count($repo->findAll())); - - $msg2 = new MyRecord(); - $msg2->insert(); - - assertType('int<0, max>', count($repo->findAll())); - } - - public function testStaticCallInvalidation(): void - { - assert(count(MyRecord::find()) === 1); - assertType('1', count(MyRecord::find())); - - $msg2 = new MyRecord(); - $msg2->insert(); - - assertType('int<0, max>', count(MyRecord::find())); - } + // Pure expressions stay narrowed + assertType('1', count($arr)); } From e731e8890e8e569031931637af459bcf9c54655b Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 08:27:47 +0000 Subject: [PATCH 03/11] Remove redundant early returns subsumed by expressionContainsNonPureCall The individual FuncCall, MethodCall, and StaticCall early returns in createForExpr() are fully covered by the expressionContainsNonPureCall() check that walks the entire expression tree. Remove the redundant top-level checks. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 90 ---------------------------------- 1 file changed, 90 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 3fb293928c4..7e4fd75e9d2 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2538,96 +2538,6 @@ private function createForExpr( } } - if ( - $expr instanceof FuncCall - && $expr->name instanceof Name - ) { - $has = $this->reflectionProvider->hasFunction($expr->name, $scope); - if (!$has) { - // backwards compatibility with previous behaviour - return new SpecifiedTypes([], []); - } - - $functionReflection = $this->reflectionProvider->getFunction($expr->name, $scope); - $hasSideEffects = $functionReflection->hasSideEffects(); - if ($hasSideEffects->yes()) { - return new SpecifiedTypes([], []); - } - - if (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()) { - return new SpecifiedTypes([], []); - } - } - - if ( - $expr instanceof FuncCall - && !$expr->name instanceof Name - ) { - $nameType = $scope->getType($expr->name); - if ($nameType->isCallable()->yes()) { - $isPure = null; - foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { - $variantIsPure = $variant->isPure(); - $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); - } - - if ($isPure !== null) { - if ($isPure->no()) { - return new SpecifiedTypes([], []); - } - - if (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()) { - return new SpecifiedTypes([], []); - } - } - } - } - - if ( - $expr instanceof MethodCall - && $expr->name instanceof Node\Identifier - ) { - $methodName = $expr->name->toString(); - $calledOnType = $scope->getType($expr->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } - - return new SpecifiedTypes([], []); - } - } - - if ( - $expr instanceof StaticCall - && $expr->name instanceof Node\Identifier - ) { - $methodName = $expr->name->toString(); - if ($expr->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($expr->class); - } else { - $calledOnType = $scope->getType($expr->class); - } - - $methodReflection = $scope->getMethodReflection($calledOnType, $methodName); - if ( - $methodReflection === null - || $methodReflection->hasSideEffects()->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$methodReflection->hasSideEffects()->no()) - ) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } - - return new SpecifiedTypes([], []); - } - } - if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) { if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); From 452a3c82973f4ee7fb1d43e0f3e50dda2e8cd7af Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 09:03:57 +0000 Subject: [PATCH 04/11] Return TrinaryLogic from expressionContainsNonPureCall Refactor expressionContainsNonPureCall to return TrinaryLogic instead of bool, separating the purity determination from the rememberPossiblyImpureFunctionValues policy decision. Extract callNodeHasSideEffects helper that returns the raw TrinaryLogic purity of each call node. Replace NodeFinder::findFirst (which requires a bool callback) with NodeFinder::find to collect all call nodes, then aggregate their purity via TrinaryLogic::or with early exit on yes. Unknown methods and dynamic call names return maybe (we genuinely don't know their purity). Unknown named functions keep returning yes for backward compatibility. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 123 +++++++++++++++++---------------- 1 file changed, 63 insertions(+), 60 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7e4fd75e9d2..7df5a1b73f8 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2538,12 +2538,15 @@ private function createForExpr( } } - if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } + if (!($expr instanceof AlwaysRememberedExpr)) { + $containsNonPureCall = $this->expressionContainsNonPureCall($expr, $scope); + if ($containsNonPureCall->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$containsNonPureCall->no())) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); + } - return new SpecifiedTypes([], []); + return new SpecifiedTypes([], []); + } } $sureTypes = []; @@ -2574,76 +2577,76 @@ private function createForExpr( return $types; } - private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool + private function expressionContainsNonPureCall(Expr $expr, Scope $scope): TrinaryLogic { $nodeFinder = new NodeFinder(); - $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool { - if (!$node instanceof Expr) { - return false; + $callNodes = $nodeFinder->find([$expr], static fn (Node $node): bool => $node instanceof FuncCall || $node instanceof MethodCall || $node instanceof StaticCall); + + $result = TrinaryLogic::createNo(); + foreach ($callNodes as $callNode) { + $result = $result->or($this->callNodeHasSideEffects($callNode, $scope)); + if ($result->yes()) { + return $result; } + } - if ($node instanceof FuncCall) { - if ($node->name instanceof Name) { - if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { - return true; - } - $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); - return $hasSideEffects->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); - } + return $result; + } - $nameType = $scope->getType($node->name); - if ($nameType->isCallable()->yes()) { - $isPure = null; - foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { - $variantIsPure = $variant->isPure(); - $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); - } - if ($isPure !== null) { - return $isPure->no() - || (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()); - } + private function callNodeHasSideEffects(Node $node, Scope $scope): TrinaryLogic + { + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return TrinaryLogic::createYes(); } - - return false; + return $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); } - if ($node instanceof MethodCall) { - if ($node->name instanceof Identifier) { - $calledOnType = $scope->getType($node->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return true; - } - $hasSideEffects = $methodReflection->hasSideEffects(); - return $hasSideEffects->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); + $nameType = $scope->getType($node->name); + if ($nameType->isCallable()->yes()) { + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + if ($isPure !== null) { + return $isPure->negate(); } - return true; } - if ($node instanceof StaticCall) { - if ($node->name instanceof Identifier) { - if ($node->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($node->class); - } else { - $calledOnType = $scope->getType($node->class); - } - $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return true; - } - $hasSideEffects = $methodReflection->hasSideEffects(); - return $hasSideEffects->yes() - || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); + return TrinaryLogic::createNo(); + } + + if ($node instanceof MethodCall) { + if ($node->name instanceof Identifier) { + $calledOnType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return TrinaryLogic::createMaybe(); } - return true; + return $methodReflection->hasSideEffects(); } + return TrinaryLogic::createMaybe(); + } - return false; - }); + if ($node instanceof StaticCall) { + if ($node->name instanceof Identifier) { + if ($node->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($node->class); + } else { + $calledOnType = $scope->getType($node->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return TrinaryLogic::createMaybe(); + } + return $methodReflection->hasSideEffects(); + } + return TrinaryLogic::createMaybe(); + } - return $found !== null; + return TrinaryLogic::createNo(); } private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes From 7687df3d30950bb5fc6874c2a8067824eaba5b5e Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 09:24:56 +0000 Subject: [PATCH 05/11] Add tests for impure closures and arrow functions Verify that expressionContainsNonPureCall correctly handles: - Impure arrow function IIFEs - Impure closure IIFEs - strlen wrapping impure arrow function IIFEs - Impure closures called via variable - Closures with echo (non-call impurity) wrapping impure calls - Pure closure IIFEs staying narrowed (positive case) - Pure function wrapping impure function with coalesce Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/nsrt/bug-13416.php | 55 +++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/bug-13416.php b/tests/PHPStan/Analyser/nsrt/bug-13416.php index f8324f6edf3..899c815f0ad 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-13416.php +++ b/tests/PHPStan/Analyser/nsrt/bug-13416.php @@ -90,3 +90,58 @@ function testPureFunctionStaysNarrowed(): void // Pure expressions stay narrowed assertType('1', count($arr)); } + +function testImpureArrowFunctionIIFE(): void +{ + assert(count((fn() => MyRecord::find())()) === 1); + assertType('int<0, max>', count((fn() => MyRecord::find())())); +} + +function testImpureClosureIIFE(): void +{ + assert(count((function() { return MyRecord::find(); })()) === 1); + assertType('int<0, max>', count((function() { return MyRecord::find(); })())); +} + +function testStrlenOfImpureArrowFunctionIIFE(): void +{ + $record = new MyRecord(); + assert(strlen((fn() => $record->getName())()) === 3); + assertType('int<1, max>', strlen((fn() => $record->getName())())); +} + +function testImpureClosureViaVariable(): void +{ + $fn = function(): array { return MyRecord::find(); }; + assert(count($fn()) === 1); + assertType('int<0, max>', count($fn())); +} + +function testImpureClosureWithEchoIIFE(): void +{ + assert(strlen((function() { echo 'side-effect'; return MyRecord::find()[0]->getName(); })()) === 5); + assertType('int<1, max>', strlen((function() { echo 'side-effect'; return MyRecord::find()[0]->getName(); })())); +} + +function testPureClosureIIFEStaysNarrowed(): void +{ + /** @var list $arr */ + $arr = [1, 2, 3]; + assert(count((fn() => $arr)()) === 3); + assertType('3', count((fn() => $arr)())); +} + +/** + * @param string|null $val + * @phpstan-impure + */ +function impureFunction(?string $val): ?string +{ + return $val; +} + +function testPureOfImpureNotNarrowedByCoalesce(): void +{ + $a = strlen(impureFunction('hello') ?? '') > 0; + assertType('bool', strlen(impureFunction('hello') ?? '') > 0); +} From 1fd7cb8c278c17b0f0b7d03f6607d3580a52b66e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 15 May 2026 11:37:49 +0200 Subject: [PATCH 06/11] Revert "Return TrinaryLogic from expressionContainsNonPureCall" This reverts commit 452a3c82973f4ee7fb1d43e0f3e50dda2e8cd7af. --- src/Analyser/TypeSpecifier.php | 123 ++++++++++++++++----------------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7df5a1b73f8..7e4fd75e9d2 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2538,15 +2538,12 @@ private function createForExpr( } } - if (!($expr instanceof AlwaysRememberedExpr)) { - $containsNonPureCall = $this->expressionContainsNonPureCall($expr, $scope); - if ($containsNonPureCall->yes() || (!$this->rememberPossiblyImpureFunctionValues && !$containsNonPureCall->no())) { - if (isset($containsNull) && !$containsNull) { - return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); - } - - return new SpecifiedTypes([], []); + if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) { + if (isset($containsNull) && !$containsNull) { + return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); } + + return new SpecifiedTypes([], []); } $sureTypes = []; @@ -2577,76 +2574,76 @@ private function createForExpr( return $types; } - private function expressionContainsNonPureCall(Expr $expr, Scope $scope): TrinaryLogic + private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool { $nodeFinder = new NodeFinder(); - $callNodes = $nodeFinder->find([$expr], static fn (Node $node): bool => $node instanceof FuncCall || $node instanceof MethodCall || $node instanceof StaticCall); - - $result = TrinaryLogic::createNo(); - foreach ($callNodes as $callNode) { - $result = $result->or($this->callNodeHasSideEffects($callNode, $scope)); - if ($result->yes()) { - return $result; + $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool { + if (!$node instanceof Expr) { + return false; } - } - - return $result; - } - private function callNodeHasSideEffects(Node $node, Scope $scope): TrinaryLogic - { - if ($node instanceof FuncCall) { - if ($node->name instanceof Name) { - if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { - return TrinaryLogic::createYes(); + if ($node instanceof FuncCall) { + if ($node->name instanceof Name) { + if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { + return true; + } + $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); } - return $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); - } - $nameType = $scope->getType($node->name); - if ($nameType->isCallable()->yes()) { - $isPure = null; - foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { - $variantIsPure = $variant->isPure(); - $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); - } - if ($isPure !== null) { - return $isPure->negate(); + $nameType = $scope->getType($node->name); + if ($nameType->isCallable()->yes()) { + $isPure = null; + foreach ($nameType->getCallableParametersAcceptors($scope) as $variant) { + $variantIsPure = $variant->isPure(); + $isPure = $isPure === null ? $variantIsPure : $isPure->and($variantIsPure); + } + if ($isPure !== null) { + return $isPure->no() + || (!$this->rememberPossiblyImpureFunctionValues && !$isPure->yes()); + } } - } - return TrinaryLogic::createNo(); - } + return false; + } - if ($node instanceof MethodCall) { - if ($node->name instanceof Identifier) { - $calledOnType = $scope->getType($node->var); - $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return TrinaryLogic::createMaybe(); + if ($node instanceof MethodCall) { + if ($node->name instanceof Identifier) { + $calledOnType = $scope->getType($node->var); + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); } - return $methodReflection->hasSideEffects(); + return true; } - return TrinaryLogic::createMaybe(); - } - if ($node instanceof StaticCall) { - if ($node->name instanceof Identifier) { - if ($node->class instanceof Name) { - $calledOnType = $scope->resolveTypeByName($node->class); - } else { - $calledOnType = $scope->getType($node->class); - } - $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); - if ($methodReflection === null) { - return TrinaryLogic::createMaybe(); + if ($node instanceof StaticCall) { + if ($node->name instanceof Identifier) { + if ($node->class instanceof Name) { + $calledOnType = $scope->resolveTypeByName($node->class); + } else { + $calledOnType = $scope->getType($node->class); + } + $methodReflection = $scope->getMethodReflection($calledOnType, $node->name->name); + if ($methodReflection === null) { + return true; + } + $hasSideEffects = $methodReflection->hasSideEffects(); + return $hasSideEffects->yes() + || (!$this->rememberPossiblyImpureFunctionValues && !$hasSideEffects->no()); } - return $methodReflection->hasSideEffects(); + return true; } - return TrinaryLogic::createMaybe(); - } - return TrinaryLogic::createNo(); + return false; + }); + + return $found !== null; } private function createNullsafeTypes(Expr $expr, Scope $scope, TypeSpecifierContext $context, ?Type $type): SpecifiedTypes From 968e01a2830cfc5a715c5478fa174ebcf51745e4 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 15 May 2026 11:37:57 +0200 Subject: [PATCH 07/11] Update TypeSpecifier.php --- src/Analyser/TypeSpecifier.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index 7e4fd75e9d2..d67f97115dc 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2578,10 +2578,6 @@ private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool { $nodeFinder = new NodeFinder(); $found = $nodeFinder->findFirst([$expr], function (Node $node) use ($scope): bool { - if (!$node instanceof Expr) { - return false; - } - if ($node instanceof FuncCall) { if ($node->name instanceof Name) { if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { From aadf81a46913327d6b839b31eda34e04a5b336d7 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 09:07:59 +0000 Subject: [PATCH 08/11] Do not treat unknown functions as non-pure in sub-expression purity checks The expressionContainsNonPureCall() method walks all sub-expressions looking for impure calls. Unknown functions (not found in the reflection provider) were treated as non-pure, which broke extensions using FAUX_FUNCTION_ markers in rootExpr (e.g. phpstan-beberlei-assert's isJsonString). The old code only checked the top-level expression, so unknown functions in sub-expressions were never flagged. Fix: return false for unknown functions in the tree walk (sub-expression check), but keep a separate top-level guard for unknown FuncCall expressions in createForExpr for backwards compatibility. Co-Authored-By: Claude Opus 4.6 --- src/Analyser/TypeSpecifier.php | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src/Analyser/TypeSpecifier.php b/src/Analyser/TypeSpecifier.php index d67f97115dc..15521841c40 100644 --- a/src/Analyser/TypeSpecifier.php +++ b/src/Analyser/TypeSpecifier.php @@ -2538,6 +2538,14 @@ private function createForExpr( } } + if ( + $expr instanceof FuncCall + && $expr->name instanceof Name + && !$this->reflectionProvider->hasFunction($expr->name, $scope) + ) { + return new SpecifiedTypes([], []); + } + if (!($expr instanceof AlwaysRememberedExpr) && $this->expressionContainsNonPureCall($expr, $scope)) { if (isset($containsNull) && !$containsNull) { return $this->createNullsafeTypes($originalExpr, $scope, $context, $type); @@ -2581,7 +2589,7 @@ private function expressionContainsNonPureCall(Expr $expr, Scope $scope): bool if ($node instanceof FuncCall) { if ($node->name instanceof Name) { if (!$this->reflectionProvider->hasFunction($node->name, $scope)) { - return true; + return false; } $hasSideEffects = $this->reflectionProvider->getFunction($node->name, $scope)->hasSideEffects(); return $hasSideEffects->yes() From 9d80f7d55de637601aaa96f75120c1de25d9a5d3 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 10:36:54 +0000 Subject: [PATCH 09/11] Add non-regression test for unknown function sub-expression handling Verifies that an unknown function (like FAUX_FUNCTION used by StrContainingTypeSpecifyingExtension and third-party extensions) appearing as a sub-expression does not prevent type narrowing in createForExpr. The test calls TypeSpecifier::create() with count(FAUX_FUNCTION($foo)) and asserts the expression is narrowed, which would fail if expressionContainsNonPureCall incorrectly treated unknown functions as non-pure. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/TypeSpecifierTest.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index aa66c940016..100346a4fff 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -26,6 +26,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\IntegerType; @@ -1354,6 +1355,22 @@ private static function createInstanceOf(string $className, string $variableName return new Expr\Instanceof_(new Variable($variableName), new Name($className)); } + public function testUnknownFunctionSubExpressionDoesNotPreventNarrowing(): void + { + $fauxFuncCall = new FuncCall(new Name('FAUX_FUNCTION'), [new Arg(new Variable('foo'))]); + $countCall = new FuncCall(new Name('count'), [new Arg($fauxFuncCall)]); + + $specifiedTypes = $this->typeSpecifier->create( + $countCall, + new ConstantIntegerType(1), + TypeSpecifierContext::createTrue(), + $this->scope, + ); + + $result = $this->toReadableResult($specifiedTypes); + $this->assertSame(['count(FAUX_FUNCTION($foo))' => '1'], $result); + } + /** * @param non-empty-string $functionName */ From 5ef8c3d707dbb9ba75ff2f45c2d1c6ba7fa52efd Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 13:20:13 +0000 Subject: [PATCH 10/11] Replace unit test with NSRT integration test for FAUX_FUNCTION handling Replace the contrived TypeSpecifierTest that manually built count(FAUX_FUNCTION($foo)) AST nodes with an NSRT integration test via StrContainingTypeSpecifyingExtension. The new test exercises str_contains with a non-empty needle (which triggers FAUX_FUNCTION in the rootExpr) and verifies narrowing works correctly across repeated calls. Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/TypeSpecifierTest.php | 17 ----------------- .../non-empty-string-str-containing-fns.php | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index 100346a4fff..aa66c940016 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -26,7 +26,6 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; -use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\IntegerType; @@ -1355,22 +1354,6 @@ private static function createInstanceOf(string $className, string $variableName return new Expr\Instanceof_(new Variable($variableName), new Name($className)); } - public function testUnknownFunctionSubExpressionDoesNotPreventNarrowing(): void - { - $fauxFuncCall = new FuncCall(new Name('FAUX_FUNCTION'), [new Arg(new Variable('foo'))]); - $countCall = new FuncCall(new Name('count'), [new Arg($fauxFuncCall)]); - - $specifiedTypes = $this->typeSpecifier->create( - $countCall, - new ConstantIntegerType(1), - TypeSpecifierContext::createTrue(), - $this->scope, - ); - - $result = $this->toReadableResult($specifiedTypes); - $this->assertSame(['count(FAUX_FUNCTION($foo))' => '1'], $result); - } - /** * @param non-empty-string $functionName */ diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 415f00c5020..4059214a042 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -86,6 +86,21 @@ public function strContains(string $s, string $s2, $nonES, $nonFalsy, $numS, $li assertType('lowercase-string', $lower); } + /** + * @param non-empty-string $nonEmpty + */ + public function strContainsWithNonEmptyNeedleNarrowsTwice(string $s, string $nonEmpty): void + { + if (str_contains($s, $nonEmpty)) { + assertType('non-empty-string', $s); + } + assertType('string', $s); + + if (str_contains($s, $nonEmpty)) { + assertType('non-empty-string', $s); + } + } + public function variants(string $s) { if (fnmatch("*gr[ae]y", $s)) { assertType('non-falsy-string', $s); From 159e29afea179c73e9f33744da34a6463dde5ec4 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Fri, 15 May 2026 13:36:37 +0000 Subject: [PATCH 11/11] Restore unit test for unknown function sub-expression handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The NSRT test via StrContainingTypeSpecifyingExtension does not exercise the unknown-function-in-sub-expression code path because str_contains narrows a Variable, not an expression containing FAUX_FUNCTION. Replace it with a direct TypeSpecifierTest that calls create() with count(FAUX_FUNCTION($foo)) — this properly fails when reverting aadf81a46 (unknown functions treated as non-pure in sub-expressions). Co-Authored-By: Claude Opus 4.6 --- tests/PHPStan/Analyser/TypeSpecifierTest.php | 17 +++++++++++++++++ .../non-empty-string-str-containing-fns.php | 15 --------------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/tests/PHPStan/Analyser/TypeSpecifierTest.php b/tests/PHPStan/Analyser/TypeSpecifierTest.php index aa66c940016..100346a4fff 100644 --- a/tests/PHPStan/Analyser/TypeSpecifierTest.php +++ b/tests/PHPStan/Analyser/TypeSpecifierTest.php @@ -26,6 +26,7 @@ use PHPStan\Type\ArrayType; use PHPStan\Type\ClassStringType; use PHPStan\Type\Constant\ConstantBooleanType; +use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\FloatType; use PHPStan\Type\Generic\GenericClassStringType; use PHPStan\Type\IntegerType; @@ -1354,6 +1355,22 @@ private static function createInstanceOf(string $className, string $variableName return new Expr\Instanceof_(new Variable($variableName), new Name($className)); } + public function testUnknownFunctionSubExpressionDoesNotPreventNarrowing(): void + { + $fauxFuncCall = new FuncCall(new Name('FAUX_FUNCTION'), [new Arg(new Variable('foo'))]); + $countCall = new FuncCall(new Name('count'), [new Arg($fauxFuncCall)]); + + $specifiedTypes = $this->typeSpecifier->create( + $countCall, + new ConstantIntegerType(1), + TypeSpecifierContext::createTrue(), + $this->scope, + ); + + $result = $this->toReadableResult($specifiedTypes); + $this->assertSame(['count(FAUX_FUNCTION($foo))' => '1'], $result); + } + /** * @param non-empty-string $functionName */ diff --git a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php index 4059214a042..415f00c5020 100644 --- a/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php +++ b/tests/PHPStan/Analyser/nsrt/non-empty-string-str-containing-fns.php @@ -86,21 +86,6 @@ public function strContains(string $s, string $s2, $nonES, $nonFalsy, $numS, $li assertType('lowercase-string', $lower); } - /** - * @param non-empty-string $nonEmpty - */ - public function strContainsWithNonEmptyNeedleNarrowsTwice(string $s, string $nonEmpty): void - { - if (str_contains($s, $nonEmpty)) { - assertType('non-empty-string', $s); - } - assertType('string', $s); - - if (str_contains($s, $nonEmpty)) { - assertType('non-empty-string', $s); - } - } - public function variants(string $s) { if (fnmatch("*gr[ae]y", $s)) { assertType('non-falsy-string', $s);